# HG changeset patch # User Goffi # Date 1522691090 -7200 # Node ID 26edcf3a30eb5f9e6f4dbabfdfbaebddcfb3820d # Parent bd30dc3ffe5acb24dc938798872eafb1d92a579d 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/ diff -r bd30dc3ffe5a -r 26edcf3a30eb MANIFEST.in --- a/MANIFEST.in Mon Apr 02 08:56:24 2018 +0200 +++ b/MANIFEST.in Mon Apr 02 19:44:50 2018 +0200 @@ -7,6 +7,6 @@ include src/sat.* include misc include frontends/src/jp/jp frontends/src/primitivus/primitivus -include src/bridge/bridge_constructor/mediawiki_template.tpl +include src/bridge/bridge_constructor global-exclude *.un~ prune src/bridge/bridge_constructor/generated diff -r bd30dc3ffe5a -r 26edcf3a30eb bin/sat --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/sat Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,107 @@ +#!/bin/sh + +DEBUG="" +DAEMON="" +PYTHON="python2" +TWISTD="$(which twistd)" + +kill_process() { + # $1 is the file containing the PID to kill, $2 is the process name + if [ -f $1 ]; then + PID=`cat $1` + if ps -p $PID > /dev/null; then + printf "Terminating $2... " + kill $PID + while ps -p $PID > /dev/null; do + sleep 0.2 + done + printf "OK\n" + else + echo "No running process of ID $PID... removing PID file" + rm -f $1 + fi + else + echo "$2 is probably not running (PID file doesn't exist)" + fi +} + +#We use python to parse config files +eval `"$PYTHON" << PYTHONEND +from sat.core.constants import Const as C +from sat.memory.memory import fixLocalDir +from ConfigParser import SafeConfigParser +from os.path import expanduser, join +import sys +import codecs +import locale + +sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout) + +fixLocalDir() # XXX: tmp update code, will be removed in the future + +config = SafeConfigParser(defaults=C.DEFAULT_CONFIG) +try: + config.read(C.CONFIG_FILES) +except: + print ("echo \"/!\\ Can't read main config ! Please check the syntax\";") + print ("exit 1") + sys.exit() + +env=[] +env.append("PID_DIR='%s'" % join(expanduser(config.get('DEFAULT', 'pid_dir')),'')) +env.append("LOG_DIR='%s'" % join(expanduser(config.get('DEFAULT', 'log_dir')),'')) +env.append("APP_NAME='%s'" % C.APP_NAME) +env.append("APP_NAME_FILE='%s'" % C.APP_NAME_FILE) +print ";".join(env) +PYTHONEND +` +APP_NAME="$APP_NAME" +PID_FILE="$PID_DIR$APP_NAME_FILE.pid" +LOG_FILE="$LOG_DIR$APP_NAME_FILE.log" +RUNNING_MSG="$APP_NAME is running" +NOT_RUNNING_MSG="$APP_NAME is *NOT* running" + +# if there is one argument which is "stop", then we kill SaT +if [ $# -eq 1 ];then + if [ $1 = "stop" ];then + kill_process $PID_FILE "$APP_NAME" + exit 0 + elif [ $1 = "debug" ];then + echo "Launching $APP_NAME in debug mode" + DEBUG="--debug" + elif [ $1 = "fg" ];then + echo "Launching $APP_NAME in foreground mode" + DAEMON="n" + elif [ $1 = "status" ];then + if [ -f $PID_FILE ]; then + PID=`cat $PID_FILE` + ps -p$PID 2>&1 > /dev/null + if [ $? = 0 ];then + echo "$RUNNING_MSG (pid: $PID)" + exit 0 + else + echo "$NOT_RUNNING_MSG, but a pid file is present (bad exit ?): $PID_FILE" + exit 2 + fi + else + echo "$NOT_RUNNING_MSG" + exit 1 + fi + else + echo "bad argument, please use one of (stop, debug, fg, status) or no argument" + exit 1 + fi +fi + +MAIN_OPTIONS="-${DAEMON}o" + +#Don't change the next lines +AUTO_OPTIONS="" +ADDITIONAL_OPTIONS="--pidfile $PID_FILE --logfile $LOG_FILE $AUTO_OPTIONS $DEBUG" + +log_dir=`dirname "$LOG_FILE"` +if [ ! -d $log_dir ] ; then + mkdir $log_dir +fi + +exec $PYTHON $TWISTD $MAIN_OPTIONS $ADDITIONAL_OPTIONS $APP_NAME_FILE diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/bridge/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/bridge/bridge_frontend.py --- a/frontends/src/bridge/bridge_frontend.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,42 +0,0 @@ -#!/usr/bin/env python2 -#-*- coding: utf-8 -*- - -# SAT communication bridge -# 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 . - - -class BridgeException(Exception): - """An exception which has been raised from the backend and arrived to the frontend.""" - - def __init__(self, name, message='', condition=''): - """ - - @param name (str): full exception class name (with module) - @param message (str): error message - @param condition (str) : error condition - """ - Exception.__init__(self) - self.fullname = unicode(name) - self.message = unicode(message) - self.condition = unicode(condition) if condition else '' - self.module, dummy, self.classname = unicode(self.fullname).rpartition('.') - - def __str__(self): - message = (': %s' % self.message) if self.message else '' - return self.classname + message - - def __eq__(self, other): - return self.classname == other diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/bridge/dbus_bridge.py --- a/frontends/src/bridge/dbus_bridge.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,732 +0,0 @@ -#!/usr/bin/env python2 -#-*- coding: utf-8 -*- - -# SAT communication bridge -# 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 . - -from sat.core.i18n import _ -from bridge_frontend import BridgeException -import dbus -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core.exceptions import BridgeExceptionNoService, BridgeInitError - -from dbus.mainloop.glib import DBusGMainLoop -DBusGMainLoop(set_as_default=True) - -import ast - -const_INT_PREFIX = "org.goffi.SAT" # Interface prefix -const_ERROR_PREFIX = const_INT_PREFIX + ".error" -const_OBJ_PATH = '/org/goffi/SAT/bridge' -const_CORE_SUFFIX = ".core" -const_PLUGIN_SUFFIX = ".plugin" -const_TIMEOUT = 120 - - -def dbus_to_bridge_exception(dbus_e): - """Convert a DBusException to a BridgeException. - - @param dbus_e (DBusException) - @return: BridgeException - """ - full_name = dbus_e.get_dbus_name() - if full_name.startswith(const_ERROR_PREFIX): - name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:] - else: - name = full_name - # XXX: dbus_e.args doesn't contain the original DBusException args, but we - # receive its serialized form in dbus_e.args[0]. From that we can rebuild - # the original arguments list thanks to ast.literal_eval (secure eval). - message = dbus_e.get_dbus_message() # similar to dbus_e.args[0] - try: - message, condition = ast.literal_eval(message) - except (SyntaxError, ValueError, TypeError): - condition = '' - return BridgeException(name, message, condition) - - -class Bridge(object): - - def bridgeConnect(self, callback, errback): - try: - self.sessions_bus = dbus.SessionBus() - self.db_object = self.sessions_bus.get_object(const_INT_PREFIX, - const_OBJ_PATH) - self.db_core_iface = dbus.Interface(self.db_object, - dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX) - self.db_plugin_iface = dbus.Interface(self.db_object, - dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX) - except dbus.exceptions.DBusException, e: - if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown', - 'org.freedesktop.DBus.Error.Spawn.ExecFailed'): - errback(BridgeExceptionNoService()) - elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported': - log.error(_(u"D-Bus is not launched, please see README to see instructions on how to launch it")) - errback(BridgeInitError) - else: - errback(e) - callback() - #props = self.db_core_iface.getProperties() - - def register_signal(self, functionName, handler, iface="core"): - if iface == "core": - self.db_core_iface.connect_to_signal(functionName, handler) - elif iface == "plugin": - self.db_plugin_iface.connect_to_signal(functionName, handler) - else: - log.error(_('Unknown interface')) - - def __getattribute__(self, name): - """ usual __getattribute__ if the method exists, else try to find a plugin method """ - try: - return object.__getattribute__(self, name) - except AttributeError: - # The attribute is not found, we try the plugin proxy to find the requested method - - def getPluginMethod(*args, **kwargs): - # We first check if we have an async call. We detect this in two ways: - # - if we have the 'callback' and 'errback' keyword arguments - # - or if the last two arguments are callable - - async = False - args = list(args) - - if kwargs: - if 'callback' in kwargs: - async = True - _callback = kwargs.pop('callback') - _errback = kwargs.pop('errback', lambda failure: log.error(unicode(failure))) - try: - args.append(kwargs.pop('profile')) - except KeyError: - try: - args.append(kwargs.pop('profile_key')) - except KeyError: - pass - # at this point, kwargs should be empty - if kwargs: - log.warnings(u"unexpected keyword arguments, they will be ignored: {}".format(kwargs)) - elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]): - async = True - _errback = args.pop() - _callback = args.pop() - - method = getattr(self.db_plugin_iface, name) - - if async: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = _callback - kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err)) - - return method(*args, **kwargs) - - return getPluginMethod - - def actionsGet(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.actionsGet(profile_key, **kwargs) - - def addContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.addContact(entity_jid, profile_key, **kwargs) - - def asyncDeleteProfile(self, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.asyncDeleteProfile(profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def asyncGetParamA(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return unicode(self.db_core_iface.asyncGetParamA(name, category, attribute, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)) - - def asyncGetParamsValuesFromCategory(self, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.asyncGetParamsValuesFromCategory(category, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.connect(profile_key, password, options, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.delContact(entity_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def discoFindByFeatures(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, profile_key=u"@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.discoFindByFeatures(namespaces, identities, bare_jid, service, roster, own_jid, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def discoInfos(self, entity_jid, node=u'', use_cache=True, profile_key=u"@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.discoInfos(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def discoItems(self, entity_jid, node=u'', use_cache=True, profile_key=u"@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.discoItems(entity_jid, node, use_cache, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.disconnect(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def getConfig(self, section, name, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.getConfig(section, name, **kwargs)) - - def getContacts(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.getContacts(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def getContactsFromGroup(self, group, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getContactsFromGroup(group, profile_key, **kwargs) - - def getEntitiesData(self, jids, keys, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getEntitiesData(jids, keys, profile, **kwargs) - - def getEntityData(self, jid, keys, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getEntityData(jid, keys, profile, **kwargs) - - def getFeatures(self, profile_key, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.getFeatures(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def getMainResource(self, contact_jid, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.getMainResource(contact_jid, profile_key, **kwargs)) - - def getParamA(self, name, category, attribute="value", profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.getParamA(name, category, attribute, profile_key, **kwargs)) - - def getParamsCategories(self, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getParamsCategories(**kwargs) - - def getParamsUI(self, security_limit=-1, app='', profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return unicode(self.db_core_iface.getParamsUI(security_limit, app, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler)) - - def getPresenceStatuses(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getPresenceStatuses(profile_key, **kwargs) - - def getReady(self, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.getReady(timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def getVersion(self, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.getVersion(**kwargs)) - - def getWaitingSub(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.getWaitingSub(profile_key, **kwargs) - - def historyGet(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.historyGet(from_jid, to_jid, limit, between, filters, profile, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def isConnected(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.isConnected(profile_key, **kwargs) - - def launchAction(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.launchAction(callback_id, data, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def loadParamsTemplate(self, filename, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.loadParamsTemplate(filename, **kwargs) - - def menuHelpGet(self, menu_id, language, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.menuHelpGet(menu_id, language, **kwargs)) - - def menuLaunch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.menuLaunch(menu_type, path, data, security_limit, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def menusGet(self, language, security_limit, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.menusGet(language, security_limit, **kwargs) - - def messageSend(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.messageSend(to_jid, message, subject, mess_type, extra, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def namespacesGet(self, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.namespacesGet(**kwargs) - - def paramsRegisterApp(self, xml, security_limit=-1, app='', callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.paramsRegisterApp(xml, security_limit, app, **kwargs) - - def profileCreate(self, profile, password='', component='', callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.profileCreate(profile, password, component, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def profileIsSessionStarted(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.profileIsSessionStarted(profile_key, **kwargs) - - def profileNameGet(self, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return unicode(self.db_core_iface.profileNameGet(profile_key, **kwargs)) - - def profileSetDefault(self, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.profileSetDefault(profile, **kwargs) - - def profileStartSession(self, password='', profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.profileStartSession(password, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def profilesListGet(self, clients=True, components=False, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.profilesListGet(clients, components, **kwargs) - - def progressGet(self, id, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.progressGet(id, profile, **kwargs) - - def progressGetAll(self, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.progressGetAll(profile, **kwargs) - - def progressGetAllMetadata(self, profile, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.progressGetAllMetadata(profile, **kwargs) - - def saveParamsTemplate(self, filename, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.saveParamsTemplate(filename, **kwargs) - - def sessionInfosGet(self, profile_key, callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.sessionInfosGet(profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) - - def setParam(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.setParam(name, value, category, security_limit, profile_key, **kwargs) - - def setPresence(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.setPresence(to_jid, show, statuses, profile_key, **kwargs) - - def subscription(self, sub_type, entity, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - kwargs={} - if callback is not None: - kwargs['timeout'] = const_TIMEOUT - kwargs['reply_handler'] = callback - kwargs['error_handler'] = error_handler - return self.db_core_iface.subscription(sub_type, entity, profile_key, **kwargs) - - def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None): - if callback is None: - error_handler = None - else: - if errback is None: - errback = log.error - error_handler = lambda err:errback(dbus_to_bridge_exception(err)) - return self.db_core_iface.updateContact(entity_jid, name, groups, profile_key, timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/arg_tools.py --- a/frontends/src/jp/arg_tools.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,98 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - -from sat.core.i18n import _ -from sat.core import exceptions - - -def escape(arg, smart=True): - """format arg with quotes - - @param smart(bool): if True, only escape if needed - """ - if smart and not ' ' in arg and not '"' in arg: - return arg - return u'"' + arg.replace(u'"',u'\\"') + u'"' - - -def get_cmd_choices(cmd=None, parser=None): - try: - choices = parser._subparsers._group_actions[0].choices - return choices[cmd] if cmd is not None else choices - except (KeyError, AttributeError): - raise exceptions.NotFound - - -def get_use_args(host, args, use, verbose=False, parser=None): - """format args for argparse parser with values prefilled - - @param host(JP): jp instance - @param args(list(str)): arguments to use - @param use(dict[str, str]): arguments to fill if found in parser - @param verbose(bool): if True a message will be displayed when argument is used or not - @param parser(argparse.ArgumentParser): parser to use - @return (tuple[list[str],list[str]]): 2 args lists: - - parser args, i.e. given args corresponding to parsers - - use args, i.e. generated args from use - """ - # FIXME: positional args are not handled correclty - # if there is more that one, the position is not corrected - if parser is None: - parser = host.parser - - # we check not optional args to see if there - # is a corresonding parser - # else USE args would not work correctly (only for current parser) - parser_args = [] - for arg in args: - if arg.startswith('-'): - break - try: - parser = get_cmd_choices(arg, parser) - except exceptions.NotFound: - break - parser_args.append(arg) - - # post_args are remaning given args, - # without the ones corresponding to parsers - post_args = args[len(parser_args):] - - opt_args = [] - pos_args = [] - actions = {a.dest: a for a in parser._actions} - for arg, value in use.iteritems(): - try: - if arg == u'item' and not u'item' in actions: - # small hack when --item is appended to a --items list - arg = u'items' - action = actions[arg] - except KeyError: - if verbose: - host.disp(_(u'ignoring {name}={value}, not corresponding to any argument (in USE)').format( - name=arg, - value=escape(value))) - else: - if verbose: - host.disp(_(u'arg {name}={value} (in USE)').format(name=arg, value=escape(value))) - if not action.option_strings: - pos_args.append(value) - else: - opt_args.append(action.option_strings[0]) - opt_args.append(value) - return parser_args, opt_args + pos_args + post_args diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/base.py --- a/frontends/src/jp/base.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1084 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -from sat.core.i18n import _ - -### logging ### -import logging as log -log.basicConfig(level=log.DEBUG, - format='%(message)s') -### - -import sys -import locale -import os.path -import argparse -from glob import iglob -from importlib import import_module -from sat_frontends.tools.jid import JID -from sat.tools import config -from sat.tools.common import dynamic_import -from sat.tools.common import uri -from sat.core import exceptions -import sat_frontends.jp -from sat_frontends.jp.constants import Const as C -from sat_frontends.tools import misc -import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI -import shlex -from collections import OrderedDict - -## bridge handling -# we get bridge name from conf and initialise the right class accordingly -main_config = config.parseMainConf() -bridge_name = config.getConfig(main_config, '', 'bridge', 'dbus') - - -# TODO: move loops handling in a separated module -if 'dbus' in bridge_name: - from gi.repository import GLib - - - class JPLoop(object): - - def __init__(self): - self.loop = GLib.MainLoop() - - def run(self): - self.loop.run() - - def quit(self): - self.loop.quit() - - def call_later(self, delay, callback, *args): - """call a callback repeatedly - - @param delay(int): delay between calls in ms - @param callback(callable): method to call - if the callback return True, the call will continue - else the calls will stop - @param *args: args of the callbac - """ - GLib.timeout_add(delay, callback, *args) - -else: - print u"can't start jp: only D-Bus bridge is currently handled" - sys.exit(C.EXIT_ERROR) - # FIXME: twisted loop can be used when jp can handle fully async bridges - # from twisted.internet import reactor - - # class JPLoop(object): - - # def run(self): - # reactor.run() - - # def quit(self): - # reactor.stop() - - # def _timeout_cb(self, args, callback, delay): - # ret = callback(*args) - # if ret: - # reactor.callLater(delay, self._timeout_cb, args, callback, delay) - - # def call_later(self, delay, callback, *args): - # delay = float(delay) / 1000 - # reactor.callLater(delay, self._timeout_cb, args, callback, delay) - -if bridge_name == "embedded": - from sat.core import sat_main - sat = sat_main.SAT() - -if sys.version_info < (2, 7, 3): - # XXX: shlex.split only handle unicode since python 2.7.3 - # this is a workaround for older versions - old_split = shlex.split - new_split = (lambda s, *a, **kw: [t.decode('utf-8') for t in old_split(s.encode('utf-8'), *a, **kw)] - if isinstance(s, unicode) else old_split(s, *a, **kw)) - shlex.split = new_split - -try: - import progressbar -except ImportError: - msg = (_(u'ProgressBar not available, please download it at http://pypi.python.org/pypi/progressbar\n') + - _(u'Progress bar deactivated\n--\n')) - print >>sys.stderr,msg.encode('utf-8') - progressbar=None - -#consts -PROG_NAME = u"jp" -DESCRIPTION = """This software is a command line tool for XMPP. -Get the latest version at """ + C.APP_URL - -COPYLEFT = u"""Copyright (C) 2009-2018 Jérôme Poisson, Adrien Cossa -This program comes with ABSOLUTELY NO WARRANTY; -This is free software, and you are welcome to redistribute it under certain conditions. -""" - -PROGRESS_DELAY = 10 # the progression will be checked every PROGRESS_DELAY ms - - -def unicode_decoder(arg): - # Needed to have unicode strings from arguments - return arg.decode(locale.getpreferredencoding()) - - -class Jp(object): - """ - This class can be use to establish a connection with the - bridge. Moreover, it should manage a main loop. - - To use it, you mainly have to redefine the method run to perform - specify what kind of operation you want to perform. - - """ - def __init__(self): - """ - - @attribute quit_on_progress_end (bool): set to False if you manage yourself exiting, - or if you want the user to stop by himself - @attribute progress_success(callable): method to call when progress just started - by default display a message - @attribute progress_success(callable): method to call when progress is successfully finished - by default display a message - @attribute progress_failure(callable): method to call when progress failed - by default display a message - """ - # FIXME: need_loop should be removed, everything must be async in bridge so - # loop will always be needed - bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') - if bridge_module is None: - log.error(u"Can't import {} bridge".format(bridge_name)) - sys.exit(1) - - self.bridge = bridge_module.Bridge() - self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb) - - def _bridgeCb(self): - self.parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, - description=DESCRIPTION) - self._make_parents() - self.add_parser_options() - self.subparsers = self.parser.add_subparsers(title=_(u'Available commands'), dest='subparser_name') - self._auto_loop = False # when loop is used for internal reasons - self._need_loop = False - - # progress attributes - self._progress_id = None # TODO: manage several progress ids - self.quit_on_progress_end = True - - # outputs - self._outputs = {} - for type_ in C.OUTPUT_TYPES: - self._outputs[type_] = OrderedDict() - self.default_output = {} - - def _bridgeEb(self, failure): - if isinstance(failure, exceptions.BridgeExceptionNoService): - print(_(u"Can't connect to SàT backend, are you sure it's launched ?")) - elif isinstance(failure, exceptions.BridgeInitError): - print(_(u"Can't init bridge")) - else: - print(_(u"Error while initialising bridge: {}".format(failure))) - sys.exit(C.EXIT_BRIDGE_ERROR) - - @property - def version(self): - return self.bridge.getVersion() - - @property - def progress_id(self): - return self._progress_id - - @progress_id.setter - def progress_id(self, value): - self._progress_id = value - self.replayCache('progress_ids_cache') - - @property - def watch_progress(self): - try: - self.pbar - except AttributeError: - return False - else: - return True - - @watch_progress.setter - def watch_progress(self, watch_progress): - if watch_progress: - self.pbar = None - - @property - def verbosity(self): - try: - return self.args.verbose - except AttributeError: - return 0 - - def replayCache(self, cache_attribute): - """Replay cached signals - - @param cache_attribute(str): name of the attribute containing the cache - if the attribute doesn't exist, there is no cache and the call is ignored - else the cache must be a list of tuples containing the replay callback as first item, - then the arguments to use - """ - try: - cache = getattr(self, cache_attribute) - except AttributeError: - pass - else: - for cache_data in cache: - cache_data[0](*cache_data[1:]) - - def disp(self, msg, verbosity=0, error=False, no_lf=False): - """Print a message to user - - @param msg(unicode): message to print - @param verbosity(int): minimal verbosity to display the message - @param error(bool): if True, print to stderr instead of stdout - @param no_lf(bool): if True, do not emit line feed at the end of line - """ - if self.verbosity >= verbosity: - if error: - if no_lf: - print >>sys.stderr,msg.encode('utf-8'), - else: - print >>sys.stderr,msg.encode('utf-8') - else: - if no_lf: - print msg.encode('utf-8'), - else: - print msg.encode('utf-8') - - def output(self, type_, name, extra_outputs, data): - if name in extra_outputs: - extra_outputs[name](data) - else: - self._outputs[type_][name]['callback'](data) - - def addOnQuitCallback(self, callback, *args, **kwargs): - """Add a callback which will be called on quit command - - @param callback(callback): method to call - """ - try: - callbacks_list = self._onQuitCallbacks - except AttributeError: - callbacks_list = self._onQuitCallbacks = [] - finally: - callbacks_list.append((callback, args, kwargs)) - - def getOutputChoices(self, output_type): - """Return valid output filters for output_type - - @param output_type: True for default, - else can be any registered type - """ - return self._outputs[output_type].keys() - - def _make_parents(self): - self.parents = {} - - # we have a special case here as the start-session option is present only if connection is not needed, - # so we create two similar parents, one with the option, the other one without it - for parent_name in ('profile', 'profile_session'): - parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False) - parent.add_argument("-p", "--profile", action="store", type=str, default='@DEFAULT@', help=_("Use PROFILE profile key (default: %(default)s)")) - parent.add_argument("--pwd", action="store", type=unicode_decoder, default='', metavar='PASSWORD', help=_("Password used to connect profile, if necessary")) - - profile_parent, profile_session_parent = self.parents['profile'], self.parents['profile_session'] - - connect_short, connect_long, connect_action, connect_help = "-c", "--connect", "store_true", _(u"Connect the profile before doing anything else") - profile_parent.add_argument(connect_short, connect_long, action=connect_action, help=connect_help) - - profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group() - profile_session_connect_group.add_argument(connect_short, connect_long, action=connect_action, help=connect_help) - profile_session_connect_group.add_argument("--start-session", action="store_true", help=_("Start a profile session without connecting")) - - progress_parent = self.parents['progress'] = argparse.ArgumentParser(add_help=False) - if progressbar: - progress_parent.add_argument("-P", "--progress", action="store_true", help=_("Show progress bar")) - - verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False) - verbose_parent.add_argument('--verbose', '-v', action='count', default=0, help=_(u"Add a verbosity level (can be used multiple times)")) - - draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False) - draft_group = draft_parent.add_argument_group(_('draft handling')) - draft_group.add_argument("-D", "--current", action="store_true", help=_(u"load current draft")) - draft_group.add_argument("-F", "--draft-path", type=unicode_decoder, help=_(u"path to a draft file to retrieve")) - - - def make_pubsub_group(self, flags, defaults): - """generate pubsub options according to flags - - @param flags(iterable[unicode]): see [CommandBase.__init__] - @param defaults(dict[unicode, unicode]): help text for default value - key can be "service" or "node" - value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT - @return (ArgumentParser): parser to add - """ - flags = misc.FlagsHandler(flags) - parent = argparse.ArgumentParser(add_help=False) - pubsub_group = parent.add_argument_group('pubsub') - pubsub_group.add_argument("-u", "--pubsub-url", type=unicode_decoder, - help=_(u"Pubsub URL (xmpp or http)")) - - service_help = _(u"JID of the PubSub service") - if not flags.service: - default = defaults.pop(u'service', _(u'PEP service')) - if default is not None: - service_help += _(u" (DEFAULT: {default})".format(default=default)) - pubsub_group.add_argument("-s", "--service", type=unicode_decoder, default=u'', - help=service_help) - - node_help = _(u"node to request") - if not flags.node: - default = defaults.pop(u'node', _(u'standard node')) - if default is not None: - node_help += _(u" (DEFAULT: {default})".format(default=default)) - pubsub_group.add_argument("-n", "--node", type=unicode_decoder, default=u'', help=node_help) - - if flags.single_item: - item_help = (u"item to retrieve") - if not flags.item: - default = defaults.pop(u'item', _(u'last item')) - if default is not None: - item_help += _(u" (DEFAULT: {default})".format(default=default)) - pubsub_group.add_argument("-i", "--item", type=unicode_decoder, help=item_help) - pubsub_group.add_argument("-L", "--last-item", action='store_true', help=_(u'retrieve last item')) - elif flags.multi_items: - # mutiple items - pubsub_group.add_argument("-i", "--item", type=unicode_decoder, action='append', dest='items', default=[], help=_(u"items to retrieve (DEFAULT: all)")) - if not flags.no_max: - pubsub_group.add_argument("-m", "--max", type=int, default=10, - help=_(u"maximum number of items to get ({no_limit} to get all items)".format(no_limit=C.NO_LIMIT))) - - if flags: - raise exceptions.InternalError('unknowns flags: {flags}'.format(flags=u', '.join(flags))) - if defaults: - raise exceptions.InternalError('unused defaults: {defaults}'.format(defaults=defaults)) - - return parent - - def add_parser_options(self): - self.parser.add_argument('--version', action='version', version=("%(name)s %(version)s %(copyleft)s" % {'name': PROG_NAME, 'version': self.version, 'copyleft': COPYLEFT})) - - def register_output(self, type_, name, callback, description="", default=False): - if type_ not in C.OUTPUT_TYPES: - log.error(u"Invalid output type {}".format(type_)) - return - self._outputs[type_][name] = {'callback': callback, - 'description': description - } - if default: - if type_ in self.default_output: - self.disp(_(u'there is already a default output for {}, ignoring new one').format(type_)) - else: - self.default_output[type_] = name - - - def parse_output_options(self): - options = self.command.args.output_opts - options_dict = {} - for option in options: - try: - key, value = option.split(u'=', 1) - except ValueError: - key, value = option, None - options_dict[key.strip()] = value.strip() if value is not None else None - return options_dict - - def check_output_options(self, accepted_set, options): - if not accepted_set.issuperset(options): - self.disp(u"The following output options are invalid: {invalid_options}".format( - invalid_options = u', '.join(set(options).difference(accepted_set))), - error=True) - self.quit(C.EXIT_BAD_ARG) - - def import_plugins(self): - """Automaticaly import commands and outputs in jp - - looks from modules names cmd_*.py in jp path and import them - """ - path = os.path.dirname(sat_frontends.jp.__file__) - # XXX: outputs must be imported before commands as they are used for arguments - for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'), (C.PLUGIN_CMD, 'cmd_*.py')): - modules = (os.path.splitext(module)[0] for module in map(os.path.basename, iglob(os.path.join(path, pattern)))) - for module_name in modules: - module_path = "sat_frontends.jp." + module_name - try: - module = import_module(module_path) - self.import_plugin_module(module, type_) - except ImportError as e: - self.disp(_(u"Can't import {module_path} plugin, ignoring it: {msg}".format( - module_path = module_path, - msg = e)), error=True) - except exceptions.CancelError: - continue - except exceptions.MissingModule as e: - self.disp(_(u"Missing module for plugin {name}: {missing}".format( - name = module_path, - missing = e)), error=True) - - - def import_plugin_module(self, module, type_): - """add commands or outpus from a module to jp - - @param module: module containing commands or outputs - @param type_(str): one of C_PLUGIN_* - """ - try: - class_names = getattr(module, '__{}__'.format(type_)) - except AttributeError: - log.disp(_(u"Invalid plugin module [{type}] {module}").format(type=type_, module=module), error=True) - raise ImportError - else: - for class_name in class_names: - cls = getattr(module, class_name) - cls(self) - - def get_xmpp_uri_from_http(self, http_url): - """parse HTML page at http(s) URL, and looks for xmpp: uri""" - if http_url.startswith('https'): - scheme = u'https' - elif http_url.startswith('http'): - scheme = u'http' - else: - raise exceptions.InternalError(u'An HTTP scheme is expected in this method') - self.disp(u"{scheme} URL found, trying to find associated xmpp: URI".format(scheme=scheme.upper()),1) - # HTTP URL, we try to find xmpp: links - try: - from lxml import etree - except ImportError: - self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True) - self.host.quit(1) - import urllib2 - parser = etree.HTMLParser() - try: - root = etree.parse(urllib2.urlopen(http_url), parser) - except etree.XMLSyntaxError as e: - self.disp(_(u"Can't parse HTML page : {msg}").format(msg=e)) - links = [] - else: - links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]") - if not links: - self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True) - self.host.quit(1) - xmpp_uri = links[0].get('href') - return xmpp_uri - - def parse_pubsub_args(self): - if self.args.pubsub_url is not None: - url = self.args.pubsub_url - - if url.startswith('http'): - # http(s) URL, we try to retrieve xmpp one from there - url = self.get_xmpp_uri_from_http(url) - - try: - uri_data = uri.parseXMPPUri(url) - except ValueError: - self.parser.error(_(u'invalid XMPP URL: {url}').format(url=url)) - else: - if uri_data[u'type'] == 'pubsub': - # URL is alright, we only set data not already set by other options - if not self.args.service: - self.args.service = uri_data[u'path'] - if not self.args.node: - self.args.node = uri_data[u'node'] - uri_item = uri_data.get(u'item') - if uri_item: - # there is an item in URI - # we use it only if item is not already set - # and item_last is not used either - try: - item = self.args.item - except AttributeError: - if not self.args.items: - self.args.items = [uri_item] - else: - if not item: - try: - item_last = self.args.item_last - except AttributeError: - item_last = False - if not item_last: - self.args.item = uri_item - else: - self.parser.error(_(u'XMPP URL is not a pubsub one: {url}').format(url=url)) - flags = self.args._cmd._pubsub_flags - # we check required arguments here instead of using add_arguments' required option - # because the required argument can be set in URL - if C.SERVICE in flags and not self.args.service: - self.parser.error(_(u"argument -s/--service is required")) - if C.NODE in flags and not self.args.node: - self.parser.error(_(u"argument -n/--node is required")) - if C.ITEM in flags and not self.args.item: - self.parser.error(_(u"argument -i/--item is required")) - - # FIXME: mutually groups can't be nested in a group and don't support title - # so we check conflict here. This may be fixed in Python 3, to be checked - try: - if self.args.item and self.args.item_last: - self.parser.error(_(u"--item and --item-last can't be used at the same time")) - except AttributeError: - pass - - def run(self, args=None, namespace=None): - self.args = self.parser.parse_args(args, namespace=None) - if self.args._cmd._use_pubsub: - self.parse_pubsub_args() - try: - self.args._cmd.run() - if self._need_loop or self._auto_loop: - self._start_loop() - except KeyboardInterrupt: - log.info(_("User interruption: good bye")) - - def _start_loop(self): - self.loop = JPLoop() - self.loop.run() - - def stop_loop(self): - try: - self.loop.quit() - except AttributeError: - pass - - def confirmOrQuit(self, message, cancel_message=_(u"action cancelled by user")): - """Request user to confirm action, and quit if he doesn't""" - - res = raw_input("{} (y/N)? ".format(message)) - if res not in ("y", "Y"): - self.disp(cancel_message) - self.quit(C.EXIT_USER_CANCELLED) - - def quitFromSignal(self, errcode=0): - """Same as self.quit, but from a signal handler - - /!\: return must be used after calling this method ! - """ - assert self._need_loop - # XXX: python-dbus will show a traceback if we exit in a signal handler - # so we use this little timeout trick to avoid it - self.loop.call_later(0, self.quit, errcode) - - def quit(self, errcode=0): - # first the onQuitCallbacks - try: - callbacks_list = self._onQuitCallbacks - except AttributeError: - pass - else: - for callback, args, kwargs in callbacks_list: - callback(*args, **kwargs) - - self.stop_loop() - sys.exit(errcode) - - def check_jids(self, jids): - """Check jids validity, transform roster name to corresponding jids - - @param profile: profile name - @param jids: list of jids - @return: List of jids - - """ - names2jid = {} - nodes2jid = {} - - for contact in self.bridge.getContacts(self.profile): - jid_s, attr, groups = contact - _jid = JID(jid_s) - try: - names2jid[attr["name"].lower()] = jid_s - except KeyError: - pass - - if _jid.node: - nodes2jid[_jid.node.lower()] = jid_s - - def expand_jid(jid): - _jid = jid.lower() - if _jid in names2jid: - expanded = names2jid[_jid] - elif _jid in nodes2jid: - expanded = nodes2jid[_jid] - else: - expanded = jid - return expanded.decode('utf-8') - - def check(jid): - if not jid.is_valid: - log.error (_("%s is not a valid JID !"), jid) - self.quit(1) - - dest_jids=[] - try: - for i in range(len(jids)): - dest_jids.append(expand_jid(jids[i])) - check(dest_jids[i]) - except AttributeError: - pass - - return dest_jids - - def connect_profile(self, callback): - """ Check if the profile is connected and do it if requested - - @param callback: method to call when profile is connected - @exit: - 1 when profile is not connected and --connect is not set - - 1 when the profile doesn't exists - - 1 when there is a connection error - """ - # FIXME: need better exit codes - - def cant_connect(failure): - log.error(_(u"Can't connect profile: {reason}").format(reason=failure)) - self.quit(1) - - def cant_start_session(failure): - log.error(_(u"Can't start {profile}'s session: {reason}").format(profile=self.profile, reason=failure)) - self.quit(1) - - self.profile = self.bridge.profileNameGet(self.args.profile) - - if not self.profile: - log.error(_("The profile [{profile}] doesn't exist").format(profile=self.args.profile)) - self.quit(1) - - try: - start_session = self.args.start_session - except AttributeError: - pass - else: - if start_session: - self.bridge.profileStartSession(self.args.pwd, self.profile, lambda dummy: callback(), cant_start_session) - self._auto_loop = True - return - elif not self.bridge.profileIsSessionStarted(self.profile): - if not self.args.connect: - log.error(_(u"Session for [{profile}] is not started, please start it before using jp, or use either --start-session or --connect option").format(profile=self.profile)) - self.quit(1) - else: - callback() - return - - - if not hasattr(self.args, 'connect'): - # a profile can be present without connect option (e.g. on profile creation/deletion) - return - elif self.args.connect is True: # if connection is asked, we connect the profile - self.bridge.connect(self.profile, self.args.pwd, {}, lambda dummy: callback(), cant_connect) - self._auto_loop = True - return - else: - if not self.bridge.isConnected(self.profile): - log.error(_(u"Profile [{profile}] is not connected, please connect it before using jp, or use --connect option").format(profile=self.profile)) - self.quit(1) - - callback() - - def get_full_jid(self, param_jid): - """Return the full jid if possible (add main resource when find a bare jid)""" - _jid = JID(param_jid) - if not _jid.resource: - #if the resource is not given, we try to add the main resource - main_resource = self.bridge.getMainResource(param_jid, self.profile) - if main_resource: - return "%s/%s" % (_jid.bare, main_resource) - return param_jid - - -class CommandBase(object): - - def __init__(self, host, name, use_profile=True, use_output=False, extra_outputs=None, - need_connect=None, help=None, **kwargs): - """Initialise CommandBase - - @param host: Jp instance - @param name(unicode): name of the new command - @param use_profile(bool): if True, add profile selection/connection commands - @param use_output(bool, unicode): if not False, add --output option - @param extra_outputs(dict): list of command specific outputs: - key is output name ("default" to use as main output) - value is a callable which will format the output (data will be used as only argument) - if a key already exists with normal outputs, the extra one will be used - @param need_connect(bool, None): True if profile connection is needed - False else (profile session must still be started) - None to set auto value (i.e. True if use_profile is set) - Can't be set if use_profile is False - @param help(unicode): help message to display - @param **kwargs: args passed to ArgumentParser - use_* are handled directly, they can be: - - use_progress(bool): if True, add progress bar activation option - progress* signals will be handled - - use_verbose(bool): if True, add verbosity option - - use_pubsub(bool): if True, add pubsub options - mandatory arguments are controlled by pubsub_req - - use_draft(bool): if True, add draft handling options - ** other arguments ** - - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options, can be: - C.SERVICE: service is required - C.NODE: node is required - C.SINGLE_ITEM: only one item is allowed - @attribute need_loop(bool): to set by commands when loop is needed - """ - self.need_loop = False # to be set by commands when loop is needed - try: # If we have subcommands, host is a CommandBase and we need to use host.host - self.host = host.host - except AttributeError: - self.host = host - - # --profile option - parents = kwargs.setdefault('parents', set()) - if use_profile: - #self.host.parents['profile'] is an ArgumentParser with profile connection arguments - if need_connect is None: - need_connect = True - parents.add(self.host.parents['profile' if need_connect else 'profile_session']) - else: - assert need_connect is None - self.need_connect = need_connect - # from this point, self.need_connect is None if connection is not needed at all - # False if session starting is needed, and True if full connection is needed - - # --output option - if use_output: - if extra_outputs is None: - extra_outputs = {} - self.extra_outputs = extra_outputs - if use_output == True: - use_output = C.OUTPUT_TEXT - assert use_output in C.OUTPUT_TYPES - self._output_type = use_output - output_parent = argparse.ArgumentParser(add_help=False) - choices = set(self.host.getOutputChoices(use_output)) - choices.update(extra_outputs) - if not choices: - raise exceptions.InternalError("No choice found for {} output type".format(use_output)) - try: - default = self.host.default_output[use_output] - except KeyError: - if u'default' in choices: - default = u'default' - elif u'simple' in choices: - default = u'simple' - else: - default = list(choices)[0] - output_parent.add_argument('--output', '-O', choices=sorted(choices), default=default, help=_(u"select output format (default: {})".format(default))) - output_parent.add_argument('--output-option', '--oo', type=unicode_decoder, action="append", dest='output_opts', default=[], help=_(u"output specific option")) - parents.add(output_parent) - else: - assert extra_outputs is None - - self._use_pubsub = kwargs.pop('use_pubsub', False) - if self._use_pubsub: - flags = kwargs.pop('pubsub_flags', []) - defaults = kwargs.pop('pubsub_defaults', {}) - parents.add(self.host.make_pubsub_group(flags, defaults)) - self._pubsub_flags = flags - - # other common options - use_opts = {k:v for k,v in kwargs.iteritems() if k.startswith('use_')} - for param, do_use in use_opts.iteritems(): - opt=param[4:] # if param is use_verbose, opt is verbose - if opt not in self.host.parents: - raise exceptions.InternalError(u"Unknown parent option {}".format(opt)) - del kwargs[param] - if do_use: - parents.add(self.host.parents[opt]) - - self.parser = host.subparsers.add_parser(name, help=help, **kwargs) - if hasattr(self, "subcommands"): - self.subparsers = self.parser.add_subparsers() - else: - self.parser.set_defaults(_cmd=self) - self.add_parser_options() - - @property - def args(self): - return self.host.args - - @property - def profile(self): - return self.host.profile - - @property - def verbosity(self): - return self.host.verbosity - - @property - def progress_id(self): - return self.host.progress_id - - @progress_id.setter - def progress_id(self, value): - self.host.progress_id = value - - def progressStartedHandler(self, uid, metadata, profile): - if profile != self.profile: - return - if self.progress_id is None: - # the progress started message can be received before the id - # so we keep progressStarted signals in cache to replay they - # when the progress_id is received - cache_data = (self.progressStartedHandler, uid, metadata, profile) - try: - self.host.progress_ids_cache.append(cache_data) - except AttributeError: - self.host.progress_ids_cache = [cache_data] - else: - if self.host.watch_progress and uid == self.progress_id: - self.onProgressStarted(metadata) - self.host.loop.call_later(PROGRESS_DELAY, self.progressUpdate) - - def progressFinishedHandler(self, uid, metadata, profile): - if profile != self.profile: - return - if uid == self.progress_id: - try: - self.host.pbar.finish() - except AttributeError: - pass - self.onProgressFinished(metadata) - if self.host.quit_on_progress_end: - self.host.quitFromSignal() - - def progressErrorHandler(self, uid, message, profile): - if profile != self.profile: - return - if uid == self.progress_id: - if self.args.progress: - self.disp('') # progress is not finished, so we skip a line - if self.host.quit_on_progress_end: - self.onProgressError(message) - self.host.quitFromSignal(1) - - def progressUpdate(self): - """This method is continualy called to update the progress bar""" - data = self.host.bridge.progressGet(self.progress_id, self.profile) - if data: - try: - size = data['size'] - except KeyError: - self.disp(_(u"file size is not known, we can't show a progress bar"), 1, error=True) - return False - if self.host.pbar is None: - #first answer, we must construct the bar - self.host.pbar = progressbar.ProgressBar(max_value=int(size), - widgets=[_(u"Progress: "),progressbar.Percentage(), - " ", - progressbar.Bar(), - " ", - progressbar.FileTransferSpeed(), - " ", - progressbar.ETA()]) - self.host.pbar.start() - - self.host.pbar.update(int(data['position'])) - - elif self.host.pbar is not None: - return False - - self.onProgressUpdate(data) - - return True - - def onProgressStarted(self, metadata): - """Called when progress has just started - - can be overidden by a command - @param metadata(dict): metadata as sent by bridge.progressStarted - """ - self.disp(_(u"Operation started"), 2) - - def onProgressUpdate(self, metadata): - """Method called on each progress updata - - can be overidden by a command to handle progress metadata - @para metadata(dict): metadata as returned by bridge.progressGet - """ - pass - - def onProgressFinished(self, metadata): - """Called when progress has just finished - - can be overidden by a command - @param metadata(dict): metadata as sent by bridge.progressFinished - """ - self.disp(_(u"Operation successfully finished"), 2) - - def onProgressError(self, error_msg): - """Called when a progress failed - - @param error_msg(unicode): error message as sent by bridge.progressError - """ - self.disp(_(u"Error while doing operation: {}").format(error_msg), error=True) - - def disp(self, msg, verbosity=0, error=False, no_lf=False): - return self.host.disp(msg, verbosity, error, no_lf) - - def output(self, data): - try: - output_type = self._output_type - except AttributeError: - raise exceptions.InternalError(_(u'trying to use output when use_output has not been set')) - return self.host.output(output_type, self.args.output, self.extra_outputs, data) - - def exitCb(self, msg=None): - """generic callback for success - - optionally print a message, and quit - msg(None, unicode): if not None, print this message - """ - if msg is not None: - self.disp(msg) - self.host.quit(C.EXIT_OK) - - def errback(self, failure_, msg=None, exit_code=C.EXIT_ERROR): - """generic callback for errbacks - - display failure_ then quit with generic error - @param failure_: arguments returned by errback - @param msg(unicode, None): message template - use {} if you want to display failure message - @param exit_code(int): shell exit code - """ - if msg is None: - msg = _(u"error: {}") - self.disp(msg.format(failure_), error=True) - self.host.quit(exit_code) - - def add_parser_options(self): - try: - subcommands = self.subcommands - except AttributeError: - # We don't have subcommands, the class need to implements add_parser_options - raise NotImplementedError - - # now we add subcommands to ourself - for cls in subcommands: - cls(self) - - def run(self): - """this method is called when a command is actually run - - It set stuff like progression callbacks and profile connection - You should not overide this method: you should call self.start instead - """ - # we keep a reference to run command, it may be useful e.g. for outputs - self.host.command = self - # host._need_loop is set here from our current value and not before - # as the need_loop decision must be taken only by then running command - self.host._need_loop = self.need_loop - - try: - show_progress = self.args.progress - except AttributeError: - # the command doesn't use progress bar - pass - else: - if show_progress: - self.host.watch_progress = True - # we need to register the following signal even if we don't display the progress bar - self.host.bridge.register_signal("progressStarted", self.progressStartedHandler) - self.host.bridge.register_signal("progressFinished", self.progressFinishedHandler) - self.host.bridge.register_signal("progressError", self.progressErrorHandler) - - if self.need_connect is not None: - self.host.connect_profile(self.connected) - else: - self.start() - - def connected(self): - """this method is called when profile is connected (or session is started) - - this method is only called when use_profile is True - most of time you should override self.start instead of this method, but if loop - if not always needed depending on your arguments, you may override this method, - but don't forget to call the parent one (i.e. this one) after self.need_loop is set - """ - if not self.need_loop: - self.host.stop_loop() - self.start() - - def start(self): - """This is the starting point of the command, this method should be overriden - - at this point, profile are connected if needed - """ - pass - - -class CommandAnswering(CommandBase): - """Specialised commands which answer to specific actions - - to manage action_types answer, - """ - action_callbacks = {} # XXX: set managed action types in a dict here: - # key is the action_type, value is the callable - # which will manage the answer. profile filtering is - # already managed when callback is called - - def __init__(self, *args, **kwargs): - super(CommandAnswering, self).__init__(*args, **kwargs) - self.need_loop = True - - def onActionNew(self, action_data, action_id, security_limit, profile): - if profile != self.profile: - return - try: - action_type = action_data['meta_type'] - except KeyError: - try: - xml_ui = action_data["xmlui"] - except KeyError: - pass - else: - self.onXMLUI(xml_ui) - else: - try: - callback = self.action_callbacks[action_type] - except KeyError: - pass - else: - callback(action_data, action_id, security_limit, profile) - - def onXMLUI(self, xml_ui): - """Display a dialog received from the backend. - - @param xml_ui (unicode): dialog XML representation - """ - # FIXME: we temporarily use ElementTree, but a real XMLUI managing module - # should be available in the future - # TODO: XMLUI module - ui = ET.fromstring(xml_ui.encode('utf-8')) - dialog = ui.find("dialog") - if dialog is not None: - self.disp(dialog.findtext("message"), error=dialog.get("level") == "error") - - def connected(self): - """Auto reply to confirmations requests""" - self.need_loop = True - super(CommandAnswering, self).connected() - self.host.bridge.register_signal("actionNew", self.onActionNew) - actions = self.host.bridge.actionsGet(self.profile) - for action_data, action_id, security_limit in actions: - self.onActionNew(action_data, action_id, security_limit, self.profile) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_account.py --- a/frontends/src/jp/cmd_account.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,132 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -"""This module permits to manage XMPP accounts using in-band registration (XEP-0077)""" - -from sat_frontends.jp.constants import Const as C -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core.i18n import _ -from sat_frontends.jp import base -from sat_frontends.tools import jid - -__commands__ = ["Account"] - - -class AccountCreate(base.CommandBase): - - def __init__(self, host): - super(AccountCreate, self).__init__(host, 'create', use_profile=False, use_verbose=True, help=_(u'create a XMPP account')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument('jid', type=base.unicode_decoder, help=_(u'jid to create')) - self.parser.add_argument('password', type=base.unicode_decoder, help=_(u'password of the account')) - self.parser.add_argument('-p', '--profile', type=base.unicode_decoder, help=_(u"create a profile to use this account (default: don't create profile)")) - self.parser.add_argument('-e', '--email', type=base.unicode_decoder, default="", help=_(u"email (usage depends of XMPP server)")) - self.parser.add_argument('-H', '--host', type=base.unicode_decoder, default="", help=_(u"server host (IP address or domain, default: use localhost)")) - self.parser.add_argument('-P', '--port', type=int, default=0, help=_(u"server port (IP address or domain, default: use localhost)")) - - def _setParamCb(self): - self.host.bridge.setParam("Password", self.args.password, "Connection", profile_key=self.args.profile, callback=self.host.quit, errback=self.errback) - - def _session_started(self, dummy): - self.host.bridge.setParam("JabberID", self.args.jid, "Connection", profile_key=self.args.profile, callback=self._setParamCb, errback=self.errback) - - def _profileCreateCb(self): - self.disp(_(u"profile created"), 1) - self.host.bridge.profileStartSession(self.args.password, self.args.profile, callback=self._session_started, errback=self.errback) - - def _profileCreateEb(self, failure_): - self.disp(_(u"Can't create profile {profile} to associate with jid {jid}: {msg}").format( - profile = self.args.profile, - jid = self.args.jid, - msg = failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def accountNewCb(self): - self.disp(_(u"XMPP account created"), 1) - if self.args.profile is not None: - self.disp(_(u"creating profile"), 2) - self.host.bridge.profileCreate(self.args.profile, self.args.password, "", callback=self._profileCreateCb, errback=self._profileCreateEb) - else: - self.host.quit() - - def accountNewEb(self, failure_): - self.disp(_(u"Can't create new account on server {host} with jid {jid}: {msg}").format( - host = self.args.host or u"localhost", - jid = self.args.jid, - msg = failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.inBandAccountNew(self.args.jid, self.args.password, self.args.email, self.args.host, self.args.port, - callback=self.accountNewCb, errback=self.accountNewEb) - - - -class AccountModify(base.CommandBase): - - def __init__(self, host): - super(AccountModify, self).__init__(host, 'modify', help=_(u'change password for XMPP account')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument('password', type=base.unicode_decoder, help=_(u'new XMPP password')) - - def start(self): - self.host.bridge.inBandPasswordChange(self.args.password, self.args.profile, - callback=self.host.quit, errback=self.errback) - - -class AccountDelete(base.CommandBase): - - def __init__(self, host): - super(AccountDelete, self).__init__(host, 'delete', help=_(u'delete a XMPP account')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument('-f', '--force', action='store_true', help=_(u'delete account without confirmation')) - - def _got_jid(self, jid_str): - jid_ = jid.JID(jid_str) - if not self.args.force: - message = (u"You are about to delete the XMPP account with jid {jid_}\n" - u"This is the XMPP account of profile \"{profile}\"\n" - u"Are you sure that you want to delete this account ?".format( - jid_ = jid_, - profile=self.profile - )) - res = raw_input("{} (y/N)? ".format(message)) - if res not in ("y", "Y"): - self.disp(_(u"Account deletion cancelled")) - self.host.quit(2) - self.host.bridge.inBandUnregister(jid_.domain, self.args.profile, - callback=self.host.quit, errback=self.errback) - - def start(self): - self.host.bridge.asyncGetParamA("JabberID", "Connection", profile_key=self.profile, - callback=self._got_jid, errback=self.errback) - - -class Account(base.CommandBase): - subcommands = (AccountCreate, AccountModify, AccountDelete) - - def __init__(self, host): - super(Account, self).__init__(host, 'account', use_profile=False, help=(u'XMPP account management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_adhoc.py --- a/frontends/src/jp/cmd_adhoc.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,138 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -import base -from sat.core.i18n import _ -from functools import partial -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import xmlui_manager - -__commands__ = ["AdHoc"] - -FLAG_LOOP = 'LOOP' -MAGIC_BAREJID = '@PROFILE_BAREJID@' - - -class Remote(base.CommandBase): - def __init__(self, host): - super(Remote, self).__init__(host, 'remote', use_verbose=True, help=_(u'remote control a software')) - - def add_parser_options(self): - self.parser.add_argument("software", type=str, help=_(u"software name")) - self.parser.add_argument("-j", "--jids", type=base.unicode_decoder, nargs='*', default=[], help=_(u"jids allowed to use the command")) - self.parser.add_argument("-g", "--groups", type=base.unicode_decoder, nargs='*', default=[], help=_(u"groups allowed to use the command")) - self.parser.add_argument("--forbidden-groups", type=base.unicode_decoder, nargs='*', default=[], help=_(u"groups that are *NOT* allowed to use the command")) - self.parser.add_argument("--forbidden-jids", type=base.unicode_decoder, nargs='*', default=[], help=_(u"jids that are *NOT* allowed to use the command")) - self.parser.add_argument("-l", "--loop", action="store_true", help=_(u"loop on the commands")) - - def start(self): - name = self.args.software.lower() - flags = [] - magics = {jid for jid in self.args.jids if jid.count('@')>1} - magics.add(MAGIC_BAREJID) - jids = set(self.args.jids).difference(magics) - if self.args.loop: - flags.append(FLAG_LOOP) - bus_name, methods = self.host.bridge.adHocDBusAddAuto(name, jids, self.args.groups, magics, - self.args.forbidden_jids, self.args.forbidden_groups, - flags, self.profile) - if not bus_name: - self.disp(_("No bus name found"), 1) - return - self.disp(_("Bus name found: [%s]" % bus_name), 1) - for method in methods: - path, iface, command = method - self.disp(_("Command found: (path:%(path)s, iface: %(iface)s) [%(command)s]" % {'path': path, - 'iface': iface, - 'command': command - }),1) - - -class Run(base.CommandBase): - """Run an Ad-Hoc command""" - - def __init__(self, host): - super(Run, self).__init__(host, 'run', use_verbose=True, help=_(u'run an Ad-Hoc command')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-j', '--jid', type=base.unicode_decoder, default=u'', help=_(u"jid of the service (default: profile's server")) - self.parser.add_argument("-S", "--submit", action='append_const', const=xmlui_manager.SUBMIT, dest='workflow', help=_(u"submit form/page")) - self.parser.add_argument("-f", - "--field", - type=base.unicode_decoder, - action='append', - nargs=2, - dest='workflow', - metavar=(u"KEY", u"VALUE"), - help=_(u"field value")) - self.parser.add_argument('node', type=base.unicode_decoder, nargs='?', default=u'', help=_(u"node of the command (default: list commands)")) - - def adHocRunCb(self, xmlui_raw): - xmlui = xmlui_manager.create(self.host, xmlui_raw) - workflow = self.args.workflow - xmlui.show(workflow) - if not workflow: - if xmlui.type == 'form': - xmlui.submitForm() - else: - self.host.quit() - - def start(self): - self.host.bridge.adHocRun( - self.args.jid, - self.args.node, - self.profile, - callback=self.adHocRunCb, - errback=partial(self.errback, - msg=_(u"can't get ad-hoc commands list: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class List(base.CommandBase): - """Run an Ad-Hoc command""" - - def __init__(self, host): - super(List, self).__init__(host, 'list', use_verbose=True, help=_(u'list Ad-Hoc commands of a service')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-j', '--jid', type=base.unicode_decoder, default=u'', help=_(u"jid of the service (default: profile's server")) - - def adHocListCb(self, xmlui_raw): - xmlui = xmlui_manager.create(self.host, xmlui_raw) - xmlui.readonly = True - xmlui.show() - self.host.quit() - - def start(self): - self.host.bridge.adHocList( - self.args.jid, - self.profile, - callback=self.adHocListCb, - errback=partial(self.errback, - msg=_(u"can't get ad-hoc commands list: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class AdHoc(base.CommandBase): - subcommands = (Run, List, Remote) - - def __init__(self, host): - super(AdHoc, self).__init__(host, 'ad-hoc', use_profile=False, help=_('Ad-hoc commands')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_avatar.py --- a/frontends/src/jp/cmd_avatar.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - - -import base -import os -import os.path -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat.tools import config -import subprocess - - -__commands__ = ["Avatar"] -DISPLAY_CMD = ['xv', 'display', 'gwenview', 'showtell'] - - -class Set(base.CommandBase): - def __init__(self, host): - super(Set, self).__init__(host, 'set', use_verbose=True, help=_('set avatar of the profile')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("image_path", type=str, help=_("path to the image to upload")) - - def start(self): - """Send files to jabber contact""" - path = self.args.image_path - if not os.path.exists(path): - self.disp(_(u"file [{}] doesn't exist !").format(path), error=True) - self.host.quit(1) - path = os.path.abspath(path) - self.host.bridge.avatarSet(path, self.profile, callback=self._avatarCb, errback=self._avatarEb) - - def _avatarCb(self): - self.disp(_("avatar has been set"), 1) - self.host.quit() - - def _avatarEb(self, failure_): - self.disp(_("error while uploading avatar: {msg}").format(msg=failure_), error=True) - self.host.quit(C.EXIT_ERROR) - - -class Get(base.CommandBase): - - def __init__(self, host): - super(Get, self).__init__(host, 'get', use_verbose=True, help=_('retrieve avatar of an entity')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("jid", type=base.unicode_decoder, help=_("entity")) - self.parser.add_argument("-s", "--show", action="store_true", help=_(u"show avatar")) - - def showImage(self, path): - sat_conf = config.parseMainConf() - cmd = config.getConfig(sat_conf, 'jp', 'image_cmd') - cmds = [cmd] + DISPLAY_CMD if cmd else DISPLAY_CMD - for cmd in cmds: - try: - ret = subprocess.call([cmd] + [path]) - except OSError: - pass - else: - if ret == 0: - break - else: - # didn't worked with commands, we try our luck with webbrowser - # in some cases, webbrowser can actually open the associated display program - import webbrowser - webbrowser.open(path) - - def _avatarGetCb(self, avatar_path): - if not avatar_path: - self.disp(_(u"No avatar found."), 1) - self.host.quit(C.EXIT_NOT_FOUND) - - self.disp(avatar_path) - if self.args.show: - self.showImage(avatar_path) - - self.host.quit() - - def _avatarGetEb(self, failure_): - self.disp(_("error while getting avatar: {msg}").format(msg=failure_), error=True) - self.host.quit(C.EXIT_ERROR) - - def start(self): - self.host.bridge.avatarGet(self.args.jid, False, False, self.profile, callback=self._avatarGetCb, errback=self._avatarGetEb) - - -class Avatar(base.CommandBase): - subcommands = (Set, Get) - - def __init__(self, host): - super(Avatar, self).__init__(host, 'avatar', use_profile=False, help=_('avatar uploading/retrieving')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_blog.py --- a/frontends/src/jp/cmd_blog.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,691 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common -from sat.tools.common.ansi import ANSI as A -from sat.tools.common import data_objects -from sat.tools.common import uri -from sat.tools import config -from ConfigParser import NoSectionError, NoOptionError -from functools import partial -import json -import sys -import os.path -import os -import time -import tempfile -import subprocess -import codecs -from sat.tools.common import data_format - -__commands__ = ["Blog"] - -SYNTAX_XHTML = u'xhtml' -# extensions to use with known syntaxes -SYNTAX_EXT = { - '': 'txt', # used when the syntax is not found - SYNTAX_XHTML: "xhtml", - "markdown": "md" - } - - -CONF_SYNTAX_EXT = u'syntax_ext_dict' -BLOG_TMP_DIR = u"blog" -# key to remove from metadata tmp file if they exist -KEY_TO_REMOVE_METADATA = ('id','content', 'content_xhtml', 'comments_node', 'comments_service', 'updated') - -URL_REDIRECT_PREFIX = 'url_redirect_' -INOTIFY_INSTALL = '"pip install inotify"' -MB_KEYS = (u"id", - u"url", - u"atom_id", - u"updated", - u"published", - u"language", - u"comments", # this key is used for all comments* keys - u"tags", # this key is used for all tag* keys - u"author", - u"author_jid", - u"author_email", - u"author_jid_verified", - u"content", - u"content_xhtml", - u"title", - u"title_xhtml", - ) -OUTPUT_OPT_NO_HEADER = u'no-header' - - -def guessSyntaxFromPath(host, sat_conf, path): - """Return syntax guessed according to filename extension - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param path(str): path to the content file - @return(unicode): syntax to use - """ - # we first try to guess syntax with extension - ext = os.path.splitext(path)[1][1:] # we get extension without the '.' - if ext: - for k,v in SYNTAX_EXT.iteritems(): - if k and ext == v: - return k - - # if not found, we use current syntax - return host.bridge.getParamA("Syntax", "Composition", "value", host.profile) - - -class BlogPublishCommon(object): - """handle common option for publising commands (Set and Edit)""" - - def add_parser_options(self): - self.parser.add_argument("-T", '--title', type=base.unicode_decoder, help=_(u"title of the item")) - self.parser.add_argument("-t", '--tag', type=base.unicode_decoder, action='append', help=_(u"tag (category) of your item")) - self.parser.add_argument("-C", "--comments", action='store_true', help=_(u"enable comments")) - self.parser.add_argument("-S", '--syntax', type=base.unicode_decoder, help=_(u"syntax to use (default: get profile's default syntax)")) - - def setMbDataContent(self, content, mb_data): - if self.args.syntax is None: - # default syntax has been used - mb_data['content_rich'] = content - elif self.current_syntax == SYNTAX_XHTML: - mb_data['content_xhtml'] = content - else: - mb_data['content_xhtml'] = self.host.bridge.syntaxConvert(content, self.current_syntax, SYNTAX_XHTML, False, self.profile) - - def setMbDataFromArgs(self, mb_data): - """set microblog metadata according to command line options - - if metadata already exist, it will be overwritten - """ - mb_data['allow_comments'] = C.boolConst(self.args.comments) - if self.args.tag: - data_format.iter2dict('tag', self.args.tag, mb_data, check_conflict=False) - if self.args.title is not None: - mb_data['title'] = self.args.title - - -class Set(base.CommandBase, BlogPublishCommon): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.SINGLE_ITEM}, - help=_(u'publish a new blog item or update an existing one')) - BlogPublishCommon.__init__(self) - self.need_loop=True - - def add_parser_options(self): - BlogPublishCommon.add_parser_options(self) - - def mbSendCb(self): - self.disp(u"Item published") - self.host.quit(C.EXIT_OK) - - def start(self): - self.pubsub_item = self.args.item - mb_data = {} - self.setMbDataFromArgs(mb_data) - content = codecs.getreader('utf-8')(sys.stdin).read() - self.setMbDataContent(content, mb_data) - - self.host.bridge.mbSend( - self.args.service, - self.args.node, - mb_data, - self.profile, - callback=self.exitCb, - errback=partial(self.errback, - msg=_(u"can't send item: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Get(base.CommandBase): - TEMPLATE = u"blog/articles.html" - - def __init__(self, host): - extra_outputs = {'default': self.default_output, - 'fancy': self.fancy_output} - base.CommandBase.__init__(self, host, 'get', use_verbose=True, use_pubsub=True, pubsub_flags={C.MULTI_ITEMS}, - use_output=C.OUTPUT_COMPLEX, extra_outputs=extra_outputs, help=_(u'get blog item(s)')) - self.need_loop=True - - def add_parser_options(self): - # TODO: a key(s) argument to select keys to display - self.parser.add_argument("-k", "--key", type=base.unicode_decoder, action='append', dest='keys', - help=_(u"microblog data key(s) to display (default: depend of verbosity)")) - # TODO: add MAM filters - - def template_data_mapping(self, data): - return {u'items': data_objects.BlogItems(data)} - - def format_comments(self, item, keys): - comments_data = data_format.dict2iterdict(u'comments', item, (u'node', u'service'), pop=True) - lines = [] - for data in comments_data: - lines.append(data[u'comments']) - for k in (u'node', u'service'): - if OUTPUT_OPT_NO_HEADER in self.args.output_opts: - header = u'' - else: - header = C.A_HEADER + k + u': ' + A.RESET - lines.append(header + data[k]) - return u'\n'.join(lines) - - def format_tags(self, item, keys): - tags = data_format.dict2iter('tag', item, pop=True) - return u', '.join(tags) - - def format_updated(self, item, keys): - return self.format_time(item['updated']) - - def format_published(self, item, keys): - return self.format_time(item['published']) - - def format_url(self, item, keys): - return uri.buildXMPPUri(u'pubsub', - subtype=u'microblog', - path=self.metadata[u'service'], - node=self.metadata[u'node'], - item=item[u'id']) - - def get_keys(self): - """return keys to display according to verbosity or explicit key request""" - verbosity = self.args.verbose - if self.args.keys: - if not set(MB_KEYS).issuperset(self.args.keys): - self.disp(u"following keys are invalid: {invalid}.\n" - u"Valid keys are: {valid}.".format( - invalid = u', '.join(set(self.args.keys).difference(MB_KEYS)), - valid = u', '.join(sorted(MB_KEYS))), - error=True) - self.host.quit(C.EXIT_BAD_ARG) - return self.args.keys - else: - if verbosity == 0: - return (u'title', u'content') - elif verbosity == 1: - return (u"title", u"tags", u"author", u"author_jid", u"author_email", u"author_jid_verified", u"published", u"updated", u"content") - else: - return MB_KEYS - - def default_output(self, data): - """simple key/value output""" - items, self.metadata = data - keys = self.get_keys() - - # k_cb use format_[key] methods for complex formattings - k_cb = {} - for k in keys: - try: - callback = getattr(self, "format_" + k) - except AttributeError: - pass - else: - k_cb[k] = callback - for idx, item in enumerate(items): - for k in keys: - if k not in item and k not in k_cb: - continue - if OUTPUT_OPT_NO_HEADER in self.args.output_opts: - header = '' - else: - header = u"{k_fmt}{key}:{k_fmt_e} {sep}".format( - k_fmt = C.A_HEADER, - key = k, - k_fmt_e = A.RESET, - sep = u'\n' if 'content' in k else u'') - value = k_cb[k](item, keys) if k in k_cb else item[k] - self.disp(header + value) - # we want a separation line after each item but the last one - if idx < len(items)-1: - print(u'') - - def format_time(self, timestamp): - """return formatted date for timestamp - - @param timestamp(str,int,float): unix timestamp - @return (unicode): formatted date - """ - fmt = u"%d/%m/%Y %H:%M:%S" - return time.strftime(fmt, time.localtime(float(timestamp))) - - def fancy_output(self, data): - """display blog is a nice to read way - - this output doesn't use keys filter - """ - # thanks to http://stackoverflow.com/a/943921 - rows, columns = map(int, os.popen('stty size', 'r').read().split()) - items, metadata = data - verbosity = self.args.verbose - sep = A.color(A.FG_BLUE, columns * u'▬') - if items: - print(u'\n' + sep + '\n') - - for idx, item in enumerate(items): - title = item.get(u'title') - if verbosity > 0: - author = item[u'author'] - published, updated = item[u'published'], item.get('updated') - else: - author = published = updated = None - if verbosity > 1: - tags = list(data_format.dict2iter('tag', item, pop=True)) - else: - tags = None - content = item.get(u'content') - - if title: - print(A.color(A.BOLD, A.FG_CYAN, item[u'title'])) - meta = [] - if author: - meta.append(A.color(A.FG_YELLOW, author)) - if published: - meta.append(A.color(A.FG_YELLOW, u'on ', self.format_time(published))) - if updated != published: - meta.append(A.color(A.FG_YELLOW, u'(updated on ', self.format_time(updated), u')')) - print(u' '.join(meta)) - if tags: - print(A.color(A.FG_MAGENTA, u', '.join(tags))) - if (title or tags) and content: - print("") - if content: - self.disp(content) - - print(u'\n' + sep + '\n') - - - def mbGetCb(self, mb_result): - self.output(mb_result) - self.host.quit(C.EXIT_OK) - - def mbGetEb(self, failure_): - self.disp(u"can't get blog items: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.mbGet( - self.args.service, - self.args.node, - self.args.max, - self.args.items, - {}, - self.profile, - callback=self.mbGetCb, - errback=self.mbGetEb) - - -class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'edit', use_pubsub=True, pubsub_flags={C.SINGLE_ITEM}, - use_draft=True, use_verbose=True, help=_(u'edit an existing or new blog post')) - BlogPublishCommon.__init__(self) - common.BaseEdit.__init__(self, self.host, BLOG_TMP_DIR, use_metadata=True) - - @property - def current_syntax(self): - if self._current_syntax is None: - self._current_syntax = self.host.bridge.getParamA("Syntax", "Composition", "value", self.profile) - return self._current_syntax - - def add_parser_options(self): - BlogPublishCommon.add_parser_options(self) - self.parser.add_argument("-P", "--preview", action="store_true", help=_(u"launch a blog preview in parallel")) - - def buildMetadataFile(self, content_file_path, mb_data=None): - """Build a metadata file using json - - The file is named after content_file_path, with extension replaced by _metadata.json - @param content_file_path(str): path to the temporary file which will contain the body - @param mb_data(dict, None): microblog metadata (for existing items) - @return (tuple[dict, str]): merged metadata put originaly in metadata file - and path to temporary metadata file - """ - # we first construct metadata from edited item ones and CLI argumments - # or re-use the existing one if it exists - meta_file_path = os.path.splitext(content_file_path)[0] + common.METADATA_SUFF - if os.path.exists(meta_file_path): - self.disp(u"Metadata file already exists, we re-use it") - try: - with open(meta_file_path, 'rb') as f: - mb_data = json.load(f) - except (OSError, IOError, ValueError) as e: - self.disp(u"Can't read existing metadata file at {path}, aborting: {reason}".format( - path=meta_file_path, reason=e), error=True) - self.host.quit(1) - else: - mb_data = {} if mb_data is None else mb_data.copy() - - # in all cases, we want to remove unwanted keys - for key in KEY_TO_REMOVE_METADATA: - try: - del mb_data[key] - except KeyError: - pass - # and override metadata with command-line arguments - self.setMbDataFromArgs(mb_data) - - # then we create the file and write metadata there, as JSON dict - # XXX: if we port jp one day on Windows, O_BINARY may need to be added here - with os.fdopen(os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC,0o600), 'w+b') as f: - # we need to use an intermediate unicode buffer to write to the file unicode without escaping characters - unicode_dump = json.dumps(mb_data, ensure_ascii=False, indent=4, separators=(',', ': '), sort_keys=True) - f.write(unicode_dump.encode('utf-8')) - - return mb_data, meta_file_path - - def edit(self, content_file_path, content_file_obj, - mb_data=None): - """Edit the file contening the content using editor, and publish it""" - # we first create metadata file - meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, mb_data) - - # do we need a preview ? - if self.args.preview: - self.disp(u"Preview requested, launching it", 1) - # we redirect outputs to /dev/null to avoid console pollution in editor - # if user wants to see messages, (s)he can call "blog preview" directly - DEVNULL = open(os.devnull, 'wb') - subprocess.Popen([sys.argv[0], "blog", "preview", "--inotify", "true", "-p", self.profile, content_file_path], stdout=DEVNULL, stderr=subprocess.STDOUT) - - # we launch editor - self.runEditor("blog_editor_args", content_file_path, content_file_obj, meta_file_path=meta_file_path, meta_ori=meta_ori) - - def publish(self, content, mb_data): - self.setMbDataContent(content, mb_data) - - if self.pubsub_item is not None: - mb_data['id'] = self.pubsub_item - - self.host.bridge.mbSend(self.pubsub_service, self.pubsub_node, mb_data, self.profile) - self.disp(u"Blog item published") - - - def getTmpSuff(self): - # we get current syntax to determine file extension - return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT['']) - - def getItemData(self, service, node, item): - items = [item] if item is not None else [] - mb_data = self.host.bridge.mbGet(service, node, 1, items, {}, self.profile)[0][0] - try: - content = mb_data['content_xhtml'] - except KeyError: - content = mb_data['content'] - if content: - content = self.host.bridge.syntaxConvert(content, 'text', SYNTAX_XHTML, False, self.profile) - if content and self.current_syntax != SYNTAX_XHTML: - content = self.host.bridge.syntaxConvert(content, SYNTAX_XHTML, self.current_syntax, False, self.profile) - if content and self.current_syntax == SYNTAX_XHTML: - try: - from lxml import etree - except ImportError: - self.disp(_(u"You need lxml to edit pretty XHTML")) - else: - parser = etree.XMLParser(remove_blank_text=True) - root = etree.fromstring(content, parser) - content = etree.tostring(root, encoding=unicode, pretty_print=True) - - return content, mb_data, mb_data['id'] - - def start(self): - # if there are user defined extension, we use them - SYNTAX_EXT.update(config.getConfig(self.sat_conf, 'jp', CONF_SYNTAX_EXT, {})) - self._current_syntax = self.args.syntax - if self._current_syntax is not None: - try: - self._current_syntax = self.args.syntax = self.host.bridge.syntaxGet(self.current_syntax) - except Exception as e: - if "NotFound" in unicode(e): # FIXME: there is not good way to check bridge errors - self.parser.error(_(u"unknown syntax requested ({syntax})").format(syntax=self.args.syntax)) - else: - raise e - - self.pubsub_service, self.pubsub_node, self.pubsub_item, content_file_path, content_file_obj, mb_data = self.getItemPath() - - self.edit(content_file_path, content_file_obj, mb_data=mb_data) - - -class Preview(base.CommandBase): - # TODO: need to be rewritten with template output - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'preview', use_verbose=True, help=_(u'preview a blog content')) - - def add_parser_options(self): - self.parser.add_argument("--inotify", type=str, choices=('auto', 'true', 'false'), default=u'auto', help=_(u"use inotify to handle preview")) - self.parser.add_argument("file", type=base.unicode_decoder, nargs='?', default=u'current', help=_(u"path to the content file")) - - def showPreview(self): - # we implement showPreview here so we don't have to import webbrowser and urllib - # when preview is not used - url = 'file:{}'.format(self.urllib.quote(self.preview_file_path)) - self.webbrowser.open_new_tab(url) - - def _launchPreviewExt(self, cmd_line, opt_name): - url = 'file:{}'.format(self.urllib.quote(self.preview_file_path)) - args = common.parse_args(self.host, cmd_line, url=url, preview_file=self.preview_file_path) - if not args: - self.disp(u"Couln't find command in \"{name}\", abording".format(name=opt_name), error=True) - self.host.quit(1) - subprocess.Popen(args) - - def openPreviewExt(self): - self._launchPreviewExt(self.open_cb_cmd, "blog_preview_open_cmd") - - def updatePreviewExt(self): - self._launchPreviewExt(self.update_cb_cmd, "blog_preview_update_cmd") - - def updateContent(self): - with open(self.content_file_path, 'rb') as f: - content = f.read().decode('utf-8-sig') - if content and self.syntax != SYNTAX_XHTML: - # we use safe=True because we want to have a preview as close as possible to what the - # people will see - content = self.host.bridge.syntaxConvert(content, self.syntax, SYNTAX_XHTML, True, self.profile) - - xhtml = (u'' + - u''+ - '{}' + - u'').format(content) - - with open(self.preview_file_path, 'wb') as f: - f.write(xhtml.encode('utf-8')) - - def start(self): - import webbrowser - import urllib - self.webbrowser, self.urllib = webbrowser, urllib - - if self.args.inotify != 'false': - try: - import inotify.adapters - import inotify.constants - from inotify.calls import InotifyError - except ImportError: - if self.args.inotify == 'auto': - inotify = None - self.disp(u'inotify module not found, deactivating feature. You can install it with {install}'.format(install=INOTIFY_INSTALL)) - else: - self.disp(u"inotify not found, can't activate the feature! Please install it with {install}".format(install=INOTIFY_INSTALL), error=True) - self.host.quit(1) - else: - # we deactivate logging in inotify, which is quite annoying - try: - inotify.adapters._LOGGER.setLevel(40) - except AttributeError: - self.disp(u"Logger doesn't exists, inotify may have chanded", error=True) - else: - inotify=None - - sat_conf = config.parseMainConf() - SYNTAX_EXT.update(config.getConfig(sat_conf, 'jp', CONF_SYNTAX_EXT, {})) - - try: - self.open_cb_cmd = config.getConfig(sat_conf, 'jp', "blog_preview_open_cmd", Exception) - except (NoOptionError, NoSectionError): - self.open_cb_cmd = None - open_cb = self.showPreview - else: - open_cb = self.openPreviewExt - - self.update_cb_cmd = config.getConfig(sat_conf, 'jp', "blog_preview_update_cmd", self.open_cb_cmd) - if self.update_cb_cmd is None: - update_cb = self.showPreview - else: - update_cb = self.updatePreviewExt - - # which file do we need to edit? - if self.args.file == 'current': - self.content_file_path = self.getCurrentFile(sat_conf, self.profile) - else: - self.content_file_path = os.path.abspath(self.args.file) - - self.syntax = self.guessSyntaxFromPath(sat_conf, self.content_file_path) - - - # at this point the syntax is converted, we can display the preview - preview_file = tempfile.NamedTemporaryFile(suffix='.xhtml', delete=False) - self.preview_file_path = preview_file.name - preview_file.close() - self.updateContent() - - if inotify is None: - # XXX: we don't delete file automatically because browser need it (and webbrowser.open can return before it is read) - self.disp(u'temporary file created at {}\nthis file will NOT BE DELETED AUTOMATICALLY, please delete it yourself when you have finished'.format(self.preview_file_path)) - open_cb() - else: - open_cb() - i = inotify.adapters.Inotify(block_duration_s=60) # no need for 1 s duraction, inotify drive actions here - - def add_watch(): - i.add_watch(self.content_file_path, mask=inotify.constants.IN_CLOSE_WRITE | - inotify.constants.IN_DELETE_SELF | - inotify.constants.IN_MOVE_SELF) - add_watch() - - try: - for event in i.event_gen(): - if event is not None: - self.disp(u"Content updated", 1) - if {"IN_DELETE_SELF", "IN_MOVE_SELF"}.intersection(event[1]): - self.disp(u"{} event catched, changing the watch".format(", ".join(event[1])), 2) - try: - add_watch() - except InotifyError: - # if the new file is not here yet we can have an error - # as a workaround, we do a little rest - time.sleep(1) - add_watch() - self.updateContent() - update_cb() - except InotifyError: - self.disp(u"Can't catch inotify events, as the file been deleted?", error=True) - finally: - os.unlink(self.preview_file_path) - try: - i.remove_watch(self.content_file_path) - except InotifyError: - pass - - -class Import(base.CommandAnswering): - def __init__(self, host): - super(Import, self).__init__(host, 'import', use_pubsub=True, use_progress=True, help=_(u'import an external blog')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("importer", type=base.unicode_decoder, nargs='?', help=_(u"importer name, nothing to display importers list")) - self.parser.add_argument('--host', type=base.unicode_decoder, help=_(u"original blog host")) - self.parser.add_argument('--no-images-upload', action='store_true', help=_(u"do *NOT* upload images (default: do upload images)")) - self.parser.add_argument('--upload-ignore-host', help=_(u"do not upload images from this host (default: upload all images)")) - self.parser.add_argument("--ignore-tls-errors", action="store_true", help=_("ignore invalide TLS certificate for uploads")) - self.parser.add_argument('-o', '--option', action='append', nargs=2, default=[], metavar=(u'NAME', u'VALUE'), - help=_(u"importer specific options (see importer description)")) - self.parser.add_argument("location", type=base.unicode_decoder, nargs='?', - help=_(u"importer data location (see importer description), nothing to show importer description")) - - def onProgressStarted(self, metadata): - self.disp(_(u'Blog upload started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'Blog uploaded successfully'),2) - redirections = {k[len(URL_REDIRECT_PREFIX):]:v for k,v in metadata.iteritems() - if k.startswith(URL_REDIRECT_PREFIX)} - if redirections: - conf = u'\n'.join([ - u'url_redirections_profile = {}'.format(self.profile), - u"url_redirections_dict = {}".format( - # we need to add ' ' before each new line and to double each '%' for ConfigParser - u'\n '.join(json.dumps(redirections, indent=1, separators=(',',': ')).replace(u'%', u'%%').split(u'\n'))), - ]) - self.disp(_(u'\nTo redirect old URLs to new ones, put the following lines in your sat.conf file, in [libervia] section:\n\n{conf}'.format(conf=conf))) - - def onProgressError(self, error_msg): - self.disp(_(u'Error while uploading blog: {}').format(error_msg),error=True) - - def error(self, failure): - self.disp(_("Error while trying to upload a blog: {reason}").format(reason=failure), error=True) - self.host.quit(1) - - def start(self): - if self.args.location is None: - for name in ('option', 'service', 'no_images_upload'): - if getattr(self.args, name): - self.parser.error(_(u"{name} argument can't be used without location argument").format(name=name)) - if self.args.importer is None: - self.disp(u'\n'.join([u'{}: {}'.format(name, desc) for name, desc in self.host.bridge.blogImportList()])) - else: - try: - short_desc, long_desc = self.host.bridge.blogImportDesc(self.args.importer) - except Exception as e: - msg = [l for l in unicode(e).split('\n') if l][-1] # we only keep the last line - self.disp(msg) - self.host.quit(1) - else: - self.disp(u"{name}: {short_desc}\n\n{long_desc}".format(name=self.args.importer, short_desc=short_desc, long_desc=long_desc)) - self.host.quit() - else: - # we have a location, an import is requested - options = {key: value for key, value in self.args.option} - if self.args.host: - options['host'] = self.args.host - if self.args.ignore_tls_errors: - options['ignore_tls_errors'] = C.BOOL_TRUE - if self.args.no_images_upload: - options['upload_images'] = C.BOOL_FALSE - if self.args.upload_ignore_host: - self.parser.error(u"upload-ignore-host option can't be used when no-images-upload is set") - elif self.args.upload_ignore_host: - options['upload_ignore_host'] = self.args.upload_ignore_host - def gotId(id_): - self.progress_id = id_ - self.host.bridge.blogImport(self.args.importer, self.args.location, options, self.args.service, self.args.node, self.profile, - callback=gotId, errback=self.error) - - -class Blog(base.CommandBase): - subcommands = (Set, Get, Edit, Preview, Import) - - def __init__(self, host): - super(Blog, self).__init__(host, 'blog', use_profile=False, help=_('blog/microblog management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_bookmarks.py --- a/frontends/src/jp/cmd_bookmarks.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,114 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -import base -from sat.core.i18n import _ - -__commands__ = ["Bookmarks"] - -STORAGE_LOCATIONS = ('local', 'private', 'pubsub') -TYPES = ('muc', 'url') - -class BookmarksCommon(base.CommandBase): - """Class used to group common options of bookmarks subcommands""" - - def add_parser_options(self, location_default='all'): - self.parser.add_argument('-l', '--location', type=str, choices=(location_default,) + STORAGE_LOCATIONS, default=location_default, help=_("storage location (default: %(default)s)")) - self.parser.add_argument('-t', '--type', type=str, choices=TYPES, default=TYPES[0], help=_("bookmarks type (default: %(default)s)")) - - def _errback(self, failure): - print (("Something went wrong: [%s]") % failure) - self.host.quit(1) - -class BookmarksList(BookmarksCommon): - - def __init__(self, host): - super(BookmarksList, self).__init__(host, 'list', help=_('list bookmarks')) - - def start(self): - data = self.host.bridge.bookmarksList(self.args.type, self.args.location, self.host.profile) - mess = [] - for location in STORAGE_LOCATIONS: - if not data[location]: - continue - loc_mess = [] - loc_mess.append(u"%s:" % location) - book_mess = [] - for book_link, book_data in data[location].items(): - name = book_data.get('name') - autojoin = book_data.get('autojoin', 'false') == 'true' - nick = book_data.get('nick') - book_mess.append(u"\t%s[%s%s]%s" % ((name+' ') if name else '', - book_link, - u' (%s)' % nick if nick else '', - u' (*)' if autojoin else '')) - loc_mess.append(u'\n'.join(book_mess)) - mess.append(u'\n'.join(loc_mess)) - - print u'\n\n'.join(mess) - - -class BookmarksRemove(BookmarksCommon): - - def __init__(self, host): - super(BookmarksRemove, self).__init__(host, 'remove', help=_('remove a bookmark')) - self.need_loop = True - - def add_parser_options(self): - super(BookmarksRemove, self).add_parser_options() - self.parser.add_argument('bookmark', type=base.unicode_decoder, help=_('jid (for muc bookmark) or url of to remove')) - - def start(self): - self.host.bridge.bookmarksRemove(self.args.type, self.args.bookmark, self.args.location, self.host.profile, callback = lambda: self.host.quit(), errback=self._errback) - - -class BookmarksAdd(BookmarksCommon): - - def __init__(self, host): - super(BookmarksAdd, self).__init__(host, 'add', help=_('add a bookmark')) - self.need_loop = True - - def add_parser_options(self): - super(BookmarksAdd, self).add_parser_options(location_default='auto') - self.parser.add_argument('bookmark', type=base.unicode_decoder, help=_('jid (for muc bookmark) or url of to remove')) - self.parser.add_argument('-n', '--name', type=base.unicode_decoder, help=_("bookmark name")) - muc_group = self.parser.add_argument_group(_('MUC specific options')) - muc_group.add_argument('-N', '--nick', type=base.unicode_decoder, help=_('nickname')) - muc_group.add_argument('-a', '--autojoin', action='store_true', help=_('join room on profile connection')) - - def start(self): - if self.args.type == 'url' and (self.args.autojoin or self.args.nick is not None): - # XXX: Argparse doesn't seem to manage this case, any better way ? - print _(u"You can't use --autojoin or --nick with --type url") - self.host.quit(1) - data = {} - if self.args.autojoin: - data['autojoin'] = 'true' - if self.args.nick is not None: - data['nick'] = self.args.nick - if self.args.name is not None: - data['name'] = self.args.name - self.host.bridge.bookmarksAdd(self.args.type, self.args.bookmark, data, self.args.location, self.host.profile, callback = lambda: self.host.quit(), errback=self._errback) - - -class Bookmarks(base.CommandBase): - subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd) - - def __init__(self, host): - super(Bookmarks, self).__init__(host, 'bookmarks', use_profile=False, help=_('manage bookmarks')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_debug.py --- a/frontends/src/jp/cmd_debug.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,160 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat.tools.common.ansi import ANSI as A -import json - -__commands__ = ["Debug"] - - -class BridgeCommon(object): - - def evalArgs(self): - if self.args.arg: - try: - return eval(u'[{}]'.format(u",".join(self.args.arg))) - except SyntaxError as e: - self.disp(u"Can't evaluate arguments: {mess}\n{text}\n{offset}^".format( - mess=e, - text=e.text.decode('utf-8'), - offset=u" "*(e.offset-1) - ), error=True) - self.host.quit(C.EXIT_BAD_ARG) - else: - return [] - - -class Method(base.CommandBase, BridgeCommon): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'method', help=_(u'call a bridge method')) - BridgeCommon.__init__(self) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("method", type=str, help=_(u"name of the method to execute")) - self.parser.add_argument("arg", type=base.unicode_decoder, nargs="*", help=_(u"argument of the method")) - - def method_cb(self, ret=None): - if ret is not None: - self.disp(unicode(ret)) - self.host.quit() - - def method_eb(self, failure): - self.disp(_(u"Error while executing {}: {}".format(self.args.method, failure)), error=True) - self.host.quit(C.EXIT_ERROR) - - def start(self): - method = getattr(self.host.bridge, self.args.method) - args = self.evalArgs() - try: - method(*args, profile=self.profile, callback=self.method_cb, errback=self.method_eb) - except TypeError: - # maybe the method doesn't need a profile ? - try: - method(*args, callback=self.method_cb, errback=self.method_eb) - except TypeError: - self.method_eb(_(u"bad arguments")) - - -class Signal(base.CommandBase, BridgeCommon): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'signal', help=_(u'send a fake signal from backend')) - BridgeCommon.__init__(self) - - def add_parser_options(self): - self.parser.add_argument("signal", type=str, help=_(u"name of the signal to send")) - self.parser.add_argument("arg", type=base.unicode_decoder, nargs="*", help=_(u"argument of the signal")) - - def start(self): - args = self.evalArgs() - json_args = json.dumps(args) - # XXX: we use self.args.profile and not self.profile - # because we want the raw profile_key (so plugin handle C.PROF_KEY_NONE) - self.host.bridge.debugFakeSignal(self.args.signal, json_args, self.args.profile) - - -class Bridge(base.CommandBase): - subcommands = (Method, Signal) - - def __init__(self, host): - super(Bridge, self).__init__(host, 'bridge', use_profile=False, help=_('bridge s(t)imulation')) - - -class Monitor(base.CommandBase): - - def __init__(self, host): - super(Monitor, self).__init__(host, - 'monitor', - use_verbose=True, - use_profile=False, - use_output=C.OUTPUT_XML, - help=_('monitor XML stream')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument("-d", "--direction", choices=('in', 'out', 'both'), default='both', help=_(u"stream direction filter")) - - def printXML(self, direction, xml_data, profile): - if self.args.direction == 'in' and direction != 'IN': - return - if self.args.direction == 'out' and direction != 'OUT': - return - verbosity = self.host.verbosity - if not xml_data.strip(): - if verbosity <= 2: - return - whiteping = True - else: - whiteping = False - - if verbosity: - profile_disp = u' ({})'.format(profile) if verbosity>1 else u'' - if direction == 'IN': - self.disp(A.color(A.BOLD, A.FG_YELLOW, '<<<===== IN ====', A.FG_WHITE, profile_disp)) - else: - self.disp(A.color(A.BOLD, A.FG_CYAN, '==== OUT ====>>>', A.FG_WHITE, profile_disp)) - if whiteping: - self.disp('[WHITESPACE PING]') - else: - try: - self.output(xml_data) - except Exception: - # initial stream is not valid XML, - # in this case we print directly to data - # FIXME: we should test directly lxml.etree.XMLSyntaxError - # but importing lxml directly here is not clean - # should be wrapped in a custom Exception - self.disp(xml_data) - self.disp(u'') - - def start(self): - self.host.bridge.register_signal('xmlLog', self.printXML, 'plugin') - - -class Debug(base.CommandBase): - subcommands = (Bridge, Monitor) - - def __init__(self, host): - super(Debug, self).__init__(host, 'debug', use_profile=False, help=_('debugging tools')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_event.py --- a/frontends/src/jp/cmd_event.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,416 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat.tools.common.ansi import ANSI as A -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common -from functools import partial -from dateutil import parser as du_parser -import calendar -import time - -__commands__ = ["Event"] - -OUTPUT_OPT_TABLE = u'table' - -# TODO: move date parsing to base, it may be useful for other commands - - -class Get(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, - host, - 'get', - use_output=C.OUTPUT_DICT, - use_pubsub=True, pubsub_flags={C.NODE, C.SINGLE_ITEM}, - use_verbose=True, - help=_(u'get event data')) - self.need_loop=True - - def add_parser_options(self): - pass - - def eventInviteeGetCb(self, result): - event_date, event_data = result - event_data['date'] = event_date - self.output(event_data) - self.host.quit() - - def start(self): - self.host.bridge.eventGet( - self.args.service, - self.args.node, - self.args.item, - self.profile, - callback=self.eventInviteeGetCb, - errback=partial(self.errback, - msg=_(u"can't get event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class EventBase(object): - - def add_parser_options(self): - self.parser.add_argument("-i", "--id", type=base.unicode_decoder, default=u'', help=_(u"ID of the PubSub Item")) - self.parser.add_argument("-d", "--date", type=unicode, help=_(u"date of the event")) - self.parser.add_argument("-f", "--field", type=base.unicode_decoder, action='append', nargs=2, dest='fields', - metavar=(u"KEY", u"VALUE"), help=_(u"configuration field to set")) - - def parseFields(self): - return dict(self.args.fields) if self.args.fields else {} - - def parseDate(self): - if self.args.date: - try: - date = int(self.args.date) - except ValueError: - try: - date_time = du_parser.parse(self.args.date, dayfirst=not (u'-' in self.args.date)) - except ValueError as e: - self.parser.error(_(u"Can't parse date: {msg}").format(msg=e)) - if date_time.tzinfo is None: - date = calendar.timegm(date_time.timetuple()) - else: - date = time.mktime(date_time.timetuple()) - else: - date = -1 - return date - - -class Create(EventBase, base.CommandBase): - def __init__(self, host): - super(Create, self).__init__(host, 'create', use_pubsub=True, pubsub_flags={C.NODE}, help=_('create or replace event')) - EventBase.__init__(self) - self.need_loop=True - - def eventCreateCb(self, node): - self.disp(_(u'Event created successfuly on node {node}').format(node=node)) - self.host.quit() - - def start(self): - fields = self.parseFields() - date = self.parseDate() - self.host.bridge.eventCreate( - date, - fields, - self.args.service, - self.args.node, - self.args.id, - self.profile, - callback=self.eventCreateCb, - errback=partial(self.errback, - msg=_(u"can't create event: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Modify(EventBase, base.CommandBase): - def __init__(self, host): - super(Modify, self).__init__(host, 'modify', use_pubsub=True, pubsub_flags={C.NODE}, help=_('modify an existing event')) - EventBase.__init__(self) - self.need_loop=True - - def start(self): - fields = self.parseFields() - date = 0 if not self.args.date else self.parseDate() - self.host.bridge.eventModify( - self.args.service, - self.args.node, - self.args.id, - date, - fields, - self.profile, - callback=self.host.quit, - errback=partial(self.errback, - msg=_(u"can't update event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class InviteeGet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, - host, - 'get', - use_output=C.OUTPUT_DICT, - use_pubsub=True, pubsub_flags={C.NODE}, - use_verbose=True, - help=_(u'get event attendance')) - self.need_loop=True - - def add_parser_options(self): - pass - - def eventInviteeGetCb(self, event_data): - self.output(event_data) - self.host.quit() - - def start(self): - self.host.bridge.eventInviteeGet( - self.args.service, - self.args.node, - self.profile, - callback=self.eventInviteeGetCb, - errback=partial(self.errback, - msg=_(u"can't get event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class InviteeSet(base.CommandBase): - def __init__(self, host): - super(InviteeSet, self).__init__(host, 'set', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, help=_('set event attendance')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-f", "--field", type=base.unicode_decoder, action='append', nargs=2, dest='fields', - metavar=(u"KEY", u"VALUE"), help=_(u"configuration field to set")) - - def start(self): - fields = dict(self.args.fields) if self.args.fields else {} - self.host.bridge.eventInviteeSet( - self.args.service, - self.args.node, - fields, - self.profile, - callback=self.host.quit, - errback=partial(self.errback, - msg=_(u"can't set event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class InviteesList(base.CommandBase): - - def __init__(self, host): - extra_outputs = {'default': self.default_output} - base.CommandBase.__init__(self, - host, - 'list', - use_output=C.OUTPUT_DICT_DICT, - extra_outputs=extra_outputs, - use_pubsub=True, pubsub_flags={C.NODE}, - use_verbose=True, - help=_(u'get event attendance')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-m', '--missing', action='store_true', help=_(u'show missing people (invited but no R.S.V.P. so far)')) - self.parser.add_argument('-R', '--no-rsvp', action='store_true', help=_(u"don't show people which gave R.S.V.P.")) - - def _attend_filter(self, attend, row): - if attend == u'yes': - attend_color = C.A_SUCCESS - elif attend == u'no': - attend_color = C.A_FAILURE - else: - attend_color = A.FG_WHITE - return A.color(attend_color, attend) - - def _guests_filter(self, guests): - return u'(' + unicode(guests) + ')' if guests else u'' - - def default_output(self, event_data): - data = [] - attendees_yes = 0 - attendees_maybe = 0 - attendees_no = 0 - attendees_missing = 0 - guests = 0 - guests_maybe = 0 - for jid_, jid_data in event_data.iteritems(): - jid_data[u'jid'] = jid_ - try: - guests_int = int(jid_data['guests']) - except (ValueError, KeyError): - pass - attend = jid_data.get(u'attend',u'') - if attend == 'yes': - attendees_yes += 1 - guests += guests_int - elif attend == 'maybe': - attendees_maybe += 1 - guests_maybe += guests_int - elif attend == 'no': - attendees_no += 1 - jid_data[u'guests'] = '' - else: - attendees_missing += 1 - jid_data[u'guests'] = '' - data.append(jid_data) - - show_table = OUTPUT_OPT_TABLE in self.args.output_opts - - table = common.Table.fromDict(self.host, - data, - (u'nick',) + ((u'jid',) if self.host.verbosity else ()) + (u'attend', 'guests'), - headers=None, - filters = { u'nick': A.color(C.A_HEADER, u'{}' if show_table else u'{} '), - u'jid': u'{}' if show_table else u'{} ', - u'attend': self._attend_filter, - u'guests': u'{}' if show_table else self._guests_filter, - }, - defaults = { u'nick': u'', - u'attend': u'', - u'guests': 1 - } - ) - if show_table: - table.display() - else: - table.display_blank(show_header=False, col_sep=u'') - - if not self.args.no_rsvp: - self.disp(u'') - self.disp(A.color( - C.A_SUBHEADER, - _(u'Attendees: '), - A.RESET, - unicode(len(data)), - _(u' ('), - C.A_SUCCESS, - _(u'yes: '), - unicode(attendees_yes), - A.FG_WHITE, - _(u', maybe: '), - unicode(attendees_maybe), - u', ', - C.A_FAILURE, - _(u'no: '), - unicode(attendees_no), - A.RESET, - u')' - )) - self.disp(A.color(C.A_SUBHEADER, _(u'confirmed guests: '), A.RESET, unicode(guests))) - self.disp(A.color(C.A_SUBHEADER, _(u'unconfirmed guests: '), A.RESET, unicode(guests_maybe))) - self.disp(A.color(C.A_SUBHEADER, _(u'total: '), A.RESET, unicode(guests+guests_maybe))) - if attendees_missing: - self.disp('') - self.disp(A.color(C.A_SUBHEADER, _(u'missing people (no reply): '), A.RESET, unicode(attendees_missing))) - - def eventInviteesListCb(self, event_data, prefilled_data): - """fill nicknames and keep only requested people - - @param event_data(dict): R.S.V.P. answers - @param prefilled_data(dict): prefilled data with all people - only filled if --missing is used - """ - if self.args.no_rsvp: - for jid_ in event_data: - # if there is a jid in event_data - # it must be there in prefilled_data too - # so no need to check for KeyError - del prefilled_data[jid_] - else: - # we replace empty dicts for existing people with R.S.V.P. data - prefilled_data.update(event_data) - - # we get nicknames for everybody, make it easier for organisers - for jid_, data in prefilled_data.iteritems(): - id_data = self.host.bridge.identityGet(jid_, self.profile) - data[u'nick'] = id_data.get(u'nick', u'') - - self.output(prefilled_data) - self.host.quit() - - def getList(self, prefilled_data={}): - self.host.bridge.eventInviteesList( - self.args.service, - self.args.node, - self.profile, - callback=partial(self.eventInviteesListCb, - prefilled_data=prefilled_data), - errback=partial(self.errback, - msg=_(u"can't get event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def psNodeAffiliationsGetCb(self, affiliations): - # we fill all affiliations with empty data - # answered one will be filled in eventInviteesListCb - # we only consider people with "publisher" affiliation as invited, creators are not, and members can just observe - prefilled = {jid_: {} for jid_, affiliation in affiliations.iteritems() if affiliation in (u'publisher',)} - self.getList(prefilled) - - def start(self): - if self.args.no_rsvp and not self.args.missing: - self.parser.error(_(u"you need to use --missing if you use --no-rsvp")) - if self.args.missing: - self.host.bridge.psNodeAffiliationsGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psNodeAffiliationsGetCb, - errback=partial(self.errback, - msg=_(u"can't get event data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - else: - self.getList() - - -class InviteeInvite(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'invite', use_pubsub=True, pubsub_flags={C.NODE, C.SINGLE_ITEM}, help=_(u'invite someone to the event through email')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-e", "--email", action="append", type=base.unicode_decoder, default=[], help='email(s) to send the invitation to') - self.parser.add_argument("-N", "--name", type=base.unicode_decoder, default='', help='name of the invitee') - self.parser.add_argument("-H", "--host-name", type=base.unicode_decoder, default='', help='name of the host') - self.parser.add_argument("-l", "--lang", type=base.unicode_decoder, default='', help='main language spoken by the invitee') - self.parser.add_argument("-U", "--url-template", type=base.unicode_decoder, default='', help='template to construct the URL') - self.parser.add_argument("-S", "--subject", type=base.unicode_decoder, default='', help='subject of the invitation email (default: generic subject)') - self.parser.add_argument("-b", "--body", type=base.unicode_decoder, default='', help='body of the invitation email (default: generic body)') - - def start(self): - email = self.args.email[0] if self.args.email else None - emails_extra = self.args.email[1:] - - self.host.bridge.eventInvite( - self.args.service, - self.args.node, - self.args.item, - email, - emails_extra, - self.args.name, - self.args.host_name, - self.args.lang, - self.args.url_template, - self.args.subject, - self.args.body, - self.args.profile, - callback=self.host.quit, - errback=partial(self.errback, - msg=_(u"can't create invitation: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Invitee(base.CommandBase): - subcommands = (InviteeGet, InviteeSet, InviteesList, InviteeInvite) - - def __init__(self, host): - super(Invitee, self).__init__(host, 'invitee', use_profile=False, help=_(u'manage invities')) - - -class Event(base.CommandBase): - subcommands = (Get, Create, Modify, Invitee) - - def __init__(self, host): - super(Event, self).__init__(host, 'event', use_profile=False, help=_('event management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_file.py --- a/frontends/src/jp/cmd_file.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,503 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - - -import base -import sys -import os -import os.path -import tarfile -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common -from sat_frontends.tools import jid -from sat.tools.common.ansi import ANSI as A -import tempfile -import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI -from functools import partial -import json - -__commands__ = ["File"] - - -class Send(base.CommandBase): - def __init__(self, host): - super(Send, self).__init__(host, 'send', use_progress=True, use_verbose=True, help=_('send a file to a contact')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("files", type=str, nargs='+', metavar='file', help=_(u"a list of file")) - self.parser.add_argument("jid", type=base.unicode_decoder, help=_(u"the destination jid")) - self.parser.add_argument("-b", "--bz2", action="store_true", help=_(u"make a bzip2 tarball")) - self.parser.add_argument("-d", "--path", type=base.unicode_decoder, help=(u"path to the directory where the file must be stored")) - self.parser.add_argument("-N", "--namespace", type=base.unicode_decoder, help=(u"namespace of the file")) - self.parser.add_argument("-n", "--name", type=base.unicode_decoder, default=u'', help=(u"name to use (DEFAULT: use source file name)")) - - def start(self): - """Send files to jabber contact""" - self.send_files() - - def onProgressStarted(self, metadata): - self.disp(_(u'File copy started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'File sent successfully'),2) - - def onProgressError(self, error_msg): - if error_msg == C.PROGRESS_ERROR_DECLINED: - self.disp(_(u'The file has been refused by your contact')) - else: - self.disp(_(u'Error while sending file: {}').format(error_msg),error=True) - - def gotId(self, data, file_): - """Called when a progress id has been received - - @param pid(unicode): progress id - @param file_(str): file path - """ - #FIXME: this show progress only for last progress_id - self.disp(_(u"File request sent to {jid}".format(jid=self.full_dest_jid)), 1) - try: - self.progress_id = data['progress'] - except KeyError: - # TODO: if 'xmlui' key is present, manage xmlui message display - self.disp(_(u"Can't send file to {jid}".format(jid=self.full_dest_jid)), error=True) - self.host.quit(2) - - def error(self, failure): - self.disp(_("Error while trying to send a file: {reason}").format(reason=failure), error=True) - self.host.quit(1) - - def send_files(self): - for file_ in self.args.files: - if not os.path.exists(file_): - self.disp(_(u"file [{}] doesn't exist !").format(file_), error=True) - self.host.quit(1) - if not self.args.bz2 and os.path.isdir(file_): - self.disp(_(u"[{}] is a dir ! Please send files inside or use compression").format(file_)) - self.host.quit(1) - - self.full_dest_jid = self.host.get_full_jid(self.args.jid) - extra = {} - if self.args.path: - extra[u'path'] = self.args.path - if self.args.namespace: - extra[u'namespace'] = self.args.namespace - - if self.args.bz2: - with tempfile.NamedTemporaryFile('wb', delete=False) as buf: - self.host.addOnQuitCallback(os.unlink, buf.name) - self.disp(_(u"bz2 is an experimental option, use with caution")) - #FIXME: check free space - self.disp(_(u"Starting compression, please wait...")) - sys.stdout.flush() - bz2 = tarfile.open(mode="w:bz2", fileobj=buf) - archive_name = u'{}.tar.bz2'.format(os.path.basename(self.args.files[0]) or u'compressed_files') - for file_ in self.args.files: - self.disp(_(u"Adding {}").format(file_), 1) - bz2.add(file_) - bz2.close() - self.disp(_(u"Done !"), 1) - - self.host.bridge.fileSend(self.full_dest_jid, buf.name, self.args.name or archive_name, '', extra, self.profile, - callback=lambda pid, file_=buf.name: self.gotId(pid, file_), errback=self.error) - else: - for file_ in self.args.files: - path = os.path.abspath(file_) - self.host.bridge.fileSend(self.full_dest_jid, path, self.args.name, '', extra, self.profile, - callback=lambda pid, file_=file_: self.gotId(pid, file_), errback=self.error) - - -class Request(base.CommandBase): - - def __init__(self, host): - super(Request, self).__init__(host, 'request', use_progress=True, use_verbose=True, help=_('request a file from a contact')) - self.need_loop=True - - @property - def filename(self): - return self.args.name or self.args.hash or u"output" - - def add_parser_options(self): - self.parser.add_argument("jid", type=base.unicode_decoder, help=_(u"the destination jid")) - self.parser.add_argument("-D", "--dest", type=base.unicode_decoder, help=_(u"destination path where the file will be saved (default: [current_dir]/[name|hash])")) - self.parser.add_argument("-n", "--name", type=base.unicode_decoder, default=u'', help=_(u"name of the file")) - self.parser.add_argument("-H", "--hash", type=base.unicode_decoder, default=u'', help=_(u"hash of the file")) - self.parser.add_argument("-a", "--hash-algo", type=base.unicode_decoder, default=u'sha-256', help=_(u"hash algorithm use for --hash (default: sha-256)")) - self.parser.add_argument("-d", "--path", type=base.unicode_decoder, help=(u"path to the directory containing the file")) - self.parser.add_argument("-N", "--namespace", type=base.unicode_decoder, help=(u"namespace of the file")) - self.parser.add_argument("-f", "--force", action='store_true', help=_(u"overwrite existing file without confirmation")) - - def onProgressStarted(self, metadata): - self.disp(_(u'File copy started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'File received successfully'),2) - - def onProgressError(self, error_msg): - if error_msg == C.PROGRESS_ERROR_DECLINED: - self.disp(_(u'The file request has been refused')) - else: - self.disp(_(u'Error while requesting file: {}').format(error_msg), error=True) - - def gotId(self, progress_id): - """Called when a progress id has been received - - @param progress_id(unicode): progress id - """ - self.progress_id = progress_id - - def error(self, failure): - self.disp(_("Error while trying to send a file: {reason}").format(reason=failure), error=True) - self.host.quit(1) - - def start(self): - if not self.args.name and not self.args.hash: - self.parser.error(_(u'at least one of --name or --hash must be provided')) - # extra = dict(self.args.extra) - if self.args.dest: - path = os.path.abspath(os.path.expanduser(self.args.dest)) - if os.path.isdir(path): - path = os.path.join(path, self.filename) - else: - path = os.path.abspath(self.filename) - - if os.path.exists(path) and not self.args.force: - message = _(u'File {path} already exists! Do you want to overwrite?').format(path=path) - confirm = raw_input(u"{} (y/N) ".format(message).encode('utf-8')) - if confirm not in (u"y", u"Y"): - self.disp(_(u"file request cancelled")) - self.host.quit(2) - - self.full_dest_jid = self.host.get_full_jid(self.args.jid) - extra = {} - if self.args.path: - extra[u'path'] = self.args.path - if self.args.namespace: - extra[u'namespace'] = self.args.namespace - self.host.bridge.fileJingleRequest(self.full_dest_jid, - path, - self.args.name, - self.args.hash, - self.args.hash_algo if self.args.hash else u'', - extra, - self.profile, - callback=self.gotId, - errback=partial(self.errback, - msg=_(u"can't request file: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Receive(base.CommandAnswering): - - def __init__(self, host): - super(Receive, self).__init__(host, 'receive', use_progress=True, use_verbose=True, help=_('wait for a file to be sent by a contact')) - self._overwrite_refused = False # True when one overwrite as already been refused - self.action_callbacks = {C.META_TYPE_FILE: self.onFileAction, - C.META_TYPE_OVERWRITE: self.onOverwriteAction} - - def onProgressStarted(self, metadata): - self.disp(_(u'File copy started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'File received successfully'),2) - if metadata.get('hash_verified', False): - try: - self.disp(_(u'hash checked: {algo}:{checksum}').format( - algo=metadata['hash_algo'], - checksum=metadata['hash']), - 1) - except KeyError: - self.disp(_(u'hash is checked but hash value is missing', 1), error=True) - else: - self.disp(_(u"hash can't be verified"), 1) - - def onProgressError(self, error_msg): - self.disp(_(u'Error while receiving file: {}').format(error_msg),error=True) - - def getXmluiId(self, action_data): - # FIXME: we temporarily use ElementTree, but a real XMLUI managing module - # should be available in the futur - # TODO: XMLUI module - try: - xml_ui = action_data['xmlui'] - except KeyError: - self.disp(_(u"Action has no XMLUI"), 1) - else: - ui = ET.fromstring(xml_ui.encode('utf-8')) - xmlui_id = ui.get('submit') - if not xmlui_id: - self.disp(_(u"Invalid XMLUI received"), error=True) - return xmlui_id - - def onFileAction(self, action_data, action_id, security_limit, profile): - xmlui_id = self.getXmluiId(action_data) - if xmlui_id is None: - return self.host.quitFromSignal(1) - try: - from_jid = jid.JID(action_data['meta_from_jid']) - except KeyError: - self.disp(_(u"Ignoring action without from_jid data"), 1) - return - try: - progress_id = action_data['meta_progress_id'] - except KeyError: - self.disp(_(u"ignoring action without progress id"), 1) - return - - if not self.bare_jids or from_jid.bare in self.bare_jids: - if self._overwrite_refused: - self.disp(_(u"File refused because overwrite is needed"), error=True) - self.host.bridge.launchAction(xmlui_id, {'cancelled': C.BOOL_TRUE}, profile_key=profile) - return self.host.quitFromSignal(2) - self.progress_id = progress_id - xmlui_data = {'path': self.path} - self.host.bridge.launchAction(xmlui_id, xmlui_data, profile_key=profile) - - def onOverwriteAction(self, action_data, action_id, security_limit, profile): - xmlui_id = self.getXmluiId(action_data) - if xmlui_id is None: - return self.host.quitFromSignal(1) - try: - progress_id = action_data['meta_progress_id'] - except KeyError: - self.disp(_(u"ignoring action without progress id"), 1) - return - self.disp(_(u"Overwriting needed"), 1) - - if progress_id == self.progress_id: - if self.args.force: - self.disp(_(u"Overwrite accepted"), 2) - else: - self.disp(_(u"Refused to overwrite"), 2) - self._overwrite_refused = True - - xmlui_data = {'answer': C.boolConst(self.args.force)} - self.host.bridge.launchAction(xmlui_id, xmlui_data, profile_key=profile) - - def add_parser_options(self): - self.parser.add_argument("jids", type=base.unicode_decoder, nargs="*", help=_(u'jids accepted (accept everything if none is specified)')) - self.parser.add_argument("-m", "--multiple", action="store_true", help=_(u"accept multiple files (you'll have to stop manually)")) - self.parser.add_argument("-f", "--force", action="store_true", help=_(u"force overwritting of existing files (/!\\ name is choosed by sender)")) - self.parser.add_argument("--path", default='.', metavar='DIR', help=_(u"destination path (default: working directory)")) - - def start(self): - self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids] - self.path = os.path.abspath(self.args.path) - if not os.path.isdir(self.path): - self.disp(_(u"Given path is not a directory !", error=True)) - self.host.quit(2) - if self.args.multiple: - self.host.quit_on_progress_end = False - self.disp(_(u"waiting for incoming file request"),2) - - -class Upload(base.CommandBase): - - def __init__(self, host): - super(Upload, self).__init__(host, 'upload', use_progress=True, use_verbose=True, help=_('upload a file')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("file", type=str, help=_("file to upload")) - self.parser.add_argument("jid", type=base.unicode_decoder, nargs='?', help=_("jid of upload component (nothing to autodetect)")) - self.parser.add_argument("--ignore-tls-errors", action="store_true", help=_("ignore invalide TLS certificate")) - - def onProgressStarted(self, metadata): - self.disp(_(u'File upload started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'File uploaded successfully'),2) - try: - url = metadata['url'] - except KeyError: - self.disp(u'download URL not found in metadata') - else: - self.disp(_(u'URL to retrieve the file:'),1) - # XXX: url is display alone on a line to make parsing easier - self.disp(url) - - def onProgressError(self, error_msg): - self.disp(_(u'Error while uploading file: {}').format(error_msg),error=True) - - def gotId(self, data, file_): - """Called when a progress id has been received - - @param pid(unicode): progress id - @param file_(str): file path - """ - try: - self.progress_id = data['progress'] - except KeyError: - # TODO: if 'xmlui' key is present, manage xmlui message display - self.disp(_(u"Can't upload file"), error=True) - self.host.quit(2) - - def error(self, failure): - self.disp(_("Error while trying to upload a file: {reason}").format(reason=failure), error=True) - self.host.quit(1) - - def start(self): - file_ = self.args.file - if not os.path.exists(file_): - self.disp(_(u"file [{}] doesn't exist !").format(file_), error=True) - self.host.quit(1) - if os.path.isdir(file_): - self.disp(_(u"[{}] is a dir! Can't upload a dir").format(file_)) - self.host.quit(1) - - self.full_dest_jid = self.host.get_full_jid(self.args.jid) if self.args.jid is not None else '' - options = {} - if self.args.ignore_tls_errors: - options['ignore_tls_errors'] = C.BOOL_TRUE - - path = os.path.abspath(file_) - self.host.bridge.fileUpload(path, '', self.full_dest_jid, options, self.profile, callback=lambda pid, file_=file_: self.gotId(pid, file_), errback=self.error) - - -class ShareList(base.CommandBase): - - def __init__(self, host): - extra_outputs = {'default': self.default_output} - super(ShareList, self).__init__(host, 'list', use_output=C.OUTPUT_LIST_DICT, extra_outputs=extra_outputs, help=_(u'retrieve files shared by an entity'), use_verbose=True) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-d", "--path", default=u'', help=_(u"path to the directory containing the files")) - self.parser.add_argument("jid", type=base.unicode_decoder, nargs='?', help=_("jid of sharing entity (nothing to check our own jid)")) - - def file_gen(self, files_data): - for file_data in files_data: - yield file_data[u'name'] - yield file_data.get(u'size', '') - yield file_data.get(u'hash','') - - def _name_filter(self, name, row): - if row.type == C.FILE_TYPE_DIRECTORY: - return A.color(C.A_DIRECTORY, name) - elif row.type == C.FILE_TYPE_FILE: - return A.color(C.A_FILE, name) - else: - self.disp(_(u'unknown file type: {type}').format(type=row.type), error=True) - return name - - def _size_filter(self, size, row): - if not size: - return u'' - size = int(size) - # cf. https://stackoverflow.com/a/1094933 (thanks) - suffix = u'o' - for unit in [u'', u'Ki', u'Mi', u'Gi', u'Ti', u'Pi', u'Ei', u'Zi']: - if abs(size) < 1024.0: - return A.color(A.BOLD, u"{:.2f}".format(size), unit, suffix) - size /= 1024.0 - - return A.color(A.BOLD, u"{:.2f}".format(size), u'Yi', suffix) - - def default_output(self, files_data): - """display files a way similar to ls""" - files_data.sort(key=lambda d: d['name'].lower()) - show_header = False - if self.verbosity == 0: - headers = (u'name', u'type') - elif self.verbosity == 1: - headers = (u'name', u'type', u'size') - elif self.verbosity > 1: - show_header = True - headers = (u'name', u'type', u'size', u'hash') - table = common.Table.fromDict(self.host, - files_data, - headers, - filters={u'name': self._name_filter, - u'size': self._size_filter}, - defaults={u'size': u'', - u'hash': u''}, - ) - table.display_blank(show_header=show_header, hide_cols=['type']) - - def _FISListCb(self, files_data): - self.output(files_data) - self.host.quit() - - def start(self): - self.host.bridge.FISList( - self.args.jid, - self.args.path, - {}, - self.profile, - callback=self._FISListCb, - errback=partial(self.errback, - msg=_(u"can't retrieve shared files: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class SharePath(base.CommandBase): - - def __init__(self, host): - super(SharePath, self).__init__(host, 'path', help=_(u'share a file or directory'), use_verbose=True) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-n", "--name", type=base.unicode_decoder, default=u'', help=_(u"virtual name to use (default: use directory/file name)")) - perm_group = self.parser.add_mutually_exclusive_group() - perm_group.add_argument("-j", "--jid", type=base.unicode_decoder, action='append', dest="jids", default=[], help=_(u"jid of contacts allowed to retrieve the files")) - perm_group.add_argument("--public", action='store_true', help=_(u"share publicly the file(s) (/!\\ *everybody* will be able to access them)")) - self.parser.add_argument("path", type=base.unicode_decoder, help=_(u"path to a file or directory to share")) - - - def _FISSharePathCb(self, name): - self.disp(_(u'{path} shared under the name "{name}"').format( - path = self.path, - name = name)) - self.host.quit() - - def start(self): - self.path = os.path.abspath(self.args.path) - if self.args.public: - access = {u'read': {u'type': u'public'}} - else: - jids = self.args.jids - if jids: - access = {u'read': {u'type': 'whitelist', - u'jids': jids}} - else: - access = {} - self.host.bridge.FISSharePath( - self.args.name, - self.path, - json.dumps(access, ensure_ascii=False), - self.profile, - callback=self._FISSharePathCb, - errback=partial(self.errback, - msg=_(u"can't share path: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Share(base.CommandBase): - subcommands = (ShareList, SharePath) - - def __init__(self, host): - super(Share, self).__init__(host, 'share', use_profile=False, help=_(u'files sharing management')) - - -class File(base.CommandBase): - subcommands = (Send, Request, Receive, Upload, Share) - - def __init__(self, host): - super(File, self).__init__(host, 'file', use_profile=False, help=_(u'files sending/receiving/management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_forums.py --- a/frontends/src/jp/cmd_forums.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,163 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common -from sat.tools.common.ansi import ANSI as A -from functools import partial -import json - -__commands__ = ["Forums"] - -FORUMS_TMP_DIR = u"forums" - - -class Edit(base.CommandBase, common.BaseEdit): - use_items=False - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'edit', use_pubsub=True, use_draft=True, use_verbose=True, help=_(u'edit forums')) - common.BaseEdit.__init__(self, self.host, FORUMS_TMP_DIR) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-k", "--key", type=base.unicode_decoder, default=u'', - help=_(u"forum key (DEFAULT: default forums)")) - - def getTmpSuff(self): - """return suffix used for content file""" - return u'json' - - def forumsSetCb(self): - self.disp(_(u'forums have been edited'), 1) - self.host.quit() - - def publish(self, forums_raw): - self.host.bridge.forumsSet( - forums_raw, - self.args.service, - self.args.node, - self.args.key, - self.profile, - callback=self.forumsSetCb, - errback=partial(self.errback, - msg=_(u"can't set forums: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def forumsGetCb(self, forums_json): - content_file_obj, content_file_path = self.getTmpFile() - forums_json = forums_json.strip() - if forums_json: - # we loads and dumps to have pretty printed json - forums = json.loads(forums_json) - json.dump(forums, content_file_obj, ensure_ascii=False, indent=4) - content_file_obj.seek(0) - self.runEditor("forums_editor_args", content_file_path, content_file_obj) - - def forumsGetEb(self, failure_): - # FIXME: error handling with bridge is broken, need to be properly fixed - if failure_.condition == u'item-not-found': - self.forumsGetCb(u'') - else: - self.errback(failure_, - msg=_(u"can't get forums structure: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.forumsGet( - self.args.service, - self.args.node, - self.args.key, - self.profile, - callback=self.forumsGetCb, - errback=self.forumsGetEb) - - -class Get(base.CommandBase): - - def __init__(self, host): - extra_outputs = {'default': self.default_output} - base.CommandBase.__init__(self, host, 'get', use_output=C.OUTPUT_COMPLEX, extra_outputs=extra_outputs, use_pubsub=True, use_verbose=True, help=_(u'get forums structure')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-k", "--key", type=base.unicode_decoder, default=u'', - help=_(u"forum key (DEFAULT: default forums)")) - - def default_output(self, forums, level=0): - for forum in forums: - keys = list(forum.keys()) - keys.sort() - try: - keys.remove(u'title') - except ValueError: - pass - else: - keys.insert(0, u'title') - try: - keys.remove(u'sub-forums') - except ValueError: - pass - else: - keys.append(u'sub-forums') - - for key in keys: - value = forum[key] - if key == 'sub-forums': - self.default_output(value, level+1) - else: - if self.host.verbosity < 1 and key != u'title': - continue - head_color = C.A_LEVEL_COLORS[level % len(C.A_LEVEL_COLORS)] - self.disp(A.color(level * 4 * u' ', - head_color, - key, - A.RESET, - u': ', - value)) - - def forumsGetCb(self, forums_raw): - if not forums_raw: - self.disp(_(u'no schema found'), 1) - self.host.quit(1) - forums = json.loads(forums_raw) - self.output(forums) - self.host.quit() - - def start(self): - self.host.bridge.forumsGet( - self.args.service, - self.args.node, - self.args.key, - self.profile, - callback=self.forumsGetCb, - errback=partial(self.errback, - msg=_(u"can't get forums: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - - -class Forums(base.CommandBase): - subcommands = (Get, Edit) - - def __init__(self, host): - super(Forums, self).__init__(host, 'forums', use_profile=False, help=_(u'Forums structure edition')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_identity.py --- a/frontends/src/jp/cmd_identity.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from functools import partial - -__commands__ = ["Identity"] - -# TODO: move date parsing to base, it may be useful for other commands - - -class Get(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, - host, - 'get', - use_output=C.OUTPUT_DICT, - use_verbose=True, - help=_(u'get identity data')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("jid", type=base.unicode_decoder, help=_(u"entity to check")) - - def identityGetCb(self, data): - self.output(data) - self.host.quit() - - def start(self): - jid_ = self.host.check_jids([self.args.jid])[0] - self.host.bridge.identityGet( - jid_, - self.profile, - callback=self.identityGetCb, - errback=partial(self.errback, - msg=_(u"can't get identity data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Set(base.CommandBase): - def __init__(self, host): - super(Set, self).__init__(host, 'set', help=_('modify an existing event')) - - def add_parser_options(self): - self.parser.add_argument("-f", "--field", type=base.unicode_decoder, action='append', nargs=2, dest='fields', - metavar=(u"KEY", u"VALUE"), required=True, help=_(u"identity field(s) to set")) - self.need_loop=True - - def start(self): - fields = dict(self.args.fields) - self.host.bridge.identitySet( - fields, - self.profile, - callback=self.host.quit, - errback=partial(self.errback, - msg=_(u"can't set identity data data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Identity(base.CommandBase): - subcommands = (Get, Set) - - def __init__(self, host): - super(Identity, self).__init__(host, 'identity', use_profile=False, help=_('identity management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_info.py --- a/frontends/src/jp/cmd_info.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,193 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -import base -from sat.core.i18n import _ -from sat.tools.common.ansi import ANSI as A -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common - -__commands__ = ["Info"] - - -class Disco(base.CommandBase): - - def __init__(self, host): - extra_outputs = {'default': self.default_output} - super(Disco, self).__init__(host, 'disco', use_output='complex', extra_outputs=extra_outputs, help=_('service discovery')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument(u"jid", type=base.unicode_decoder, help=_(u"entity to discover")) - self.parser.add_argument(u"-t", u"--type", type=str, choices=('infos', 'items', 'both'), default='both', help=_(u"type of data to discover")) - self.parser.add_argument(u"-n", u"--node", type=base.unicode_decoder, default=u'', help=_(u"node to use")) - self.parser.add_argument(u"-C", u"--no-cache", dest='use_cache', action="store_false", help=_(u"ignore cache")) - - def start(self): - self.get_infos = self.args.type in ('infos', 'both') - self.get_items = self.args.type in ('items', 'both') - jids = self.host.check_jids([self.args.jid]) - jid = jids[0] - if not self.get_infos: - self.gotInfos(None, jid) - else: - self.host.bridge.discoInfos(jid, node=self.args.node, use_cache=self.args.use_cache, profile_key=self.host.profile, callback=lambda infos: self.gotInfos(infos, jid), errback=self.error) - - def error(self, failure): - print (_("Error while doing discovery [%s]") % failure) - self.host.quit(1) - - def gotInfos(self, infos, jid): - if not self.get_items: - self.gotItems(infos, None) - else: - self.host.bridge.discoItems(jid, node=self.args.node, use_cache=self.args.use_cache, profile_key=self.host.profile, callback=lambda items: self.gotItems(infos, items), errback=self.error) - - def gotItems(self, infos, items): - data = {} - - if self.get_infos: - features, identities, extensions = infos - features.sort() - identities.sort(key=lambda identity: identity[2]) - data.update({ - u'features': features, - u'identities': identities, - u'extensions': extensions}) - - if self.get_items: - items.sort(key=lambda item: item[2]) - data[u'items'] = items - - self.output(data) - self.host.quit() - - def default_output(self, data): - features = data.get(u'features', []) - identities = data.get(u'identities', []) - extensions = data.get(u'extensions', {}) - items = data.get(u'items', []) - - identities_table = common.Table(self.host, - identities, - headers=(_(u'category'), - _(u'type'), - _(u'name')), - use_buffer=True) - - extensions_tpl = [] - extensions_types = extensions.keys() - extensions_types.sort() - for type_ in extensions_types: - fields = [] - for field in extensions[type_]: - field_lines = [] - data, values = field - data_keys = data.keys() - data_keys.sort() - for key in data_keys: - field_lines.append(A.color(u'\t', C.A_SUBHEADER, key, data[key])) - for value in values: - field_lines.append(A.color(u'\t', A.BOLD, value)) - fields.append(u'\n'.join(field_lines)) - extensions_tpl.append(u'{type_}\n{fields}'.format(type_=type_, - fields='\n\n'.join(fields))) - - items_table = common.Table(self.host, - items, - headers=(_(u'entity'), - _(u'node'), - _(u'name')), - use_buffer=True) - - template = [] - if features: - template.append(A.color(C.A_HEADER, _(u"Features")) + u"\n\n{features}") - if identities: - template.append(A.color(C.A_HEADER, _(u"Identities")) + u"\n\n{identities}") - if extensions: - template.append(A.color(C.A_HEADER, _(u"Extensions")) + u"\n\n{extensions}") - if items: - template.append(A.color(C.A_HEADER, _(u"Items")) + u"\n\n{items}") - - print u"\n\n".join(template).format(features = u'\n'.join(features), - identities = identities_table.display().string, - extensions = u'\n'.join(extensions_tpl), - items = items_table.display().string, - ) - - -class Version(base.CommandBase): - - def __init__(self, host): - super(Version, self).__init__(host, 'version', help=_('software version')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("jid", type=str, help=_("Entity to request")) - - def start(self): - jids = self.host.check_jids([self.args.jid]) - jid = jids[0] - self.host.bridge.getSoftwareVersion(jid, self.host.profile, callback=self.gotVersion, errback=self.error) - - def error(self, failure): - print (_("Error while trying to get version [%s]") % failure) - self.host.quit(1) - - def gotVersion(self, data): - infos = [] - name, version, os = data - if name: - infos.append(_("Client name: %s") % name) - if version: - infos.append(_("Client version: %s") % version) - if os: - infos.append(_("Operating System: %s") % os) - - print "\n".join(infos) - self.host.quit() - - -class Session(base.CommandBase): - - def __init__(self, host): - super(Session, self).__init__(host, 'session', use_output='dict', help=_('running session')) - self.need_loop=True - - def add_parser_options(self): - pass - - def start(self): - self.host.bridge.sessionInfosGet(self.host.profile, callback=self._sessionInfoGetCb, errback=self._sessionInfoGetEb) - - def _sessionInfoGetCb(self, data): - self.output(data) - self.host.quit() - - def _sessionInfoGetEb(self, error_data): - self.disp(_(u'Error getting session infos: {}').format(error_data), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - -class Info(base.CommandBase): - subcommands = (Disco, Version, Session) - - def __init__(self, host): - super(Info, self).__init__(host, 'info', use_profile=False, help=_('Get various pieces of information on entities')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_input.py --- a/frontends/src/jp/cmd_input.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,227 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat.core import exceptions -from sat_frontends.jp.constants import Const as C -from sat.tools.common.ansi import ANSI as A -import subprocess -import argparse -import sys - -__commands__ = ["Input"] -OPT_STDIN = 'stdin' -OPT_SHORT = 'short' -OPT_LONG = 'long' -OPT_POS = 'positional' -OPT_IGNORE = 'ignore' -OPT_TYPES = (OPT_STDIN, OPT_SHORT, OPT_LONG, OPT_POS, OPT_IGNORE) -OPT_EMPTY_SKIP = 'skip' -OPT_EMPTY_IGNORE = 'ignore' -OPT_EMPTY_CHOICES = (OPT_EMPTY_SKIP, OPT_EMPTY_IGNORE) - - -class InputCommon(base.CommandBase): - - def __init__(self, host, name, help): - base.CommandBase.__init__(self, host, name, use_verbose=True, use_profile=False, help=help) - self.idx = 0 - self.reset() - - def reset(self): - self.args_idx = 0 - self._stdin = [] - self._opts = [] - self._pos = [] - self._values_ori = [] - - def add_parser_options(self): - self.parser.add_argument("--encoding", default='utf-8', help=_(u"encoding of the input data")) - self.parser.add_argument("-i", "--stdin", action='append_const', const=(OPT_STDIN, None), dest='arguments', help=_(u"standard input")) - self.parser.add_argument("-s", "--short", type=self.opt(OPT_SHORT), action='append', dest='arguments', help=_(u"short option")) - self.parser.add_argument("-l", "--long", type=self.opt(OPT_LONG), action='append', dest='arguments', help=_(u"long option")) - self.parser.add_argument("-p", "--positional", type=self.opt(OPT_POS), action='append', dest='arguments', help=_(u"positional argument")) - self.parser.add_argument("-x", "--ignore", action='append_const', const=(OPT_IGNORE, None), dest='arguments', help=_(u"ignore value")) - self.parser.add_argument("-D", "--debug", action='store_true', help=_(u"don't actually run commands but echo what would be launched")) - self.parser.add_argument("--log", type=argparse.FileType('wb'), help=_(u"log stdout to FILE")) - self.parser.add_argument("--log-err", type=argparse.FileType('wb'), help=_(u"log stderr to FILE")) - self.parser.add_argument("command", nargs=argparse.REMAINDER) - - def opt(self, type_): - return lambda s: (type_, s) - - def addValue(self, value): - """add a parsed value according to arguments sequence""" - self._values_ori.append(value) - arguments = self.args.arguments - try: - arg_type, arg_name = arguments[self.args_idx] - except IndexError: - self.disp(_(u"arguments in input data and in arguments sequence don't match"), error=True) - self.host.quit(C.EXIT_DATA_ERROR) - self.args_idx += 1 - while self.args_idx < len(arguments): - next_arg = arguments[self.args_idx] - if next_arg[0] not in OPT_TYPES: - # value will not be used if False or None, so we skip filter - if value not in (False, None): - # we have a filter - filter_type, filter_arg = arguments[self.args_idx] - value = self.filter(filter_type, filter_arg, value) - else: - break - self.args_idx += 1 - - if value is None: - # we ignore this argument - return - - if value is False: - # we skip the whole row - if self.args.debug: - self.disp(A.color(C.A_SUBHEADER, _(u'values: '), A.RESET, u', '.join(self._values_ori)), 2) - self.disp(A.color(A.BOLD, _(u'**SKIPPING**\n'))) - self.reset() - self.idx += 1 - raise exceptions.CancelError - - if not isinstance(value, list): - value = [value] - - for v in value: - if arg_type == OPT_STDIN: - self._stdin.append(v.encode('utf-8')) - elif arg_type == OPT_SHORT: - self._opts.append('-{}'.format(arg_name)) - self._opts.append(v.encode('utf-8')) - elif arg_type == OPT_LONG: - self._opts.append('--{}'.format(arg_name)) - self._opts.append(v.encode('utf-8')) - elif arg_type == OPT_POS: - self._pos.append(v.encode('utf-8')) - elif arg_type == OPT_IGNORE: - pass - else: - self.parser.error(_(u"Invalid argument, an option type is expected, got {type_}:{name}").format( - type_=arg_type, name=arg_name)) - - def runCommand(self): - """run requested command with parsed arguments""" - if self.args_idx != len(self.args.arguments): - self.disp(_(u"arguments in input data and in arguments sequence don't match"), error=True) - self.host.quit(C.EXIT_DATA_ERROR) - self.disp(A.color(C.A_HEADER, _(u'command {idx}').format(idx=self.idx)), no_lf=not self.args.debug) - stdin = ''.join(self._stdin) - if self.args.debug: - self.disp(A.color(C.A_SUBHEADER, _(u'values: '), A.RESET, u', '.join(self._values_ori)), 2) - - if stdin: - self.disp(A.color(C.A_SUBHEADER, u'--- STDIN ---')) - self.disp(stdin.decode('utf-8')) - self.disp(A.color(C.A_SUBHEADER, u'-------------')) - self.disp(u'{indent}{prog} {static} {options} {positionals}'.format( - indent = 4*u' ', - prog=sys.argv[0], - static = ' '.join(self.args.command).decode('utf-8'), - options = u' '.join([o.decode('utf-8') for o in self._opts]), - positionals = u' '.join([p.decode('utf-8') for p in self._pos]) - )) - self.disp(u'\n') - else: - self.disp(u' (' + u', '.join(self._values_ori) + u')', 2, no_lf=True) - args = [sys.argv[0]] + self.args.command + self._opts + self._pos - p = subprocess.Popen(args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - (stdout, stderr) = p.communicate(stdin) - log = self.args.log - log_err = self.args.log_err - log_tpl = '{command}\n{buff}\n\n' - if log: - log.write(log_tpl.format(command=' '.join(args), buff=stdout)) - if log_err: - log_err.write(log_tpl.format(command=' '.join(args), buff=stderr)) - ret = p.wait() - if ret == 0: - self.disp(A.color(C.A_SUCCESS, _(u'OK'))) - else: - self.disp(A.color(C.A_FAILURE, _(u'FAILED'))) - - self.reset() - self.idx += 1 - - def filter(self, filter_type, filter_arg, value): - """change input value - - @param filter_type(unicode): name of the filter - @param filter_arg(unicode, None): argument of the filter - @param value(unicode): value to filter - @return (unicode, False, None): modified value - False to skip the whole row - None to ignore this argument (but continue row with other ones) - """ - raise NotImplementedError - - -class Csv(InputCommon): - - def __init__(self, host): - super(Csv, self).__init__(host, 'csv', _(u'comma-separated values')) - - def add_parser_options(self): - InputCommon.add_parser_options(self) - self.parser.add_argument("-r", "--row", type=int, default=0, help=_(u"starting row (previous ones will be ignored)")) - self.parser.add_argument("-S", "--split", action='append_const', const=('split', None), dest='arguments', help=_(u"split value in several options")) - self.parser.add_argument("-E", "--empty", action='append', type=self.opt('empty'), dest='arguments', - help=_(u"action to do on empty value ({choices})").format(choices=u', '.join(OPT_EMPTY_CHOICES))) - - def filter(self, filter_type, filter_arg, value): - if filter_type == 'split': - return value.split() - elif filter_type == 'empty': - if filter_arg == OPT_EMPTY_IGNORE: - return value if value else None - elif filter_arg == OPT_EMPTY_SKIP: - return value if value else False - else: - self.parser.error(_(u"--empty value must be one of {choices}").format(choices=u', '.join(OPT_EMPTY_CHOICES))) - - super(Csv, self).filter(filter_type, filter_arg, value) - - def start(self): - import csv - reader = csv.reader(sys.stdin) - for idx, row in enumerate(reader): - try: - if idx < self.args.row: - continue - for value in row: - self.addValue(value.decode(self.args.encoding)) - self.runCommand() - except exceptions.CancelError: - # this row has been cancelled, we skip it - continue - - -class Input(base.CommandBase): - subcommands = (Csv,) - - def __init__(self, host): - super(Input, self).__init__(host, 'input', use_profile=False, help=_(u'launch command with external input')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_invitation.py --- a/frontends/src/jp/cmd_invitation.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,219 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat.tools.common.ansi import ANSI as A -from sat.tools.common import data_format -from functools import partial - -__commands__ = ["Invitation"] - - -class Create(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'create', use_profile=False, use_output=C.OUTPUT_DICT, help=_(u'create and send an invitation')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-j", "--jid", type=base.unicode_decoder, default='', help='jid of the invitee (default: generate one)') - self.parser.add_argument("-P", "--password", type=base.unicode_decoder, default='', help='password of the invitee profile/XMPP account (default: generate one)') - self.parser.add_argument("-n", "--name", type=base.unicode_decoder, default='', help='name of the invitee') - self.parser.add_argument("-N", "--host-name", type=base.unicode_decoder, default='', help='name of the host') - self.parser.add_argument("-e", "--email", action="append", type=base.unicode_decoder, default=[], help='email(s) to send the invitation to (if --no-email is set, email will just be saved)') - self.parser.add_argument("--no-email", action="store_true", help='do NOT send invitation email') - self.parser.add_argument("-l", "--lang", type=base.unicode_decoder, default='', help='main language spoken by the invitee') - self.parser.add_argument("-u", "--url", type=base.unicode_decoder, default='', help='template to construct the URL') - self.parser.add_argument("-s", "--subject", type=base.unicode_decoder, default='', help='subject of the invitation email (default: generic subject)') - self.parser.add_argument("-b", "--body", type=base.unicode_decoder, default='', help='body of the invitation email (default: generic body)') - self.parser.add_argument("-x", "--extra", metavar=('KEY', 'VALUE'), type=base.unicode_decoder, action='append', nargs=2, default=[], help='extra data to associate with invitation/invitee') - self.parser.add_argument("-p", "--profile", type=base.unicode_decoder, default='', help="profile doing the invitation (default: don't associate profile)") - - def invitationCreateCb(self, invitation_data): - self.output(invitation_data) - self.host.quit(C.EXIT_OK) - - def invitationCreateEb(self, failure_): - self.disp(u"can't create invitation: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - extra = dict(self.args.extra) - email = self.args.email[0] if self.args.email else None - emails_extra = self.args.email[1:] - if self.args.no_email: - if email: - extra['email'] = email - data_format.iter2dict(u'emails_extra', emails_extra) - else: - if not email: - self.parser.error(_(u'you need to specify an email address to send email invitation')) - - self.host.bridge.invitationCreate( - email, - emails_extra, - self.args.jid, - self.args.password, - self.args.name, - self.args.host_name, - self.args.lang, - self.args.url, - self.args.subject, - self.args.body, - extra, - self.args.profile, - callback=self.invitationCreateCb, - errback=self.invitationCreateEb) - - -class Get(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_profile=False, use_output=C.OUTPUT_DICT, help=_(u'get invitation data')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("id", type=base.unicode_decoder, - help=_(u"invitation UUID")) - self.parser.add_argument("-j", "--with-jid", action="store_true", help=_(u"start profile session and retrieve jid")) - - def output_data(self, data, jid_=None): - if jid_ is not None: - data['jid'] = jid_ - self.output(data) - self.host.quit() - - def invitationGetCb(self, invitation_data): - if self.args.with_jid: - profile = invitation_data[u'guest_profile'] - def session_started(dummy): - self.host.bridge.asyncGetParamA( - u'JabberID', - u'Connection', - profile_key=profile, - callback=lambda jid_: self.output_data(invitation_data, jid_), - errback=partial(self.errback, - msg=_(u"can't retrieve jid: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - self.host.bridge.profileStartSession( - invitation_data[u'password'], - profile, - callback=session_started, - errback=partial(self.errback, - msg=_(u"can't start session: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - else: - self.output_data(invitation_data) - - def start(self): - self.host.bridge.invitationGet( - self.args.id, - callback=self.invitationGetCb, - errback=partial(self.errback, - msg=_(u"can't get invitation data: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Modify(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'modify', use_profile=False, help=_(u'modify existing invitation')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("--replace", action='store_true', help='replace the whole data') - self.parser.add_argument("-n", "--name", type=base.unicode_decoder, default='', help='name of the invitee') - self.parser.add_argument("-N", "--host-name", type=base.unicode_decoder, default='', help='name of the host') - self.parser.add_argument("-e", "--email", type=base.unicode_decoder, default='', help='email to send the invitation to (if --no-email is set, email will just be saved)') - self.parser.add_argument("-l", "--lang", dest="language", type=base.unicode_decoder, default='', - help='main language spoken by the invitee') - self.parser.add_argument("-x", "--extra", metavar=('KEY', 'VALUE'), type=base.unicode_decoder, action='append', nargs=2, default=[], help='extra data to associate with invitation/invitee') - self.parser.add_argument("-p", "--profile", type=base.unicode_decoder, default='', help="profile doing the invitation (default: don't associate profile") - self.parser.add_argument("id", type=base.unicode_decoder, - help=_(u"invitation UUID")) - - def invitationModifyCb(self): - self.disp(_(u'invitations have been modified correctly')) - self.host.quit(C.EXIT_OK) - - def invitationModifyEb(self, failure_): - self.disp(u"can't create invitation: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - extra = dict(self.args.extra) - for arg_name in ('name', 'host_name', 'email', 'language', 'profile'): - value = getattr(self.args, arg_name) - if not value: - continue - if arg_name in extra: - self.parser.error(_(u"you can't set {arg_name} in both optional argument and extra").format(arg_name=arg_name)) - extra[arg_name] = value - self.host.bridge.invitationModify( - self.args.id, - extra, - self.args.replace, - callback=self.invitationModifyCb, - errback=self.invitationModifyEb) - - -class List(base.CommandBase): - - def __init__(self, host): - extra_outputs = {'default': self.default_output} - base.CommandBase.__init__(self, host, 'list', use_profile=False, use_output=C.OUTPUT_COMPLEX, extra_outputs=extra_outputs, help=_(u'list invitations data')) - self.need_loop=True - - def default_output(self, data): - for idx, datum in enumerate(data.iteritems()): - if idx: - self.disp(u"\n") - key, invitation_data = datum - self.disp(A.color(C.A_HEADER, key)) - indent = u' ' - for k, v in invitation_data.iteritems(): - self.disp(indent + A.color(C.A_SUBHEADER, k + u':') + u' ' + unicode(v)) - - def add_parser_options(self): - self.parser.add_argument("-p", "--profile", default=C.PROF_KEY_NONE, help=_(u"return only invitations linked to this profile")) - - def invitationListCb(self, data): - self.output(data) - self.host.quit() - - def start(self): - self.host.bridge.invitationList( - self.args.profile, - callback=self.invitationListCb, - errback=partial(self.errback, - msg=_(u"can't list invitations: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Invitation(base.CommandBase): - subcommands = (Create, Get, Modify, List) - - def __init__(self, host): - super(Invitation, self).__init__(host, 'invitation', use_profile=False, help=_(u'invitation of user(s) without XMPP account')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_merge_request.py --- a/frontends/src/jp/cmd_merge_request.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,165 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import xmlui_manager -from sat_frontends.jp import common -from functools import partial -import os.path - -__commands__ = ["MergeRequest"] - - -class Set(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, - pubsub_defaults = {u'service': _(u'auto'), u'node': _(u'auto')}, - help=_(u'publish or update a merge request')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-i", "--item", type=base.unicode_decoder, default=u'', help=_(u"id or URL of the request to update, or nothing for a new one")) - self.parser.add_argument("-r", "--repository", metavar="PATH", type=base.unicode_decoder, default=u'.', help=_(u"path of the repository (DEFAULT: current directory)")) - self.parser.add_argument("-f", "--force", action="store_true", help=_(u"publish merge request without confirmation")) - self.parser.add_argument("-l", "--label", dest="labels", type=base.unicode_decoder, action='append', help=_(u"labels to categorize your request")) - - def mergeRequestSetCb(self, published_id): - if published_id: - self.disp(u"Merge request published at {pub_id}".format(pub_id=published_id)) - else: - self.disp(u"Merge request published") - self.host.quit(C.EXIT_OK) - - def sendRequest(self): - extra = {'update': 'true'} if self.args.item else {} - values = {} - if self.args.labels is not None: - values[u'labels'] = self.args.labels - self.host.bridge.mergeRequestSet( - self.args.service, - self.args.node, - self.repository, - u'auto', - values, - u'', - self.args.item, - extra, - self.profile, - callback=self.mergeRequestSetCb, - errback=partial(self.errback, - msg=_(u"can't create merge request: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def askConfirmation(self): - if not self.args.force: - message = _(u"You are going to publish your changes to service [{service}], are you sure ?").format( - service=self.args.service) - self.host.confirmOrQuit(message, _(u"merge request publication cancelled")) - self.sendRequest() - - def start(self): - self.repository = os.path.expanduser(os.path.abspath(self.args.repository)) - common.URIFinder(self, self.repository, 'merge requests', self.askConfirmation) - - -class Get(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_verbose=True, - use_pubsub=True, pubsub_flags={C.MULTI_ITEMS}, - pubsub_defaults = {u'service': _(u'auto'), u'node': _(u'auto')}, - help=_(u'get a merge request')) - self.need_loop=True - - def add_parser_options(self): - pass - - def mergeRequestGetCb(self, requests_data): - if self.verbosity >= 1: - whitelist = None - else: - whitelist = {'id', 'title', 'body'} - for request_xmlui in requests_data[0]: - xmlui = xmlui_manager.create(self.host, request_xmlui, whitelist=whitelist) - xmlui.show(values_only=True) - self.disp(u'') - self.host.quit(C.EXIT_OK) - - def getRequests(self): - extra = {} - self.host.bridge.mergeRequestsGet( - self.args.service, - self.args.node, - self.args.max, - self.args.items, - u'', - extra, - self.profile, - callback=self.mergeRequestGetCb, - errback=partial(self.errback, - msg=_(u"can't get merge request: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def start(self): - common.URIFinder(self, os.getcwd(), 'merge requests', self.getRequests, meta_map={}) - - -class Import(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'import', - use_pubsub=True, pubsub_flags={C.SINGLE_ITEM, C.ITEM}, - pubsub_defaults = {u'service': _(u'auto'), u'node': _(u'auto')}, - help=_(u'import a merge request')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-r", "--repository", metavar="PATH", type=base.unicode_decoder, default=u'.', help=_(u"path of the repository (DEFAULT: current directory)")) - - def mergeRequestImportCb(self): - self.host.quit(C.EXIT_OK) - - def importRequest(self): - extra = {} - self.host.bridge.mergeRequestsImport( - self.repository, - self.args.item, - self.args.service, - self.args.node, - extra, - self.profile, - callback=self.mergeRequestImportCb, - errback=partial(self.errback, - msg=_(u"can't import merge request: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def start(self): - self.repository = os.path.expanduser(os.path.abspath(self.args.repository)) - common.URIFinder(self, self.repository, 'merge requests', self.importRequest, meta_map={}) - - -class MergeRequest(base.CommandBase): - subcommands = (Set, Get, Import) - - def __init__(self, host): - super(MergeRequest, self).__init__(host, 'merge-request', use_profile=False, help=_('merge-request management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_message.py --- a/frontends/src/jp/cmd_message.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,91 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -from sat_frontends.jp import base -import sys -from sat.core.i18n import _ -from sat.core.constants import Const as C -from sat.tools.utils import clean_ustr - -__commands__ = ["Message"] - - -class Send(base.CommandBase): - - def __init__(self, host): - super(Send, self).__init__(host, 'send', help=_('send a message to a contact')) - - def add_parser_options(self): - self.parser.add_argument("-l", "--lang", type=str, default='', help=_(u"language of the message")) - self.parser.add_argument("-s", "--separate", action="store_true", help=_(u"separate xmpp messages: send one message per line instead of one message alone.")) - self.parser.add_argument("-n", "--new-line", action="store_true", help=_(u"add a new line at the beginning of the input (usefull for ascii art ;))")) - self.parser.add_argument("-S", "--subject", type=base.unicode_decoder, help=_(u"subject of the message")) - self.parser.add_argument("-L", "--subject_lang", type=str, default='', help=_(u"language of subject")) - self.parser.add_argument("-t", "--type", choices=C.MESS_TYPE_STANDARD + (C.MESS_TYPE_AUTO,), default=C.MESS_TYPE_AUTO, help=_("type of the message")) - syntax = self.parser.add_mutually_exclusive_group() - syntax.add_argument("-x", "--xhtml", action="store_true", help=_(u"XHTML body")) - syntax.add_argument("-r", "--rich", action="store_true", help=_(u"rich body")) - self.parser.add_argument("jid", type=base.unicode_decoder, help=_(u"the destination jid")) - - def start(self): - if self.args.xhtml and self.args.separate: - self.disp(u"argument -s/--separate is not compatible yet with argument -x/--xhtml", error=True) - self.host.quit(2) - - jids = self.host.check_jids([self.args.jid]) - jid = jids[0] - self.sendStdin(jid) - - def sendStdin(self, dest_jid): - """Send incomming data on stdin to jabber contact - - @param dest_jid: destination jid - """ - header = "\n" if self.args.new_line else "" - stdin_lines = [stream.decode('utf-8','ignore') for stream in sys.stdin.readlines()] - extra = {} - if self.args.subject is None: - subject = {} - else: - subject = {self.args.subject_lang: self.args.subject} - - if self.args.xhtml or self.args.rich: - key = u"xhtml" if self.args.xhtml else u"rich" - if self.args.lang: - key = u"{}_{}".format(key, self.args.lang) - extra[key] = clean_ustr(u"".join(stdin_lines)) - stdin_lines = [] - - if self.args.separate: #we send stdin in several messages - if header: - self.host.bridge.messageSend(dest_jid, {self.args.lang: header}, subject, self.args.type, profile_key=self.profile, callback=lambda: None, errback=lambda ignore: ignore) - - for line in stdin_lines: - self.host.bridge.messageSend(dest_jid, {self.args.lang: line.replace("\n","")}, subject, self.args.type, extra, profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) - - else: - msg = {self.args.lang: header + clean_ustr(u"".join(stdin_lines))} if not (self.args.xhtml or self.args.rich) else {} - self.host.bridge.messageSend(dest_jid, msg, subject, self.args.type, extra, profile_key=self.host.profile, callback=lambda: None, errback=lambda ignore: ignore) - - -class Message(base.CommandBase): - subcommands = (Send, ) - - def __init__(self, host): - super(Message, self).__init__(host, 'message', use_profile=False, help=_('messages handling')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_param.py --- a/frontends/src/jp/cmd_param.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) -# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . - - -import base -from sat.core.i18n import _ -__commands__ = ["Param"] - - -class Get(base.CommandBase): - def __init__(self, host): - super(Get, self).__init__(host, 'get', need_connect=False, help=_('Get a parameter value')) - - def add_parser_options(self): - self.parser.add_argument("category", nargs='?', type=base.unicode_decoder, help=_(u"Category of the parameter")) - self.parser.add_argument("name", nargs='?', type=base.unicode_decoder, help=_(u"Name of the parameter")) - self.parser.add_argument("-a", "--attribute", type=str, default="value", help=_(u"Name of the attribute to get")) - self.parser.add_argument("--security-limit", type=int, default=-1, help=_(u"Security limit")) - - def start(self): - if self.args.category is None: - categories = self.host.bridge.getParamsCategories() - print u"\n".join(categories) - elif self.args.name is None: - try: - values_dict = self.host.bridge.asyncGetParamsValuesFromCategory(self.args.category, self.args.security_limit, self.profile) - except Exception as e: - print u"Can't find requested parameters: {}".format(e) - self.host.quit(1) - for name, value in values_dict.iteritems(): - print u"{}\t{}".format(name, value) - else: - try: - value = self.host.bridge.asyncGetParamA(self.args.name, self.args.category, self.args.attribute, - self.args.security_limit, self.profile) - except Exception as e: - print u"Can't find requested parameter: {}".format(e) - self.host.quit(1) - print value - - -class Set(base.CommandBase): - def __init__(self, host): - super(Set, self).__init__(host, 'set', need_connect=False, help=_('Set a parameter value')) - - def add_parser_options(self): - self.parser.add_argument("category", type=base.unicode_decoder, help=_(u"Category of the parameter")) - self.parser.add_argument("name", type=base.unicode_decoder, help=_(u"Name of the parameter")) - self.parser.add_argument("value", type=base.unicode_decoder, help=_(u"Name of the parameter")) - self.parser.add_argument("--security-limit", type=int, default=-1, help=_(u"Security limit")) - - def start(self): - try: - self.host.bridge.setParam(self.args.name, self.args.value, self.args.category, self.args.security_limit, self.profile) - except Exception as e: - print u"Can set requested parameter: {}".format(e) - - -class SaveTemplate(base.CommandBase): - def __init__(self, host): - super(SaveTemplate, self).__init__(host, 'save', use_profile=False, help=_('Save parameters template to xml file')) - - def add_parser_options(self): - self.parser.add_argument("filename", type=str, help=_("Output file")) - - def start(self): - """Save parameters template to xml file""" - if self.host.bridge.saveParamsTemplate(self.args.filename): - print _("Parameters saved to file %s") % self.args.filename - else: - print _("Can't save parameters to file %s") % self.args.filename - - -class LoadTemplate(base.CommandBase): - - def __init__(self, host): - super(LoadTemplate, self).__init__(host, 'load', use_profile=False, help=_('Load parameters template from xml file')) - - def add_parser_options(self): - self.parser.add_argument("filename", type=str, help=_("Input file")) - - def start(self): - """Load parameters template from xml file""" - if self.host.bridge.loadParamsTemplate(self.args.filename): - print _("Parameters loaded from file %s") % self.args.filename - else: - print _("Can't load parameters from file %s") % self.args.filename - - -class Param(base.CommandBase): - subcommands = (Get, Set, SaveTemplate, LoadTemplate) - - def __init__(self, host): - super(Param, self).__init__(host, 'param', use_profile=False, help=_('Save/load parameters template')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_pipe.py --- a/frontends/src/jp/cmd_pipe.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,151 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -from sat_frontends.jp import base - -from sat_frontends.jp.constants import Const as C -import sys -from sat.core.i18n import _ -from sat_frontends.tools import jid -import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI -from functools import partial -import socket -import SocketServer -import errno - -__commands__ = ["Pipe"] - -START_PORT = 9999 - -class PipeOut(base.CommandBase): - - def __init__(self, host): - super(PipeOut, self).__init__(host, 'out', help=_('send a pipe a stream')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument("jid", type=base.unicode_decoder, help=_("the destination jid")) - - def streamOutCb(self, port): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect(('127.0.0.1', int(port))) - while True: - buf = sys.stdin.read(4096) - if not buf: - break - try: - s.sendall(buf) - except socket.error as e: - if e.errno == errno.EPIPE: - sys.stderr.write(str(e) + '\n') - self.host.quit(1) - else: - raise e - self.host.quit() - - def start(self): - """ Create named pipe, and send stdin to it """ - self.host.bridge.streamOut( - self.host.get_full_jid(self.args.jid), - self.profile, - callback=self.streamOutCb, - errback=partial(self.errback, - msg=_(u"can't start stream: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class StreamServer(SocketServer.BaseRequestHandler): - - def handle(self): - while True: - data = self.request.recv(4096) - if not data: - break - sys.stdout.write(data) - try: - sys.stdout.flush() - except IOError as e: - sys.stderr.write(str(e) + '\n') - break - # calling shutdown will do a deadlock as we don't use separate thread - # this is a workaround (cf. https://stackoverflow.com/a/36017741) - self.server._BaseServer__shutdown_request = True - - -class PipeIn(base.CommandAnswering): - - def __init__(self, host): - super(PipeIn, self).__init__(host, 'in', help=_('receive a pipe stream')) - self.action_callbacks = {"STREAM": self.onStreamAction} - - def add_parser_options(self): - self.parser.add_argument("jids", type=base.unicode_decoder, nargs="*", help=_('Jids accepted (none means "accept everything")')) - - def getXmluiId(self, action_data): - # FIXME: we temporarily use ElementTree, but a real XMLUI managing module - # should be available in the future - # TODO: XMLUI module - try: - xml_ui = action_data['xmlui'] - except KeyError: - self.disp(_(u"Action has no XMLUI"), 1) - else: - ui = ET.fromstring(xml_ui.encode('utf-8')) - xmlui_id = ui.get('submit') - if not xmlui_id: - self.disp(_(u"Invalid XMLUI received"), error=True) - return xmlui_id - - def onStreamAction(self, action_data, action_id, security_limit, profile): - xmlui_id = self.getXmluiId(action_data) - if xmlui_id is None: - return self.host.quitFromSignal(1) - try: - from_jid = jid.JID(action_data['meta_from_jid']) - except KeyError: - self.disp(_(u"Ignoring action without from_jid data"), 1) - return - - if not self.bare_jids or from_jid.bare in self.bare_jids: - host, port = "localhost", START_PORT - while True: - try: - server = SocketServer.TCPServer((host, port), StreamServer) - except socket.error as e: - if e.errno == errno.EADDRINUSE: - port += 1 - else: - raise e - else: - break - xmlui_data = {'answer': C.BOOL_TRUE, - 'port': unicode(port)} - self.host.bridge.launchAction(xmlui_id, xmlui_data, profile_key=profile) - server.serve_forever() - self.host.quitFromSignal() - - def start(self): - self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids] - - -class Pipe(base.CommandBase): - subcommands = (PipeOut, PipeIn) - - def __init__(self, host): - super(Pipe, self).__init__(host, 'pipe', use_profile=False, help=_('stream piping through XMPP')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_profile.py --- a/frontends/src/jp/cmd_profile.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,209 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# 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 . - -"""This module permits to manage profiles. It can list, create, delete -and retrieve information about a profile.""" - -from sat_frontends.jp.constants import Const as C -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core.i18n import _ -from sat_frontends.jp import base -from functools import partial - -__commands__ = ["Profile"] - -PROFILE_HELP = _('The name of the profile') - - -class ProfileConnect(base.CommandBase): - """Dummy command to use profile_session parent, i.e. to be able to connect without doing anything else""" - - def __init__(self, host): - # it's weird to have a command named "connect" with need_connect=False, but it can be handy to be able - # to launch just the session, so some paradoxes don't hurt - super(ProfileConnect, self).__init__(host, 'connect', need_connect=False, help=(u'connect a profile')) - - def add_parser_options(self): - pass - - -class ProfileDisconnect(base.CommandBase): - - def __init__(self, host): - super(ProfileDisconnect, self).__init__(host, 'disconnect', need_connect=False, help=(u'disconnect a profile')) - self.need_loop = True - - def add_parser_options(self): - pass - - def start(self): - self.host.bridge.disconnect(self.args.profile, callback=self.host.quit) - - -class ProfileDefault(base.CommandBase): - def __init__(self, host): - super(ProfileDefault, self).__init__(host, 'default', use_profile=False, help=(u'print default profile')) - - def add_parser_options(self): - pass - - def start(self): - print self.host.bridge.profileNameGet('@DEFAULT@') - - -class ProfileDelete(base.CommandBase): - def __init__(self, host): - super(ProfileDelete, self).__init__(host, 'delete', use_profile=False, help=(u'delete a profile')) - - def add_parser_options(self): - self.parser.add_argument('profile', type=str, help=PROFILE_HELP) - self.parser.add_argument('-f', '--force', action='store_true', help=_(u'delete profile without confirmation')) - - def start(self): - if self.args.profile not in self.host.bridge.profilesListGet(): - log.error("Profile %s doesn't exist." % self.args.profile) - self.host.quit(1) - if not self.args.force: - message = u"Are you sure to delete profile [{}] ?".format(self.args.profile) - res = raw_input("{} (y/N)? ".format(message)) - if res not in ("y", "Y"): - self.disp(_(u"Profile deletion cancelled")) - self.host.quit(2) - - self.host.bridge.asyncDeleteProfile(self.args.profile, callback=lambda dummy: None) - - -class ProfileInfo(base.CommandBase): - def __init__(self, host): - super(ProfileInfo, self).__init__(host, 'info', need_connect=False, help=_(u'get information about a profile')) - self.need_loop = True - self.to_show = [(_(u"jid"), "Connection", "JabberID"),] - self.largest = max([len(item[0]) for item in self.to_show]) - - - def add_parser_options(self): - self.parser.add_argument('--show-password', action='store_true', help=_(u'show the XMPP password IN CLEAR TEXT')) - - def showNextValue(self, label=None, category=None, value=None): - """Show next value from self.to_show and quit on last one""" - if label is not None: - print((u"{label:<"+unicode(self.largest+2)+"}{value}").format(label=label+": ", value=value)) - try: - label, category, name = self.to_show.pop(0) - except IndexError: - self.host.quit() - else: - self.host.bridge.asyncGetParamA(name, category, profile_key=self.host.profile, - callback=lambda value: self.showNextValue(label, category, value)) - - def start(self): - if self.args.show_password: - self.to_show.append((_(u"XMPP password"), "Connection", "Password")) - self.showNextValue() - - -class ProfileList(base.CommandBase): - def __init__(self, host): - super(ProfileList, self).__init__(host, 'list', use_profile=False, use_output='list', help=(u'list profiles')) - - def add_parser_options(self): - group = self.parser.add_mutually_exclusive_group() - group.add_argument('-c', '--clients', action='store_true', help=_(u'get clients profiles only')) - group.add_argument('-C', '--components', action='store_true', help=(u'get components profiles only')) - - - def start(self): - if self.args.clients: - clients, components = True, False - elif self.args.components: - clients, components = False, True - else: - clients, components = True, True - self.output(self.host.bridge.profilesListGet(clients, components)) - - -class ProfileCreate(base.CommandBase): - def __init__(self, host): - super(ProfileCreate, self).__init__(host, 'create', use_profile=False, help=(u'create a new profile')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument('profile', type=str, help=_(u'the name of the profile')) - self.parser.add_argument('-p', '--password', type=str, default='', help=_(u'the password of the profile')) - self.parser.add_argument('-j', '--jid', type=str, help=_(u'the jid of the profile')) - self.parser.add_argument('-x', '--xmpp-password', type=str, help=_(u'the password of the XMPP account (use profile password if not specified)'), - metavar='PASSWORD') - self.parser.add_argument('-C', '--component', type=base.unicode_decoder, default='', - help=_(u'set to component import name (entry point) if this is a component')) - - def _session_started(self, dummy): - if self.args.jid: - self.host.bridge.setParam("JabberID", self.args.jid, "Connection", profile_key=self.args.profile) - xmpp_pwd = self.args.password or self.args.xmpp_password - if xmpp_pwd: - self.host.bridge.setParam("Password", xmpp_pwd, "Connection", profile_key=self.args.profile) - self.host.quit() - - def _profile_created(self): - self.host.bridge.profileStartSession(self.args.password, self.args.profile, callback=self._session_started, errback=None) - - def start(self): - """Create a new profile""" - if self.args.profile in self.host.bridge.profilesListGet(): - log.error("Profile %s already exists." % self.args.profile) - self.host.quit(1) - self.host.bridge.profileCreate(self.args.profile, self.args.password, self.args.component, - callback=self._profile_created, - errback=partial(self.errback, - msg=_(u"can't create profile: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class ProfileModify(base.CommandBase): - def __init__(self, host): - super(ProfileModify, self).__init__(host, 'modify', need_connect=False, help=_(u'modify an existing profile')) - - def add_parser_options(self): - profile_pwd_group = self.parser.add_mutually_exclusive_group() - profile_pwd_group.add_argument('-w', '--password', type=base.unicode_decoder, help=_(u'change the password of the profile')) - profile_pwd_group.add_argument('--disable-password', action='store_true', help=_(u'disable profile password (dangerous!)')) - self.parser.add_argument('-j', '--jid', type=base.unicode_decoder, help=_(u'the jid of the profile')) - self.parser.add_argument('-x', '--xmpp-password', type=base.unicode_decoder, help=_(u'change the password of the XMPP account'), - metavar='PASSWORD') - self.parser.add_argument('-D', '--default', action='store_true', help=_(u'set as default profile')) - - def start(self): - if self.args.disable_password: - self.args.password = '' - if self.args.password is not None: - self.host.bridge.setParam("Password", self.args.password, "General", profile_key=self.host.profile) - if self.args.jid is not None: - self.host.bridge.setParam("JabberID", self.args.jid, "Connection", profile_key=self.host.profile) - if self.args.xmpp_password is not None: - self.host.bridge.setParam("Password", self.args.xmpp_password, "Connection", profile_key=self.host.profile) - if self.args.default: - self.host.bridge.profileSetDefault(self.host.profile) - - -class Profile(base.CommandBase): - subcommands = (ProfileConnect, ProfileDisconnect, ProfileCreate, ProfileDefault, ProfileDelete, ProfileInfo, ProfileList, ProfileModify) - - def __init__(self, host): - super(Profile, self).__init__(host, 'profile', use_profile=False, help=_(u'profile commands')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_pubsub.py --- a/frontends/src/jp/cmd_pubsub.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1198 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat.core import exceptions -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import common -from sat_frontends.jp import arg_tools -from functools import partial -from sat.tools.common import uri -from sat.tools.common.ansi import ANSI as A -from sat_frontends.tools import jid, strings -import argparse -import os.path -import re -import subprocess -import sys - -__commands__ = ["Pubsub"] - -PUBSUB_TMP_DIR = u"pubsub" -PUBSUB_SCHEMA_TMP_DIR = PUBSUB_TMP_DIR + "_schema" -ALLOWED_SUBSCRIPTIONS_OWNER = ('subscribed', 'pending', 'none') - -# TODO: need to split this class in several modules, plugin should handle subcommands - - -class NodeInfo(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'info', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'retrieve node configuration')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-k", "--key", type=base.unicode_decoder, action='append', dest='keys', - help=_(u"data key to filter")) - - def removePrefix(self, key): - return key[7:] if key.startswith(u"pubsub#") else key - - def filterKey(self, key): - return any((key == k or key == u'pubsub#' + k) for k in self.args.keys) - - def psNodeConfigurationGetCb(self, config_dict): - key_filter = (lambda k: True) if not self.args.keys else self.filterKey - config_dict = {self.removePrefix(k):v for k,v in config_dict.iteritems() if key_filter(k)} - self.output(config_dict) - self.host.quit() - - def psNodeConfigurationGetEb(self, failure_): - self.disp(u"can't get node configuration: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psNodeConfigurationGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psNodeConfigurationGetCb, - errback=self.psNodeConfigurationGetEb) - - -class NodeCreate(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'create', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'create a node')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-f", "--field", type=base.unicode_decoder, action='append', nargs=2, dest='fields', - default=[], metavar=(u"KEY", u"VALUE"), help=_(u"configuration field to set")) - self.parser.add_argument("-F", "--full-prefix", action="store_true", help=_(u"don't prepend \"pubsub#\" prefix to field names")) - - def psNodeCreateCb(self, node_id): - if self.host.verbosity: - announce = _(u'node created successfully: ') - else: - announce = u'' - self.disp(announce + node_id) - self.host.quit() - - def psNodeCreateEb(self, failure_): - self.disp(u"can't create: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - if not self.args.full_prefix: - options = {u'pubsub#' + k: v for k,v in self.args.fields} - else: - options = dict(self.args.fields) - self.host.bridge.psNodeCreate( - self.args.service, - self.args.node, - options, - self.profile, - callback=self.psNodeCreateCb, - errback=partial(self.errback, - msg=_(u"can't create node: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class NodeDelete(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'delete', use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'delete a node')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-f', '--force', action='store_true', help=_(u'delete node without confirmation')) - - def psNodeDeleteCb(self): - self.disp(_(u'node [{node}] deleted successfully').format(node=self.args.node)) - self.host.quit() - - def start(self): - if not self.args.force: - if not self.args.service: - message = _(u"Are you sure to delete pep node [{node_id}] ?").format( - node_id=self.args.node) - else: - message = _(u"Are you sure to delete node [{node_id}] on service [{service}] ?").format( - node_id=self.args.node, service=self.args.service) - self.host.confirmOrQuit(message, _(u"node deletion cancelled")) - - self.host.bridge.psNodeDelete( - self.args.service, - self.args.node, - self.profile, - callback=self.psNodeDeleteCb, - errback=partial(self.errback, - msg=_(u"can't delete node: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class NodeSet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'set node configuration')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-f", "--field", type=base.unicode_decoder, action='append', nargs=2, dest='fields', - required=True, metavar=(u"KEY", u"VALUE"), help=_(u"configuration field to set (required)")) - - def psNodeConfigurationSetCb(self): - self.disp(_(u'node configuration successful'), 1) - self.host.quit() - - def psNodeConfigurationSetEb(self, failure_): - self.disp(u"can't set node configuration: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def getKeyName(self, k): - if not k.startswith(u'pubsub#'): - return u'pubsub#' + k - else: - return k - - def start(self): - self.host.bridge.psNodeConfigurationSet( - self.args.service, - self.args.node, - {self.getKeyName(k): v for k,v in self.args.fields}, - self.profile, - callback=self.psNodeConfigurationSetCb, - errback=self.psNodeConfigurationSetEb) - - -class NodeAffiliationsGet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'retrieve node affiliations (for node owner)')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psNodeAffiliationsGetCb(self, affiliations): - self.output(affiliations) - self.host.quit() - - def psNodeAffiliationsGetEb(self, failure_): - self.disp(u"can't get node affiliations: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psNodeAffiliationsGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psNodeAffiliationsGetCb, - errback=self.psNodeAffiliationsGetEb) - - -class NodeAffiliationsSet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'set affiliations (for node owner)')) - self.need_loop=True - - def add_parser_options(self): - # XXX: we use optional argument syntax for a required one because list of list of 2 elements - # (uses to construct dicts) don't work with positional arguments - self.parser.add_argument("-a", - "--affiliation", - dest="affiliations", - metavar=('JID', 'AFFILIATION'), - required=True, - type=base.unicode_decoder, - action="append", - nargs=2, - help=_(u"entity/affiliation couple(s)")) - - def psNodeAffiliationsSetCb(self): - self.disp(_(u"affiliations have been set"), 1) - self.host.quit() - - def psNodeAffiliationsSetEb(self, failure_): - self.disp(u"can't set node affiliations: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - affiliations = dict(self.args.affiliations) - self.host.bridge.psNodeAffiliationsSet( - self.args.service, - self.args.node, - affiliations, - self.profile, - callback=self.psNodeAffiliationsSetCb, - errback=self.psNodeAffiliationsSetEb) - - -class NodeAffiliations(base.CommandBase): - subcommands = (NodeAffiliationsGet, NodeAffiliationsSet) - - def __init__(self, host): - super(NodeAffiliations, self).__init__(host, 'affiliations', use_profile=False, help=_(u'set or retrieve node affiliations')) - - -class NodeSubscriptionsGet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_output=C.OUTPUT_DICT, use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'retrieve node subscriptions (for node owner)')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psNodeSubscriptionsGetCb(self, subscriptions): - self.output(subscriptions) - self.host.quit() - - def psNodeSubscriptionsGetEb(self, failure_): - self.disp(u"can't get node subscriptions: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psNodeSubscriptionsGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psNodeSubscriptionsGetCb, - errback=self.psNodeSubscriptionsGetEb) - - -class StoreSubscriptionAction(argparse.Action): - """Action which handle subscription parameter for owner - - list is given by pairs: jid and subscription state - if subscription state is not specified, it default to "subscribed" - """ - - def __call__(self, parser, namespace, values, option_string): - dest_dict = getattr(namespace, self.dest) - while values: - jid_s = values.pop(0) - try: - subscription = values.pop(0) - except IndexError: - subscription = 'subscribed' - if subscription not in ALLOWED_SUBSCRIPTIONS_OWNER: - parser.error(_(u"subscription must be one of {}").format(u', '.join(ALLOWED_SUBSCRIPTIONS_OWNER))) - dest_dict[jid_s] = subscription - - -class NodeSubscriptionsSet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'set/modify subscriptions (for node owner)')) - self.need_loop=True - - def add_parser_options(self): - # XXX: we use optional argument syntax for a required one because list of list of 2 elements - # (uses to construct dicts) don't work with positional arguments - self.parser.add_argument("-S", - "--subscription", - dest="subscriptions", - default={}, - nargs='+', - metavar=('JID [SUSBSCRIPTION]'), - required=True, - type=base.unicode_decoder, - action=StoreSubscriptionAction, - help=_(u"entity/subscription couple(s)")) - - def psNodeSubscriptionsSetCb(self): - self.disp(_(u"subscriptions have been set"), 1) - self.host.quit() - - def psNodeSubscriptionsSetEb(self, failure_): - self.disp(u"can't set node subscriptions: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psNodeSubscriptionsSet( - self.args.service, - self.args.node, - self.args.subscriptions, - self.profile, - callback=self.psNodeSubscriptionsSetCb, - errback=self.psNodeSubscriptionsSetEb) - - -class NodeSubscriptions(base.CommandBase): - subcommands = (NodeSubscriptionsGet, NodeSubscriptionsSet) - - def __init__(self, host): - super(NodeSubscriptions, self).__init__(host, 'subscriptions', use_profile=False, help=_(u'get or modify node subscriptions')) - - -class NodeSchemaSet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'set/replace a schema')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument('schema', help=_(u"schema to set (must be XML)")) - - def psSchemaSetCb(self): - self.disp(_(u'schema has been set'), 1) - self.host.quit() - - def start(self): - self.host.bridge.psSchemaSet( - self.args.service, - self.args.node, - self.args.schema, - self.profile, - callback=self.psSchemaSetCb, - errback=partial(self.errback, - msg=_(u"can't set schema: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class NodeSchemaEdit(base.CommandBase, common.BaseEdit): - use_items=False - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'edit', use_pubsub=True, pubsub_flags={C.NODE}, use_draft=True, use_verbose=True, help=_(u'edit a schema')) - common.BaseEdit.__init__(self, self.host, PUBSUB_SCHEMA_TMP_DIR) - self.need_loop=True - - def add_parser_options(self): - pass - - def psSchemaSetCb(self): - self.disp(_(u'schema has been set'), 1) - self.host.quit() - - def publish(self, schema): - self.host.bridge.psSchemaSet( - self.args.service, - self.args.node, - schema, - self.profile, - callback=self.psSchemaSetCb, - errback=partial(self.errback, - msg=_(u"can't set schema: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - def psSchemaGetCb(self, schema): - try: - from lxml import etree - except ImportError: - self.disp(u"lxml module must be installed to use edit, please install it with \"pip install lxml\"", error=True) - self.host.quit(1) - content_file_obj, content_file_path = self.getTmpFile() - schema = schema.strip() - if schema: - parser = etree.XMLParser(remove_blank_text=True) - schema_elt = etree.fromstring(schema, parser) - content_file_obj.write(etree.tostring(schema_elt, encoding="utf-8", pretty_print=True)) - content_file_obj.seek(0) - self.runEditor("pubsub_schema_editor_args", content_file_path, content_file_obj) - - def start(self): - self.host.bridge.psSchemaGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psSchemaGetCb, - errback=partial(self.errback, - msg=_(u"can't edit schema: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class NodeSchemaGet(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_output=C.OUTPUT_XML, use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'get schema')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psSchemaGetCb(self, schema): - if not schema: - self.disp(_(u'no schema found'), 1) - self.host.quit(1) - self.output(schema) - self.host.quit() - - def start(self): - self.host.bridge.psSchemaGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psSchemaGetCb, - errback=partial(self.errback, - msg=_(u"can't get schema: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class NodeSchema(base.CommandBase): - subcommands = (NodeSchemaSet, NodeSchemaEdit, NodeSchemaGet) - - def __init__(self, host): - super(NodeSchema, self).__init__(host, 'schema', use_profile=False, help=_(u"data schema manipulation")) - - -class Node(base.CommandBase): - subcommands = (NodeInfo, NodeCreate, NodeDelete, NodeSet, NodeAffiliations, NodeSubscriptions, NodeSchema) - - def __init__(self, host): - super(Node, self).__init__(host, 'node', use_profile=False, help=_('node handling')) - - -class Set(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'set', use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'publish a new item or update an existing one')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("item", type=base.unicode_decoder, nargs='?', default=u'', help=_(u"id, URL of the item to update, keyword, or nothing for new item")) - - def psItemsSendCb(self, published_id): - if published_id: - self.disp(u"Item published at {pub_id}".format(pub_id=published_id)) - else: - self.disp(u"Item published") - self.host.quit(C.EXIT_OK) - - def start(self): - try: - from lxml import etree - except ImportError: - self.disp(u"lxml module must be installed to use edit, please install it with \"pip install lxml\"", error=True) - self.host.quit(1) - try: - element = etree.parse(sys.stdin).getroot() - except Exception as e: - self.parser.error(_(u"Can't parse the payload XML in input: {msg}").format(msg=e)) - if element.tag in ('item', '{http://jabber.org/protocol/pubsub}item'): - if len(element) > 1: - self.parser.error(_(u" can only have one child element (the payload)")) - element = element[0] - payload = etree.tostring(element, encoding='unicode') - - self.host.bridge.psItemSend(self.args.service, - self.args.node, - payload, - self.args.item, - {}, - self.profile, - callback=self.psItemsSendCb, - errback=partial(self.errback, - msg=_(u"can't send item: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Get(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'get', use_output=C.OUTPUT_LIST_XML, use_pubsub=True, pubsub_flags={C.NODE, C.MULTI_ITEMS}, help=_(u'get pubsub item(s)')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-S", "--sub-id", type=base.unicode_decoder, default=u'', - help=_(u"subscription id")) - # TODO: a key(s) argument to select keys to display - # TODO: add MAM filters - - - def psItemsGetCb(self, ps_result): - self.output(ps_result[0]) - self.host.quit(C.EXIT_OK) - - def psItemsGetEb(self, failure_): - self.disp(u"can't get pubsub items: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psItemsGet( - self.args.service, - self.args.node, - self.args.max, - self.args.items, - self.args.sub_id, - {}, - self.profile, - callback=self.psItemsGetCb, - errback=self.psItemsGetEb) - -class Delete(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'delete', use_pubsub=True, pubsub_flags={C.NODE, C.SINGLE_ITEM}, help=_(u'delete an item')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-f", "--force", action='store_true', help=_(u"delete without confirmation")) - self.parser.add_argument("-N", "--notify", action='store_true', help=_(u"notify deletion")) - - def psItemsDeleteCb(self): - self.disp(_(u'item {item_id} has been deleted').format(item_id=self.args.item)) - self.host.quit(C.EXIT_OK) - - def start(self): - if not self.args.item: - self.parser.error(_(u"You need to specify an item to delete")) - if not self.args.force: - message = _(u"Are you sure to delete item {item_id} ?").format(item_id=self.args.item) - self.host.confirmOrQuit(message, _(u"item deletion cancelled")) - self.host.bridge.psRetractItem( - self.args.service, - self.args.node, - self.args.item, - self.args.notify, - self.profile, - callback=self.psItemsDeleteCb, - errback=partial(self.errback, - msg=_(u"can't delete item: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Edit(base.CommandBase, common.BaseEdit): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'edit', use_verbose=True, use_pubsub=True, - pubsub_flags={C.NODE, C.SINGLE_ITEM}, use_draft=True, help=_(u'edit an existing or new pubsub item')) - common.BaseEdit.__init__(self, self.host, PUBSUB_TMP_DIR) - - def add_parser_options(self): - pass - - def edit(self, content_file_path, content_file_obj): - # we launch editor - self.runEditor("pubsub_editor_args", content_file_path, content_file_obj) - - def publish(self, content): - published_id = self.host.bridge.psItemSend(self.pubsub_service, self.pubsub_node, content, self.pubsub_item or '', {}, self.profile) - if published_id: - self.disp(u"Item published at {pub_id}".format(pub_id=published_id)) - else: - self.disp(u"Item published") - - def getItemData(self, service, node, item): - try: - from lxml import etree - except ImportError: - self.disp(u"lxml module must be installed to use edit, please install it with \"pip install lxml\"", error=True) - self.host.quit(1) - items = [item] if item is not None else [] - item_raw = self.host.bridge.psItemsGet(service, node, 1, items, "", {}, self.profile)[0][0] - parser = etree.XMLParser(remove_blank_text=True) - item_elt = etree.fromstring(item_raw, parser) - item_id = item_elt.get('id') - try: - payload = item_elt[0] - except IndexError: - self.disp(_(u'Item has not payload'), 1) - return u'' - return etree.tostring(payload, encoding="unicode", pretty_print=True), item_id - - def start(self): - self.pubsub_service, self.pubsub_node, self.pubsub_item, content_file_path, content_file_obj = self.getItemPath() - self.edit(content_file_path, content_file_obj) - - -class Subscribe(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'subscribe', use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'subscribe to a node')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psSubscribeCb(self, sub_id): - self.disp(_(u'subscription done'), 1) - if sub_id: - self.disp(_(u'subscription id: {sub_id}').format(sub_id=sub_id)) - self.host.quit() - - def start(self): - self.host.bridge.psSubscribe( - self.args.service, - self.args.node, - {}, - self.profile, - callback=self.psSubscribeCb, - errback=partial(self.errback, - msg=_(u"can't subscribe to node: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Unsubscribe(base.CommandBase): - # TODO: voir pourquoi NodeNotFound sur subscribe juste après unsubscribe - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'unsubscribe', use_pubsub=True, pubsub_flags={C.NODE}, use_verbose=True, help=_(u'unsubscribe from a node')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psUnsubscribeCb(self): - self.disp(_(u'subscription removed'), 1) - self.host.quit() - - def start(self): - self.host.bridge.psUnsubscribe( - self.args.service, - self.args.node, - self.profile, - callback=self.psUnsubscribeCb, - errback=partial(self.errback, - msg=_(u"can't unsubscribe from node: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Subscriptions(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'subscriptions', use_output=C.OUTPUT_LIST_DICT, use_pubsub=True, help=_(u'retrieve all subscriptions on a service')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psSubscriptionsGetCb(self, subscriptions): - self.output(subscriptions) - self.host.quit() - - def start(self): - self.host.bridge.psSubscriptionsGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psSubscriptionsGetCb, - errback=partial(self.errback, - msg=_(u"can't retrieve subscriptions: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Affiliations(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'affiliations', use_output=C.OUTPUT_DICT, use_pubsub=True, help=_(u'retrieve all affiliations on a service')) - self.need_loop=True - - def add_parser_options(self): - pass - - def psAffiliationsGetCb(self, affiliations): - self.output(affiliations) - self.host.quit() - - def psAffiliationsGetEb(self, failure_): - self.disp(u"can't get node affiliations: {reason}".format( - reason=failure_), error=True) - self.host.quit(C.EXIT_BRIDGE_ERRBACK) - - def start(self): - self.host.bridge.psAffiliationsGet( - self.args.service, - self.args.node, - self.profile, - callback=self.psAffiliationsGetCb, - errback=self.psAffiliationsGetEb) - - -class Search(base.CommandBase): - """this command to a search without using MAM, i.e. by checking every items if dound by itself, so it may be heavy in resources both for server and client""" - RE_FLAGS = re.MULTILINE | re.UNICODE - EXEC_ACTIONS = (u'exec', u'external') - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'search', use_output=C.OUTPUT_XML, use_pubsub=True, pubsub_flags={C.MULTI_ITEMS, C.NO_MAX}, - use_verbose=True, help=_(u'search items corresponding to filters')) - self.need_loop=True - - @property - def etree(self): - """load lxml.etree only if needed""" - if self._etree is None: - from lxml import etree - self._etree = etree - return self._etree - - def filter_opt(self, value, type_): - value = base.unicode_decoder(value) - return (type_, value) - - def filter_flag(self, value, type_): - value = C.bool(value) - return (type_, value) - - def add_parser_options(self): - self.parser.add_argument("-D", "--max-depth", type=int, default=0, help=_(u"maximum depth of recursion (will search linked nodes if > 0, default: 0)")) - self.parser.add_argument("-m", "--max", type=int, default=30, help=_(u"maximum number of items to get per node ({} to get all items, default: 30)".format(C.NO_LIMIT))) - self.parser.add_argument("-N", "--namespace", action='append', nargs=2, default=[], - metavar="NAME NAMESPACE", help=_(u"namespace to use for xpath")) - - # filters - filter_text = partial(self.filter_opt, type_=u'text') - filter_re = partial(self.filter_opt, type_=u'regex') - filter_xpath = partial(self.filter_opt, type_=u'xpath') - filter_python = partial(self.filter_opt, type_=u'python') - filters = self.parser.add_argument_group(_(u'filters'), _(u'only items corresponding to following filters will be kept')) - filters.add_argument("-t", "--text", - action='append', dest='filters', type=filter_text, - metavar='TEXT', - help=_(u"full text filter, item must contain this string (XML included)")) - filters.add_argument("-r", "--regex", - action='append', dest='filters', type=filter_re, - metavar='EXPRESSION', - help=_(u"like --text but using a regular expression")) - filters.add_argument("-x", "--xpath", - action='append', dest='filters', type=filter_xpath, - metavar='XPATH', - help=_(u"filter items which has elements matching this xpath")) - filters.add_argument("-P", "--python", - action='append', dest='filters', type=filter_python, - metavar='PYTHON_CODE', - help=_(u'Python expression which much return a bool (True to keep item, False to reject it). "item" is raw text item, "item_xml" is lxml\'s etree.Element')) - - # filters flags - flag_case = partial(self.filter_flag, type_=u'ignore-case') - flag_invert = partial(self.filter_flag, type_=u'invert') - flag_dotall = partial(self.filter_flag, type_=u'dotall') - flag_matching = partial(self.filter_flag, type_=u'only-matching') - flags = self.parser.add_argument_group(_(u'filters flags'), _(u'filters modifiers (change behaviour of following filters)')) - flags.add_argument("-C", "--ignore-case", - action='append', dest='filters', type=flag_case, - const=('ignore-case', True), nargs='?', - metavar='BOOLEAN', - help=_(u"(don't) ignore case in following filters (default: case sensitive)")) - flags.add_argument("-I", "--invert", - action='append', dest='filters', type=flag_invert, - const=('invert', True), nargs='?', - metavar='BOOLEAN', - help=_(u"(don't) invert effect of following filters (default: don't invert)")) - flags.add_argument("-A", "--dot-all", - action='append', dest='filters', type=flag_dotall, - const=('dotall', True), nargs='?', - metavar='BOOLEAN', - help=_(u"(don't) use DOTALL option for regex (default: don't use)")) - flags.add_argument("-o", "--only-matching", - action='append', dest='filters', type=flag_matching, - const=('only-matching', True), nargs='?', - metavar='BOOLEAN', - help=_(u"keep only the matching part of the item")) - - # action - self.parser.add_argument("action", - default="print", - nargs='?', - choices=('print', 'exec', 'external'), - help=_(u"action to do on found items (default: print)")) - self.parser.add_argument("command", nargs=argparse.REMAINDER) - - def psItemsGetEb(self, failure_, service, node): - self.disp(u"can't get pubsub items at {service} (node: {node}): {reason}".format( - service=service, - node=node, - reason=failure_), error=True) - self.to_get -= 1 - - def getItems(self, depth, service, node, items): - search = partial(self.search, depth=depth) - errback = partial(self.psItemsGetEb, service=service, node=node) - self.host.bridge.psItemsGet( - service, - node, - self.args.max, - items, - "", - {}, - self.profile, - callback=search, - errback=errback - ) - self.to_get += 1 - - def _checkPubsubURL(self, match, found_nodes): - """check that the matched URL is an xmpp: one - - @param found_nodes(list[unicode]): found_nodes - this list will be filled while xmpp: URIs are discovered - """ - url = match.group(0) - if url.startswith(u'xmpp'): - try: - url_data = uri.parseXMPPUri(url) - except ValueError: - return - if url_data[u'type'] == u'pubsub': - found_node = {u'service': url_data[u'path'], - u'node': url_data[u'node']} - if u'item' in url_data: - found_node[u'item'] = url_data[u'item'] - found_nodes.append(found_node) - - def getSubNodes(self, item, depth): - """look for pubsub URIs in item, and getItems on the linked nodes""" - found_nodes = [] - checkURI = partial(self._checkPubsubURL, found_nodes=found_nodes) - strings.RE_URL.sub(checkURI, item) - for data in found_nodes: - self.getItems(depth+1, - data[u'service'], - data[u'node'], - [data[u'item']] if u'item' in data else [] - ) - - def parseXml(self, item): - try: - return self.etree.fromstring(item) - except self.etree.XMLSyntaxError: - self.disp(_(u"item doesn't looks like XML, you have probably used --only-matching somewhere before and we have no more XML"), error=True) - self.host.quit(C.EXIT_BAD_ARG) - - def filter(self, item): - """apply filters given on command line - - if only-matching is used, item may be modified - @return (tuple[bool, unicode]): a tuple with: - - keep: True if item passed the filters - - item: it is returned in case of modifications - """ - ignore_case = False - invert = False - dotall = False - only_matching = False - item_xml = None - for type_, value in self.args.filters: - keep = True - - ## filters - - if type_ == u'text': - if ignore_case: - if value.lower() not in item.lower(): - keep = False - else: - if value not in item: - keep = False - if keep and only_matching: - # doesn't really make sens to keep a fixed string - # so we raise an error - self.host.disp(_(u"--only-matching used with fixed --text string, are you sure?"), error=True) - self.host.quit(C.EXIT_BAD_ARG) - elif type_ == u'regex': - flags = self.RE_FLAGS - if ignore_case: - flags |= re.IGNORECASE - if dotall: - flags |= re.DOTALL - match = re.search(value, item, flags) - keep = match != None - if keep and only_matching: - item = match.group() - item_xml = None - elif type_ == u'xpath': - if item_xml is None: - item_xml = self.parseXml(item) - try: - elts = item_xml.xpath(value, namespaces=self.args.namespace) - except self.etree.XPathEvalError as e: - self.disp(_(u"can't use xpath: {reason}").format(reason=e), error=True) - self.host.quit(C.EXIT_BAD_ARG) - keep = bool(elts) - if keep and only_matching: - item_xml = elts[0] - try: - item = self.etree.tostring(item_xml, encoding='unicode') - except TypeError: - # we have a string only, not an element - item = unicode(item_xml) - item_xml = None - elif type_ == u'python': - if item_xml is None: - item_xml = self.parseXml(item) - cmd_ns = {u'item': item, - u'item_xml': item_xml - } - try: - keep = eval(value, cmd_ns) - except SyntaxError as e: - self.disp(unicode(e), error=True) - self.host.quit(C.EXIT_BAD_ARG) - - ## flags - - elif type_ == u'ignore-case': - ignore_case = value - elif type_ == u'invert': - invert = value - # we need to continue, else loop would end here - continue - elif type_ == u'dotall': - dotall = value - elif type_ == u'only-matching': - only_matching = value - else: - raise exceptions.InternalError(_(u"unknown filter type {type}").format(type=type_)) - - if invert: - keep = not keep - if not keep: - return False, item - - return True, item - - def doItemAction(self, item, metadata): - """called when item has been kepts and the action need to be done - - @param item(unicode): accepted item - """ - action = self.args.action - if action == u'print' or self.host.verbosity > 0: - try: - self.output(item) - except self.etree.XMLSyntaxError: - # item is not valid XML, but a string - # can happen when --only-matching is used - self.disp(item) - if action in self.EXEC_ACTIONS: - item_elt = self.parseXml(item) - if action == u'exec': - use = {'service': metadata[u'service'], - 'node': metadata[u'node'], - 'item': item_elt.get('id'), - 'profile': self.profile - } - # we need to send a copy of self.args.command - # else it would be modified - parser_args, use_args = arg_tools.get_use_args(self.host, - self.args.command, - use, - verbose=self.host.verbosity > 1 - ) - cmd_args = sys.argv[0:1] + parser_args + use_args - else: - cmd_args = self.args.command - - - self.disp(u'COMMAND: {command}'.format( - command = u' '.join([arg_tools.escape(a) for a in cmd_args])), 2) - if action == u'exec': - ret = subprocess.call(cmd_args) - else: - p = subprocess.Popen(cmd_args, stdin=subprocess.PIPE) - p.communicate(item.encode('utf-8')) - ret = p.wait() - if ret != 0: - self.disp(A.color(C.A_FAILURE, _(u"executed command failed with exit code {code}").format(code=ret))) - - def search(self, items_data, depth): - """callback of getItems - - this method filters items, get sub nodes if needed, - do the requested action, and exit the command when everything is done - @param items_data(tuple): result of getItems - @param depth(int): current depth level - 0 for first node, 1 for first children, and so on - """ - items, metadata = items_data - for item in items: - if depth < self.args.max_depth: - self.getSubNodes(item, depth) - keep, item = self.filter(item) - if not keep: - continue - self.doItemAction(item, metadata) - - # we check if we got all getItems results - self.to_get -= 1 - if self.to_get == 0: - # yes, we can quit - self.host.quit() - assert self.to_get > 0 - - def start(self): - if self.args.command: - if self.args.action not in self.EXEC_ACTIONS: - self.parser.error(_(u"Command can only be used with {actions} actions").format( - actions=u', '.join(self.EXEC_ACTIONS))) - else: - if self.args.action in self.EXEC_ACTIONS: - self.parser.error(_(u"you need to specify a command to execute")) - if not self.args.node: - # TODO: handle get service affiliations when node is not set - self.parser.error(_(u"empty node is not handled yet")) - # to_get is increased on each get and decreased on each answer - # when it reach 0 again, the command is finished - self.to_get = 0 - self._etree = None - if self.args.filters is None: - self.args.filters = [] - self.args.namespace = dict(self.args.namespace + [('pubsub', "http://jabber.org/protocol/pubsub")]) - self.getItems(0, self.args.service, self.args.node, self.args.items) - - -class Uri(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'uri', use_profile=False, use_pubsub=True, pubsub_flags={C.NODE, C.SINGLE_ITEM}, help=_(u'build URI')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("-p", "--profile", type=base.unicode_decoder, default=C.PROF_KEY_DEFAULT, help=_(u"profile (used when no server is specified)")) - - def display_uri(self, jid_): - uri_args = {} - if not self.args.service: - self.args.service = jid.JID(jid_).bare - - for key in ('node', 'service', 'item'): - value = getattr(self.args, key) - if key == 'service': - key = 'path' - if value: - uri_args[key] = value - self.disp(uri.buildXMPPUri(u'pubsub', **uri_args)) - self.host.quit() - - def start(self): - if not self.args.service: - self.host.bridge.asyncGetParamA( - u'JabberID', - u'Connection', - profile_key=self.args.profile, - callback=self.display_uri, - errback=partial(self.errback, - msg=_(u"can't retrieve jid: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - else: - self.display_uri(None) - - -class HookCreate(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'create', use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'create a Pubsub hook')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-t', '--type', default=u'python', choices=('python', 'python_file', 'python_code'), help=_(u"hook type")) - self.parser.add_argument('-P', '--persistent', action='store_true', help=_(u"make hook persistent across restarts")) - self.parser.add_argument("hook_arg", type=base.unicode_decoder, help=_(u"argument of the hook (depend of the type)")) - - @staticmethod - def checkArgs(self): - if self.args.type == u'python_file': - self.args.hook_arg = os.path.abspath(self.args.hook_arg) - if not os.path.isfile(self.args.hook_arg): - self.parser.error(_(u"{path} is not a file").format(path=self.args.hook_arg)) - - def start(self): - self.checkArgs(self) - self.host.bridge.psHookAdd( - self.args.service, - self.args.node, - self.args.type, - self.args.hook_arg, - self.args.persistent, - self.profile, - callback=self.host.quit, - errback=partial(self.errback, - msg=_(u"can't create hook: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class HookDelete(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'delete', use_pubsub=True, pubsub_flags={C.NODE}, help=_(u'delete a Pubsub hook')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument('-t', '--type', default=u'', choices=('', 'python', 'python_file', 'python_code'), help=_(u"hook type to remove, empty to remove all (default: remove all)")) - self.parser.add_argument('-a', '--arg', dest='hook_arg', type=base.unicode_decoder, default=u'', help=_(u"argument of the hook to remove, empty to remove all (default: remove all)")) - - def psHookRemoveCb(self, nb_deleted): - self.disp(_(u'{nb_deleted} hook(s) have been deleted').format( - nb_deleted = nb_deleted)) - self.host.quit() - - def start(self): - HookCreate.checkArgs(self) - self.host.bridge.psHookRemove( - self.args.service, - self.args.node, - self.args.type, - self.args.hook_arg, - self.profile, - callback=self.psHookRemoveCb, - errback=partial(self.errback, - msg=_(u"can't delete hook: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class HookList(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'list', use_output=C.OUTPUT_LIST_DICT, help=_(u'list hooks of a profile')) - self.need_loop = True - - def add_parser_options(self): - pass - - def psHookListCb(self, data): - if not data: - self.disp(_(u'No hook found.')) - self.output(data) - self.host.quit() - - def start(self): - self.host.bridge.psHookList( - self.profile, - callback=self.psHookListCb, - errback=partial(self.errback, - msg=_(u"can't list hooks: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class Hook(base.CommandBase): - subcommands = (HookCreate, HookDelete, HookList) - - def __init__(self, host): - super(Hook, self).__init__(host, 'hook', use_profile=False, use_verbose=True, help=_('trigger action on Pubsub notifications')) - - -class Pubsub(base.CommandBase): - subcommands = (Set, Get, Delete, Edit, Subscribe, Unsubscribe, Subscriptions, Node, Affiliations, Search, Hook, Uri) - - def __init__(self, host): - super(Pubsub, self).__init__(host, 'pubsub', use_profile=False, help=_('PubSub nodes/items management')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_roster.py --- a/frontends/src/jp/cmd_roster.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,214 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) -# Copyright (C) 2003-2016 Adrien Cossa (souliane@mailoo.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 . - -import base -from sat_frontends.jp.constants import Const as C -from sat.core.i18n import _ - -from twisted.words.protocols.jabber import jid -from collections import OrderedDict - -__commands__ = ["Roster"] - - - -class Purge(base.CommandBase): - - def __init__(self, host): - super(Purge, self).__init__(host, 'purge', help=_('Purge the roster from its contacts with no subscription')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument("--no_from", action="store_true", help=_("Also purge contacts with no 'from' subscription")) - self.parser.add_argument("--no_to", action="store_true", help=_("Also purge contacts with no 'to' subscription")) - - def start(self): - self.host.bridge.getContacts(profile_key=self.host.profile, callback=self.gotContacts, errback=self.error) - - def error(self, failure): - print (_("Error while retrieving the contacts [%s]") % failure) - self.host.quit(1) - - def ask_confirmation(self, no_sub, no_from, no_to): - """Ask the confirmation before removing contacts. - - @param no_sub (list[unicode]): list of contacts with no subscription - @param no_from (list[unicode]): list of contacts with no 'from' subscription - @param no_to (list[unicode]): list of contacts with no 'to' subscription - @return bool - """ - if no_sub: - print "There's no subscription between profile [%s] and the following contacts:" % self.host.profile - print " " + "\n ".join(no_sub) - if no_from: - print "There's no 'from' subscription between profile [%s] and the following contacts:" % self.host.profile - print " " + "\n ".join(no_from) - if no_to: - print "There's no 'to' subscription between profile [%s] and the following contacts:" % self.host.profile - print " " + "\n ".join(no_to) - message = "REMOVE them from profile [%s]'s roster" % self.host.profile - while True: - res = raw_input("%s (y/N)? " % message) - if not res or res.lower() == 'n': - return False - if res.lower() == 'y': - return True - - def gotContacts(self, contacts): - """Process the list of contacts. - - @param contacts(list[tuple]): list of contacts with their attributes and groups - """ - no_sub, no_from, no_to = [], [], [] - for contact, attrs, groups in contacts: - from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) - if not from_: - if not to: - no_sub.append(contact) - elif self.args.no_from: - no_from.append(contact) - elif not to and self.args.no_to: - no_to.append(contact) - if not no_sub and not no_from and not no_to: - print "Nothing to do - there's a from and/or to subscription(s) between profile [%s] and each of its contacts" % self.host.profile - elif self.ask_confirmation(no_sub, no_from, no_to): - for contact in no_sub + no_from + no_to: - self.host.bridge.delContact(contact, profile_key=self.host.profile, callback=lambda dummy: None, errback=lambda failure: None) - self.host.quit() - - -class Stats(base.CommandBase): - - def __init__(self, host): - super(Stats, self).__init__(host, 'stats', help=_('Show statistics about a roster')) - self.need_loop = True - - def add_parser_options(self): - pass - - def start(self): - self.host.bridge.getContacts(profile_key=self.host.profile, callback=self.gotContacts, errback=self.error) - - def error(self, failure): - print (_("Error while retrieving the contacts [%s]") % failure) - self.host.quit(1) - - def gotContacts(self, contacts): - """Process the list of contacts. - - @param contacts(list[tuple]): list of contacts with their attributes and groups - """ - hosts = {} - unique_groups = set() - no_sub, no_from, no_to, no_group, total_group_subscription = 0, 0, 0, 0, 0 - for contact, attrs, groups in contacts: - from_, to = C.bool(attrs["from"]), C.bool(attrs["to"]) - if not from_: - if not to: - no_sub += 1 - else: - no_from += 1 - elif not to: - no_to += 1 - host = jid.JID(contact).host - hosts.setdefault(host, 0) - hosts[host] += 1 - if groups: - unique_groups.update(groups) - total_group_subscription += len(groups) - if not groups: - no_group += 1 - hosts = OrderedDict(sorted(hosts.items(), key=lambda item:-item[1])) - - print - print "Total number of contacts: %d" % len(contacts) - print "Number of different hosts: %d" % len(hosts) - print - for host, count in hosts.iteritems(): - print "Contacts on {host}: {count} ({rate:.1f}%)".format(host=host, count=count, rate=100 * float(count) / len(contacts)) - print - print "Contacts with no 'from' subscription: %d" % no_from - print "Contacts with no 'to' subscription: %d" % no_to - print "Contacts with no subscription at all: %d" % no_sub - print - print "Total number of groups: %d" % len(unique_groups) - try: - contacts_per_group = float(total_group_subscription) / len(unique_groups) - except ZeroDivisionError: - contacts_per_group = 0 - print "Average contacts per group: {:.1f}".format(contacts_per_group) - try: - groups_per_contact = float(total_group_subscription) / len(contacts) - except ZeroDivisionError: - groups_per_contact = 0 - print "Average groups' subscriptions per contact: {:.1f}".format(groups_per_contact) - print "Contacts not assigned to any group: %d" % no_group - self.host.quit() - - -class Get(base.CommandBase): - - def __init__(self, host): - super(Get, self).__init__(host, 'get', help=_('Retrieve the roster contacts')) - self.need_loop = True - - def add_parser_options(self): - self.parser.add_argument("--subscriptions", action="store_true", help=_("Show the contacts' subscriptions")) - self.parser.add_argument("--groups", action="store_true", help=_("Show the contacts' groups")) - self.parser.add_argument("--name", action="store_true", help=_("Show the contacts' names")) - - def start(self): - self.host.bridge.getContacts(profile_key=self.host.profile, callback=self.gotContacts, errback=self.error) - - def error(self, failure): - print (_("Error while retrieving the contacts [%s]") % failure) - self.host.quit(1) - - def gotContacts(self, contacts): - """Process the list of contacts. - - @param contacts(list[tuple]): list of contacts with their attributes and groups - """ - field_count = 1 # only display the contact by default - if self.args.subscriptions: - field_count += 3 # ask, from, to - if self.args.name: - field_count += 1 - if self.args.groups: - field_count += 1 - for contact, attrs, groups in contacts: - args = [contact] - if self.args.subscriptions: - args.append("ask" if C.bool(attrs["ask"]) else "") - args.append("from" if C.bool(attrs["from"]) else "") - args.append("to" if C.bool(attrs["to"]) else "") - if self.args.name: - args.append(unicode(attrs.get("name", ""))) - if self.args.groups: - args.append(u"\t".join(groups) if groups else "") - print u";".join(["{}"] * field_count).format(*args).encode("utf-8") - self.host.quit() - - -class Roster(base.CommandBase): - subcommands = (Get, Stats, Purge) - - def __init__(self, host): - super(Roster, self).__init__(host, 'roster', use_profile=True, help=_("Manage an entity's roster")) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_shell.py --- a/frontends/src/jp/cmd_shell.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,264 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -import cmd -import sys -from sat.core.i18n import _ -from sat.core import exceptions -from sat_frontends.jp.constants import Const as C -from sat_frontends.jp import arg_tools -from sat.tools.common.ansi import ANSI as A -import shlex -import subprocess - -__commands__ = ["Shell"] -INTRO = _(u"""Welcome to {app_name} shell, the Salut à Toi shell ! - -This enrironment helps you using several {app_name} commands with similar parameters. - -To quit, just enter "quit" or press C-d. -Enter "help" or "?" to know what to do -""").format(app_name = C.APP_NAME) - - -class Shell(base.CommandBase, cmd.Cmd): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'shell', help=_(u'launch jp in shell (REPL) mode')) - cmd.Cmd.__init__(self) - - def parse_args(self, args): - """parse line arguments""" - return shlex.split(args, posix=True) - - def update_path(self): - self._cur_parser = self.host.parser - self.help = u'' - for idx, path_elt in enumerate(self.path): - try: - self._cur_parser = arg_tools.get_cmd_choices(path_elt, self._cur_parser) - except exceptions.NotFound: - self.disp(_(u'bad command path'), error=True) - self.path=self.path[:idx] - break - else: - self.help = self._cur_parser - - self.prompt = A.color(C.A_PROMPT_PATH, u'/'.join(self.path)) + A.color(C.A_PROMPT_SUF, u'> ') - try: - self.actions = arg_tools.get_cmd_choices(parser=self._cur_parser).keys() - except exceptions.NotFound: - self.actions = [] - - def add_parser_options(self): - pass - - def format_args(self, args): - """format argument to be printed with quotes if needed""" - for arg in args: - if " " in arg: - yield arg_tools.escape(arg) - else: - yield arg - - def run_cmd(self, args, external=False): - """run command and retur exit code - - @param args[list[string]]: arguments of the command - must not include program name - @param external(bool): True if it's an external command (i.e. not jp) - @return (int): exit code (0 success, any other int failure) - """ - # FIXME: we have to use subprocess - # and relaunch whole python for now - # because if host.quit() is called in D-Bus callback - # GLib quit the whole app without possibility to stop it - # didn't found a nice way to work around it so far - # Situation should be better when we'll move away from python-dbus - if self.verbose: - self.disp(_(u"COMMAND {external}=> {args}").format( - external = _(u'(external) ') if external else u'', - args=u' '.join(self.format_args(args)))) - if not external: - args = sys.argv[0:1] + args - ret_code= subprocess.call(args) - # XXX: below is a way to launch the command without creating a new process - # may be used when a solution to the aforementioned issue is there - # try: - # self.host.run(args) - # except SystemExit as e: - # ret_code = e.code - # except Exception as e: - # self.disp(A.color(C.A_FAILURE, u'command failed with an exception: {msg}'.format(msg=e)), error=True) - # ret_code = 1 - # else: - # ret_code = 0 - - if ret_code != 0: - self.disp(A.color(C.A_FAILURE, u'command failed with an error code of {err_no}'.format(err_no=ret_code)), error=True) - return ret_code - - def default(self, args): - """called when no shell command is recognized - - will launch the command with args on the line - (i.e. will launch do [args]) - """ - if args=='EOF': - self.do_quit('') - self.do_do(args) - - def do_help(self, args): - """show help message""" - if not args: - self.disp(A.color(C.A_HEADER, _(u'Shell commands:')), no_lf=True) - super(Shell, self).do_help(args) - if not args: - self.disp(A.color(C.A_HEADER, _(u'Action commands:'))) - help_list = self._cur_parser.format_help().split('\n\n') - print('\n\n'.join(help_list[1 if self.path else 2:])) - - def do_debug(self, args): - """launch internal debugger""" - try: - import ipdb as pdb - except ImportError: - import pdb - pdb.set_trace() - - def do_verbose(self, args): - """show verbose mode, or (de)activate it""" - args = self.parse_args(args) - if args: - self.verbose = C.bool(args[0]) - self.disp(_(u'verbose mode is {status}').format( - status = _(u'ENABLED') if self.verbose else _(u'DISABLED'))) - - def do_cmd(self, args): - """change command path""" - if args == '..': - self.path = self.path[:-1] - else: - if not args or args[0] == '/': - self.path = [] - args = '/'.join(args.split()) - for path_elt in args.split('/'): - path_elt = path_elt.strip() - if not path_elt: - continue - self.path.append(path_elt) - self.update_path() - - def do_version(self, args): - """show current SàT/jp version""" - try: - self.host.run(['--version']) - except SystemExit: - pass - - def do_shell(self, args): - """launch an external command (you can use ![command] too)""" - args = self.parse_args(args) - self.run_cmd(args, external=True) - - def do_do(self, args): - """lauch a command""" - args = self.parse_args(args) - if (self._not_default_profile and - not '-p' in args and - not '--profile' in args and - not 'profile' in self.use): - # profile is not specified and we are not using the default profile - # so we need to add it in arguments to use current user profile - if self.verbose: - self.disp(_(u'arg profile={profile} (logged profile)').format(profile=self.profile)) - use = self.use.copy() - use['profile'] = self.profile - else: - use = self.use - - - # args may be modified by use_args - # to remove subparsers from it - parser_args, use_args = arg_tools.get_use_args(self.host, - args, - use, - verbose=self.verbose, - parser=self._cur_parser - ) - cmd_args = self.path + parser_args + use_args - self.run_cmd(cmd_args) - - def do_use(self, args): - """fix an argument""" - args = self.parse_args(args) - if not args: - if not self.use: - self.disp(_(u'no argument in USE')) - else: - self.disp(_(u'arguments in USE:')) - for arg, value in self.use.iteritems(): - self.disp(_(A.color(C.A_SUBHEADER, arg, A.RESET, u' = ', arg_tools.escape(value)))) - elif len(args) != 2: - self.disp(u'bad syntax, please use:\nuse [arg] [value]', error=True) - else: - self.use[args[0]] = u' '.join(args[1:]) - if self.verbose: - self.disp('set {name} = {value}'.format( - name = args[0], value=arg_tools.escape(args[1]))) - - def do_use_clear(self, args): - """unset one or many argument(s) in USE, or all of them if no arg is specified""" - args = self.parse_args(args) - if not args: - self.use.clear() - else: - for arg in args: - try: - del self.use[arg] - except KeyError: - self.disp(A.color(C.A_FAILURE, _(u'argument {name} not found').format(name=arg)), error=True) - else: - if self.verbose: - self.disp(_(u'argument {name} removed').format(name=arg)) - - def do_whoami(self, args): - u"""print profile currently used""" - self.disp(self.profile) - - def do_quit(self, args): - u"""quit the shell""" - self.disp(_(u'good bye!')) - self.host.quit() - - def do_exit(self, args): - u"""alias for quit""" - self.do_quit(args) - - def start(self): - default_profile = self.host.bridge.profileNameGet(C.PROF_KEY_DEFAULT) - self._not_default_profile = self.profile != default_profile - self.path = [] - self._cur_parser = self.host.parser - self.use = {} - self.verbose = False - self.update_path() - self.cmdloop(INTRO.encode('utf-8')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_ticket.py --- a/frontends/src/jp/cmd_ticket.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,98 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -import json - -__commands__ = ["Ticket"] - -FIELDS_MAP = u'mapping' - - -class Import(base.CommandAnswering): - # TODO: factorize with blog/import - - def __init__(self, host): - super(Import, self).__init__(host, 'import', use_progress=True, help=_(u'import tickets from external software/dataset')) - self.need_loop=True - - def add_parser_options(self): - self.parser.add_argument("importer", type=base.unicode_decoder, nargs='?', help=_(u"importer name, nothing to display importers list")) - self.parser.add_argument('-o', '--option', action='append', nargs=2, default=[], metavar=(u'NAME', u'VALUE'), - help=_(u"importer specific options (see importer description)")) - self.parser.add_argument('-m', '--map', action='append', nargs=2, default=[], metavar=(u'IMPORTED_FIELD', u'DEST_FIELD'), - help=_(u"specified field in import data will be put in dest field (default: use same field name, or ignore if it doesn't exist)")) - self.parser.add_argument('-s', '--service', type=base.unicode_decoder, default=u'', metavar=u'PUBSUB_SERVICE', - help=_(u"PubSub service where the items must be uploaded (default: server)")) - self.parser.add_argument('-n', '--node', type=base.unicode_decoder, default=u'', metavar=u'PUBSUB_NODE', - help=_(u"PubSub node where the items must be uploaded (default: tickets' defaults)")) - self.parser.add_argument("location", type=base.unicode_decoder, nargs='?', - help=_(u"importer data location (see importer description), nothing to show importer description")) - - def onProgressStarted(self, metadata): - self.disp(_(u'Tickets upload started'),2) - - def onProgressFinished(self, metadata): - self.disp(_(u'Tickets uploaded successfully'),2) - - def onProgressError(self, error_msg): - self.disp(_(u'Error while uploading tickets: {}').format(error_msg),error=True) - - def error(self, failure): - self.disp(_("Error while trying to upload tickets: {reason}").format(reason=failure), error=True) - self.host.quit(1) - - def start(self): - if self.args.location is None: - for name in ('option', 'service', 'node'): - if getattr(self.args, name): - self.parser.error(_(u"{name} argument can't be used without location argument").format(name=name)) - if self.args.importer is None: - self.disp(u'\n'.join([u'{}: {}'.format(name, desc) for name, desc in self.host.bridge.ticketsImportList()])) - else: - try: - short_desc, long_desc = self.host.bridge.ticketsImportDesc(self.args.importer) - except Exception as e: - msg = [l for l in unicode(e).split('\n') if l][-1] # we only keep the last line - self.disp(msg) - self.host.quit(1) - else: - self.disp(u"{name}: {short_desc}\n\n{long_desc}".format(name=self.args.importer, short_desc=short_desc, long_desc=long_desc)) - self.host.quit() - else: - # we have a location, an import is requested - options = {key: value for key, value in self.args.option} - fields_map = dict(self.args.map) - if fields_map: - if FIELDS_MAP in options: - self.parser.error(_(u"fields_map must be specified either preencoded in --option or using --map, but not both at the same time")) - options[FIELDS_MAP] = json.dumps(fields_map) - def gotId(id_): - self.progress_id = id_ - self.host.bridge.ticketsImport(self.args.importer, self.args.location, options, self.args.service, self.args.node, self.profile, - callback=gotId, errback=self.error) - - -class Ticket(base.CommandBase): - subcommands = (Import,) - - def __init__(self, host): - super(Ticket, self).__init__(host, 'ticket', use_profile=False, help=_('tickets handling')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/cmd_uri.py --- a/frontends/src/jp/cmd_uri.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,67 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - - -import base -from sat.core.i18n import _ -from sat_frontends.jp.constants import Const as C -from sat.tools.common import uri - -__commands__ = ["Uri"] - - -class Parse(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'parse', use_profile=False, use_output=C.OUTPUT_DICT, help=_(u'parse URI')) - - def add_parser_options(self): - self.parser.add_argument("uri", type=base.unicode_decoder, help=_(u"XMPP URI to parse")) - - def start(self): - self.output(uri.parseXMPPUri(self.args.uri)) - - -class Build(base.CommandBase): - - def __init__(self, host): - base.CommandBase.__init__(self, host, 'build', use_profile=False, help=_(u'build URI')) - - def add_parser_options(self): - self.parser.add_argument("type", type=base.unicode_decoder, help=_(u"URI type")) - self.parser.add_argument("path", type=base.unicode_decoder, help=_(u"URI path")) - self.parser.add_argument("-f", - "--field", - type=base.unicode_decoder, - action='append', - nargs=2, - dest='fields', - metavar=(u"KEY", u"VALUE"), - help=_(u"URI fields")) - - def start(self): - fields = dict(self.args.fields) if self.args.fields else {} - self.disp(uri.buildXMPPUri(self.args.type, path=self.args.path, **fields)) - - -class Uri(base.CommandBase): - subcommands = (Parse, Build) - - def __init__(self, host): - super(Uri, self).__init__(host, 'uri', use_profile=False, help=_('XMPP URI parsing/generation')) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/common.py --- a/frontends/src/jp/common.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,746 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . - -from sat_frontends.jp.constants import Const as C -from sat.core.i18n import _ -from sat.core import exceptions -from sat.tools.common import regex -from sat.tools.common.ansi import ANSI as A -from sat.tools.common import uri as xmpp_uri -from sat.tools import config -from ConfigParser import NoSectionError, NoOptionError -from collections import namedtuple -from functools import partial -import json -import os -import os.path -import time -import tempfile -import subprocess -import glob -import shlex - -# defaut arguments used for some known editors (editing with metadata) -VIM_SPLIT_ARGS = "-c 'set nospr|vsplit|wincmd w|next|wincmd w'" -EMACS_SPLIT_ARGS = '--eval "(split-window-horizontally)"' -EDITOR_ARGS_MAGIC = { - 'vim': VIM_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'gvim': VIM_SPLIT_ARGS + ' --nofork {content_file} {metadata_file}', - 'emacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'xemacs': EMACS_SPLIT_ARGS + ' {content_file} {metadata_file}', - 'nano': ' -F {content_file} {metadata_file}', - } - -SECURE_UNLINK_MAX = 10 -SECURE_UNLINK_DIR = ".backup" -METADATA_SUFF = '_metadata.json' - - -def ansi_ljust(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansiRemove(s) - return s + u' ' * (width - len(cleaned)) - - -def ansi_center(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansiRemove(s) - diff = width - len(cleaned) - half = diff/2 - return half * u' ' + s + (half + diff % 2) * u' ' - - -def ansi_rjust(s, width): - """ljust method handling ANSI escape codes""" - cleaned = regex.ansiRemove(s) - return u' ' * (width - len(cleaned)) + s - - -def getTmpDir(sat_conf, cat_dir, sub_dir=None): - """Return directory used to store temporary files - - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param cat_dir(unicode): directory of the category (e.g. "blog") - @param sub_dir(str): sub directory where data need to be put - profile can be used here, or special directory name - sub_dir will be escaped to be usable in path (use regex.pathUnescape to find - initial str) - @return (str): path to the dir - """ - local_dir = config.getConfig(sat_conf, '', 'local_dir', Exception) - path = [local_dir.encode('utf-8'), cat_dir.encode('utf-8')] - if sub_dir is not None: - path.append(regex.pathEscape(sub_dir)) - return os.path.join(*path) - - -def parse_args(host, cmd_line, **format_kw): - """Parse command arguments - - @param cmd_line(unicode): command line as found in sat.conf - @param format_kw: keywords used for formating - @return (list(unicode)): list of arguments to pass to subprocess function - """ - try: - # we split the arguments and add the known fields - # we split arguments first to avoid escaping issues in file names - return [a.format(**format_kw) for a in shlex.split(cmd_line)] - except ValueError as e: - host.disp(u"Couldn't parse editor cmd [{cmd}]: {reason}".format(cmd=cmd_line, reason=e)) - return [] - - -class BaseEdit(object): - u"""base class for editing commands - - This class allows to edit file for PubSub or something else. - It works with temporary files in SàT local_dir, in a "cat_dir" subdir - """ - - def __init__(self, host, cat_dir, use_metadata=False): - """ - @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration - @param cat_dir(unicode): directory to use for drafts - this will be a sub-directory of SàT's local_dir - @param use_metadata(bool): True is edition need a second file for metadata - most of signature change with use_metadata with an additional metadata argument. - This is done to raise error if a command needs metadata but forget the flag, and vice versa - """ - self.host = host - self.sat_conf = config.parseMainConf() - self.cat_dir_str = cat_dir.encode('utf-8') - self.use_metadata = use_metadata - - def secureUnlink(self, path): - """Unlink given path after keeping it for a while - - This method is used to prevent accidental deletion of a draft - If there are more file in SECURE_UNLINK_DIR than SECURE_UNLINK_MAX, - older file are deleted - @param path(str): file to unlink - """ - if not os.path.isfile(path): - raise OSError(u"path must link to a regular file") - if not path.startswith(getTmpDir(self.sat_conf, self.cat_dir_str)): - self.disp(u"File {} is not in SàT temporary hierarchy, we do not remove it".format(path.decode('utf-8')), 2) - return - # we have 2 files per draft with use_metadata, so we double max - unlink_max = SECURE_UNLINK_MAX * 2 if self.use_metadata else SECURE_UNLINK_MAX - backup_dir = getTmpDir(self.sat_conf, self.cat_dir_str, SECURE_UNLINK_DIR) - if not os.path.exists(backup_dir): - os.makedirs(backup_dir) - filename = os.path.basename(path) - backup_path = os.path.join(backup_dir, filename) - # we move file to backup dir - self.host.disp(u"Backuping file {src} to {dst}".format( - src=path.decode('utf-8'), dst=backup_path.decode('utf-8')), 1) - os.rename(path, backup_path) - # and if we exceeded the limit, we remove older file - backup_files = [os.path.join(backup_dir, f) for f in os.listdir(backup_dir)] - if len(backup_files) > unlink_max: - backup_files.sort(key=lambda path: os.stat(path).st_mtime) - for path in backup_files[:len(backup_files) - unlink_max]: - self.host.disp(u"Purging backup file {}".format(path.decode('utf-8')), 2) - os.unlink(path) - - def runEditor(self, editor_args_opt, content_file_path, - content_file_obj, meta_file_path=None, meta_ori=None): - """run editor to edit content and metadata - - @param editor_args_opt(unicode): option in [jp] section in configuration for - specific args - @param content_file_path(str): path to the content file - @param content_file_obj(file): opened file instance - @param meta_file_path(str, None): metadata file path - if None metadata will not be used - @param meta_ori(dict, None): original cotent of metadata - can't be used if use_metadata is False - """ - if not self.use_metadata: - assert meta_file_path is None - assert meta_ori is None - - # we calculate hashes to check for modifications - import hashlib - content_file_obj.seek(0) - tmp_ori_hash = hashlib.sha1(content_file_obj.read()).digest() - content_file_obj.close() - - # we prepare arguments - editor = config.getConfig(self.sat_conf, 'jp', 'editor') or os.getenv('EDITOR', 'vi') - try: - # is there custom arguments in sat.conf ? - editor_args = config.getConfig(self.sat_conf, 'jp', editor_args_opt, Exception) - except (NoOptionError, NoSectionError): - # no, we check if we know the editor and have special arguments - if self.use_metadata: - editor_args = EDITOR_ARGS_MAGIC.get(os.path.basename(editor), '') - else: - editor_args = '' - parse_kwargs = {'content_file': content_file_path} - if self.use_metadata: - parse_kwargs['metadata_file'] = meta_file_path - args = parse_args(self.host, editor_args, **parse_kwargs) - if not args: - args = [content_file_path] - - # actual editing - editor_exit = subprocess.call([editor] + args) - - # edition will now be checked, and data will be sent if it was a success - if editor_exit != 0: - self.disp(u"Editor exited with an error code, so temporary file has not be deleted, and item is not published.\nYou can find temporary file at {path}".format( - path=content_file_path), error=True) - else: - # main content - try: - with open(content_file_path, 'rb') as f: - content = f.read() - except (OSError, IOError): - self.disp(u"Can read file at {content_path}, have it been deleted?\nCancelling edition".format( - content_path=content_file_path), error=True) - self.host.quit(C.EXIT_NOT_FOUND) - - # metadata - if self.use_metadata: - try: - with open(meta_file_path, 'rb') as f: - metadata = json.load(f) - except (OSError, IOError): - self.disp(u"Can read file at {meta_file_path}, have it been deleted?\nCancelling edition".format( - content_path=content_file_path, meta_path=meta_file_path), error=True) - self.host.quit(C.EXIT_NOT_FOUND) - except ValueError: - self.disp(u"Can't parse metadata, please check it is correct JSON format. Cancelling edition.\n" + - "You can find tmp file at {content_path} and temporary meta file at {meta_path}.".format( - content_path=content_file_path, - meta_path=meta_file_path), error=True) - self.host.quit(C.EXIT_DATA_ERROR) - - if self.use_metadata and not C.bool(metadata.get('publish', "true")): - self.disp(u'Publication blocked by "publish" key in metadata, cancelling edition.\n\n' + - "temporary file path:\t{content_path}\nmetadata file path:\t{meta_path}".format( - content_path=content_file_path, meta_path=meta_file_path), error=True) - self.host.quit() - - if len(content) == 0: - self.disp(u"Content is empty, cancelling the edition") - if not content_file_path.startswith(getTmpDir(self.sat_conf, self.cat_dir_str)): - self.disp(u"File are not in SàT temporary hierarchy, we do not remove them", 2) - self.host.quit() - self.disp(u"Deletion of {}".format(content_file_path.decode('utf-8')), 2) - os.unlink(content_file_path) - if self.use_metadata: - self.disp(u"Deletion of {}".format(meta_file_path.decode('utf-8')), 2) - os.unlink(meta_file_path) - self.host.quit() - - # time to re-check the hash - elif (tmp_ori_hash == hashlib.sha1(content).digest() and - (not self.use_metadata or meta_ori == metadata)): - self.disp(u"The content has not been modified, cancelling the edition") - self.host.quit() - - else: - # we can now send the item - content = content.decode('utf-8-sig') # we use utf-8-sig to avoid BOM - try: - if self.use_metadata: - self.publish(content, metadata) - else: - self.publish(content) - except Exception as e: - if self.use_metadata: - self.disp(u"Error while sending your item, the temporary files have been kept at {content_path} and {meta_path}: {reason}".format( - content_path=content_file_path, meta_path=meta_file_path, reason=e), error=True) - else: - self.disp(u"Error while sending your item, the temporary file has been kept at {content_path}: {reason}".format( - content_path=content_file_path, reason=e), error=True) - self.host.quit(1) - - self.secureUnlink(content_file_path) - if self.use_metadata: - self.secureUnlink(meta_file_path) - - def publish(self, content): - # if metadata is needed, publish will be called with it last argument - raise NotImplementedError - - def getTmpFile(self): - """Create a temporary file - - @param suff (str): suffix to use for the filename - @return (tuple(file, str)): opened (w+b) file object and file path - """ - suff = '.' + self.getTmpSuff() - cat_dir_str = self.cat_dir_str - tmp_dir = getTmpDir(self.sat_conf, self.cat_dir_str, self.profile.encode('utf-8')) - if not os.path.exists(tmp_dir): - try: - os.makedirs(tmp_dir) - except OSError as e: - self.disp(u"Can't create {path} directory: {reason}".format( - path=tmp_dir, reason=e), error=True) - self.host.quit(1) - try: - fd, path = tempfile.mkstemp(suffix=suff.encode('utf-8'), - prefix=time.strftime(cat_dir_str + '_%Y-%m-%d_%H:%M:%S_'), - dir=tmp_dir, text=True) - return os.fdopen(fd, 'w+b'), path - except OSError as e: - self.disp(u"Can't create temporary file: {reason}".format(reason=e), error=True) - self.host.quit(1) - - def getCurrentFile(self, profile): - """Get most recently edited file - - @param profile(unicode): profile linked to the draft - @return(str): full path of current file - """ - # we guess the item currently edited by choosing - # the most recent file corresponding to temp file pattern - # in tmp_dir, excluding metadata files - cat_dir_str = self.cat_dir_str - tmp_dir = getTmpDir(self.sat_conf, self.cat_dir_str, profile.encode('utf-8')) - available = [path for path in glob.glob(os.path.join(tmp_dir, cat_dir_str + '_*')) if not path.endswith(METADATA_SUFF)] - if not available: - self.disp(u"Could not find any content draft in {path}".format(path=tmp_dir), error=True) - self.host.quit(1) - return max(available, key=lambda path: os.stat(path).st_mtime) - - def getItemData(self, service, node, item): - """return formatted content, metadata (or not if use_metadata is false), and item id""" - raise NotImplementedError - - def getTmpSuff(self): - """return suffix used for content file""" - return u'xml' - - def getItemPath(self): - """retrieve item path (i.e. service and node) from item argument - - This method is obviously only useful for edition of PubSub based features - """ - service = self.args.service - node = self.args.node - item = self.args.item - last_item = self.args.last_item - - if self.args.current: - # user wants to continue current draft - content_file_path = self.getCurrentFile(self.profile) - self.disp(u'Continuing edition of current draft', 2) - content_file_obj = open(content_file_path, 'r+b') - # we seek at the end of file in case of an item already exist - # this will write content of the existing item at the end of the draft. - # This way no data should be lost. - content_file_obj.seek(0, os.SEEK_END) - elif self.args.draft_path: - # there is an existing draft that we use - content_file_path = os.path.expanduser(self.args.item) - content_file_obj = open(content_file_path, 'r+b') - # we seek at the end for the same reason as above - content_file_obj.seek(0, os.SEEK_END) - else: - # we need a temporary file - content_file_obj, content_file_path = self.getTmpFile() - - if item or last_item: - self.disp(u'Editing requested published item', 2) - try: - if self.use_metadata: - content, metadata, item = self.getItemData(service, node, item) - else: - content, item = self.getItemData(service, node, item) - except Exception as e: - # FIXME: ugly but we have not good may to check errors in bridge - if u'item-not-found' in unicode(e): - # item doesn't exist, we create a new one with requested id - metadata = None - if last_item: - self.disp(_(u'no item found at all, we create a new one'), 2) - else: - self.disp(_(u'item "{item_id}" not found, we create a new item with this id').format(item_id=item), 2) - content_file_obj.seek(0) - else: - self.disp(u"Error while retrieving item: {}".format(e)) - self.host.quit(C.EXIT_ERROR) - else: - # item exists, we write content - if content_file_obj.tell() != 0: - # we already have a draft, - # we copy item content after it and add an indicator - content_file_obj.write('\n*****\n') - content_file_obj.write(content.encode('utf-8')) - content_file_obj.seek(0) - self.disp(_(u'item "{item_id}" found, we edit it').format(item_id=item), 2) - else: - self.disp(u'Editing a new item', 2) - if self.use_metadata: - metadata = None - - if self.use_metadata: - return service, node, item, content_file_path, content_file_obj, metadata - else: - return service, node, item, content_file_path, content_file_obj - - -class Table(object): - - def __init__(self, host, data, headers=None, filters=None, use_buffer=False): - """ - @param data(iterable[list]): table data - all lines must have the same number of columns - @param headers(iterable[unicode], None): names/titles of the columns - if not None, must have same number of columns as data - @param filters(iterable[(callable, unicode)], None): values filters - the callable will get col value as argument and must return a string - if it's unicode, it will be used with .format and must countain u'{}' which will be replaced with the string - if not None, must have same number of columns as data - @param use_buffer(bool): if True, bufferise output instead of printing it directly - """ - self.host = host - self._buffer = [] if use_buffer else None - # headers are columns names/titles, can be None - self.headers = headers - # sizes fof columns without headers, - # headers may be larger - self.sizes = [] - # rows countains one list per row with columns values - self.rows = [] - - size = None - if headers: - row_cls = namedtuple('RowData', headers) - else: - row_cls = tuple - - for row_data in data: - new_row = [] - row_data_list = list(row_data) - for idx, value in enumerate(row_data_list): - if filters is not None and filters[idx] is not None: - filter_ = filters[idx] - if isinstance(filter_, basestring): - col_value = filter_.format(value) - else: - col_value = filter_(value, row_cls(*row_data_list)) - # we count size without ANSI code as they will change length of the string - # when it's mostly style/color changes. - col_size = len(regex.ansiRemove(col_value)) - else: - col_value = unicode(value) - col_size = len(col_value) - new_row.append(col_value) - if size is None: - self.sizes.append(col_size) - else: - self.sizes[idx] = max(self.sizes[idx], col_size) - if size is None: - size = len(new_row) - if headers is not None and len(headers) != size: - raise exceptions.DataError(u'headers size is not coherent with rows') - else: - if len(new_row) != size: - raise exceptions.DataError(u'rows size is not coherent') - self.rows.append(new_row) - - if not data and headers is not None: - # the table is empty, we print headers at their lenght - self.sizes = [len(h) for h in headers] - - @property - def string(self): - if self._buffer is None: - raise exceptions.InternalError(u'buffer must be used to get a string') - return u'\n'.join(self._buffer) - - @staticmethod - def readDictValues(data, keys, defaults=None): - if defaults is None: - defaults = {} - for key in keys: - try: - yield data[key] - except KeyError as e: - default = defaults.get(key) - if default is not None: - yield default - else: - raise e - - @classmethod - def fromDict(cls, host, data, keys=None, headers=None, filters=None, defaults=None): - """Prepare a table to display it - - the whole data will be read and kept into memory, - to be printed - @param data(list[dict[unicode, unicode]]): data to create the table from - @param keys(iterable[unicode], None): keys to get - if None, all keys will be used - @param headers(iterable[unicode], None): name of the columns - names must be in same order as keys - @param filters(dict[unicode, (callable,unicode)), None): filter to use on values - keys correspond to keys to filter, and value is a callable or unicode which - will get the value as argument and must return a string - @param defaults(dict[unicode, unicode]): default value to use - if None, an exception will be raised if not value is found - """ - if keys is None and headers is not None: - # FIXME: keys are not needed with OrderedDict, - raise exceptions.DataError(u'You must specify keys order to used headers') - if keys is None: - keys = data[0].keys() - if headers is None: - headers = keys - filters = [filters.get(k) for k in keys] - return cls(host, (cls.readDictValues(d, keys, defaults) for d in data), headers, filters) - - def _headers(self, head_sep, headers, sizes, alignment=u'left', style=None): - """Render headers - - @param head_sep(unicode): sequence to use as separator - @param alignment(unicode): how to align, can be left, center or right - @param style(unicode, iterable[unicode], None): ANSI escape sequences to apply - @param headers(list[unicode]): headers to show - @param sizes(list[int]): sizes of columns - """ - rendered_headers = [] - if isinstance(style, basestring): - style = [style] - for idx, header in enumerate(headers): - size = sizes[idx] - if alignment == u'left': - rendered = header[:size].ljust(size) - elif alignment == u'center': - rendered = header[:size].center(size) - elif alignment == u'right': - rendered = header[:size].rjust(size) - else: - raise exceptions.InternalError(u'bad alignment argument') - if style: - args = style + [rendered] - rendered = A.color(*args) - rendered_headers.append(rendered) - return head_sep.join(rendered_headers) - - def _disp(self, data): - """output data (can be either bufferised or printed)""" - if self._buffer is not None: - self._buffer.append(data) - else: - self.host.disp(data) - - def display(self, - head_alignment = u'left', - columns_alignment = u'left', - head_style = None, - show_header=True, - show_borders=True, - hide_cols=None, - col_sep=u' │ ', - top_left=u'┌', - top=u'─', - top_sep=u'─┬─', - top_right=u'┐', - left=u'│', - right=None, - head_sep=None, - head_line=u'┄', - head_line_left=u'├', - head_line_sep=u'┄┼┄', - head_line_right=u'┤', - bottom_left=u'└', - bottom=None, - bottom_sep=u'─┴─', - bottom_right=u'┘', - ): - """Print the table - - @param show_header(bool): True if header need no be shown - @param show_borders(bool): True if borders need no be shown - @param hide_cols(None, iterable(unicode)): columns which should not be displayed - @param head_alignment(unicode): how to align headers, can be left, center or right - @param columns_alignment(unicode): how to align columns, can be left, center or right - @param col_sep(unicode): separator betweens columns - @param head_line(unicode): character to use to make line under head - @param disp(callable, None): method to use to display the table - None to use self.host.disp - """ - if not self.sizes: - # the table is empty - return - col_sep_size = len(regex.ansiRemove(col_sep)) - - # if we have columns to hide, we remove them from headers and size - if not hide_cols: - headers = self.headers - sizes = self.sizes - else: - headers = list(self.headers) - sizes = self.sizes[:] - ignore_idx = [headers.index(to_hide) for to_hide in hide_cols] - for to_hide in hide_cols: - hide_idx = headers.index(to_hide) - del headers[hide_idx] - del sizes[hide_idx] - - if right is None: - right = left - if top_sep is None: - top_sep = col_sep_size * top - if head_sep is None: - head_sep = col_sep - if bottom is None: - bottom = top - if bottom_sep is None: - bottom_sep = col_sep_size * bottom - if not show_borders: - left = right = head_line_left = head_line_right = u'' - # top border - if show_borders: - self._disp( - top_left - + top_sep.join([top*size for size in sizes]) - + top_right - ) - - # headers - if show_header: - self._disp( - left - + self._headers(head_sep, headers, sizes, head_alignment, head_style) - + right - ) - # header line - self._disp( - head_line_left - + head_line_sep.join([head_line*size for size in sizes]) - + head_line_right - ) - - # content - if columns_alignment == u'left': - alignment = lambda idx, s: ansi_ljust(s, sizes[idx]) - elif columns_alignment == u'center': - alignment = lambda idx, s: ansi_center(s, sizes[idx]) - elif columns_alignment == u'right': - alignment = lambda idx, s: ansi_rjust(s, sizes[idx]) - else: - raise exceptions.InternalError(u'bad columns alignment argument') - - for row in self.rows: - if hide_cols: - row = [v for idx,v in enumerate(row) if idx not in ignore_idx] - self._disp(left + col_sep.join([alignment(idx,c) for idx,c in enumerate(row)]) + right) - - if show_borders: - # bottom border - self._disp( - bottom_left - + bottom_sep.join([bottom*size for size in sizes]) - + bottom_right - ) - # we return self so string can be used after display (table.display().string) - return self - - def display_blank(self, **kwargs): - """Display table without visible borders""" - kwargs_ = {'col_sep':u' ', 'head_line_sep':u' ', 'show_borders':False} - kwargs_.update(kwargs) - return self.display(**kwargs_) - - -class URIFinder(object): - """Helper class to find URIs in well-known locations""" - - def __init__(self, command, path, key, callback, meta_map=None): - """ - @param command(CommandBase): command instance - args of this instance will be updated with found values - @param path(unicode): absolute path to use as a starting point to look for URIs - @param key(unicode): key to look for - @param callback(callable): method to call once URIs are found (or not) - @param meta_map(dict, None): if not None, map metadata to arg name - key is metadata used attribute name - value is name to actually use, or None to ignore - use empty dict to only retrieve URI - possible keys are currently: - - labels - """ - if not command.args.service and not command.args.node: - self.host = command.host - self.args = command.args - self.key = key - self.callback = callback - self.meta_map = meta_map - self.host.bridge.URIFind(path, - [key], - callback=self.URIFindCb, - errback=partial(command.errback, - msg=_(u"can't find " + key + u" URI: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - else: - callback() - - def setMetadataList(self, uri_data, key): - """Helper method to set list of values from metadata - - @param uri_data(dict): data of the found URI - @param key(unicode): key of the value to retrieve - """ - new_values_json = uri_data.get(key) - if uri_data is not None: - if self.meta_map is None: - dest = key - else: - dest = self.meta_map.get(key) - if dest is None: - return - - try: - values = getattr(self.args, key) - except AttributeError: - raise exceptions.InternalError(u'there is no "{key}" arguments'.format( - key=key)) - else: - if values is None: - values = [] - values.extend(json.loads(new_values_json)) - setattr(self.args, dest, values) - - - def URIFindCb(self, uris_data): - try: - uri_data = uris_data[self.key] - except KeyError: - self.host.disp(_(u"No {key} URI specified for this project, please specify service and node").format(key=self.key), error=True) - self.host.quit(C.EXIT_NOT_FOUND) - else: - uri = uri_data[u'uri'] - - self.setMetadataList(uri_data, u'labels') - parsed_uri = xmpp_uri.parseXMPPUri(uri) - try: - self.args.service = parsed_uri[u'path'] - self.args.node = parsed_uri[u'node'] - except KeyError: - self.host.disp(_(u"Invalid URI found: {uri}").format(uri=uri), error=True) - self.host.quit(C.EXIT_DATA_ERROR) - self.callback() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/constants.py --- a/frontends/src/jp/constants.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,72 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat_frontends.quick_frontend import constants -from sat.tools.common.ansi import ANSI as A - - -class Const(constants.Const): - - APP_NAME = u"jp" - PLUGIN_CMD = u"commands" - PLUGIN_OUTPUT = u"outputs" - OUTPUT_TEXT = u'text' # blob of unicode text - OUTPUT_DICT = u'dict' # simple key/value dictionary - OUTPUT_LIST = u'list' - OUTPUT_LIST_DICT = u'list_dict' # list of dictionaries - OUTPUT_DICT_DICT = u'dict_dict' # dict of nested dictionaries - OUTPUT_COMPLEX = u'complex' # complex data (e.g. multi-level dictionary) - OUTPUT_XML = u'xml' # XML node (as unicode string) - OUTPUT_LIST_XML = u'list_xml' # list of XML nodes (as unicode strings) - OUTPUT_TYPES = (OUTPUT_TEXT, OUTPUT_DICT, OUTPUT_LIST, OUTPUT_LIST_DICT, OUTPUT_DICT_DICT, OUTPUT_COMPLEX, OUTPUT_XML, OUTPUT_LIST_XML) - - # Pubsub options flags - SERVICE = u'service' # service required - NODE = u'node' # node required - ITEM = u'item' # item required - SINGLE_ITEM = u'single_item' # only one item is allowed - MULTI_ITEMS = u'multi_items' # multiple items are allowed - NO_MAX = u'no_max' # don't add --max option for multi items - - # ANSI - A_HEADER = A.BOLD + A.FG_YELLOW - A_SUBHEADER = A.BOLD + A.FG_RED - # A_LEVEL_COLORS may be used to cycle on colors according to depth of data - A_LEVEL_COLORS = (A_HEADER, A.BOLD + A.FG_BLUE, A.FG_MAGENTA, A.FG_CYAN) - A_SUCCESS = A.BOLD + A.FG_GREEN - A_FAILURE = A.BOLD + A.FG_RED - # A_PROMPT_* is for shell - A_PROMPT_PATH = A.BOLD + A.FG_CYAN - A_PROMPT_SUF = A.BOLD - # Files - A_DIRECTORY = A.BOLD + A.FG_CYAN - A_FILE = A.FG_WHITE - - # exit codes - EXIT_OK = 0 - EXIT_ERROR = 1 # generic error, when nothing else match - EXIT_BAD_ARG = 2 # arguments given by user are bad - EXIT_BRIDGE_ERROR = 3 # can't connect to bridge - EXIT_BRIDGE_ERRBACK = 4 # something went wrong when calling a bridge method - EXIT_NOT_FOUND = 16 # an item required by a command was not found - EXIT_DATA_ERROR = 17 # data needed for a command is invalid - EXIT_USER_CANCELLED = 20 # user cancelled action - EXIT_FILE_NOT_EXE = 126 # a file to be executed was found, but it was not an executable utility (cf. man 1 exit) - EXIT_CMD_NOT_FOUND = 127 # a utility to be executed was not found (cf. man 1 exit) - EXIT_SIGNAL_INT = 128 # a command was interrupted by a signal (cf. man 1 exit) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/jp --- a/frontends/src/jp/jp Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# jp: a SAT command line tool -# Copyright (C) 2009-2016 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 . - -from sat_frontends.jp import base - -if __name__ == "__main__": - jp = base.Jp() - jp.import_plugins() - jp.run() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/output_std.py --- a/frontends/src/jp/output_std.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,98 +0,0 @@ -#! /usr/bin/python -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . -"""Standard outputs""" - - -from sat_frontends.jp.constants import Const as C -from sat.tools.common.ansi import ANSI as A -import json - -__outputs__ = ["Simple", "Json"] -SIMPLE = u'simple' -JSON = u'json' -JSON_RAW = u'json_raw' - - -class Simple(object): - """Default outputs""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_TEXT, SIMPLE, self.simple_print) - host.register_output(C.OUTPUT_LIST, SIMPLE, self.list) - host.register_output(C.OUTPUT_DICT, SIMPLE, self.dict) - host.register_output(C.OUTPUT_LIST_DICT, SIMPLE, self.list_dict) - host.register_output(C.OUTPUT_DICT_DICT, SIMPLE, self.dict_dict) - host.register_output(C.OUTPUT_COMPLEX, SIMPLE, self.simple_print) - - def simple_print(self, data): - self.host.disp(unicode(data)) - - def list(self, data): - self.host.disp(u'\n'.join(data)) - - def dict(self, data, indent=0, header_color=C.A_HEADER): - options = self.host.parse_output_options() - self.host.check_output_options({u'no-header'}, options) - show_header = not u'no-header' in options - for k, v in data.iteritems(): - if show_header: - header = A.color(header_color, k) + u': ' - else: - header = u'' - - self.host.disp((u'{indent}{header}{value}'.format( - indent=indent*u' ', - header=header, - value=v))) - - def list_dict(self, data): - for idx, datum in enumerate(data): - if idx: - self.host.disp(u'\n') - self.dict(datum) - - def dict_dict(self, data): - for key, sub_dict in data.iteritems(): - self.host.disp(A.color(C.A_HEADER, key)) - self.dict(sub_dict, indent=4, header_color=C.A_SUBHEADER) - - -class Json(object): - """outputs in json format""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_TEXT, JSON, self.dump) - host.register_output(C.OUTPUT_LIST, JSON, self.dump_pretty) - host.register_output(C.OUTPUT_LIST, JSON_RAW, self.dump) - host.register_output(C.OUTPUT_DICT, JSON, self.dump_pretty) - host.register_output(C.OUTPUT_DICT, JSON_RAW, self.dump) - host.register_output(C.OUTPUT_LIST_DICT, JSON, self.dump_pretty) - host.register_output(C.OUTPUT_LIST_DICT, JSON_RAW, self.dump) - host.register_output(C.OUTPUT_DICT_DICT, JSON, self.dump_pretty) - host.register_output(C.OUTPUT_DICT_DICT, JSON_RAW, self.dump) - host.register_output(C.OUTPUT_COMPLEX, JSON, self.dump_pretty) - host.register_output(C.OUTPUT_COMPLEX, JSON_RAW, self.dump) - - def dump(self, data): - self.host.disp(json.dumps(data, default=str)) - - def dump_pretty(self, data): - self.host.disp(json.dumps(data, indent=4, default=str)) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/output_template.py --- a/frontends/src/jp/output_template.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,101 +0,0 @@ -#! /usr/bin/python -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . -"""Standard outputs""" - - -from sat_frontends.jp.constants import Const as C -from sat.core.i18n import _ -from sat.tools.common import template -import webbrowser -import tempfile -import os.path - -__outputs__ = ["Template"] -TEMPLATE = u'template' -OPTIONS = {u'template', u'browser', u'inline-css'} - - -class Template(object): - """outputs data using SàT templates""" - - def __init__(self, jp): - self.host = jp - jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render) - - def render(self, data): - """render output data using requested template - - template to render the data can be either command's TEMPLATE or - template output_option requested by user. - @param data(dict): data is a dict which map from variable name to use in template - to the variable itself. - command's template_data_mapping attribute will be used if it exists to convert - data to a dict usable by the template. - """ - # media_dir is needed for the template - self.host.media_dir = self.host.bridge.getConfig('', 'media_dir') - cmd = self.host.command - try: - template_path = cmd.TEMPLATE - except AttributeError: - if not 'template' in cmd.args.output_opts: - self.host.disp(u'no default template set for this command, ' - u'you need to specify a template using --oo template=[path/to/template.html]', - error=True) - self.host.quit(C.EXIT_BAD_ARG) - - options = self.host.parse_output_options() - self.host.check_output_options(OPTIONS, options) - self.renderer = template.Renderer(self.host) - try: - template_path = options['template'] - except KeyError: - # template is not specified, we use default one - pass - if template_path is None: - self.host.disp(u"Can't parse template, please check its syntax", - error=True) - self.host.quit(C.EXIT_BAD_ARG) - - try: - mapping_cb = cmd.template_data_mapping - except AttributeError: - kwargs = data - else: - kwargs = mapping_cb(data) - - css_inline = u'inline-css' in options - rendered = self.renderer.render(template_path, css_inline=css_inline, **kwargs) - - if 'browser' in options: - template_name = os.path.basename(template_path) - tmp_dir = tempfile.mkdtemp() - self.host.disp(_(u"Browser opening requested.\nTemporary files are put in the following directory, " - u"you'll have to delete it yourself once finished viewing: {}").format(tmp_dir)) - tmp_file = os.path.join(tmp_dir, template_name) - with open(tmp_file, 'w') as f: - f.write(rendered.encode('utf-8')) - theme, theme_root_path = self.renderer.getThemeAndRoot(template_path) - static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR) - if os.path.exists(static_dir): - import shutil - shutil.copytree(static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR)) - webbrowser.open(tmp_file) - else: - self.host.disp(rendered) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/output_xml.py --- a/frontends/src/jp/output_xml.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,82 +0,0 @@ -#! /usr/bin/python -# -*- coding: utf-8 -*- - -# jp: a SàT command line tool -# 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 . -"""Standard outputs""" - - -from sat_frontends.jp.constants import Const as C -from sat.core.i18n import _ -from lxml import etree -from sat.core.log import getLogger -log = getLogger(__name__) -import sys -try: - import pygments - from pygments.lexers.html import XmlLexer - from pygments.formatters import TerminalFormatter -except ImportError: - pygments = None - - -__outputs__ = ["XML"] -RAW = u'xml_raw' -PRETTY = u'xml_pretty' - - -class XML(object): - """Default outputs""" - - def __init__(self, host): - self.host = host - host.register_output(C.OUTPUT_XML, PRETTY, self.pretty, default=True) - host.register_output(C.OUTPUT_LIST_XML, PRETTY, self.pretty_list, default=True) - host.register_output(C.OUTPUT_XML, RAW, self.raw) - host.register_output(C.OUTPUT_LIST_XML, RAW, self.list_raw) - - def colorize(self, xml): - if pygments is None: - self.host.disp(_(u'Pygments is not available, syntax highlighting is not possible. Please install if from http://pygments.org or with pip install pygments'), error=True) - return xml - if not sys.stdout.isatty(): - return xml - lexer = XmlLexer(encoding='utf-8') - formatter = TerminalFormatter(bg=u'dark') - return pygments.highlight(xml, lexer, formatter) - - def format(self, data, pretty=True): - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.fromstring(data, parser) - xml = etree.tostring(tree, encoding='unicode', pretty_print=pretty) - return self.colorize(xml) - - def format_no_pretty(self, data): - return self.format(data, pretty=False) - - def pretty(self, data): - self.host.disp(self.format(data)) - - def pretty_list(self, data, separator=u'\n'): - list_pretty = map(self.format, data) - self.host.disp(separator.join(list_pretty)) - - def raw(self, data): - self.host.disp(self.format_no_pretty(data)) - - def list_raw(self, data, separator=u'\n'): - list_no_pretty = map(self.format_no_pretty, data) - self.host.disp(separator.join(list_no_pretty)) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/jp/xmlui_manager.py --- a/frontends/src/jp/xmlui_manager.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,520 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# JP: a SàT frontend -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.tools import xmlui as xmlui_manager -from sat_frontends.jp.constants import Const as C -from sat.tools.common.ansi import ANSI as A -from sat.core.i18n import _ -from functools import partial - -# workflow constants - -SUBMIT = 'SUBMIT' # submit form - - - -## Widgets ## - -class Base(object): - """Base for Widget and Container""" - type = None - _root = None - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - self.host = self.xmlui_parent.host - - @property - def root(self): - """retrieve main XMLUI parent class""" - if self._root is not None: - return self._root - root = self - while not isinstance(root, xmlui_manager.XMLUIBase): - root = root.xmlui_parent - self._root = root - return root - - def disp(self, *args, **kwargs): - self.host.disp(*args, **kwargs) - - -class Widget(Base): - category = u'widget' - enabled = True - - @property - def name(self): - return self._xmlui_name - - def show(self): - """display current widget - - must be overriden by subclasses - """ - raise NotImplementedError(self.__class__) - - def verboseName(self, elems=None, value=None): - """add name in color to the elements - - helper method to display name which can then be used to automate commands - elems is only modified if verbosity is > 0 - @param elems(list[unicode], None): elements to display - None to display name directly - @param value(unicode, None): value to show - use self.name if None - """ - if value is None: - value = self.name - if self.host.verbosity: - to_disp = [A.FG_MAGENTA, - u' ' if elems else u'', - u'({})'.format(value), A.RESET] - if elems is None: - self.host.disp(A.color(*to_disp)) - else: - elems.extend(to_disp) - -class ValueWidget(Widget): - - def __init__(self, xmlui_parent, value): - super(ValueWidget, self).__init__(xmlui_parent) - self.value = value - - @property - def values(self): - return [self.value] - - -class InputWidget(ValueWidget): - - def __init__(self, xmlui_parent, value, read_only=False): - super(InputWidget, self).__init__(xmlui_parent, value) - self.read_only = read_only - - def _xmluiGetValue(self): - return self.value - - -class OptionsWidget(Widget): - - def __init__(self, xmlui_parent, options, selected, style): - super(OptionsWidget, self).__init__(xmlui_parent) - self.options = options - self.selected = selected - self.style = style - - @property - def values(self): - return self.selected - - @values.setter - def values(self, values): - self.selected = values - - @property - def value(self): - return self.selected[0] - - @value.setter - def value(self, value): - self.selected = [value] - - def _xmluiSelectValue(self, value): - self.value = value - - def _xmluiSelectValues(self, values): - self.values = values - - def _xmluiGetSelectedValues(self): - return self.values - - @property - def labels(self): - """return only labels from self.items""" - for value, label in self.items: - yield label - - @property - def items(self): - """return suitable items, according to style""" - no_select = self.no_select - for value,label in self.options: - if no_select or value in self.selected: - yield value,label - - @property - def inline(self): - return u'inline' in self.style - - @property - def no_select(self): - return u'noselect' in self.style - - -class EmptyWidget(xmlui_manager.EmptyWidget, Widget): - - def __init__(self, _xmlui_parent): - Widget.__init__(self) - - -class TextWidget(xmlui_manager.TextWidget, ValueWidget): - type = u"text" - - def show(self): - self.host.disp(self.value) - - -class LabelWidget(xmlui_manager.LabelWidget, ValueWidget): - type = u"label" - - @property - def for_name(self): - try: - return self._xmlui_for_name - except AttributeError: - return None - - def show(self, no_lf=False, ansi=u''): - """show label - - @param no_lf(bool): same as for [JP.disp] - @param ansi(unicode): ansi escape code to print before label - """ - self.disp(A.color(ansi, self.value), no_lf=no_lf) - - -class StringWidget(xmlui_manager.StringWidget, InputWidget): - type = u"string" - - def show(self): - if self.read_only: - self.disp(self.value) - else: - elems = [] - self.verboseName(elems) - if self.value: - elems.append(_(u'(enter: {default})').format(default=self.value)) - elems.extend([C.A_HEADER, u'> ']) - value = raw_input(A.color(*elems)) - if value: - # TODO: empty value should be possible - # an escape key should be used for default instead of enter with empty value - self.value = value - - - -class JidInputWidget(xmlui_manager.JidInputWidget, StringWidget): - type = u'jid_input' - - -class TextBoxWidget(xmlui_manager.TextWidget, StringWidget): - type = u"textbox" - - -class ListWidget(xmlui_manager.ListWidget, OptionsWidget): - type = u'list' - # TODO: handle flags, notably multi - - def show(self): - if self.root.values_only: - for value in self.values: - self.disp(self.value) - return - if not self.options: - return - - # list display - self.verboseName() - - for idx, (value, label) in enumerate(self.options): - elems = [] - if not self.root.read_only: - elems.extend([C.A_SUBHEADER, unicode(idx), A.RESET, u': ']) - elems.append(label) - self.verboseName(elems, value) - self.disp(A.color(*elems)) - - if self.root.read_only: - return - - if len(self.options) == 1: - # we have only one option, no need to ask - self.value = self.options[0][0] - return - - # we ask use to choose an option - choice = None - limit_max = len(self.options)-1 - while choice is None or choice<0 or choice>limit_max: - choice = raw_input(A.color(C.A_HEADER, _(u'your choice (0-{max}): ').format(max=limit_max))) - try: - choice = int(choice) - except ValueError: - choice = None - self.value = self.options[choice][0] - self.disp('') - - -class BoolWidget(xmlui_manager.BoolWidget, InputWidget): - type = u'bool' - - def show(self): - disp_true = A.color(A.FG_GREEN, u'TRUE') - disp_false = A.color(A.FG_RED,u'FALSE') - if self.read_only: - self.disp(disp_true if self.value else disp_false) - else: - self.disp(A.color(C.A_HEADER, u'0: ', disp_false)) - self.disp(A.color(C.A_HEADER, u'1: ', disp_true)) - choice = None - while choice not in ('0', '1'): - elems = [C.A_HEADER, _(u'your choice (0,1): ')] - self.verboseName(elems) - choice = raw_input(A.color(*elems)) - self.value = bool(int(choice)) - self.disp('') - - def _xmluiGetValue(self): - return C.boolConst(self.value) - -## Containers ## - -class Container(Base): - category = u'container' - - def __init__(self, xmlui_parent): - super(Container, self).__init__(xmlui_parent) - self.children = [] - - def __iter__(self): - return iter(self.children) - - def _xmluiAppend(self, widget): - self.children.append(widget) - - def _xmluiRemove(self, widget): - self.children.remove(widget) - - def show(self): - for child in self.children: - child.show() - - -class VerticalContainer(xmlui_manager.VerticalContainer, Container): - type = u'vertical' - - -class PairsContainer(xmlui_manager.PairsContainer, Container): - type = u'pairs' - - -class LabelContainer(xmlui_manager.PairsContainer, Container): - type = u'label' - - def show(self): - for child in self.children: - no_lf = False - # we check linked widget type - # to see if we want the label on the same line or not - if child.type == u'label': - for_name = child.for_name - if for_name is not None: - for_widget = self.root.widgets[for_name] - wid_type = for_widget.type - if self.root.values_only or wid_type in ('text', 'string', 'jid_input'): - no_lf = True - elif wid_type == 'bool' and for_widget.read_only: - no_lf = True - child.show(no_lf=no_lf, ansi=A.FG_CYAN) - else: - child.show() - -## Dialogs ## - - -class Dialog(object): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - self.host = self.xmlui_parent.host - - def disp(self, *args, **kwargs): - self.host.disp(*args, **kwargs) - - def show(self): - """display current dialog - - must be overriden by subclasses - """ - raise NotImplementedError(self.__class__) - - -class NoteDialog(xmlui_manager.NoteDialog, Dialog): - - def show(self): - # TODO: handle title and level - self.disp(self.message) - - def __init__(self, _xmlui_parent, title, message, level): - Dialog.__init__(self, _xmlui_parent) - xmlui_manager.NoteDialog.__init__(self, _xmlui_parent) - self.title, self.message, self.level = title, message, level - -## Factory ## - - -class WidgetFactory(object): - - def __getattr__(self, attr): - if attr.startswith("create"): - cls = globals()[attr[6:]] - return cls - - -class XMLUIPanel(xmlui_manager.XMLUIPanel): - widget_factory = WidgetFactory() - _actions = 0 # use to keep track of bridge's launchAction calls - read_only = False - values_only = False - workflow = None - _submit_cb = None - - def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, ignore=None, whitelist=None, profile=None): - xmlui_manager.XMLUIPanel.__init__(self, - host, - parsed_dom, - title = title, - flags = flags, - ignore = ignore, - whitelist = whitelist, - profile=host.profile) - self.submitted = False - - @property - def command(self): - return self.host.command - - def show(self, workflow=None, read_only=False, values_only=False): - """display the panel - - @param workflow(list, None): command to execute if not None - put here for convenience, the main workflow is the class attribute - (because workflow can continue in subclasses) - command are a list of consts or lists: - - SUBMIT is the only constant so far, it submits the XMLUI - - list must contain widget name/widget value to fill - @param read_only(bool): if True, don't request values - @param values_only(bool): if True, only show select values (imply read_only) - """ - self.read_only = read_only - self.values_only = values_only - if self.values_only: - self.read_only = True - if workflow: - XMLUIPanel.workflow = workflow - if XMLUIPanel.workflow: - self.runWorkflow() - else: - self.main_cont.show() - - def runWorkflow(self): - """loop into workflow commands and execute commands - - SUBMIT will interrupt workflow (which will be continue on callback) - @param workflow(list): same as [show] - """ - workflow = XMLUIPanel.workflow - while True: - try: - cmd = workflow.pop(0) - except IndexError: - break - if cmd == SUBMIT: - self.onFormSubmitted() - self.submit_id = None # avoid double submit - return - elif isinstance(cmd, list): - name, value = cmd - widget = self.widgets[name] - if widget.type == 'bool': - value = C.bool(value) - widget.value = value - self.show() - - def submitForm(self, callback=None): - XMLUIPanel._submit_cb = callback - self.onFormSubmitted() - - def onFormSubmitted(self, ignore=None): - # self.submitted is a Q&D workaround to avoid - # double submit when a workflow is set - if self.submitted: - return - self.submitted = True - super(XMLUIPanel, self).onFormSubmitted(ignore) - - def _xmluiClose(self): - pass - - def _launchActionCb(self, data): - XMLUIPanel._actions -= 1 - assert XMLUIPanel._actions >= 0 - if u'xmlui' in data: - xmlui_raw = data['xmlui'] - xmlui = xmlui_manager.create(self.host, xmlui_raw) - xmlui.show() - if xmlui.submit_id: - xmlui.onFormSubmitted() - # TODO: handle data other than XMLUI - if not XMLUIPanel._actions: - if self._submit_cb is None: - self.host.quit() - else: - self._submit_cb() - - def _xmluiLaunchAction(self, action_id, data): - XMLUIPanel._actions += 1 - self.host.bridge.launchAction( - action_id, - data, - self.profile, - callback=self._launchActionCb, - errback=partial(self.command.errback, - msg=_(u"can't launch XMLUI action: {}"), - exit_code=C.EXIT_BRIDGE_ERRBACK)) - - -class XMLUIDialog(xmlui_manager.XMLUIDialog): - type = 'dialog' - dialog_factory = WidgetFactory() - read_only = False - - def show(self, dummy=None): - self.dlg.show() - - def _xmluiClose(self): - pass - - -xmlui_manager.registerClass(xmlui_manager.CLASS_PANEL, XMLUIPanel) -xmlui_manager.registerClass(xmlui_manager.CLASS_DIALOG, XMLUIDialog) -create = xmlui_manager.create diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/chat.py --- a/frontends/src/primitivus/chat.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,641 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -from sat.core import log as logging -log = logging.getLogger(__name__) -import urwid -from urwid_satext import sat_widgets -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend import quick_chat -from sat_frontends.quick_frontend import quick_games -from sat_frontends.primitivus import game_tarot -from sat_frontends.primitivus.constants import Const as C -from sat_frontends.primitivus.keys import action_key_map as a_key -from sat_frontends.primitivus.widget import PrimitivusWidget -from sat_frontends.primitivus.contact_list import ContactList -from functools import total_ordering -import bisect - - -OCCUPANTS_FOOTER = _(u"{} occupants") - -class MessageWidget(urwid.WidgetWrap): - - def __init__(self, mess_data): - """ - @param mess_data(quick_chat.Message, None): message data - None: used only for non text widgets (e.g.: focus separator) - """ - self.mess_data = mess_data - mess_data.widgets.add(self) - super(MessageWidget, self).__init__(urwid.Text(self.markup)) - - @property - def markup(self): - return self._generateInfoMarkup() if self.mess_data.type == C.MESS_TYPE_INFO else self._generateMarkup() - - @property - def info_type(self): - return self.mess_data.info_type - - @property - def parent(self): - return self.mess_data.parent - - @property - def message(self): - """Return currently displayed message""" - return self.mess_data.main_message - - @message.setter - def message(self, value): - self.mess_data.message = {'':value} - self.redraw() - - @property - def type(self): - try: - return self.mess_data.type - except AttributeError: - return C.MESS_TYPE_INFO - - def redraw(self): - self._w.set_text(self.markup) - self.mess_data.parent.host.redraw() # FIXME: should not be necessary - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - def get_cursor_coords(self, size): - return 0, 0 - - def render(self, size, focus=False): - # Text widget doesn't render cursor, but we want one - # so we add it here - canvas = urwid.CompositeCanvas(self._w.render(size, focus)) - if focus: - canvas.set_cursor(self.get_cursor_coords(size)) - return canvas - - def _generateInfoMarkup(self): - return ('info_msg', self.message) - - def _generateMarkup(self): - """Generate text markup according to message data and Widget options""" - markup = [] - d = self.mess_data - mention = d.mention - - # message status - if d.status is None: - markup.append(u' ') - elif d.status == "delivered": - markup.append(('msg_status_received', u'✔')) - else: - log.warning(u"Unknown status: {}".format(d.status)) - - # timestamp - if self.parent.show_timestamp: - attr = 'msg_mention' if mention else 'date' - markup.append((attr, u"[{}]".format(d.time_text))) - else: - if mention: - markup.append(('msg_mention', '[*]')) - - # nickname - if self.parent.show_short_nick: - markup.append(('my_nick' if d.own_mess else 'other_nick', "**" if d.own_mess else "*")) - else: - markup.append(('my_nick' if d.own_mess else 'other_nick', u"[{}] ".format(d.nick or ''))) - - msg = self.message # needed to generate self.selected_lang - - if d.selected_lang: - markup.append(("msg_lang", u"[{}] ".format(d.selected_lang))) - - # message body - markup.append(msg) - - return markup - - # events - def update(self, update_dict=None): - """update all the linked message widgets - - @param update_dict(dict, None): key=attribute updated value=new_value - """ - self.redraw() - -@total_ordering -class OccupantWidget(urwid.WidgetWrap): - - def __init__(self, occupant_data): - self.occupant_data = occupant_data - occupant_data.widgets.add(self) - markup = self._generateMarkup() - text = sat_widgets.ClickableText(markup) - urwid.connect_signal(text, - 'click', - self.occupant_data.parent._occupantsClicked, - user_args=[self.occupant_data]) - super(OccupantWidget, self).__init__(text) - - def __eq__(self, other): - if other is None: - return False - return self.occupant_data.nick == other.occupant_data.nick - - def __lt__(self, other): - return self.occupant_data.nick.lower() < other.occupant_data.nick.lower() - - @property - def markup(self): - return self._generateMarkup() - - @property - def parent(self): - return self.mess_data.parent - - @property - def nick(self): - return self.occupant_data.nick - - def redraw(self): - self._w.set_text(self.markup) - self.occupant_data.parent.host.redraw() # FIXME: should not be necessary - - def selectable(self): - return True - - def keypress(self, size, key): - return key - - def get_cursor_coords(self, size): - return 0, 0 - - def render(self, size, focus=False): - # Text widget doesn't render cursor, but we want one - # so we add it here - canvas = urwid.CompositeCanvas(self._w.render(size, focus)) - if focus: - canvas.set_cursor(self.get_cursor_coords(size)) - return canvas - - def _generateMarkup(self): - # TODO: role and affiliation are shown in a Q&D way - # should be more intuitive and themable - o = self.occupant_data - markup = [] - markup.append(('info_msg', u'{}{} '.format( - o.role[0].upper(), - o.affiliation[0].upper(), - ))) - markup.append(o.nick) - if o.state is not None: - markup.append(u' {}'.format(C.CHAT_STATE_ICON[o.state])) - return markup - - # events - def update(self, update_dict=None): - self.redraw() - - -class OccupantsWidget(urwid.WidgetWrap): - - def __init__(self, parent): - self.parent = parent - self.occupants_walker = urwid.SimpleListWalker([]) - self.occupants_footer = urwid.Text('', align='center') - self.updateFooter() - occupants_widget = urwid.Frame(urwid.ListBox(self.occupants_walker), footer=self.occupants_footer) - super(OccupantsWidget, self).__init__(occupants_widget) - occupants_list = sorted(self.parent.occupants.keys(), key=lambda o:o.lower()) - for occupant in occupants_list: - occupant_data = self.parent.occupants[occupant] - self.occupants_walker.append(OccupantWidget(occupant_data)) - - def updateFooter(self): - """update footer widget""" - txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants)) - self.occupants_footer.set_text(txt) - - def getNicks(self, start=u''): - """Return nicks of all occupants - - @param start(unicode): only return nicknames which start with this text - """ - return [w.nick for w in self.occupants_walker if isinstance(w, OccupantWidget) and w.nick.startswith(start)] - - def addUser(self, occupant_data): - """add a user to the list""" - bisect.insort(self.occupants_walker, OccupantWidget(occupant_data)) - self.updateFooter() - self.parent.host.redraw() # FIXME: should not be necessary - - def removeUser(self, occupant_data): - """remove a user from the list""" - for widget in occupant_data.widgets: - self.occupants_walker.remove(widget) - self.updateFooter() - self.parent.host.redraw() # FIXME: should not be necessary - - -class Chat(PrimitivusWidget, quick_chat.QuickChat): - - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None): - quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles) - self.filters = [] # list of filter callbacks to apply - self.mess_walker = urwid.SimpleListWalker([]) - self.mess_widgets = urwid.ListBox(self.mess_walker) - self.chat_widget = urwid.Frame(self.mess_widgets) - self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)]) - self.pile = urwid.Pile([self.chat_colums]) - PrimitivusWidget.__init__(self, self.pile, self.target) - - # we must adapt the behaviour with the type - if type_ == C.CHAT_GROUP: - if len(self.chat_colums.contents) == 1: - self.occupants_widget = OccupantsWidget(self) - self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_widget) - self._appendOccupantsPanel() - self.host.addListener('presence', self.presenceListener, [profiles]) - - # focus marker is a separator indicated last visible message before focus was lost - self.focus_marker = None # link to current marker - self.focus_marker_set = None # True if a new marker has been inserted - self.show_timestamp = True - self.show_short_nick = False - self.show_title = 1 # 0: clip title; 1: full title; 2: no title - self.postInit() - - def keypress(self, size, key): - if key == a_key['OCCUPANTS_HIDE']: # user wants to (un)hide the occupants panel - if self.type == C.CHAT_GROUP: - widgets = [widget for (widget, options) in self.chat_colums.contents] - if self.occupants_panel in widgets: - self._removeOccupantsPanel() - else: - self._appendOccupantsPanel() - elif key == a_key['TIMESTAMP_HIDE']: # user wants to (un)hide timestamp - self.show_timestamp = not self.show_timestamp - self.redraw() - elif key == a_key['SHORT_NICKNAME']: # user wants to (not) use short nick - self.show_short_nick = not self.show_short_nick - self.redraw() - elif key == a_key['SUBJECT_SWITCH']: # user wants to (un)hide group's subject or change its apperance - if self.subject: - self.show_title = (self.show_title + 1) % 3 - if self.show_title == 0: - self.setSubject(self.subject, 'clip') - elif self.show_title == 1: - self.setSubject(self.subject, 'space') - elif self.show_title == 2: - self.chat_widget.header = None - self._invalidate() - elif key == a_key['GOTO_BOTTOM']: # user wants to focus last message - self.mess_widgets.focus_position = len(self.mess_walker) - 1 - - return super(Chat, self).keypress(size, key) - - def completion(self, text, completion_data): - """Completion method which complete nicknames in group chat - - for params, see [sat_widgets.AdvancedEdit] - """ - if self.type != C.CHAT_GROUP: - return text - - space = text.rfind(" ") - start = text[space + 1:] - words = self.occupants_widget.getNicks(start) - if not words: - return text - try: - word_idx = words.index(completion_data['last_word']) + 1 - except (KeyError, ValueError): - word_idx = 0 - else: - if word_idx == len(words): - word_idx = 0 - word = completion_data['last_word'] = words[word_idx] - return u"{}{}{}".format(text[:space + 1], word, ': ' if space < 0 else '') - - def getMenu(self): - """Return Menu bar""" - menu = sat_widgets.Menu(self.host.loop) - if self.type == C.CHAT_GROUP: - self.host.addMenus(menu, C.MENU_ROOM, {'room_jid': self.target.bare}) - game = _("Game") - menu.addMenu(game, "Tarot", self.onTarotRequest) - elif self.type == C.CHAT_ONE2ONE: - # FIXME: self.target is a bare jid, we need to check that - contact_list = self.host.contact_lists[self.profile] - if not self.target.resource: - full_jid = contact_list.getFullJid(self.target) - else: - full_jid = self.target - self.host.addMenus(menu, C.MENU_SINGLE, {'jid': full_jid}) - return menu - - def setFilter(self, args): - """set filtering of messages - - @param args(list[unicode]): filters following syntax "[filter]=[value]" - empty list to clear all filters - only lang=XX is handled for now - """ - del self.filters[:] - if args: - if args[0].startswith("lang="): - lang = args[0][5:].strip() - self.filters.append(lambda mess_data: lang in mess_data.message) - - self.printMessages() - - def presenceListener(self, entity, show, priority, statuses, profile): - """Update entity's presence status - - @param entity (jid.JID): entity updated - @param show: availability - @param priority: resource's priority - @param statuses: dict of statuses - @param profile: %(doc_profile)s - """ - # FIXME: disable for refactoring, need to be checked and re-enabled - return - # assert self.type == C.CHAT_GROUP - # if entity.bare != self.target: - # return - # self.update(entity) - - def createMessage(self, message): - self.appendMessage(message) - - def _user_moved(self, message): - """return true if message is a user left/joined message - - @param message(quick_chat.Message): message to add - """ - if message.type != C.MESS_TYPE_INFO: - return False - try: - info_type = message.extra['info_type'] - except KeyError: - return False - else: - return info_type in quick_chat.ROOM_USER_MOVED - - def _scrollDown(self): - """scroll down message only if we are already at the bottom (minus 1)""" - current_focus = self.mess_widgets.focus_position - bottom = len(self.mess_walker) - 1 - if current_focus == bottom - 1: - self.mess_widgets.focus_position = bottom # scroll down - self.host.redraw() # FIXME: should not be necessary - - def appendMessage(self, message): - """Create a MessageWidget and append it - - Can merge messages together is desirable (e.g.: multiple joined/leave) - @param message(quick_chat.Message): message to add - """ - if self.filters: - if not all([f(message) for f in self.filters]): - return - if self._user_moved(message): - for wid in reversed(self.mess_walker): - # we merge in/out messages if no message was sent meanwhile - if not isinstance(wid, MessageWidget): - continue - if wid.mess_data.type != C.MESS_TYPE_INFO: - break - if wid.info_type in quick_chat.ROOM_USER_MOVED and wid.mess_data.nick == message.nick: - try: - count = wid.reentered_count - except AttributeError: - count = wid.reentered_count = 1 - nick = wid.mess_data.nick - if message.info_type == quick_chat.ROOM_USER_LEFT: - wid.message = _(u"<= {nick} has left the room ({count})").format(nick=nick, count=count) - else: - wid.message = _(u"<=> {nick} re-entered the room ({count})") .format(nick=nick, count=count) - wid.reentered_count+=1 - return - - if ((self.host.selected_widget != self or not self.host.x_notify.hasFocus()) - and self.focus_marker_set is not None): - if not self.focus_marker_set and not self._locked and self.mess_walker: - if self.focus_marker is not None: - self.mess_walker.remove(self.focus_marker) - self.focus_marker = urwid.Divider('—') - self.mess_walker.append(self.focus_marker) - self.focus_marker_set = True - self._scrollDown() - else: - if self.focus_marker_set: - self.focus_marker_set = False - - if not message.message: - log.error(u"Received an empty message for uid {}".format(message.uid)) - else: - wid = MessageWidget(message) - self.mess_walker.append(wid) - self._scrollDown() - if self._user_moved(message): - return # no notification for moved messages - - # notifications - - if self._locked: - # we don't want notifications when locked - # because that's history messages - return - - if wid.mess_data.mention: - from_jid = wid.mess_data.from_jid - msg = _(u'You have been mentioned by {nick} in {room}'.format( - nick=wid.mess_data.nick, - room=self.target, - )) - self.host.notify(C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile) - elif self.type == C.CHAT_ONE2ONE: - from_jid = wid.mess_data.from_jid - msg = _(u'{entity} is talking to you'.format( - entity=from_jid, - )) - self.host.notify(C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile) - else: - self.host.notify(C.NOTIFY_MESSAGE, self.target, widget=self, profile=self.profile) - - - def addUser(self, nick): - occupant = super(Chat, self).addUser(nick) - self.occupants_widget.addUser(occupant) - - def removeUser(self, occupant_data): - occupant = super(Chat, self).removeUser(occupant_data) - if occupant is not None: - self.occupants_widget.removeUser(occupant) - - def _occupantsClicked(self, occupant, clicked_wid): - assert self.type == C.CHAT_GROUP - contact_list = self.host.contact_lists[self.profile] - - # we have a click on a nick, we need to create the widget if it doesn't exists - self.getOrCreatePrivateWidget(occupant.jid) - - # now we select the new window - for contact_list in self.host.widgets.getWidgets(ContactList, profiles=(self.profile,)): - contact_list.setFocus(occupant.jid, True) - - def _appendOccupantsPanel(self): - self.chat_colums.contents.append((self.occupants_panel, ('weight', 2, False))) - - def _removeOccupantsPanel(self): - for widget, options in self.chat_colums.contents: - if widget is self.occupants_panel: - self.chat_colums.contents.remove((widget, options)) - break - - def addGamePanel(self, widget): - """Insert a game panel to this Chat dialog. - - @param widget (Widget): the game panel - """ - assert (len(self.pile.contents) == 1) - self.pile.contents.insert(0, (widget, ('weight', 1))) - self.pile.contents.insert(1, (urwid.Filler(urwid.Divider('-'), ('fixed', 1)))) - self.host.redraw() - - def removeGamePanel(self, widget): - """Remove the game panel from this Chat dialog. - - @param widget (Widget): the game panel - """ - assert (len(self.pile.contents) == 3) - del self.pile.contents[0] - self.host.redraw() - - def setSubject(self, subject, wrap='space'): - """Set title for a group chat""" - quick_chat.QuickChat.setSubject(self, subject) - self.subj_wid = urwid.Text(unicode(subject.replace('\n', '|') if wrap == 'clip' else subject), - align='left' if wrap == 'clip' else 'center', wrap=wrap) - self.chat_widget.header = urwid.AttrMap(self.subj_wid, 'title') - self.host.redraw() - - ## Messages - - def printMessages(self, clear=True): - """generate message widgets - - @param clear(bool): clear message before printing if true - """ - if clear: - del self.mess_walker[:] - for message in self.messages.itervalues(): - self.appendMessage(message) - - def redraw(self): - """redraw all messages""" - for w in self.mess_walker: - try: - w.redraw() - except AttributeError: - pass - - def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'): - del self.mess_walker[:] - if filters and 'search' in filters: - self.mess_walker.append(urwid.Text(_(u"Results for searching the globbing pattern: {}").format(filters['search']))) - self.mess_walker.append(urwid.Text(_(u"Type ':history ' to reset the chat history"))) - super(Chat, self).updateHistory(size, filters, profile) - - def _onHistoryPrinted(self): - """Refresh or scroll down the focus after the history is printed""" - self.printMessages(clear=False) - super(Chat, self)._onHistoryPrinted() - - def onPrivateCreated(self, widget): - self.host.contact_lists[widget.profile].setSpecial(widget.target, C.CONTACT_SPECIAL_GROUP) - - def onSelected(self): - self.focus_marker_set = False - - def notify(self, contact="somebody", msg=""): - """Notify the user of a new message if primitivus doesn't have the focus. - - @param contact (unicode): contact who wrote to the users - @param msg (unicode): the message that has been received - """ - # FIXME: not called anymore after refactoring - if msg == "": - return - if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2: - # we don't change focus if user is not at the bottom - # as that mean that he is probably watching discussion history - self.mess_widgets.focus_position = len(self.mess_walker) - 1 - self.host.redraw() - if not self.host.x_notify.hasFocus(): - if self.type == C.CHAT_ONE2ONE: - self.host.x_notify.sendNotification(_("Primitivus: %s is talking to you") % contact) - elif self.nick is not None and self.nick.lower() in msg.lower(): - self.host.x_notify.sendNotification(_("Primitivus: %(user)s mentioned you in room '%(room)s'") % {'user': contact, 'room': self.target}) - - # MENU EVENTS # - def onTarotRequest(self, menu): - # TODO: move this to plugin_misc_tarot with dynamic menu - if len(self.occupants) != 4: - self.host.showPopUp(sat_widgets.Alert(_("Can't start game"), _("You need to be exactly 4 peoples in the room to start a Tarot game"), ok_cb=self.host.removePopUp)) - else: - self.host.bridge.tarotGameCreate(self.target, list(self.occupants), self.profile) - - # MISC EVENTS # - - def onDelete(self): - # FIXME: to be checked after refactoring - super(Chat, self).onDelete() - if self.type == C.CHAT_GROUP: - self.host.removeListener('presence', self.presenceListener) - - def onChatState(self, from_jid, state, profile): - super(Chat, self).onChatState(from_jid, state, profile) - if self.type == C.CHAT_ONE2ONE: - self.title_dynamic = C.CHAT_STATE_ICON[state] - self.host.redraw() # FIXME: should not be necessary - - def _onSubjectDialogCb(self, button, dialog): - self.changeSubject(dialog.text) - self.host.removePopUp(dialog) - - def onSubjectDialog(self, new_subject=None): - dialog = sat_widgets.InputDialog( - _(u'Change title'), - _(u'Enter the new title'), - default_txt=new_subject if new_subject is not None else self.subject) - dialog.setCallback('ok', self._onSubjectDialogCb, dialog) - dialog.setCallback('cancel', lambda dummy: self.host.removePopUp(dialog)) - self.host.showPopUp(dialog) - -quick_widgets.register(quick_chat.QuickChat, Chat) -quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/config.py --- a/frontends/src/primitivus/config.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,50 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -"""This module manage configuration specific to Primitivus""" - -from sat_frontends.primitivus.constants import Const as C -from sat_frontends.primitivus.keys import action_key_map -import ConfigParser - - -def applyConfig(host): - """Parse configuration and apply found change - - raise: can raise various Exceptions if configuration is not good - """ - config = ConfigParser.SafeConfigParser() - config.read(C.CONFIG_FILES) - try: - options = config.items(C.CONFIG_SECTION) - except ConfigParser.NoSectionError: - options = [] - shortcuts = {} - for name, value in options: - if name.startswith(C.CONFIG_OPT_KEY_PREFIX.lower()): - action = name[len(C.CONFIG_OPT_KEY_PREFIX):].upper() - shortcut = value - if not action or not shortcut: - raise ValueError("Bad option: {} = {}".format(name, value)) - shortcuts[action] = shortcut - if name == "disable_mouse": - host.loop.screen.set_mouse_tracking(False) - - action_key_map.replace(shortcuts) - action_key_map.check_namespaces() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/constants.py --- a/frontends/src/primitivus/constants.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,106 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat_frontends.quick_frontend import constants - - -class Const(constants.Const): - - APP_NAME = "Primitivus" - SECTION_NAME = APP_NAME.lower() - PALETTE = [ - ('title', 'black', 'light gray', 'standout,underline'), - ('title_focus', 'white,bold', 'light gray', 'standout,underline'), - ('selected', 'default', 'dark red'), - ('selected_focus', 'default,bold', 'dark red'), - ('default', 'default', 'default'), - ('default_focus', 'default,bold', 'default'), - ('cl_notifs', 'yellow', 'default'), - ('cl_notifs_focus', 'yellow,bold', 'default'), - ('cl_mention', 'dark red', 'default'), - ('cl_mention_focus', 'dark red,bold', 'default'), - # Messages - ('date', 'light gray', 'default'), - ('my_nick', 'dark red,bold', 'default'), - ('other_nick', 'dark cyan,bold', 'default'), - ('info_msg', 'yellow', 'default', 'bold'), - ('msg_lang', 'dark cyan', 'default'), - ('msg_mention', 'dark red, bold', 'default'), - ('msg_status_received', 'light green, bold', 'default'), - - ('menubar', 'light gray,bold', 'dark red'), - ('menubar_focus', 'light gray,bold', 'dark green'), - ('selected_menu', 'light gray,bold', 'dark green'), - ('menuitem', 'light gray,bold', 'dark red'), - ('menuitem_focus', 'light gray,bold', 'dark green'), - ('notifs', 'black,bold', 'yellow'), - ('notifs_focus', 'dark red', 'yellow'), - ('card_neutral', 'dark gray', 'white', 'standout,underline'), - ('card_neutral_selected', 'dark gray', 'dark green', 'standout,underline'), - ('card_special', 'brown', 'white', 'standout,underline'), - ('card_special_selected', 'brown', 'dark green', 'standout,underline'), - ('card_red', 'dark red', 'white', 'standout,underline'), - ('card_red_selected', 'dark red', 'dark green', 'standout,underline'), - ('card_black', 'black', 'white', 'standout,underline'), - ('card_black_selected', 'black', 'dark green', 'standout,underline'), - ('directory', 'dark cyan, bold', 'default'), - ('directory_focus', 'dark cyan, bold', 'dark green'), - ('separator', 'brown', 'default'), - ('warning', 'light red', 'default'), - ('progress_normal', 'default', 'brown'), - ('progress_complete', 'default', 'dark green'), - ('show_disconnected', 'dark gray', 'default'), - ('show_normal', 'default', 'default'), - ('show_normal_focus', 'default, bold', 'default'), - ('show_chat', 'dark green', 'default'), - ('show_chat_focus', 'dark green, bold', 'default'), - ('show_away', 'brown', 'default'), - ('show_away_focus', 'brown, bold', 'default'), - ('show_dnd', 'dark red', 'default'), - ('show_dnd_focus', 'dark red, bold', 'default'), - ('show_xa', 'dark red', 'default'), - ('show_xa_focus', 'dark red, bold', 'default'), - ('resource', 'light blue', 'default'), - ('resource_main', 'dark blue', 'default'), - ('status', 'yellow', 'default'), - ('status_focus', 'yellow, bold', 'default'), - ('param_selected','default, bold', 'dark red'), - ('table_selected','default, bold', 'default'), - ] - PRESENCE = {"unavailable": (u'⨯', "show_disconnected"), - "": (u'✔', "show_normal"), - "chat": (u'✆', "show_chat"), - "away": (u'✈', "show_away"), - "dnd": (u'✖', "show_dnd"), - "xa": (u'☄', "show_xa") - } - LOG_OPT_SECTION = APP_NAME.lower() - LOG_OPT_OUTPUT = ('output', constants.Const.LOG_OPT_OUTPUT_SEP + constants.Const.LOG_OPT_OUTPUT_MEMORY) - - CONFIG_SECTION = APP_NAME.lower() - CONFIG_OPT_KEY_PREFIX = "KEY_" - - MENU_ID_MAIN = "MAIN_MENU" - MENU_ID_WIDGET = "WIDGET_MENU" - - MODE_NORMAL = 'NORMAL' - MODE_INSERTION = 'INSERTION' - MODE_COMMAND = 'COMMAND' - - GROUP_DATA_FOLDED = 'folded' diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/contact_list.py --- a/frontends/src/primitivus/contact_list.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,304 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -import urwid -from urwid_satext import sat_widgets -from sat_frontends.quick_frontend.quick_contact_list import QuickContactList -from sat_frontends.primitivus.status import StatusBar -from sat_frontends.primitivus.constants import Const as C -from sat_frontends.primitivus.keys import action_key_map as a_key -from sat_frontends.primitivus.widget import PrimitivusWidget -from sat_frontends.tools import jid -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat_frontends.quick_frontend import quick_widgets - - -class ContactList(PrimitivusWidget, QuickContactList): - PROFILES_MULTIPLE=False - PROFILES_ALLOW_NONE=False - signals = ['click','change'] - # FIXME: Only single profile is managed so far - - def __init__(self, host, target, on_click=None, on_change=None, user_data=None, profiles=None): - QuickContactList.__init__(self, host, profiles) - self.contact_list = self.host.contact_lists[self.profile] - - #we now build the widget - self.status_bar = StatusBar(host) - self.frame = sat_widgets.FocusFrame(self._buildList(), None, self.status_bar) - PrimitivusWidget.__init__(self, self.frame, _(u'Contacts')) - if on_click: - urwid.connect_signal(self, 'click', on_click, user_data) - if on_change: - urwid.connect_signal(self, 'change', on_change, user_data) - self.host.addListener('notification', self.onNotification, [self.profile]) - self.host.addListener('notificationsClear', self.onNotification, [self.profile]) - self.postInit() - - def update(self, entities=None, type_=None, profile=None): - """Update display, keep focus""" - # FIXME: full update is done each time, must handle entities, type_ and profile - widget, position = self.frame.body.get_focus() - self.frame.body = self._buildList() - if position: - try: - self.frame.body.focus_position = position - except IndexError: - pass - self._invalidate() - self.host.redraw() # FIXME: check if can be avoided - - def keypress(self, size, key): - # FIXME: we have a temporary behaviour here: FOCUS_SWITCH change focus globally in the parent, - # and FOCUS_UP/DOWN is transwmitter to parent if we are respectively on the first or last element - if key in sat_widgets.FOCUS_KEYS: - if (key == a_key['FOCUS_SWITCH'] or (key == a_key['FOCUS_UP'] and self.frame.focus_position == 'body') or - (key == a_key['FOCUS_DOWN'] and self.frame.focus_position == 'footer')): - return key - if key == a_key['STATUS_HIDE']: #user wants to (un)hide contacts' statuses - self.contact_list.show_status = not self.contact_list.show_status - self.update() - elif key == a_key['DISCONNECTED_HIDE']: #user wants to (un)hide disconnected contacts - self.host.bridge.setParam(C.SHOW_OFFLINE_CONTACTS, C.boolConst(not self.contact_list.show_disconnected), "General", profile_key=self.profile) - elif key == a_key['RESOURCES_HIDE']: #user wants to (un)hide contacts resources - self.contact_list.showResources(not self.contact_list.show_resources) - self.update() - return super(ContactList, self).keypress(size, key) - - # QuickWidget methods - - @staticmethod - def getWidgetHash(target, profiles): - profiles = sorted(profiles) - return tuple(profiles) - - # modify the contact list - - def setFocus(self, text, select=False): - """give focus to the first element that matches the given text. You can also - pass in text a sat_frontends.tools.jid.JID (it's a subclass of unicode). - - @param text: contact group name, contact or muc userhost, muc private dialog jid - @param select: if True, the element is also clicked - """ - idx = 0 - for widget in self.frame.body.body: - try: - if isinstance(widget, sat_widgets.ClickableText): - # contact group - value = widget.getValue() - elif isinstance(widget, sat_widgets.SelectableText): - # contact or muc - value = widget.data - else: - # Divider instance - continue - # there's sometimes a leading space - if text.strip() == value.strip(): - self.frame.body.focus_position = idx - if select: - self._contactClicked(False, widget, True) - return - except AttributeError: - pass - idx += 1 - - log.debug(u"Not element found for {} in setFocus".format(text)) - - # events - - def _groupClicked(self, group_wid): - group = group_wid.getValue() - data = self.contact_list.getGroupData(group) - data[C.GROUP_DATA_FOLDED] = not data.setdefault(C.GROUP_DATA_FOLDED, False) - self.setFocus(group) - self.update() - - def _contactClicked(self, use_bare_jid, contact_wid, selected): - """Method called when a contact is clicked - - @param use_bare_jid: True if use_bare_jid is set in self._buildEntityWidget. - @param contact_wid: widget of the contact, must have the entity set in data attribute - @param selected: boolean returned by the widget, telling if it is selected - """ - entity = contact_wid.data - self.host.modeHint(C.MODE_INSERTION) - self._emit('click', entity) - - def onNotification(self, entity, notif, profile): - notifs = list(self.host.getNotifs(C.ENTITY_ALL, profile=self.profile)) - if notifs: - self.title_dynamic = u"({})".format(len(notifs)) - else: - self.title_dynamic = None - self.host.redraw() # FIXME: should not be necessary - - # Methods to build the widget - - def _buildEntityWidget(self, entity, keys=None, use_bare_jid=False, with_notifs=True, with_show_attr=True, markup_prepend=None, markup_append=None, special=False): - """Build one contact markup data - - @param entity (jid.JID): entity to build - @param keys (iterable): value to markup, in preferred order. - The first available key will be used. - If key starts with "cache_", it will be checked in cache, - else, getattr will be done on entity with the key (e.g. getattr(entity, 'node')). - If nothing full or keys is None, full entity is used. - @param use_bare_jid (bool): if True, use bare jid for selected comparisons - @param with_notifs (bool): if True, show notification count - @param with_show_attr (bool): if True, show color corresponding to presence status - @param markup_prepend (list): markup to prepend to the generated one before building the widget - @param markup_append (list): markup to append to the generated one before building the widget - @param special (bool): True if entity is a special one - @return (list): markup data are expected by Urwid text widgets - """ - markup = [] - if use_bare_jid: - selected = {entity.bare for entity in self.contact_list._selected} - else: - selected = self.contact_list._selected - if keys is None: - entity_txt = entity - else: - cache = self.contact_list.getCache(entity) - for key in keys: - if key.startswith('cache_'): - entity_txt = cache.get(key[6:]) - else: - entity_txt = getattr(entity, key) - if entity_txt: - break - if not entity_txt: - entity_txt = entity - - if with_show_attr: - show = self.contact_list.getCache(entity, C.PRESENCE_SHOW) - if show is None: - show = C.PRESENCE_UNAVAILABLE - show_icon, entity_attr = C.PRESENCE.get(show, ('', 'default')) - markup.insert(0, u"{} ".format(show_icon)) - else: - entity_attr = 'default' - - notifs = list(self.host.getNotifs(entity, exact_jid=special, profile=self.profile)) - if notifs: - header = [('cl_notifs', u'({})'.format(len(notifs))), u' '] - if list(self.host.getNotifs(entity.bare, C.NOTIFY_MENTION, profile=self.profile)): - header = ('cl_mention', header) - else: - header = u'' - - markup.append((entity_attr, entity_txt)) - if markup_prepend: - markup.insert(0, markup_prepend) - if markup_append: - markup.extend(markup_append) - - widget = sat_widgets.SelectableText(markup, - selected = entity in selected, - header = header) - widget.data = entity - widget.comp = entity_txt.lower() # value to use for sorting - urwid.connect_signal(widget, 'change', self._contactClicked, user_args=[use_bare_jid]) - return widget - - def _buildEntities(self, content, entities): - """Add entity representation in widget list - - @param content: widget list, e.g. SimpleListWalker - @param entities (iterable): iterable of JID to display - """ - if not entities: - return - widgets = [] # list of built widgets - - for entity in entities: - if entity in self.contact_list._specials or not self.contact_list.entityToShow(entity): - continue - markup_extra = [] - if self.contact_list.show_resources: - for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES): - resource_disp = ('resource_main' if resource == self.contact_list.getCache(entity, C.CONTACT_MAIN_RESOURCE) else 'resource', "\n " + resource) - markup_extra.append(resource_disp) - if self.contact_list.show_status: - status = self.contact_list.getCache(jid.JID('%s/%s' % (entity, resource)), 'status') - status_disp = ('status', "\n " + status) if status else "" - markup_extra.append(status_disp) - - - else: - if self.contact_list.show_status: - status = self.contact_list.getCache(entity, 'status') - status_disp = ('status', "\n " + status) if status else "" - markup_extra.append(status_disp) - widget = self._buildEntityWidget(entity, ('cache_nick', 'cache_name', 'node'), use_bare_jid=True, markup_append=markup_extra) - widgets.append(widget) - - widgets.sort(key=lambda widget: widget.comp) - - for widget in widgets: - content.append(widget) - - def _buildSpecials(self, content): - """Build the special entities""" - specials = sorted(self.contact_list.getSpecials()) - current = None - for entity in specials: - if current is not None and current.bare == entity.bare: - # nested entity (e.g. MUC private conversations) - widget = self._buildEntityWidget(entity, ('resource',), markup_prepend=' ', special=True) - else: - # the special widgets - if entity.resource: - widget = self._buildEntityWidget(entity, ('resource',), special=True) - else: - widget = self._buildEntityWidget(entity, ('cache_nick', 'cache_name', 'node'), with_show_attr=False, special=True) - content.append(widget) - - def _buildList(self): - """Build the main contact list widget""" - content = urwid.SimpleListWalker([]) - - self._buildSpecials(content) - if self.contact_list._specials: - content.append(urwid.Divider('=')) - - groups = list(self.contact_list._groups) - groups.sort(key=lambda x: x.lower() if x else x) - for group in groups: - data = self.contact_list.getGroupData(group) - folded = data.get(C.GROUP_DATA_FOLDED, False) - jids = list(data['jids']) - if group is not None and (self.contact_list.anyEntityToShow(jids) or self.contact_list.show_empty_groups): - header = '[-]' if not folded else '[+]' - widget = sat_widgets.ClickableText(group, header=header + ' ') - content.append(widget) - urwid.connect_signal(widget, 'click', self._groupClicked) - if not folded: - self._buildEntities(content, jids) - not_in_roster = set(self.contact_list._cache).difference(self.contact_list._roster).difference(self.contact_list._specials).difference((self.contact_list.whoami.bare,)) - if not_in_roster: - content.append(urwid.Divider('-')) - self._buildEntities(content, not_in_roster) - - return urwid.ListBox(content) - -quick_widgets.register(QuickContactList, ContactList) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/game_tarot.py --- a/frontends/src/primitivus/game_tarot.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,348 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -import urwid -from urwid_satext import sat_widgets -from sat_frontends.tools.games import TarotCard -from sat_frontends.quick_frontend.quick_game_tarot import QuickTarotGame -from sat_frontends.primitivus import xmlui -from sat_frontends.primitivus.keys import action_key_map as a_key - - -class CardDisplayer(urwid.Text): - """Show a card""" - signals = ['click'] - - def __init__(self, card): - self.__selected = False - self.card = card - urwid.Text.__init__(self, card.getAttrText()) - - def selectable(self): - return True - - def keypress(self, size, key): - if key == a_key['CARD_SELECT']: - self.select(not self.__selected) - self._emit('click') - return key - - def mouse_event(self, size, event, button, x, y, focus): - if urwid.is_mouse_event(event) and button == 1: - self.select(not self.__selected) - self._emit('click') - return True - - return False - - def select(self, state=True): - self.__selected = state - attr, txt = self.card.getAttrText() - if self.__selected: - attr += '_selected' - self.set_text((attr, txt)) - self._invalidate() - - def isSelected(self): - return self.__selected - - def getCard(self): - return self.card - - def render(self, size, focus=False): - canvas = urwid.CompositeCanvas(urwid.Text.render(self, size, focus)) - if focus: - canvas.set_cursor((0, 0)) - return canvas - - -class Hand(urwid.WidgetWrap): - """Used to display several cards, and manage a hand""" - signals = ['click'] - - def __init__(self, hand=[], selectable=False, on_click=None, user_data=None): - """@param hand: list of Card""" - self.__selectable = selectable - self.columns = urwid.Columns([], dividechars=1) - if on_click: - urwid.connect_signal(self, 'click', on_click, user_data) - if hand: - self.update(hand) - urwid.WidgetWrap.__init__(self, self.columns) - - def selectable(self): - return self.__selectable - - def keypress(self, size, key): - - if CardDisplayer in [wid.__class__ for wid in self.columns.widget_list]: - return self.columns.keypress(size, key) - else: - #No card displayed, we still have to manage the clicks - if key == a_key['CARD_SELECT']: - self._emit('click', None) - return key - - def getSelected(self): - """Return a list of selected cards""" - _selected = [] - for wid in self.columns.widget_list: - if isinstance(wid, CardDisplayer) and wid.isSelected(): - _selected.append(wid.getCard()) - return _selected - - def update(self, hand): - """Update the hand displayed in this widget - @param hand: list of Card""" - try: - del self.columns.widget_list[:] - del self.columns.column_types[:] - except IndexError: - pass - self.columns.contents.append((urwid.Text(''), ('weight', 1, False))) - for card in hand: - widget = CardDisplayer(card) - self.columns.widget_list.append(widget) - self.columns.column_types.append(('fixed', 3)) - urwid.connect_signal(widget, 'click', self.__onClick) - self.columns.contents.append((urwid.Text(''), ('weight', 1, False))) - self.columns.focus_position = 1 - - def __onClick(self, card_wid): - self._emit('click', card_wid) - - -class Card(TarotCard): - """This class is used to represent a card, logically - and give a text representation with attributes""" - SIZE = 3 # size of a displayed card - - def __init__(self, suit, value): - """@param file: path of the PNG file""" - TarotCard.__init__(self, (suit, value)) - - def getAttrText(self): - """return text representation of the card with attributes""" - try: - value = "%02i" % int(self.value) - except ValueError: - value = self.value[0].upper() + self.value[1] - if self.suit == "atout": - if self.value == "excuse": - suit = 'c' - else: - suit = 'A' - color = 'neutral' - elif self.suit == "pique": - suit = u'♠' - color = 'black' - elif self.suit == "trefle": - suit = u'♣' - color = 'black' - elif self.suit == "coeur": - suit = u'♥' - color = 'red' - elif self.suit == "carreau": - suit = u'♦' - color = 'red' - if self.bout: - color = 'special' - return ('card_%s' % color, u"%s%s" % (value, suit)) - - def getWidget(self): - """Return a widget representing the card""" - return CardDisplayer(self) - - -class Table(urwid.FlowWidget): - """Represent the cards currently on the table""" - - def __init__(self): - self.top = self.left = self.bottom = self.right = None - - def putCard(self, location, card): - """Put a card on the table - @param location: where to put the card (top, left, bottom or right) - @param card: Card to play or None""" - assert location in ['top', 'left', 'bottom', 'right'] - assert isinstance(card, Card) or card == None - if [getattr(self, place) for place in ['top', 'left', 'bottom', 'right']].count(None) == 0: - #If the table is full of card, we remove them - self.top = self.left = self.bottom = self.right = None - setattr(self, location, card) - self._invalidate() - - def rows(self, size, focus=False): - return self.display_widget(size, focus).rows(size, focus) - - def render(self, size, focus=False): - return self.display_widget(size, focus).render(size, focus) - - def display_widget(self, size, focus): - cards = {} - max_col, = size - separator = " - " - margin = max((max_col - Card.SIZE) / 2, 0) * ' ' - margin_center = max((max_col - Card.SIZE * 2 - len(separator)) / 2, 0) * ' ' - for location in ['top', 'left', 'bottom', 'right']: - card = getattr(self, location) - cards[location] = card.getAttrText() if card else Card.SIZE * ' ' - render_wid = [urwid.Text([margin, cards['top']]), - urwid.Text([margin_center, cards['left'], separator, cards['right']]), - urwid.Text([margin, cards['bottom']])] - return urwid.Pile(render_wid) - - -class TarotGame(QuickTarotGame, urwid.WidgetWrap): - """Widget for card games""" - - def __init__(self, parent, referee, players): - QuickTarotGame.__init__(self, parent, referee, players) - self.loadCards() - self.top = urwid.Pile([urwid.Padding(urwid.Text(self.top_nick), 'center')]) - #self.parent.host.debug() - self.table = Table() - self.center = urwid.Columns([('fixed', len(self.left_nick), urwid.Filler(urwid.Text(self.left_nick))), - urwid.Filler(self.table), - ('fixed', len(self.right_nick), urwid.Filler(urwid.Text(self.right_nick))) - ]) - """urwid.Pile([urwid.Padding(self.top_card_wid,'center'), - urwid.Columns([('fixed',len(self.left_nick),urwid.Text(self.left_nick)), - urwid.Padding(self.center_cards_wid,'center'), - ('fixed',len(self.right_nick),urwid.Text(self.right_nick)) - ]), - urwid.Padding(self.bottom_card_wid,'center') - ])""" - self.hand_wid = Hand(selectable=True, on_click=self.onClick) - self.main_frame = urwid.Frame(self.center, header=self.top, footer=self.hand_wid, focus_part='footer') - urwid.WidgetWrap.__init__(self, self.main_frame) - self.parent.host.bridge.tarotGameReady(self.player_nick, referee, self.parent.profile) - - def loadCards(self): - """Load all the cards in memory""" - QuickTarotGame.loadCards(self) - for value in map(str, range(1, 22)) + ['excuse']: - card = Card('atout', value) - self.cards[card.suit, card.value] = card - self.deck.append(card) - for suit in ["pique", "coeur", "carreau", "trefle"]: - for value in map(str, range(1, 11)) + ["valet", "cavalier", "dame", "roi"]: - card = Card(suit, value) - self.cards[card.suit, card.value] = card - self.deck.append(card) - - def tarotGameNewHandler(self, hand): - """Start a new game, with given hand""" - if hand is []: # reset the display after the scores have been showed - self.resetRound() - for location in ['top', 'left', 'bottom', 'right']: - self.table.putCard(location, None) - self.parent.host.redraw() - self.parent.host.bridge.tarotGameReady(self.player_nick, self.referee, self.parent.profile) - return - QuickTarotGame.tarotGameNewHandler(self, hand) - self.hand_wid.update(self.hand) - self.parent.host.redraw() - - def tarotGameChooseContratHandler(self, xml_data): - """Called when the player has to select his contrat - @param xml_data: SàT xml representation of the form""" - form = xmlui.create(self.parent.host, xml_data, title=_('Please choose your contrat'), flags=['NO_CANCEL'], profile=self.parent.profile) - form.show(valign='top') - - def tarotGameShowCardsHandler(self, game_stage, cards, data): - """Display cards in the middle of the game (to show for e.g. chien ou poignée)""" - QuickTarotGame.tarotGameShowCardsHandler(self, game_stage, cards, data) - self.center.widget_list[1] = urwid.Filler(Hand(self.to_show)) - self.parent.host.redraw() - - def tarotGameYourTurnHandler(self): - QuickTarotGame.tarotGameYourTurnHandler(self) - - def tarotGameScoreHandler(self, xml_data, winners, loosers): - """Called when the round is over, display the scores - @param xml_data: SàT xml representation of the form""" - if not winners and not loosers: - title = _("Draw game") - else: - title = _('You win \o/') if self.player_nick in winners else _('You loose :(') - form = xmlui.create(self.parent.host, xml_data, title=title, flags=['NO_CANCEL'], profile=self.parent.profile) - form.show() - - def tarotGameInvalidCardsHandler(self, phase, played_cards, invalid_cards): - """Invalid cards have been played - @param phase: phase of the game - @param played_cards: all the cards played - @param invalid_cards: cards which are invalid""" - QuickTarotGame.tarotGameInvalidCardsHandler(self, phase, played_cards, invalid_cards) - self.hand_wid.update(self.hand) - if self._autoplay == None: # No dialog if there is autoplay - self.parent.host.barNotify(_('Cards played are invalid !')) - self.parent.host.redraw() - - def tarotGameCardsPlayedHandler(self, player, cards): - """A card has been played by player""" - QuickTarotGame.tarotGameCardsPlayedHandler(self, player, cards) - self.table.putCard(self.getPlayerLocation(player), self.played[player]) - self._checkState() - self.parent.host.redraw() - - def _checkState(self): - if isinstance(self.center.widget_list[1].original_widget, Hand): # if we have a hand displayed - self.center.widget_list[1] = urwid.Filler(self.table) # we show again the table - if self.state == "chien": - self.to_show = [] - self.state = "wait" - elif self.state == "wait_for_ecart": - self.state = "ecart" - self.hand.extend(self.to_show) - self.hand.sort() - self.to_show = [] - self.hand_wid.update(self.hand) - - ##EVENTS## - def onClick(self, hand, card_wid): - """Called when user do an action on the hand""" - if not self.state in ['play', 'ecart', 'wait_for_ecart']: - #it's not our turn, we ignore the click - card_wid.select(False) - return - self._checkState() - if self.state == "ecart": - if len(self.hand_wid.getSelected()) == 6: - pop_up_widget = sat_widgets.ConfirmDialog(_("Do you put these cards in chien ?"), yes_cb=self.onEcartDone, no_cb=self.parent.host.removePopUp) - self.parent.host.showPopUp(pop_up_widget) - elif self.state == "play": - card = card_wid.getCard() - self.parent.host.bridge.tarotGamePlayCards(self.player_nick, self.referee, [(card.suit, card.value)], self.parent.profile) - self.hand.remove(card) - self.hand_wid.update(self.hand) - self.state = "wait" - - def onEcartDone(self, button): - """Called when player has finished his écart""" - ecart = [] - for card in self.hand_wid.getSelected(): - ecart.append((card.suit, card.value)) - self.hand.remove(card) - self.hand_wid.update(self.hand) - self.parent.host.bridge.tarotGamePlayCards(self.player_nick, self.referee, ecart, self.parent.profile) - self.state = "wait" - self.parent.host.removePopUp() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/keys.py --- a/frontends/src/primitivus/keys.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -"""This file manage the action <=> key map""" - -from urwid_satext.keys import action_key_map - - -action_key_map.update( - { - # Edit bar - ("edit", "MODE_INSERTION"): "i", - ("edit", "MODE_COMMAND"): ":", - ("edit", "HISTORY_PREV"): "up", - ("edit", "HISTORY_NEXT"): "down", - - # global - ("global", "MENU_HIDE"): 'meta m', - ("global", "NOTIFICATION_NEXT"): 'ctrl n', - ("global", "OVERLAY_HIDE"): 'ctrl s', - ("global", "DEBUG"): 'ctrl d', - ("global", "CONTACTS_HIDE"): 'f2', - ('global', "REFRESH_SCREEN"): "ctrl l", # ctrl l is used by Urwid to refresh screen - - # global menu - ("menu_global", "APP_QUIT"): 'ctrl x', - ("menu_global", "ROOM_JOIN"): 'meta j', - - # primitivus widgets - ("primitivus_widget", "DECORATION_HIDE"): "meta l", - - # contact list - ("contact_list", "STATUS_HIDE"): "meta s", - ("contact_list", "DISCONNECTED_HIDE"): "meta d", - ("contact_list", "RESOURCES_HIDE"): "meta r", - - # chat panel - ("chat_panel", "OCCUPANTS_HIDE"): "meta p", - ("chat_panel", "TIMESTAMP_HIDE"): "meta t", - ("chat_panel", "SHORT_NICKNAME"): "meta n", - ("chat_panel", "SUBJECT_SWITCH"): "meta s", - ("chat_panel", "GOTO_BOTTOM"): "G", - - #card game - ("card_game", "CARD_SELECT"): ' ', - - #focus - ("focus", "FOCUS_EXTRA"): "ctrl f", - }) - - -action_key_map.set_close_namespaces(tuple(), ('global', 'focus', 'menu_global')) -action_key_map.check_namespaces() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/notify.py --- a/frontends/src/primitivus/notify.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,77 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -import dbus - -class Notify(object): - """Used to send notification and detect if we have focus""" - - def __init__(self): - - #X11 stuff - self.display = None - self.X11_id = -1 - - try: - from Xlib import display as X_display - self.display = X_display.Display() - self.X11_id = self.getFocus() - except: - pass - - #Now we try to connect to Freedesktop D-Bus API - try: - bus = dbus.SessionBus() - db_object = bus.get_object('org.freedesktop.Notifications', '/org/freedesktop/Notifications', follow_name_owner_changes=True) - self.freedesktop_int = dbus.Interface(db_object, dbus_interface='org.freedesktop.Notifications') - except: - self.freedesktop_int = None - - def getFocus(self): - if not self.display: - return 0 - return self.display.get_input_focus().focus.id - - def hasFocus(self): - return (self.getFocus() == self.X11_id) if self.display else True - - def useX11(self): - return bool(self.display) - - def sendNotification(self, summ_mess, body_mess=""): - """Send notification to the user if possible""" - #TODO: check options before sending notifications - if self.freedesktop_int: - self.sendFDNotification(summ_mess, body_mess) - - def sendFDNotification(self, summ_mess, body_mess=""): - """Send notification with the FreeDesktop D-Bus API""" - if self.freedesktop_int: - app_name = "Primitivus" - replaces_id = 0 - app_icon = "" - summary = summ_mess - body = body_mess - actions = dbus.Array(signature='s') - hints = dbus.Dictionary(signature='sv') - expire_timeout = -1 - - self.freedesktop_int.Notify(app_name, replaces_id, app_icon, - summary, body, actions, - hints, expire_timeout) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/primitivus --- a/frontends/src/primitivus/primitivus Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,833 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# Copyright (C) 2009-2016 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 . - - -from sat.core.i18n import _, D_ -from sat_frontends.primitivus.constants import Const as C -from sat.core import log_config -log_config.satConfigure(C.LOG_BACKEND_STANDARD, C) -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.tools import config as sat_config -import urwid -from urwid.util import is_wide_char -from urwid_satext import sat_widgets -from sat_frontends.quick_frontend.quick_app import QuickApp -from sat_frontends.quick_frontend import quick_utils -from sat_frontends.quick_frontend import quick_chat -from sat_frontends.primitivus.profile_manager import ProfileManager -from sat_frontends.primitivus.contact_list import ContactList -from sat_frontends.primitivus.chat import Chat -from sat_frontends.primitivus import xmlui -from sat_frontends.primitivus.progress import Progress -from sat_frontends.primitivus.notify import Notify -from sat_frontends.primitivus.keys import action_key_map as a_key -from sat_frontends.primitivus import config -from sat_frontends.tools.misc import InputHistory -from sat.tools.common import dynamic_import -from sat_frontends.tools import jid -import signal -import sys -## bridge handling -# we get bridge name from conf and initialise the right class accordingly -main_config = sat_config.parseMainConf() -bridge_name = sat_config.getConfig(main_config, '', 'bridge', 'dbus') -if 'dbus' not in bridge_name: - print(u"only D-Bus bridge is currently supported") - sys.exit(3) - - -class EditBar(sat_widgets.ModalEdit): - """ - The modal edit bar where you would enter messages and commands. - """ - - def __init__(self, host): - modes = {None: (C.MODE_NORMAL, u''), - a_key['MODE_INSERTION']: (C.MODE_INSERTION, u'> '), - a_key['MODE_COMMAND']: (C.MODE_COMMAND, u':')} #XXX: captions *MUST* be unicode - super(EditBar, self).__init__(modes) - self.host = host - self.setCompletionMethod(self._text_completion) - urwid.connect_signal(self, 'click', self.onTextEntered) - - def _text_completion(self, text, completion_data, mode): - if mode == C.MODE_INSERTION: - if self.host.selected_widget is not None: - try: - completion = self.host.selected_widget.completion - except AttributeError: - return text - else: - return completion(text, completion_data) - else: - return text - - def onTextEntered(self, editBar): - """Called when text is entered in the main edit bar""" - if self.mode == C.MODE_INSERTION: - if isinstance(self.host.selected_widget, quick_chat.QuickChat): - chat_widget = self.host.selected_widget - self.host.messageSend( - chat_widget.target, - {'': editBar.get_edit_text()}, # TODO: handle language - mess_type = C.MESS_TYPE_GROUPCHAT if chat_widget.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat - errback=lambda failure: self.host.showDialog(_("Error while sending message ({})").format(failure), type="error"), - profile_key=chat_widget.profile - ) - editBar.set_edit_text('') - elif self.mode == C.MODE_COMMAND: - self.commandHandler() - - def commandHandler(self): - #TODO: separate class with auto documentation (with introspection) - # and completion method - tokens = self.get_edit_text().split(' ') - command, args = tokens[0], tokens[1:] - if command == 'quit': - self.host.onExit() - raise urwid.ExitMainLoop() - elif command == 'messages': - wid = sat_widgets.GenericList(logging.memoryGet()) - self.host.selectWidget(wid) - # FIXME: reactivate the command - # elif command == 'presence': - # values = [value for value in commonConst.PRESENCE.keys()] - # values = [value if value else 'online' for value in values] # the empty value actually means 'online' - # if args and args[0] in values: - # presence = '' if args[0] == 'online' else args[0] - # self.host.status_bar.onChange(user_data=sat_widgets.ClickableText(commonConst.PRESENCE[presence])) - # else: - # self.host.status_bar.onPresenceClick() - # elif command == 'status': - # if args: - # self.host.status_bar.onChange(user_data=sat_widgets.AdvancedEdit(args[0])) - # else: - # self.host.status_bar.onStatusClick() - elif command == 'history': - widget = self.host.selected_widget - if isinstance(widget, quick_chat.QuickChat): - try: - limit = int(args[0]) - except (IndexError, ValueError): - limit = 50 - widget.updateHistory(size=limit, profile=widget.profile) - elif command == 'search': - widget = self.host.selected_widget - if isinstance(widget, quick_chat.QuickChat): - pattern = " ".join(args) - if not pattern: - self.host.notif_bar.addMessage(D_("Please specify the globbing pattern to search for")) - else: - widget.updateHistory(size=C.HISTORY_LIMIT_NONE, filters={'search': pattern}, profile=widget.profile) - elif command == 'filter': - # FIXME: filter is now only for current widget, - # need to be able to set it globally or per widget - widget = self.host.selected_widget - # FIXME: Q&D way, need to be more generic - if isinstance(widget, quick_chat.QuickChat): - widget.setFilter(args) - elif command in ('topic', 'suject', 'title'): - try: - new_title = args[0].strip() - except IndexError: - new_title = None - widget = self.host.selected_widget - if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: - widget.onSubjectDialog(new_title) - else: - return - self.set_edit_text('') - - def _historyCb(self, text): - self.set_edit_text(text) - self.set_edit_pos(len(text)) - - def keypress(self, size, key): - """Callback when a key is pressed. Send "composing" states - and move the index of the temporary history stack.""" - if key == a_key['MODAL_ESCAPE']: - # first save the text to the current mode, then change to NORMAL - self.host._updateInputHistory(self.get_edit_text(), mode=self.mode) - self.host._updateInputHistory(mode=C.MODE_NORMAL) - if self._mode == C.MODE_NORMAL and key in self._modes: - self.host._updateInputHistory(mode=self._modes[key][0]) - if key == a_key['HISTORY_PREV']: - self.host._updateInputHistory(self.get_edit_text(), -1, self._historyCb, self.mode) - return - elif key == a_key['HISTORY_NEXT']: - self.host._updateInputHistory(self.get_edit_text(), +1, self._historyCb, self.mode) - return - elif key == a_key['EDIT_ENTER']: - self.host._updateInputHistory(self.get_edit_text(), mode=self.mode) - else: - if (self._mode == C.MODE_INSERTION - and isinstance(self.host.selected_widget, quick_chat.QuickChat) - and key not in sat_widgets.FOCUS_KEYS - and key not in (a_key['HISTORY_PREV'], a_key['HISTORY_NEXT'])): - self.host.bridge.chatStateComposing(self.host.selected_widget.target, self.host.selected_widget.profile) - - return super(EditBar, self).keypress(size, key) - - -class PrimitivusTopWidget(sat_widgets.FocusPile): - """Top most widget used in Primitivus""" - _focus_inversed = True - positions = ('menu', 'body', 'notif_bar', 'edit_bar') - can_hide = ('menu', 'notif_bar') - - def __init__(self, body, menu, notif_bar, edit_bar): - self._body = body - self._menu = menu - self._notif_bar = notif_bar - self._edit_bar = edit_bar - self._hidden = {'notif_bar'} - self._focus_extra = False - super(PrimitivusTopWidget, self).__init__([('pack', self._menu), self._body, ('pack', self._edit_bar)]) - for position in self.positions: - setattr(self, - position, - property(lambda: self, self.widgetGet(position=position), - lambda pos, new_wid: self.widgetSet(new_wid, position=pos)) - ) - self.focus_position = len(self.contents)-1 - - def getVisiblePositions(self, keep=None): - """Return positions that are not hidden in the right order - - @param keep: if not None, this position will be keep in the right order, even if it's hidden - (can be useful to find its index) - @return (list): list of visible positions - """ - return [pos for pos in self.positions if (keep and pos == keep) or pos not in self._hidden] - - def keypress(self, size, key): - """Manage FOCUS keys that focus directly a main part (one of self.positions) - - To avoid key conflicts, a combinaison must be made with FOCUS_EXTRA then an other key - """ - if key == a_key['FOCUS_EXTRA']: - self._focus_extra = True - return - if self._focus_extra: - self._focus_extra = False - if key in ('m', '1'): - focus = 'menu' - elif key in ('b', '2'): - focus = 'body' - elif key in ('n', '3'): - focus = 'notif_bar' - elif key in ('e', '4'): - focus = 'edit_bar' - else: - return super(PrimitivusTopWidget, self).keypress(size, key) - - if focus in self._hidden: - return - - self.focus_position = self.getVisiblePositions().index(focus) - return - - return super(PrimitivusTopWidget, self).keypress(size, key) - - def widgetGet(self, position): - if not position in self.positions: - raise ValueError("Unknown position {}".format(position)) - return getattr(self, "_{}".format(position)) - - def widgetSet(self, widget, position): - if not position in self.positions: - raise ValueError("Unknown position {}".format(position)) - return setattr(self, "_{}".format(position), widget) - - def hideSwitch(self, position): - if not position in self.can_hide: - raise ValueError("Can't switch position {}".format(position)) - hide = not position in self._hidden - widget = self.widgetGet(position) - idx = self.getVisiblePositions(position).index(position) - if hide: - del self.contents[idx] - self._hidden.add(position) - else: - self.contents.insert(idx, (widget, ('pack', None))) - self._hidden.remove(position) - - def show(self, position): - if position in self._hidden: - self.hideSwitch(position) - - def hide(self, position): - if not position in self._hidden: - self.hideSwitch(position) - - -class PrimitivusApp(QuickApp, InputHistory): - MB_HANDLER = False - AVATARS_HANDLER = False - - def __init__(self): - bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') - if bridge_module is None: - log.error(u"Can't import {} bridge".format(bridge_name)) - sys.exit(3) - else: - log.debug(u"Loading {} bridge".format(bridge_name)) - QuickApp.__init__(self, bridge_factory=bridge_module.Bridge, xmlui=xmlui, check_options=quick_utils.check_options, connect_bridge=False) - ## main loop setup ## - event_loop = urwid.GLibEventLoop if 'dbus' in bridge_name else urwid.TwistedEventLoop - self.loop = urwid.MainLoop(urwid.SolidFill(), C.PALETTE, event_loop=event_loop(), input_filter=self.inputFilter, unhandled_input=self.keyHandler) - - def onBridgeConnected(self): - - ##misc setup## - self._visible_widgets = set() - self.notif_bar = sat_widgets.NotificationBar() - urwid.connect_signal(self.notif_bar, 'change', self.onNotification) - - self.progress_wid = self.widgets.getOrCreateWidget(Progress, None, on_new_widget=None) - urwid.connect_signal(self.notif_bar.progress, 'click', lambda x: self.selectWidget(self.progress_wid)) - self.__saved_overlay = None - - self.x_notify = Notify() - - # we already manage exit with a_key['APP_QUIT'], so we don't want C-c - signal.signal(signal.SIGINT, signal.SIG_IGN) - sat_conf = sat_config.parseMainConf() - self._bracketed_paste = C.bool(sat_config.getConfig(sat_conf, C.SECTION_NAME, 'bracketed_paste', 'false')) - if self._bracketed_paste: - log.debug("setting bracketed paste mode as requested") - sys.stdout.write("\033[?2004h") - self._bracketed_mode_set = True - - self.loop.widget = self.main_widget = ProfileManager(self) - self.postInit() - - @property - def visible_widgets(self): - return self._visible_widgets - - @property - def mode(self): - return self.editBar.mode - - @mode.setter - def mode(self, value): - self.editBar.mode = value - - def modeHint(self, value): - """Change mode if make sens (i.e.: if there is nothing in the editBar)""" - if not self.editBar.get_edit_text(): - self.mode = value - - def debug(self): - """convenient method to reset screen and launch (i)p(u)db""" - log.info('Entered debug mode') - try: - import pudb - pudb.set_trace() - except ImportError: - import os - os.system('reset') - try: - import ipdb - ipdb.set_trace() - except ImportError: - import pdb - pdb.set_trace() - - def redraw(self): - """redraw the screen""" - try: - self.loop.draw_screen() - except AttributeError: - pass - - def start(self): - self.connectBridge() - self.loop.run() - - def postInit(self): - try: - config.applyConfig(self) - except Exception as e: - log.error(u"configuration error: {}".format(e)) - popup = self.alert(_(u"Configuration Error"), _(u"Something went wrong while reading the configuration, please check :messages")) - if self.options.profile: - self._early_popup = popup - else: - self.showPopUp(popup) - super(PrimitivusApp, self).postInit(self.main_widget) - - def keysToText(self, keys): - """Generator return normal text from urwid keys""" - for k in keys: - if k == 'tab': - yield u'\t' - elif k == 'enter': - yield u'\n' - elif is_wide_char(k,0) or (len(k)==1 and ord(k) >= 32): - yield k - - def inputFilter(self, input_, raw): - if self.__saved_overlay and input_ != a_key['OVERLAY_HIDE']: - return - - ## paste detection/handling - if (len(input_) > 1 and # XXX: it may be needed to increase this value if buffer - not isinstance(input_[0], tuple) and # or other things result in several chars at once - not 'window resize' in input_): # (e.g. using Primitivus through ssh). Need some testing - # and experience to adjust value. - if input_[0] == 'begin paste' and not self._bracketed_paste: - log.info(u"Bracketed paste mode detected") - self._bracketed_paste = True - - if self._bracketed_paste: - # after this block, extra will contain non pasted keys - # and input_ will contain pasted keys - try: - begin_idx = input_.index('begin paste') - except ValueError: - # this is not a paste, maybe we have something buffering - # or bracketed mode is set in conf but not enabled in term - extra = input_ - input_ = [] - else: - try: - end_idx = input_.index('end paste') - except ValueError: - log.warning(u"missing end paste sequence, discarding paste") - extra = input_[:begin_idx] - del input_[begin_idx:] - else: - extra = input_[:begin_idx] + input_[end_idx+1:] - input_ = input_[begin_idx+1:end_idx] - else: - extra = None - - log.debug(u"Paste detected (len {})".format(len(input_))) - try: - edit_bar = self.editBar - except AttributeError: - log.warning(u"Paste treated as normal text: there is no edit bar yet") - if extra is None: - extra = [] - extra.extend(input_) - else: - if self.main_widget.focus == edit_bar: - # XXX: if a paste is detected, we append it directly to the edit bar text - # so the user can check it and press [enter] if it's OK - buf_paste = u''.join(self.keysToText(input_)) - pos = edit_bar.edit_pos - edit_bar.set_edit_text(u'{}{}{}'.format(edit_bar.edit_text[:pos], buf_paste, edit_bar.edit_text[pos:])) - edit_bar.edit_pos+=len(buf_paste) - else: - # we are not on the edit_bar, - # so we treat pasted text as normal text - if extra is None: - extra = [] - extra.extend(input_) - if not extra: - return - input_ = extra - ## end of paste detection/handling - - for i in input_: - if isinstance(i,tuple): - if i[0] == 'mouse press': - if i[1] == 4: #Mouse wheel up - input_[input_.index(i)] = a_key['HISTORY_PREV'] - if i[1] == 5: #Mouse wheel down - input_[input_.index(i)] = a_key['HISTORY_NEXT'] - return input_ - - def keyHandler(self, input_): - if input_ == a_key['MENU_HIDE']: - """User want to (un)hide the menu roller""" - try: - self.main_widget.hideSwitch('menu') - except AttributeError: - pass - elif input_ == a_key['NOTIFICATION_NEXT']: - """User wants to see next notification""" - self.notif_bar.showNext() - elif input_ == a_key['OVERLAY_HIDE']: - """User wants to (un)hide overlay window""" - if isinstance(self.loop.widget,urwid.Overlay): - self.__saved_overlay = self.loop.widget - self.loop.widget = self.main_widget - else: - if self.__saved_overlay: - self.loop.widget = self.__saved_overlay - self.__saved_overlay = None - - elif input_ == a_key['DEBUG'] and 'D' in self.bridge.getVersion(): #Debug only for dev versions - self.debug() - elif input_ == a_key['CONTACTS_HIDE']: #user wants to (un)hide the contact lists - try: - for wid, options in self.center_part.contents: - if self.contact_lists_pile is wid: - self.center_part.contents.remove((wid, options)) - break - else: - self.center_part.contents.insert(0, (self.contact_lists_pile, ('weight', 2, False))) - except AttributeError: - #The main widget is not built (probably in Profile Manager) - pass - elif input_ == 'window resize': - width,height = self.loop.screen_size - if height<=5 and width<=35: - if not 'save_main_widget' in dir(self): - self.save_main_widget = self.loop.widget - self.loop.widget = urwid.Filler(urwid.Text(_("Pleeeeasse, I can't even breathe !"))) - else: - if 'save_main_widget' in dir(self): - self.loop.widget = self.save_main_widget - del self.save_main_widget - try: - return self.menu_roller.checkShortcuts(input_) - except AttributeError: - return input_ - - def addMenus(self, menu, type_filter, menu_data=None): - """Add cached menus to instance - @param menu: sat_widgets.Menu instance - @param type_filter: menu type like is sat.core.sat_main.importMenu - @param menu_data: data to send with these menus - - """ - def add_menu_cb(callback_id): - self.launchAction(callback_id, menu_data, profile=self.current_profile) - for id_, type_, path, path_i18n, extra in self.bridge.menusGet("", C.NO_SECURITY_LIMIT ): # TODO: manage extra - if type_ != type_filter: - continue - if len(path) != 2: - raise NotImplementedError("Menu with a path != 2 are not implemented yet") - menu.addMenu(path_i18n[0], path_i18n[1], lambda dummy,id_=id_: add_menu_cb(id_)) - - - def _buildMenuRoller(self): - menu = sat_widgets.Menu(self.loop) - general = _("General") - menu.addMenu(general, _("Connect"), self.onConnectRequest) - menu.addMenu(general, _("Disconnect"), self.onDisconnectRequest) - menu.addMenu(general, _("Parameters"), self.onParam) - menu.addMenu(general, _("About"), self.onAboutRequest) - menu.addMenu(general, _("Exit"), self.onExitRequest, a_key['APP_QUIT']) - menu.addMenu(_("Contacts")) # add empty menu to save the place in the menu order - groups = _("Groups") - menu.addMenu(groups) - menu.addMenu(groups, _("Join room"), self.onJoinRoomRequest, a_key['ROOM_JOIN']) - #additionals menus - #FIXME: do this in a more generic way (in quickapp) - self.addMenus(menu, C.MENU_GLOBAL) - - menu_roller = sat_widgets.MenuRoller([(_('Main menu'), menu, C.MENU_ID_MAIN)]) - return menu_roller - - def _buildMainWidget(self): - self.contact_lists_pile = urwid.Pile([]) - #self.center_part = urwid.Columns([('weight',2,self.contact_lists[profile]),('weight',8,Chat('',self))]) - self.center_part = urwid.Columns([('weight', 2, self.contact_lists_pile), ('weight', 8, urwid.Filler(urwid.Text('')))]) - - self.editBar = EditBar(self) - self.menu_roller = self._buildMenuRoller() - self.main_widget = PrimitivusTopWidget(self.center_part, self.menu_roller, self.notif_bar, self.editBar) - return self.main_widget - - def plugging_profiles(self): - self.loop.widget = self._buildMainWidget() - self.redraw() - try: - # if a popup arrived before main widget is build, we need to show it now - self.showPopUp(self._early_popup) - except AttributeError: - pass - else: - del self._early_popup - - def profilePlugged(self, profile): - QuickApp.profilePlugged(self, profile) - contact_list = self.widgets.getOrCreateWidget(ContactList, None, on_new_widget=None, on_click=self.contactSelected, on_change=lambda w: self.redraw(), profile=profile) - self.contact_lists_pile.contents.append((contact_list, ('weight', 1))) - return contact_list - - def isHidden(self): - """Tells if the frontend window is hidden. - - @return bool - """ - return False # FIXME: implement when necessary - - def alert(self, title, message): - """Shortcut method to create an alert message - - Alert will have an "OK" button, which remove it if pressed - @param title(unicode): title of the dialog - @param message(unicode): body of the dialog - @return (urwid_satext.Alert): the created Alert instance - """ - popup = sat_widgets.Alert(title, message) - popup.setCallback('ok', lambda dummy: self.removePopUp(popup)) - self.showPopUp(popup) - return popup - - def removePopUp(self, widget=None): - """Remove current pop-up, and if there is other in queue, show it - - @param widget(None, urwid.Widget): if not None remove this popup from front or queue - """ - # TODO: refactor popup management in a cleaner way - if widget is not None: - if isinstance(self.loop.widget, urwid.Overlay): - current_popup = self.loop.widget.top_w - if not current_popup == widget: - try: - self.notif_bar.removePopUp(widget) - except ValueError: - log.warning(u"Trying to remove an unknown widget {}".format(widget)) - return - self.loop.widget = self.main_widget - next_popup = self.notif_bar.getNextPopup() - if next_popup: - #we still have popup to show, we display it - self.showPopUp(next_popup) - else: - self.redraw() - - def showPopUp(self, pop_up_widget, perc_width=40, perc_height=40, align='center', valign='middle'): - "Show a pop-up window if possible, else put it in queue" - if not isinstance(self.loop.widget, urwid.Overlay): - display_widget = urwid.Overlay(pop_up_widget, self.main_widget, align, ('relative', perc_width), valign, ('relative', perc_height)) - self.loop.widget = display_widget - self.redraw() - else: - self.notif_bar.addPopUp(pop_up_widget) - - def barNotify(self, message): - """"Notify message to user via notification bar""" - self.notif_bar.addMessage(message) - self.redraw() - - def notify(self, type_, entity=None, message=None, subject=None, callback=None, cb_args=None, widget=None, profile=C.PROF_KEY_NONE): - if widget is None or widget is not None and widget != self.selected_widget: - # we ignore notification if the widget is selected but we can - # still do a desktop notification is the X window has not the focus - super(PrimitivusApp, self).notify(type_, entity, message, subject, callback, cb_args, widget, profile) - # we don't want notifications without message on desktop - if message is not None and not self.x_notify.hasFocus(): - if message is None: - message = _("{app}: a new event has just happened{entity}").format( - app=C.APP_NAME, - entity=u' ({})'.format(entity) if entity else '') - self.x_notify.sendNotification(message) - - - def newWidget(self, widget): - # FIXME: when several widgets are possible (e.g. with :split) - # do not replace current widget when self.selected_widget != None - self.selectWidget(widget) - - def selectWidget(self, widget): - """Display a widget if possible, - - else add it in the notification bar queue - @param widget: BoxWidget - """ - assert len(self.center_part.widget_list)<=2 - wid_idx = len(self.center_part.widget_list)-1 - self.center_part.widget_list[wid_idx] = widget - try: - self.menu_roller.removeMenu(C.MENU_ID_WIDGET) - except KeyError: - log.debug("No menu to delete") - self.selected_widget = widget - try: - onSelected = self.selected_widget.onSelected - except AttributeError: - pass - else: - onSelected() - self._visible_widgets = set([widget]) # XXX: we can only have one widget visible at the time for now - self.contact_lists.select(None) - - for wid in self.visible_widgets: # FIXME: check if widgets.getWidgets is not more appropriate - if isinstance(wid, Chat): - contact_list = self.contact_lists[wid.profile] - contact_list.select(wid.target) - - self.redraw() - - def removeWindow(self): - """Remove window showed on the right column""" - #TODO: better Window management than this hack - assert len(self.center_part.widget_list) <= 2 - wid_idx = len(self.center_part.widget_list)-1 - self.center_part.widget_list[wid_idx] = urwid.Filler(urwid.Text('')) - self.center_part.focus_position = 0 - self.redraw() - - def addProgress(self, pid, message, profile): - """Follow a SàT progression - - @param pid: progression id - @param message: message to show to identify the progression - """ - self.progress_wid.add(pid, message, profile) - - def setProgress(self, percentage): - """Set the progression shown in notification bar""" - self.notif_bar.setProgress(percentage) - - def contactSelected(self, contact_list, entity): - self.clearNotifs(entity, profile=contact_list.profile) - if entity.resource: - # we have clicked on a private MUC conversation - chat_widget = self.widgets.getOrCreateWidget(Chat, entity, on_new_widget=None, force_hash = Chat.getPrivateHash(contact_list.profile, entity), profile=contact_list.profile) - else: - chat_widget = self.widgets.getOrCreateWidget(Chat, entity, on_new_widget=None, profile=contact_list.profile) - self.selectWidget(chat_widget) - self.menu_roller.addMenu(_('Chat menu'), chat_widget.getMenu(), C.MENU_ID_WIDGET) - - def _dialogOkCb(self, widget, data): - popup, answer_cb, answer_data = data - self.removePopUp(popup) - if answer_cb is not None: - answer_cb(True, answer_data) - - def _dialogCancelCb(self, widget, data): - popup, answer_cb, answer_data = data - self.removePopUp(popup) - if answer_cb is not None: - answer_cb(False, answer_data) - - def showDialog(self, message, title="", type="info", answer_cb = None, answer_data = None): - if type == 'info': - popup = sat_widgets.Alert(title, message, ok_cb=answer_cb) - if answer_cb is None: - popup.setCallback('ok', lambda dummy: self.removePopUp(popup)) - elif type == 'error': - popup = sat_widgets.Alert(title, message, ok_cb=answer_cb) - if answer_cb is None: - popup.setCallback('ok', lambda dummy: self.removePopUp(popup)) - elif type == 'yes/no': - popup = sat_widgets.ConfirmDialog(message) - popup.setCallback('yes', self._dialogOkCb, (popup, answer_cb, answer_data)) - popup.setCallback('no', self._dialogCancelCb, (popup, answer_cb, answer_data)) - else: - popup = sat_widgets.Alert(title, message, ok_cb=answer_cb) - if answer_cb is None: - popup.setCallback('ok', lambda dummy: self.removePopUp(popup)) - log.error(u'unmanaged dialog type: {}'.format(type)) - self.showPopUp(popup) - - def dialogFailure(self, failure): - """Show a failure that has been returned by an asynchronous bridge method. - - @param failure (defer.Failure): Failure instance - """ - self.alert(failure.classname, failure.message) - - def onNotification(self, notif_bar): - """Called when a new notification has been received""" - if not isinstance(self.main_widget, PrimitivusTopWidget): - #if we are not in the main configuration, we ignore the notifications bar - return - if self.notif_bar.canHide(): - #No notification left, we can hide the bar - self.main_widget.hide('notif_bar') - else: - self.main_widget.show('notif_bar') - self.redraw() # FIXME: invalidate cache in a more efficient way - - def _actionManagerUnknownError(self): - self.alert(_("Error"), _(u"Unmanaged action")) - - def roomJoinedHandler(self, room_jid_s, room_nicks, user_nick, subject, profile): - super(PrimitivusApp, self).roomJoinedHandler(room_jid_s, room_nicks, user_nick, subject, profile) - # if self.selected_widget is None: - # for contact_list in self.widgets.getWidgets(ContactList): - # if profile in contact_list.profiles: - # contact_list.setFocus(jid.JID(room_jid_s), True) - - def progressStartedHandler(self, pid, metadata, profile): - super(PrimitivusApp, self).progressStartedHandler(pid, metadata, profile) - self.addProgress(pid, metadata.get('name', _(u'unkown')), profile) - - def progressFinishedHandler(self, pid, metadata, profile): - log.info(u"Progress {} finished".format(pid)) - super(PrimitivusApp, self).progressFinishedHandler(pid, metadata, profile) - - def progressErrorHandler(self, pid, err_msg, profile): - log.warning(u"Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg)) - super(PrimitivusApp, self).progressErrorHandler(pid, err_msg, profile) - - - ##DIALOGS CALLBACKS## - def onJoinRoom(self, button, edit): - self.removePopUp() - room_jid = jid.JID(edit.get_edit_text()) - self.bridge.mucJoin(room_jid, self.profiles[self.current_profile].whoami.node, {}, self.current_profile, callback=lambda dummy: None, errback=self.dialogFailure) - - #MENU EVENTS# - def onConnectRequest(self, menu): - QuickApp.connect(self, self.current_profile) - - def onDisconnectRequest(self, menu): - self.disconnect(self.current_profile) - - def onParam(self, menu): - def success(params): - ui = xmlui.create(self, xml_data=params, profile=self.current_profile) - ui.show() - - def failure(error): - self.alert(_("Error"), _("Can't get parameters (%s)") % error) - self.bridge.getParamsUI(app=C.APP_NAME, profile_key=self.current_profile, callback=success, errback=failure) - - def onExitRequest(self, menu): - QuickApp.onExit(self) - try: - if self._bracketed_mode_set: # we don't unset if bracketed paste mode was detected automatically (i.e. not in conf) - log.debug("unsetting bracketed paste mode") - sys.stdout.write("\033[?2004l") - except AttributeError: - pass - raise urwid.ExitMainLoop() - - def onJoinRoomRequest(self, menu): - """User wants to join a MUC room""" - pop_up_widget = sat_widgets.InputDialog(_("Entering a MUC room"), _("Please enter MUC's JID"), default_txt=self.bridge.mucGetDefaultService(), ok_cb=self.onJoinRoom) - pop_up_widget.setCallback('cancel', lambda dummy: self.removePopUp(pop_up_widget)) - self.showPopUp(pop_up_widget) - - def onAboutRequest(self, menu): - self.alert(_("About"), C.APP_NAME + " v" + self.bridge.getVersion()) - - #MISC CALLBACKS# - - def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): - contact_list_wid = self.widgets.getWidget(ContactList, profiles=profile) - if contact_list_wid is not None: - contact_list_wid.status_bar.setPresenceStatus(show, status) - else: - log.warning(u"No ContactList widget found for profile {}".format(profile)) - -primitivus = PrimitivusApp() -primitivus.start() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/profile_manager.py --- a/frontends/src/primitivus/profile_manager.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,179 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager -from sat_frontends.primitivus.constants import Const as C -from sat_frontends.primitivus.keys import action_key_map as a_key -from urwid_satext import sat_widgets -import urwid - - -class ProfileManager(QuickProfileManager, urwid.WidgetWrap): - - def __init__(self, host, autoconnect=None): - QuickProfileManager.__init__(self, host, autoconnect) - - #login & password box must be created before list because of onProfileChange - self.login_wid = sat_widgets.AdvancedEdit(_('Login:'), align='center') - self.pass_wid = sat_widgets.Password(_('Password:'), align='center') - - style = ['no_first_select'] - profiles = host.bridge.profilesListGet() - profiles.sort() - self.list_profile = sat_widgets.List(profiles, style=style, align='center', on_change=self.onProfileChange) - - #new & delete buttons - buttons = [urwid.Button(_("New"), self.onNewProfile), - urwid.Button(_("Delete"), self.onDeleteProfile)] - buttons_flow = urwid.GridFlow(buttons, max([len(button.get_label()) for button in buttons])+4, 1, 1, 'center') - - #second part: login information: - divider = urwid.Divider('-') - - #connect button - connect_button = sat_widgets.CustomButton(_("Connect"), self.onConnectProfiles, align='center') - - #we now build the widget - list_walker = urwid.SimpleFocusListWalker([buttons_flow,self.list_profile, divider, self.login_wid, self.pass_wid, connect_button]) - frame_body = urwid.ListBox(list_walker) - frame = urwid.Frame(frame_body,urwid.AttrMap(urwid.Text(_("Profile Manager"),align='center'),'title')) - self.main_widget = urwid.LineBox(frame) - urwid.WidgetWrap.__init__(self, self.main_widget) - - self.go(autoconnect) - - - def keypress(self, size, key): - if key == a_key['APP_QUIT']: - self.host.onExit() - raise urwid.ExitMainLoop() - elif key in (a_key['FOCUS_UP'], a_key['FOCUS_DOWN']): - focus_diff = 1 if key==a_key['FOCUS_DOWN'] else -1 - list_box = self.main_widget.base_widget.body - current_focus = list_box.body.get_focus()[1] - if current_focus is None: - return - while True: - current_focus += focus_diff - if current_focus < 0 or current_focus >= len(list_box.body): - break - if list_box.body[current_focus].selectable(): - list_box.set_focus(current_focus, 'above' if focus_diff == 1 else 'below') - list_box._invalidate() - return - return super(ProfileManager, self).keypress(size, key) - - def cancelDialog(self, button): - self.host.removePopUp() - - def newProfile(self, button, edit): - """Create the profile""" - name = edit.get_edit_text() - self.host.bridge.profileCreate(name, callback=lambda: self.newProfileCreated(name), errback=self.profileCreationFailure) - - def newProfileCreated(self, profile): - # new profile will be selected, and a selected profile assume the session is started - self.host.bridge.profileStartSession('', profile, callback=lambda dummy: self.newProfileSessionStarted(profile), errback=self.profileCreationFailure) - - def newProfileSessionStarted(self, profile): - self.host.removePopUp() - self.refillProfiles() - self.list_profile.selectValue(profile) - self.current.profile=profile - self.getConnectionParams(profile) - self.host.redraw() - - def profileCreationFailure(self, reason): - self.host.removePopUp() - message = self._getErrorMessage(reason) - self.host.alert(_("Can't create profile"), message) - - def deleteProfile(self, button): - self._deleteProfile() - self.host.removePopUp() - - def onNewProfile(self, e): - pop_up_widget = sat_widgets.InputDialog(_("New profile"), _("Please enter a new profile name"), cancel_cb=self.cancelDialog, ok_cb=self.newProfile) - self.host.showPopUp(pop_up_widget) - - def onDeleteProfile(self, e): - if self.current.profile: - pop_up_widget = sat_widgets.ConfirmDialog(_("Are you sure you want to delete the profile {} ?").format(self.current.profile), no_cb=self.cancelDialog, yes_cb=self.deleteProfile) - self.host.showPopUp(pop_up_widget) - - def onConnectProfiles(self, button): - """Connect the profiles and start the main widget - - @param button: the connect button - """ - self._onConnectProfiles() - - def resetFields(self): - """Set profile to None, and reset fields""" - super(ProfileManager, self).resetFields() - self.list_profile.unselectAll(invisible=True) - - def setProfiles(self, profiles): - """Update the list of profiles""" - self.list_profile.changeValues(profiles) - self.host.redraw() - - def getProfiles(self): - return self.list_profile.getSelectedValues() - - def getJID(self): - return self.login_wid.get_edit_text() - - def getPassword(self): - return self.pass_wid.get_edit_text() - - def setJID(self, jid_): - self.login_wid.set_edit_text(jid_) - self.current.login = jid_ - self.host.redraw() # FIXME: redraw should be avoided - - def setPassword(self, password): - self.pass_wid.set_edit_text(password) - self.current.password = password - self.host.redraw() - - def onProfileChange(self, list_wid, widget=None, selected=None): - """This is called when a profile is selected in the profile list. - - @param list_wid: the List widget who sent the event - """ - self.updateConnectionParams() - focused = list_wid.focus - selected = focused.getState() if focused is not None else False - if not selected: # profile was just unselected - return - focused.setState(False, invisible=True) # we don't want the widget to be selected until we are sure we can access it - def authenticate_cb(data, cb_id, profile): - if C.bool(data.pop('validated', C.BOOL_FALSE)): - self.current.profile = profile - focused.setState(True, invisible=True) - self.getConnectionParams(profile) - self.host.redraw() - self.host.actionManager(data, callback=authenticate_cb, profile=profile) - - self.host.launchAction(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=focused.text) - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/progress.py --- a/frontends/src/primitivus/progress.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,98 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -import urwid -from urwid_satext import sat_widgets -from sat_frontends.quick_frontend import quick_widgets - - -class Progress(urwid.WidgetWrap, quick_widgets.QuickWidget): - PROFILES_ALLOW_NONE = True - - def __init__(self, host, target, profiles): - assert target is None and profiles is None - quick_widgets.QuickWidget.__init__(self, host, target) - self.host = host - self.progress_list = urwid.SimpleListWalker([]) - self.progress_dict = {} - listbox = urwid.ListBox(self.progress_list) - buttons = [] - buttons.append(sat_widgets.CustomButton(_('Clear progress list'), self._onClear)) - max_len = max([button.getSize() for button in buttons]) - buttons_wid = urwid.GridFlow(buttons,max_len,1,0,'center') - main_wid = sat_widgets.FocusFrame(listbox, footer=buttons_wid) - urwid.WidgetWrap.__init__(self, main_wid) - - def add(self, progress_id, message, profile): - mess_wid = urwid.Text(message) - progr_wid = urwid.ProgressBar('progress_normal', 'progress_complete') - column = urwid.Columns([mess_wid, progr_wid]) - self.progress_dict[(progress_id, profile)] = {'full':column,'progress':progr_wid,'state':'init'} - self.progress_list.append(column) - self.progressCB(self.host.loop, (progress_id, message, profile)) - - def progressCB(self, loop, data): - progress_id, message, profile = data - data = self.host.bridge.progressGet(progress_id, profile) - pbar = self.progress_dict[(progress_id, profile)]['progress'] - if data: - if self.progress_dict[(progress_id, profile)]['state'] == 'init': - #first answer, we must construct the bar - self.progress_dict[(progress_id, profile)]['state'] = 'progress' - pbar.done = float(data['size']) - - pbar.set_completion(float(data['position'])) - self.updateNotBar() - else: - if self.progress_dict[(progress_id, profile)]['state'] == 'progress': - self.progress_dict[(progress_id, profile)]['state'] = 'done' - pbar.set_completion(pbar.done) - self.updateNotBar() - return - - loop.set_alarm_in(0.2,self.progressCB, (progress_id, message, profile)) - - def _removeBar(self, progress_id, profile): - wid = self.progress_dict[(progress_id, profile)]['full'] - self.progress_list.remove(wid) - del(self.progress_dict[(progress_id, profile)]) - - def _onClear(self, button): - to_remove = [] - for progress_id, profile in self.progress_dict: - if self.progress_dict[(progress_id, profile)]['state'] == 'done': - to_remove.append((progress_id, profile)) - for progress_id, profile in to_remove: - self._removeBar(progress_id, profile) - self.updateNotBar() - - def updateNotBar(self): - if not self.progress_dict: - self.host.setProgress(None) - return - progress = 0 - nb_bars = 0 - for progress_id, profile in self.progress_dict: - pbar = self.progress_dict[(progress_id, profile)]['progress'] - progress += pbar.current/pbar.done*100 - nb_bars+=1 - av_progress = progress/float(nb_bars) - self.host.setProgress(av_progress) - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/status.py --- a/frontends/src/primitivus/status.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,75 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . - -from sat.core.i18n import _ -import urwid -from urwid_satext import sat_widgets -from sat_frontends.quick_frontend.constants import Const as commonConst -from sat_frontends.primitivus.constants import Const as C - - -class StatusBar(urwid.Columns): - - def __init__(self, host): - self.host = host - self.presence = sat_widgets.ClickableText('') - status_prefix = urwid.Text('[') - status_suffix = urwid.Text(']') - self.status = sat_widgets.ClickableText('') - self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '') - urwid.Columns.__init__(self, [('weight', 1, self.presence), ('weight', 1, status_prefix), - ('weight', 9, self.status), ('weight', 1, status_suffix)]) - urwid.connect_signal(self.presence, 'click', self.onPresenceClick) - urwid.connect_signal(self.status, 'click', self.onStatusClick) - - def onPresenceClick(self, sender=None): - if not self.host.bridge.isConnected(self.host.current_profile): # FIXME: manage multi-profiles - return - options = [commonConst.PRESENCE[presence] for presence in commonConst.PRESENCE] - list_widget = sat_widgets.GenericList(options=options, option_type=sat_widgets.ClickableText, on_click=self.onChange) - decorated = sat_widgets.LabelLine(list_widget, sat_widgets.SurroundedText(_('Set your presence'))) - self.host.showPopUp(decorated) - - def onStatusClick(self, sender=None): - if not self.host.bridge.isConnected(self.host.current_profile): # FIXME: manage multi-profiles - return - pop_up_widget = sat_widgets.InputDialog(_('Set your status'), _('New status'), default_txt=self.status.get_text(), - cancel_cb=self.host.removePopUp, ok_cb=self.onChange) - self.host.showPopUp(pop_up_widget) - - def onChange(self, sender=None, user_data=None): - new_value = user_data.get_text() - previous = ([key for key in C.PRESENCE if C.PRESENCE[key][0] == self.presence.get_text()][0], self.status.get_text()) - if isinstance(user_data, sat_widgets.ClickableText): - new = ([key for key in commonConst.PRESENCE if commonConst.PRESENCE[key] == new_value][0], previous[1]) - elif isinstance(user_data, sat_widgets.AdvancedEdit): - new = (previous[0], new_value[0]) - if new != previous: - statuses = {C.PRESENCE_STATUSES_DEFAULT: new[1]} # FIXME: manage multilingual statuses - for profile in self.host.profiles: # FIXME: for now all the profiles share the same status - self.host.bridge.setPresence(show=new[0], statuses=statuses, profile_key=profile) - self.setPresenceStatus(new[0], new[1]) - self.host.removePopUp() - - def setPresenceStatus(self, show, status): - show_icon, show_attr = C.PRESENCE.get(show) - self.presence.set_text(('show_normal', show_icon)) - if status is not None: - self.status.set_text((show_attr, status)) - self.host.redraw() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/widget.py --- a/frontends/src/primitivus/widget.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,102 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core import log as logging -log = logging.getLogger(__name__) -import urwid -from urwid_satext import sat_widgets -from sat_frontends.primitivus.keys import action_key_map as a_key - - -class PrimitivusWidget(urwid.WidgetWrap): - """Base widget for Primitivus""" - - def __init__(self, w, title=''): - self._title = title - self._title_dynamic = None - self._original_widget = w - urwid.WidgetWrap.__init__(self, self._getDecoration(w)) - - @property - def title(self): - """Text shown in title bar of the widget""" - - # profiles currently managed by frontend - try: - all_profiles = self.host.profiles - except AttributeError: - all_profiles = [] - - # profiles managed by the widget - try: - profiles = self.profiles - except AttributeError: - try: - profiles = [self.profile] - except AttributeError: - profiles = [] - - title_elts = [] - if self._title: - title_elts.append(self._title) - if self._title_dynamic: - title_elts.append(self._title_dynamic) - if len(all_profiles)>1 and profiles: - title_elts.append(u'[{}]'.format(u', '.join(profiles))) - return sat_widgets.SurroundedText(u' '.join(title_elts)) - - @title.setter - def title(self, value): - self._title = value - if self.decorationVisible: - self.showDecoration() - - @property - def title_dynamic(self): - """Dynamic part of title""" - return self._title_dynamic - - @title_dynamic.setter - def title_dynamic(self, value): - self._title_dynamic = value - if self.decorationVisible: - self.showDecoration() - - @property - def decorationVisible(self): - """True if the decoration is visible""" - return isinstance(self._w, sat_widgets.LabelLine) - - - def keypress(self, size, key): - if key == a_key['DECORATION_HIDE']: #user wants to (un)hide widget decoration - show = not self.decorationVisible - self.showDecoration(show) - else: - return super(PrimitivusWidget, self).keypress(size, key) - - def _getDecoration(self, widget): - return sat_widgets.LabelLine(widget, self.title) - - def showDecoration(self, show=True): - """Show/Hide the decoration around the window""" - self._w = self._getDecoration(self._original_widget) if show else self._original_widget - - def getMenu(self): - raise NotImplementedError diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/primitivus/xmlui.py --- a/frontends/src/primitivus/xmlui.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,480 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -import urwid -import copy -from sat.core import exceptions -from urwid_satext import sat_widgets -from urwid_satext import files_management -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.primitivus.constants import Const as C -from sat_frontends.primitivus.widget import PrimitivusWidget -from sat_frontends.tools import xmlui - - -class PrimitivusEvents(object): - """ Used to manage change event of Primitivus widgets """ - - def _event_callback(self, ctrl, *args, **kwargs): - """" Call xmlui callback and ignore any extra argument """ - args[-1](ctrl) - - def _xmluiOnChange(self, callback): - """ Call callback with widget as only argument """ - urwid.connect_signal(self, 'change', self._event_callback, callback) - - -class PrimitivusEmptyWidget(xmlui.EmptyWidget, urwid.Text): - - def __init__(self, _xmlui_parent): - urwid.Text.__init__(self, '') - - -class PrimitivusTextWidget(xmlui.TextWidget, urwid.Text): - - def __init__(self, _xmlui_parent, value, read_only=False): - urwid.Text.__init__(self, value) - - -class PrimitivusLabelWidget(xmlui.LabelWidget, PrimitivusTextWidget): - - def __init__(self, _xmlui_parent, value): - super(PrimitivusLabelWidget, self).__init__(_xmlui_parent, value+": ") - - -class PrimitivusJidWidget(xmlui.JidWidget, PrimitivusTextWidget): - pass - - -class PrimitivusDividerWidget(xmlui.DividerWidget, urwid.Divider): - - def __init__(self, _xmlui_parent, style='line'): - if style == 'line': - div_char = u'─' - elif style == 'dot': - div_char = u'·' - elif style == 'dash': - div_char = u'-' - elif style == 'plain': - div_char = u'█' - elif style == 'blank': - div_char = ' ' - else: - log.warning(_("Unknown div_char")) - div_char = u'─' - - urwid.Divider.__init__(self, div_char) - - -class PrimitivusStringWidget(xmlui.StringWidget, sat_widgets.AdvancedEdit, PrimitivusEvents): - - def __init__(self, _xmlui_parent, value, read_only=False): - sat_widgets.AdvancedEdit.__init__(self, edit_text=value) - self.read_only = read_only - - def selectable(self): - if self.read_only: - return False - return super(PrimitivusStringWidget, self).selectable() - - def _xmluiSetValue(self, value): - self.set_edit_text(value) - - def _xmluiGetValue(self): - return self.get_edit_text() - - -class PrimitivusJidInputWidget(xmlui.JidInputWidget, PrimitivusStringWidget): - pass - - -class PrimitivusPasswordWidget(xmlui.PasswordWidget, sat_widgets.Password, PrimitivusEvents): - - def __init__(self, _xmlui_parent, value, read_only=False): - sat_widgets.Password.__init__(self, edit_text=value) - self.read_only = read_only - - def selectable(self): - if self.read_only: - return False - return super(PrimitivusPasswordWidget, self).selectable() - - def _xmluiSetValue(self, value): - self.set_edit_text(value) - - def _xmluiGetValue(self): - return self.get_edit_text() - - -class PrimitivusTextBoxWidget(xmlui.TextBoxWidget, sat_widgets.AdvancedEdit, PrimitivusEvents): - - def __init__(self, _xmlui_parent, value, read_only=False): - sat_widgets.AdvancedEdit.__init__(self, edit_text=value, multiline=True) - self.read_only = read_only - - def selectable(self): - if self.read_only: - return False - return super(PrimitivusTextBoxWidget, self).selectable() - - def _xmluiSetValue(self, value): - self.set_edit_text(value) - - def _xmluiGetValue(self): - return self.get_edit_text() - - -class PrimitivusBoolWidget(xmlui.BoolWidget, urwid.CheckBox, PrimitivusEvents): - - def __init__(self, _xmlui_parent, state, read_only=False): - urwid.CheckBox.__init__(self, '', state=state) - self.read_only = read_only - - def selectable(self): - if self.read_only: - return False - return super(PrimitivusBoolWidget, self).selectable() - - def _xmluiSetValue(self, value): - self.set_state(value == "true") - - def _xmluiGetValue(self): - return C.BOOL_TRUE if self.get_state() else C.BOOL_FALSE - - -class PrimitivusIntWidget(xmlui.IntWidget, sat_widgets.AdvancedEdit, PrimitivusEvents): - - def __init__(self, _xmlui_parent, value, read_only=False): - sat_widgets.AdvancedEdit.__init__(self, edit_text=value) - self.read_only = read_only - - def selectable(self): - if self.read_only: - return False - return super(PrimitivusIntWidget, self).selectable() - - def _xmluiSetValue(self, value): - self.set_edit_text(value) - - def _xmluiGetValue(self): - return self.get_edit_text() - - -class PrimitivusButtonWidget(xmlui.ButtonWidget, sat_widgets.CustomButton, PrimitivusEvents): - - def __init__(self, _xmlui_parent, value, click_callback): - sat_widgets.CustomButton.__init__(self, value, on_press=click_callback) - - def _xmluiOnClick(self, callback): - urwid.connect_signal(self, 'click', callback) - - -class PrimitivusListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents): - - def __init__(self, _xmlui_parent, options, selected, flags): - sat_widgets.List.__init__(self, options=options, style=flags) - self._xmluiSelectValues(selected) - - def _xmluiSelectValue(self, value): - return self.selectValue(value) - - def _xmluiSelectValues(self, values): - return self.selectValues(values) - - def _xmluiGetSelectedValues(self): - return [option.value for option in self.getSelectedValues()] - - def _xmluiAddValues(self, values, select=True): - current_values = self.getAllValues() - new_values = copy.deepcopy(current_values) - for value in values: - if value not in current_values: - new_values.append(value) - if select: - selected = self._xmluiGetSelectedValues() - self.changeValues(new_values) - if select: - for value in values: - if value not in selected: - selected.append(value) - self._xmluiSelectValues(selected) - -class PrimitivusJidsListWidget(xmlui.ListWidget, sat_widgets.List, PrimitivusEvents): - - def __init__(self, _xmlui_parent, jids, styles): - sat_widgets.List.__init__(self, options=jids+[''], # the empty field is here to add new jids if needed - option_type=lambda txt, align: sat_widgets.AdvancedEdit(edit_text=txt, align=align), - on_change=self._onChange) - self.delete=0 - - def _onChange(self, list_widget, jid_widget=None, text=None): - if jid_widget is not None: - if jid_widget != list_widget.contents[-1] and not text: - # if a field is empty, we delete the line (except for the last line) - list_widget.contents.remove(jid_widget) - elif jid_widget == list_widget.contents[-1] and text: - # we always want an empty field as last value to be able to add jids - list_widget.contents.append(sat_widgets.AdvancedEdit()) - - def _xmluiGetSelectedValues(self): - # XXX: there is not selection in this list, so we return all non empty values - return [jid_ for jid_ in self.getAllValues() if jid_] - - -class PrimitivusAdvancedListContainer(xmlui.AdvancedListContainer, sat_widgets.TableContainer, PrimitivusEvents): - - def __init__(self, _xmlui_parent, columns, selectable='no'): - options = {'ADAPT':()} - if selectable != 'no': - options['HIGHLIGHT'] = () - sat_widgets.TableContainer.__init__(self, columns=columns, options=options, row_selectable = selectable!='no') - - def _xmluiAppend(self, widget): - self.addWidget(widget) - - def _xmluiAddRow(self, idx): - self.setRowIndex(idx) - - def _xmluiGetSelectedWidgets(self): - return self.getSelectedWidgets() - - def _xmluiGetSelectedIndex(self): - return self.getSelectedIndex() - - def _xmluiOnSelect(self, callback): - """ Call callback with widget as only argument """ - urwid.connect_signal(self, 'click', self._event_callback, callback) - - -class PrimitivusPairsContainer(xmlui.PairsContainer, sat_widgets.TableContainer): - - def __init__(self, _xmlui_parent): - options = {'ADAPT':(0,), 'HIGHLIGHT':(0,)} - if self._xmlui_main.type == 'param': - options['FOCUS_ATTR'] = 'param_selected' - sat_widgets.TableContainer.__init__(self, columns=2, options=options) - - def _xmluiAppend(self, widget): - if isinstance(widget, PrimitivusEmptyWidget): - # we don't want highlight on empty widgets - widget = urwid.AttrMap(widget, 'default') - self.addWidget(widget) - - -class PrimitivusLabelContainer(PrimitivusPairsContainer, xmlui.LabelContainer): - pass - - -class PrimitivusTabsContainer(xmlui.TabsContainer, sat_widgets.TabsContainer): - - def __init__(self, _xmlui_parent): - sat_widgets.TabsContainer.__init__(self) - - def _xmluiAppend(self, widget): - self.body.append(widget) - - def _xmluiAddTab(self, label, selected): - tab = PrimitivusVerticalContainer(None) - self.addTab(label, tab, selected) - return tab - - -class PrimitivusVerticalContainer(xmlui.VerticalContainer, urwid.ListBox): - BOX_HEIGHT = 5 - - def __init__(self, _xmlui_parent): - urwid.ListBox.__init__(self, urwid.SimpleListWalker([])) - self._last_size = None - - def _xmluiAppend(self, widget): - if 'flow' not in widget.sizing(): - widget = urwid.BoxAdapter(widget, self.BOX_HEIGHT) - self.body.append(widget) - - def render(self, size, focus=False): - if size != self._last_size: - (maxcol, maxrow) = size - if self.body: - widget = self.body[0] - if isinstance(widget, urwid.BoxAdapter): - widget.height = maxrow - self._last_size = size - return super(PrimitivusVerticalContainer, self).render(size, focus) - - -### Dialogs ### - - -class PrimitivusDialog(object): - - def __init__(self, _xmlui_parent): - self.host = _xmlui_parent.host - - def _xmluiShow(self): - self.host.showPopUp(self) - - def _xmluiClose(self): - self.host.removePopUp(self) - - -class PrimitivusMessageDialog(PrimitivusDialog, xmlui.MessageDialog, sat_widgets.Alert): - - def __init__(self, _xmlui_parent, title, message, level): - PrimitivusDialog.__init__(self, _xmlui_parent) - xmlui.MessageDialog.__init__(self, _xmlui_parent) - sat_widgets.Alert.__init__(self, title, message, ok_cb=lambda dummy: self._xmluiClose()) - - -class PrimitivusNoteDialog(xmlui.NoteDialog, PrimitivusMessageDialog): - # TODO: separate NoteDialog - pass - - -class PrimitivusConfirmDialog(PrimitivusDialog, xmlui.ConfirmDialog, sat_widgets.ConfirmDialog): - - def __init__(self, _xmlui_parent, title, message, level, buttons_set): - PrimitivusDialog.__init__(self, _xmlui_parent) - xmlui.ConfirmDialog.__init__(self, _xmlui_parent) - sat_widgets.ConfirmDialog.__init__(self, title, message, no_cb=lambda dummy: self._xmluiCancelled(), yes_cb=lambda dummy: self._xmluiValidated()) - - -class PrimitivusFileDialog(PrimitivusDialog, xmlui.FileDialog, files_management.FileDialog): - - def __init__(self, _xmlui_parent, title, message, level, filetype): - # TODO: message is not managed yet - PrimitivusDialog.__init__(self, _xmlui_parent) - xmlui.FileDialog.__init__(self, _xmlui_parent) - style = [] - if filetype == C.XMLUI_DATA_FILETYPE_DIR: - style.append('dir') - files_management.FileDialog.__init__(self, - ok_cb=lambda path: self._xmluiValidated({'path': path}), - cancel_cb=lambda dummy: self._xmluiCancelled(), - message=message, - title=title, - style=style) - - -class GenericFactory(object): - - def __getattr__(self, attr): - if attr.startswith("create"): - cls = globals()["Primitivus" + attr[6:]] # XXX: we prefix with "Primitivus" to work around an Urwid bug, WidgetMeta in Urwid don't manage multiple inheritance with same names - return cls - - -class WidgetFactory(GenericFactory): - - def __getattr__(self, attr): - if attr.startswith("create"): - cls = GenericFactory.__getattr__(self, attr) - cls._xmlui_main = self._xmlui_main - return cls - - -class XMLUIPanel(xmlui.XMLUIPanel, PrimitivusWidget): - widget_factory = WidgetFactory() - - def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, ignore=None, profile=C.PROF_KEY_NONE): - self.widget_factory._xmlui_main = self - self._dest = None - xmlui.XMLUIPanel.__init__(self, - host, - parsed_xml, - title = title, - flags = flags, - callback = callback, - ignore = ignore, - profile = profile) - PrimitivusWidget.__init__(self, self.main_cont, self.xmlui_title) - - def constructUI(self, parsed_dom): - def postTreat(): - assert self.main_cont.body - - if self.type in ('form', 'popup'): - buttons = [] - if self.type == 'form': - buttons.append(urwid.Button(_('Submit'), self.onFormSubmitted)) - if not 'NO_CANCEL' in self.flags: - buttons.append(urwid.Button(_('Cancel'), self.onFormCancelled)) - else: - buttons.append(urwid.Button(_('OK'), on_press=lambda dummy: self._xmluiClose())) - max_len = max([len(button.get_label()) for button in buttons]) - grid_wid = urwid.GridFlow(buttons, max_len + 4, 1, 0, 'center') - self.main_cont.body.append(grid_wid) - elif self.type == 'param': - tabs_cont = self.main_cont.body[0].base_widget - assert isinstance(tabs_cont,sat_widgets.TabsContainer) - buttons = [] - buttons.append(sat_widgets.CustomButton(_('Save'),self.onSaveParams)) - buttons.append(sat_widgets.CustomButton(_('Cancel'),lambda x:self.host.removeWindow())) - max_len = max([button.getSize() for button in buttons]) - grid_wid = urwid.GridFlow(buttons,max_len,1,0,'center') - tabs_cont.addFooter(grid_wid) - - xmlui.XMLUIPanel.constructUI(self, parsed_dom, postTreat) - urwid.WidgetWrap.__init__(self, self.main_cont) - - def show(self, show_type=None, valign='middle'): - """Show the constructed UI - @param show_type: how to show the UI: - - None (follow XMLUI's recommendation) - - 'popup' - - 'window' - @param valign: vertical alignment when show_type is 'popup'. - Ignored when show_type is 'window'. - - """ - if show_type is None: - if self.type in ('window', 'param'): - show_type = 'window' - elif self.type in ('popup', 'form'): - show_type = 'popup' - - if show_type not in ('popup', 'window'): - raise ValueError('Invalid show_type [%s]' % show_type) - - self._dest = show_type - if show_type == 'popup': - self.host.showPopUp(self, valign=valign) - elif show_type == 'window': - self.host.newWidget(self) - else: - assert False - self.host.redraw() - - def _xmluiClose(self): - if self._dest == 'window': - self.host.removeWindow() - elif self._dest == 'popup': - self.host.removePopUp(self) - else: - raise exceptions.InternalError("self._dest unknown, are you sure you have called XMLUI.show ?") - - -class XMLUIDialog(xmlui.XMLUIDialog): - dialog_factory = GenericFactory() - - -xmlui.registerClass(xmlui.CLASS_PANEL, XMLUIPanel) -xmlui.registerClass(xmlui.CLASS_DIALOG, XMLUIDialog) -create = xmlui.create diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/constants.py --- a/frontends/src/quick_frontend/constants.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,91 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core import constants -from sat.core.i18n import _ -from collections import OrderedDict # only available from python 2.7 - - -class Const(constants.Const): - - PRESENCE = OrderedDict([("", _("Online")), - ("chat", _("Free for chat")), - ("away", _("Away from keyboard")), - ("dnd", _("Do not disturb")), - ("xa", _("Extended away"))]) - - # from plugin_misc_text_syntaxes - SYNTAX_XHTML = "XHTML" - SYNTAX_CURRENT = "@CURRENT@" - SYNTAX_TEXT = "text" - - # XMLUI - SAT_FORM_PREFIX = "SAT_FORM_" - SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names - XMLUI_STATUS_VALIDATED = "validated" - XMLUI_STATUS_CANCELLED = constants.Const.XMLUI_DATA_CANCELLED - - # Roster - CONTACT_GROUPS = 'groups' - CONTACT_RESOURCES = 'resources' - CONTACT_MAIN_RESOURCE = 'main_resource' - CONTACT_SPECIAL = 'special' - CONTACT_SPECIAL_GROUP = 'group' # group chat special entity - CONTACT_SELECTED = 'selected' - CONTACT_PROFILE = 'profile' # used in handler to track where the contact is coming from - CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,) # allowed values for special flag - CONTACT_DATA_FORBIDDEN = {CONTACT_GROUPS, CONTACT_RESOURCES, CONTACT_MAIN_RESOURCE, CONTACT_SELECTED, CONTACT_PROFILE} # set of forbidden names for contact data - - # Chats - CHAT_STATE_ICON = { - "": u" ", - "active": u'✔', - "inactive": u'☄', - "gone": u'✈', - "composing": u'✎', - "paused": u"…" - } - - # Blogs - ENTRY_MODE_TEXT = "text" - ENTRY_MODE_RICH = "rich" - ENTRY_MODE_XHTML = "xhtml" - - # Widgets management - # FIXME: should be in quick_frontend.constant, but Libervia doesn't inherit from it - WIDGET_NEW = 'NEW' - WIDGET_KEEP = 'KEEP' - WIDGET_RAISE = 'RAISE' - WIDGET_RECREATE = 'RECREATE' - - # Updates (generic) - UPDATE_DELETE = 'DELETE' - UPDATE_MODIFY = 'MODIFY' - UPDATE_ADD = 'ADD' - UPDATE_SELECTION = 'SELECTION' - UPDATE_STRUCTURE = 'STRUCTURE' # high level update (i.e. not item level but organisation of items) - - LISTENERS = {'avatar', 'nick', 'presence', 'profilePlugged', 'disconnect', 'gotMenus', 'menu', 'notification', 'notificationsClear', 'progressFinished', 'progressError'} - - # Notifications - NOTIFY_MESSAGE = 'MESSAGE' # a message has been received - NOTIFY_MENTION = 'MENTION' # user has been mentionned - NOTIFY_PROGRESS_END = 'PROGRESS_END' # a progression has finised - NOTIFY_GENERIC = 'GENERIC' # a notification which has not its own type - NOTIFY_ALL = (NOTIFY_MESSAGE, NOTIFY_MENTION, NOTIFY_PROGRESS_END, NOTIFY_GENERIC) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_app.py --- a/frontends/src/quick_frontend/quick_app.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,975 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) - -from sat.core.i18n import _ -from sat.core import exceptions -from sat.tools import trigger -from sat.tools.common import data_format - -from sat_frontends.tools import jid -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend import quick_menus -from sat_frontends.quick_frontend import quick_blog -from sat_frontends.quick_frontend import quick_chat, quick_games -from sat_frontends.quick_frontend import quick_contact_list -from sat_frontends.quick_frontend.constants import Const as C - -import sys -from collections import OrderedDict -import time - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - unicode = str - - -class ProfileManager(object): - """Class managing all data relative to one profile, and plugging in mechanism""" - # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore - # and a way to keep some XMLUI request between sessions is expected in backend - host = None - bridge = None - # cache_keys_to_get = ['avatar'] - - def __init__(self, profile): - self.profile = profile - self.connected = False - self.whoami = None - self.notifications = {} # key: bare jid or '' for general, value: notif data - - @property - def autodisconnect(self): - try: - autodisconnect = self._autodisconnect - except AttributeError: - autodisconnect = False - return autodisconnect - - def plug(self): - """Plug the profile to the host""" - # we get the essential params - self.bridge.asyncGetParamA("JabberID", "Connection", profile_key=self.profile, - callback=self._plug_profile_jid, errback=self._getParamError) - - def _plug_profile_jid(self, jid_s): - self.whoami = jid.JID(jid_s) # resource might change after the connection - self.bridge.isConnected(self.profile, callback=self._plug_profile_isconnected) - - def _autodisconnectEb(self, failure_): - # XXX: we ignore error on this parameter, as Libervia can't access it - log.warning(_("Error while trying to get autodisconnect param, ignoring: {}").format(failure_)) - self._plug_profile_autodisconnect("false") - - def _plug_profile_isconnected(self, connected): - self.connected = connected - self.bridge.asyncGetParamA("autodisconnect", "Connection", profile_key=self.profile, - callback=self._plug_profile_autodisconnect, errback=self._autodisconnectEb) - - def _plug_profile_autodisconnect(self, autodisconnect): - if C.bool(autodisconnect): - self._autodisconnect = True - self.bridge.asyncGetParamA("autoconnect", "Connection", profile_key=self.profile, - callback=self._plug_profile_autoconnect, errback=self._getParamError) - - def _plug_profile_autoconnect(self, value_str): - autoconnect = C.bool(value_str) - if autoconnect and not self.connected: - self.host.connect(self.profile, callback=lambda dummy: self._plug_profile_afterconnect()) - else: - self._plug_profile_afterconnect() - - def _plug_profile_afterconnect(self): - # Profile can be connected or not - # we get cached data - self.connected = True - self.host.bridge.getFeatures(profile_key=self.profile, callback=self._plug_profile_getFeaturesCb, errback=self._plug_profile_getFeaturesEb) - - def _plug_profile_getFeaturesEb(self, failure): - log.error(u"Couldn't get features: {}".format(failure)) - self._plug_profile_getFeaturesCb({}) - - def _plug_profile_getFeaturesCb(self, features): - self.host.features = features - # FIXME: we don't use cached value at the moment, but keep the code for later use - # it was previously used for avatars, but as we don't get full path here, it's better to request later - # self.host.bridge.getEntitiesData([], ProfileManager.cache_keys_to_get, profile=self.profile, callback=self._plug_profile_gotCachedValues, errback=self._plug_profile_failedCachedValues) - self._plug_profile_gotCachedValues({}) - - def _plug_profile_failedCachedValues(self, failure): - log.error(u"Couldn't get cached values: {}".format(failure)) - self._plug_profile_gotCachedValues({}) - - def _plug_profile_gotCachedValues(self, cached_values): - # add the contact list and its listener - contact_list = self.host.contact_lists.addProfile(self.profile) - - for entity_s, data in cached_values.iteritems(): - for key, value in data.iteritems(): - self.host.entityDataUpdatedHandler(entity_s, key, value, self.profile) - - if not self.connected: - self.host.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=self.profile) - else: - - contact_list.fill() - self.host.setPresenceStatus(profile=self.profile) - - #The waiting subscription requests - self.bridge.getWaitingSub(self.profile, callback=self._plug_profile_gotWaitingSub) - - def _plug_profile_gotWaitingSub(self, waiting_sub): - for sub in waiting_sub: - self.host.subscribeHandler(waiting_sub[sub], sub, self.profile) - - self.bridge.mucGetRoomsJoined(self.profile, callback=self._plug_profile_gotRoomsJoined) - - def _plug_profile_gotRoomsJoined(self, rooms_args): - #Now we open the MUC window where we already are: - for room_args in rooms_args: - self.host.mucRoomJoinedHandler(*room_args, profile=self.profile) - #Presence must be requested after rooms are filled - self.host.bridge.getPresenceStatuses(self.profile, callback=self._plug_profile_gotPresences) - - def _plug_profile_gotPresences(self, presences): - def gotEntityData(data, contact): - for key in ('avatar', 'nick'): - if key in data: - self.host.entityDataUpdatedHandler(contact, key, data[key], self.profile) - - for contact in presences: - for res in presences[contact]: - jabber_id = (u'%s/%s' % (jid.JID(contact).bare, res)) if res else contact - show = presences[contact][res][0] - priority = presences[contact][res][1] - statuses = presences[contact][res][2] - self.host.presenceUpdateHandler(jabber_id, show, priority, statuses, self.profile) - self.host.bridge.getEntityData(contact, ['avatar', 'nick'], self.profile, callback=lambda data, contact=contact: gotEntityData(data, contact), errback=lambda failure, contact=contact: log.debug(u"No cache data for {}".format(contact))) - - # At this point, profile should be fully plugged - # and we launch frontend specific method - self.host.profilePlugged(self.profile) - - def _getParamError(self, failure): - log.error(_("Can't get profile parameter: {msg}").format(msg=failure)) - - -class ProfilesManager(object): - """Class managing collection of profiles""" - - def __init__(self): - self._profiles = {} - - def __contains__(self, profile): - return profile in self._profiles - - def __iter__(self): - return self._profiles.iterkeys() - - def __getitem__(self, profile): - return self._profiles[profile] - - def __len__(self): - return len(self._profiles) - - def iteritems(self): - return self._profiles.iteritems() - - def plug(self, profile): - if profile in self._profiles: - raise exceptions.ConflictError('A profile of the name [{}] is already plugged'.format(profile)) - self._profiles[profile] = ProfileManager(profile) - self._profiles[profile].plug() - - def unplug(self, profile): - if profile not in self._profiles: - raise ValueError('The profile [{}] is not plugged'.format(profile)) - - # remove the contact list and its listener - host = self._profiles[profile].host - host.contact_lists[profile].unplug() - - del self._profiles[profile] - - def chooseOneProfile(self): - return self._profiles.keys()[0] - - -class QuickApp(object): - """This class contain the main methods needed for the frontend""" - MB_HANDLER = True # Set to False if the frontend doesn't manage microblog - AVATARS_HANDLER = True # set to False if avatars are not used - - def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True): - """Create a frontend application - - @param bridge_factory: method to use to create the Bridge - @param xmlui: xmlui module - @param check_options: method to call to check options (usually command line arguments) - """ - self.xmlui = xmlui - self.menus = quick_menus.QuickMenusManager(self) - ProfileManager.host = self - self.profiles = ProfilesManager() - self._plugs_in_progress = set() # profiles currently being plugged, used to (un)lock contact list updates - self.ready_profiles = set() # profiles which are connected and ready - self.signals_cache = {} # used to keep signal received between start of plug_profile and when the profile is actualy ready - self.contact_lists = quick_contact_list.QuickContactListHandler(self) - self.widgets = quick_widgets.QuickWidgetsManager(self) - if check_options is not None: - self.options = check_options() - else: - self.options = None - - # widgets - self.selected_widget = None # widget currently selected (must be filled by frontend) - - # listeners - self._listeners = {} # key: listener type ("avatar", "selected", etc), value: list of callbacks - - # triggers - self.trigger = trigger.TriggerManager() # trigger are used to change the default behaviour - - ## bridge ## - self.bridge = bridge_factory() - ProfileManager.bridge = self.bridge - if connect_bridge: - self.connectBridge() - - self._notif_id = 0 - self._notifications = OrderedDict() - self.features = None - - def connectBridge(self): - self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb) - - def onBridgeConnected(self): - pass - - def _bridgeCb(self): - self.registerSignal("connected") - self.registerSignal("disconnected") - self.registerSignal("actionNew") - self.registerSignal("newContact") - self.registerSignal("messageNew") - self.registerSignal("presenceUpdate") - self.registerSignal("subscribe") - self.registerSignal("paramUpdate") - self.registerSignal("contactDeleted") - self.registerSignal("entityDataUpdated") - self.registerSignal("progressStarted") - self.registerSignal("progressFinished") - self.registerSignal("progressError") - self.registerSignal("mucRoomJoined", iface="plugin") - self.registerSignal("mucRoomLeft", iface="plugin") - self.registerSignal("mucRoomUserChangedNick", iface="plugin") - self.registerSignal("mucRoomNewSubject", iface="plugin") - self.registerSignal("chatStateReceived", iface="plugin") - self.registerSignal("messageState", iface="plugin") - self.registerSignal("psEvent", iface="plugin") - - # FIXME: do it dynamically - quick_games.Tarot.registerSignals(self) - quick_games.Quiz.registerSignals(self) - quick_games.Radiocol.registerSignals(self) - self.onBridgeConnected() - - def _bridgeEb(self, failure): - if isinstance(failure, exceptions.BridgeExceptionNoService): - print(_(u"Can't connect to SàT backend, are you sure it's launched ?")) - sys.exit(1) - elif isinstance(failure, exceptions.BridgeInitError): - print(_(u"Can't init bridge")) - sys.exit(1) - else: - print(_(u"Error while initialising bridge: {}".format(failure))) - - @property - def current_profile(self): - """Profile that a user would expect to use""" - try: - return self.selected_widget.profile - except (TypeError, AttributeError): - return self.profiles.chooseOneProfile() - - @property - def visible_widgets(self): - """widgets currently visible (must be implemented by frontend) - - @return (iter[QuickWidget]): iterable on visible widgets - """ - raise NotImplementedError - - def registerSignal(self, function_name, handler=None, iface="core", with_profile=True): - """Register a handler for a signal - - @param function_name (str): name of the signal to handle - @param handler (instancemethod): method to call when the signal arrive, None for calling an automatically named handler (function_name + 'Handler') - @param iface (str): interface of the bridge to use ('core' or 'plugin') - @param with_profile (boolean): True if the signal concerns a specific profile, in that case the profile name has to be passed by the caller - """ - log.debug(u"registering signal {name}".format(name = function_name)) - if handler is None: - handler = getattr(self, "{}{}".format(function_name, 'Handler')) - if not with_profile: - self.bridge.register_signal(function_name, handler, iface) - return - - def signalReceived(*args, **kwargs): - profile = kwargs.get('profile') - if profile is None: - if not args: - raise exceptions.ProfileNotSetError - profile = args[-1] - if profile is not None: - if not self.check_profile(profile): - if profile in self.profiles: - # profile is not ready but is in self.profiles, that's mean that it's being connecting and we need to cache the signal - self.signals_cache.setdefault(profile, []).append((function_name, handler, args, kwargs)) - return # we ignore signal for profiles we don't manage - handler(*args, **kwargs) - self.bridge.register_signal(function_name, signalReceived, iface) - - def addListener(self, type_, callback, profiles_filter=None): - """Add a listener for an event - - /!\ don't forget to remove listener when not used anymore (e.g. if you delete a widget) - @param type_: type of event, can be: - - avatar: called when avatar data is updated - args: (entity, avatar file, profile) - - nick: called when nick data is updated - args: (entity, new_nick, profile) - - presence: called when a presence is received - args: (entity, show, priority, statuses, profile) - - notification: called when a new notification is emited - args: (entity, notification_data, profile) - - notification_clear: called when notifications are cleared - args: (entity, type_, profile) - - menu: called when a menu item is added or removed - args: (type_, path, path_i18n, item) were values are: - type_: same as in [sat.core.sat_main.SAT.importMenu] - path: same as in [sat.core.sat_main.SAT.importMenu] - path_i18n: translated path (or None if the item is removed) - item: instance of quick_menus.MenuItemBase or None if the item is removed - - gotMenus: called only once when menu are available (no arg) - - progressFinished: called when a progressing action has just finished - args: (progress_id, metadata, profile) - - progressError: called when a progressing action failed - args: (progress_id, error_msg, profile): - @param callback: method to call on event - @param profiles_filter (set[unicode]): if set and not empty, the - listener will be callable only by one of the given profiles. - """ - assert type_ in C.LISTENERS - self._listeners.setdefault(type_, OrderedDict())[callback] = profiles_filter - - def removeListener(self, type_, callback): - """Remove a callback from listeners - - @param type_: same as for [addListener] - @param callback: callback to remove - """ - assert type_ in C.LISTENERS - self._listeners[type_].pop(callback) - - def callListeners(self, type_, *args, **kwargs): - """Call the methods which listen type_ event. If a profiles filter has - been register with a listener and profile argument is not None, the - listener will be called only if profile is in the profiles filter list. - - @param type_: same as for [addListener] - @param *args: arguments sent to callback - @param **kwargs: keywords argument, mainly used to pass "profile" when needed - """ - assert type_ in C.LISTENERS - try: - listeners = self._listeners[type_] - except KeyError: - pass - else: - profile = kwargs.get("profile") - for listener, profiles_filter in listeners.iteritems(): - if profile is None or not profiles_filter or profile in profiles_filter: - listener(*args, **kwargs) - - def check_profile(self, profile): - """Tell if the profile is currently followed by the application, and ready""" - return profile in self.ready_profiles - - def postInit(self, profile_manager): - """Must be called after initialization is done, do all automatic task (auto plug profile) - - @param profile_manager: instance of a subclass of Quick_frontend.QuickProfileManager - """ - if self.options and self.options.profile: - profile_manager.autoconnect([self.options.profile]) - - def profilePlugged(self, profile): - """Method called when the profile is fully plugged, to launch frontend specific workflow - - /!\ if you override the method and don't call the parent, be sure to add the profile to ready_profiles ! - if you don't, all signals will stay in cache - - @param profile(unicode): %(doc_profile)s - """ - self._plugs_in_progress.remove(profile) - self.ready_profiles.add(profile) - - # profile is ready, we can call send signals that where is cache - cached_signals = self.signals_cache.pop(profile, []) - for function_name, handler, args, kwargs in cached_signals: - log.debug(u"Calling cached signal [%s] with args %s and kwargs %s" % (function_name, args, kwargs)) - handler(*args, **kwargs) - - self.callListeners('profilePlugged', profile=profile) - if not self._plugs_in_progress: - self.contact_lists.lockUpdate(False) - - def connect(self, profile, callback=None, errback=None): - if not callback: - callback = lambda dummy: None - if not errback: - def errback(failure): - log.error(_(u"Can't connect profile [%s]") % failure) - try: - module = failure.module - except AttributeError: - module = '' - try: - message = failure.message - except AttributeError: - message = 'error' - try: - fullname = failure.fullname - except AttributeError: - fullname = 'error' - if module.startswith('twisted.words.protocols.jabber') and failure.condition == "not-authorized": - self.launchAction(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile) - else: - self.showDialog(message, fullname, 'error') - self.bridge.connect(profile, callback=callback, errback=errback) - - def plug_profiles(self, profiles): - """Tell application which profiles must be used - - @param profiles: list of valid profile names - """ - self.contact_lists.lockUpdate() - self._plugs_in_progress.update(profiles) - self.plugging_profiles() - for profile in profiles: - self.profiles.plug(profile) - - def plugging_profiles(self): - """Method to subclass to manage frontend specific things to do - - will be called when profiles are choosen and are to be plugged soon - """ - pass - - def unplug_profile(self, profile): - """Tell the application to not follow anymore the profile""" - if not profile in self.profiles: - raise ValueError("The profile [{}] is not plugged".format(profile)) - self.profiles.unplug(profile) - - def clear_profile(self): - self.profiles.clear() - - def newWidget(self, widget): - raise NotImplementedError - - # bridge signals hanlers - - def connectedHandler(self, profile, jid_s): - """Called when the connection is made. - - @param jid_s (unicode): the JID that we were assigned by the server, - as the resource might differ from the JID we asked for. - """ - log.debug(_("Connected")) - self.profiles[profile].whoami = jid.JID(jid_s) - self.setPresenceStatus(profile=profile) - self.contact_lists[profile].fill() - - def disconnectedHandler(self, profile): - """called when the connection is closed""" - log.debug(_("Disconnected")) - self.contact_lists[profile].disconnect() - self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile) - - def actionNewHandler(self, action_data, id_, security_limit, profile): - self.actionManager(action_data, user_action=False, profile=profile) - - def newContactHandler(self, jid_s, attributes, groups, profile): - entity = jid.JID(jid_s) - groups = list(groups) - self.contact_lists[profile].setContact(entity, groups, attributes, in_roster=True) - - def messageNewHandler(self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra, profile): - from_jid = jid.JID(from_jid_s) - to_jid = jid.JID(to_jid_s) - if not self.trigger.point("messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile=profile): - return - - from_me = from_jid.bare == self.profiles[profile].whoami.bare - target = to_jid if from_me else from_jid - contact_list = self.contact_lists[profile] - # we want to be sure to have at least one QuickChat instance - self.widgets.getOrCreateWidget(quick_chat.QuickChat, target, type_=C.CHAT_ONE2ONE, on_new_widget=None, profile=profile) - - if not from_jid in contact_list and from_jid.bare != self.profiles[profile].whoami.bare: - #XXX: needed to show entities which haven't sent any - # presence information and which are not in roster - contact_list.setContact(from_jid) - - # we dispatch the message in the widgets - for widget in self.widgets.getWidgets(quick_chat.QuickChat, target=target, profiles=(profile,)): - widget.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile) - - def messageStateHandler(self, uid, status, profile): - for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)): - widget.onMessageState(uid, status, profile) - - def messageSend(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE): - if subject is None: - subject = {} - if extra is None: - extra = {} - if callback is None: - callback = lambda dummy=None: None # FIXME: optional argument is here because pyjamas doesn't support callback without arg with json proxy - if errback is None: - errback = lambda failure: self.showDialog(failure.fullname, failure.message, "error") - - if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key): - return - - self.bridge.messageSend(unicode(to_jid), message, subject, mess_type, extra, profile_key, callback=callback, errback=errback) - - def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): - raise NotImplementedError - - def presenceUpdateHandler(self, entity_s, show, priority, statuses, profile): - - log.debug(_(u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, statuses=%(statuses)s) [profile:%(profile)s]") - % {'entity': entity_s, C.PRESENCE_SHOW: show, C.PRESENCE_PRIORITY: priority, C.PRESENCE_STATUSES: statuses, 'profile': profile}) - entity = jid.JID(entity_s) - - if entity == self.profiles[profile].whoami: - if show == C.PRESENCE_UNAVAILABLE: - self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile) - else: - # FIXME: try to retrieve user language status before fallback to default - status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None) - self.setPresenceStatus(show, status, profile=profile) - return - - self.callListeners('presence', entity, show, priority, statuses, profile=profile) - - def mucRoomJoinedHandler(self, room_jid_s, occupants, user_nick, subject, profile): - """Called when a MUC room is joined""" - log.debug(u"Room [{room_jid}] joined by {profile}, users presents:{users}".format(room_jid=room_jid_s, profile=profile, users=occupants.keys())) - room_jid = jid.JID(room_jid_s) - self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, nick=user_nick, occupants=occupants, subject=subject, profile=profile) - self.contact_lists[profile].setSpecial(room_jid, C.CONTACT_SPECIAL_GROUP) - # chat_widget.update() - - def mucRoomLeftHandler(self, room_jid_s, profile): - """Called when a MUC room is left""" - log.debug(u"Room [%(room_jid)s] left by %(profile)s" % {'room_jid': room_jid_s, 'profile': profile}) - room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getWidget(quick_chat.QuickChat, room_jid, profile) - if chat_widget: - self.widgets.deleteWidget(chat_widget) - self.contact_lists[profile].removeContact(room_jid) - - def mucRoomUserChangedNickHandler(self, room_jid_s, old_nick, new_nick, profile): - """Called when an user joined a MUC room""" - room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) - chat_widget.changeUserNick(old_nick, new_nick) - log.debug(u"user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]" % {'old_nick': old_nick, 'new_nick': new_nick, 'room_jid': room_jid}) - - def mucRoomNewSubjectHandler(self, room_jid_s, subject, profile): - """Called when subject of MUC room change""" - room_jid = jid.JID(room_jid_s) - chat_widget = self.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) - chat_widget.setSubject(subject) - log.debug(u"new subject for room [%(room_jid)s]: %(subject)s" % {'room_jid': room_jid, "subject": subject}) - - def chatStateReceivedHandler(self, from_jid_s, state, profile): - """Called when a new chat state (XEP-0085) is received. - - @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL - @param state (unicode): new state - @param profile (unicode): current profile - """ - from_jid = jid.JID(from_jid_s) - for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)): - widget.onChatState(from_jid, state, profile) - - def notify(self, type_, entity=None, message=None, subject=None, callback=None, cb_args=None, widget=None, profile=C.PROF_KEY_NONE): - """Trigger an event notification - - @param type_(unicode): notifation kind, - one of C.NOTIFY_* constant or any custom type specific to frontend - @param entity(jid.JID, None): entity involved in the notification - if entity is in contact list, a indicator may be added in front of it - @param message(unicode, None): message of the notification - @param subject(unicode, None): subject of the notification - @param callback(callable, None): method to call when notification is selected - @param cb_args(list, None): list of args for callback - @param widget(object, None): widget where the notification happened - """ - assert type_ in C.NOTIFY_ALL - notif_dict = self.profiles[profile].notifications - key = '' if entity is None else entity.bare - type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, []) - notif_data = { - 'id': self._notif_id, - 'time': time.time(), - 'entity': entity, - 'callback': callback, - 'cb_args': cb_args, - 'message': message, - 'subject': subject, - } - if widget is not None: - notif_data[widget] = widget - type_notifs.append(notif_data) - self._notifications[self._notif_id] = notif_data - self.callListeners('notification', entity, notif_data, profile=profile) - - def getNotifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE): - """return notifications for given entity - - @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check - bare jid to get all notifications, full jid to filter on resource - None to get general notifications - C.ENTITY_ALL to get all notifications - @param type_(unicode, None): notification type to filter - None to get all notifications - @param exact_jid(bool, None): if True, only return notifications from - exact entity jid (i.e. not including other resources) - None for automatic selection (True for full jid, False else) - False to get resources notifications - False doesn't do anything if entity is not a bare jid - @return (iter[dict]): notifications - """ - main_notif_dict = self.profiles[profile].notifications - - if entity is C.ENTITY_ALL: - selected_notifs = main_notif_dict.itervalues() - exact_jid = False - else: - if entity is None: - key = '' - exact_jid = False - else: - key = entity.bare - if exact_jid is None: - exact_jid = bool(entity.resource) - selected_notifs = (main_notif_dict.setdefault(key, {}),) - - for notifs_from_select in selected_notifs: - - if type_ is None: - type_notifs = notifs_from_select.itervalues() - else: - type_notifs = (notifs_from_select.get(type_, []),) - - for notifs in type_notifs: - for notif in notifs: - if exact_jid and notif['entity'] != entity: - continue - yield notif - - def clearNotifs(self, entity, type_=None, profile=C.PROF_KEY_NONE): - """return notifications for given entity - - @param entity(jid.JID, None): bare jid of the entity to check - None to clear general notifications (but keep entities ones) - @param type_(unicode, None): notification type to filter - None to clear all notifications - @return (list[dict]): list of notifications - """ - notif_dict = self.profiles[profile].notifications - key = '' if entity is None else entity.bare - try: - if type_ is None: - del notif_dict[key] - else: - del notif_dict[key][type_] - except KeyError: - return - self.callListeners('notificationsClear', entity, type_, profile=profile) - - def psEventHandler(self, category, service_s, node, event_type, data, profile): - """Called when a PubSub event is received. - - @param category(unicode): event category (e.g. "PEP", "MICROBLOG") - @param service_s (unicode): pubsub service - @param node (unicode): pubsub node - @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE) - @param data (dict): event data - """ - service_s = jid.JID(service_s) - - if category == C.PS_MICROBLOG and self.MB_HANDLER: - if event_type == C.PS_PUBLISH: - if not 'content' in data: - log.warning("No content found in microblog data") - return - _groups = set(data_format.dict2iter('group', data)) or None # FIXME: check if [] make sense (instead of None) - - for wid in self.widgets.getWidgets(quick_blog.QuickBlog): - wid.addEntryIfAccepted(service_s, node, data, _groups, profile) - - try: - comments_node, comments_service = data['comments_node'], data['comments_service'] - except KeyError: - pass - else: - self.bridge.mbGet(comments_service, comments_node, C.NO_LIMIT, [], {"subscribe":C.BOOL_TRUE}, profile=profile) - elif event_type == C.PS_RETRACT: - for wid in self.widgets.getWidgets(quick_blog.QuickBlog): - wid.deleteEntryIfPresent(service_s, node, data['id'], profile) - pass - else: - log.warning("Unmanaged PubSub event type {}".format(event_type)) - - def progressStartedHandler(self, pid, metadata, profile): - log.info(u"Progress {} started".format(pid)) - - def progressFinishedHandler(self, pid, metadata, profile): - log.info(u"Progress {} finished".format(pid)) - self.callListeners('progressFinished', pid, metadata, profile=profile) - - def progressErrorHandler(self, pid, err_msg, profile): - log.warning(u"Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg)) - self.callListeners('progressError', pid, err_msg, profile=profile) - - def _subscribe_cb(self, answer, data): - entity, profile = data - type_ = "subscribed" if answer else "unsubscribed" - self.bridge.subscription(type_, unicode(entity.bare), profile_key=profile) - - def subscribeHandler(self, type, raw_jid, profile): - """Called when a subsciption management signal is received""" - entity = jid.JID(raw_jid) - if type == "subscribed": - # this is a subscription confirmation, we just have to inform user - # TODO: call self.getEntityMBlog to add the new contact blogs - self.showDialog(_("The contact %s has accepted your subscription") % entity.bare, _('Subscription confirmation')) - elif type == "unsubscribed": - # this is a subscription refusal, we just have to inform user - self.showDialog(_("The contact %s has refused your subscription") % entity.bare, _('Subscription refusal'), 'error') - elif type == "subscribe": - # this is a subscriptionn request, we have to ask for user confirmation - # TODO: use sat.stdui.ui_contact_list to display the groups selector - self.showDialog(_("The contact %s wants to subscribe to your presence.\nDo you accept ?") % entity.bare, _('Subscription confirmation'), 'yes/no', answer_cb=self._subscribe_cb, answer_data=(entity, profile)) - - def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None): - raise NotImplementedError - - def showAlert(self, message): - pass #FIXME - - def dialogFailure(self, failure): - log.warning(u"Failure: {}".format(failure)) - - def progressIdHandler(self, progress_id, profile): - """Callback used when an action result in a progress id""" - log.info(u"Progress ID received: {}".format(progress_id)) - - def isHidden(self): - """Tells if the frontend window is hidden. - - @return bool - """ - raise NotImplementedError - - def paramUpdateHandler(self, name, value, namespace, profile): - log.debug(_(u"param update: [%(namespace)s] %(name)s = %(value)s") % {'namespace': namespace, 'name': name, 'value': value}) - if (namespace, name) == ("Connection", "JabberID"): - log.debug(_(u"Changing JID to %s") % value) - self.profiles[profile].whoami = jid.JID(value) - elif (namespace, name) == ('General', C.SHOW_OFFLINE_CONTACTS): - self.contact_lists[profile].showOfflineContacts(C.bool(value)) - elif (namespace, name) == ('General', C.SHOW_EMPTY_GROUPS): - self.contact_lists[profile].showEmptyGroups(C.bool(value)) - - def contactDeletedHandler(self, jid_s, profile): - target = jid.JID(jid_s) - self.contact_lists[profile].removeContact(target) - - def entityDataUpdatedHandler(self, entity_s, key, value, profile): - entity = jid.JID(entity_s) - if key == "nick": # this is the roster nick, not the MUC nick - if entity in self.contact_lists[profile]: - self.contact_lists[profile].setCache(entity, 'nick', value) - self.callListeners('nick', entity, value, profile=profile) - elif key == "avatar" and self.AVATARS_HANDLER: - if value and entity in self.contact_lists[profile]: - self.getAvatar(entity, ignore_cache=True, profile=profile) - - def actionManager(self, action_data, callback=None, ui_show_cb=None, user_action=True, profile=C.PROF_KEY_NONE): - """Handle backend action - - @param action_data(dict): action dict as sent by launchAction or returned by an UI action - @param callback(None, callback): if not None, callback to use on XMLUI answer - @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI - @param user_action(bool): if True, the action is a result of a user interaction - else the action come from backend direclty (i.e. actionNew) - """ - try: - xmlui = action_data.pop('xmlui') - except KeyError: - pass - else: - ui = self.xmlui.create(self, xml_data=xmlui, flags=("FROM_BACKEND",) if not user_action else None, callback=callback, profile=profile) - if ui_show_cb is None: - ui.show() - else: - ui_show_cb(ui) - - try: - progress_id = action_data.pop('progress') - except KeyError: - pass - else: - self.progressIdHandler(progress_id, profile) - - # we ignore metadata - action_data = {k:v for k,v in action_data.iteritems() if not k.startswith("meta_")} - - if action_data: - raise exceptions.DataError(u"Not all keys in action_data are managed ({keys})".format(keys=', '.join(action_data.keys()))) - - - def _actionCb(self, data, callback, callback_id, profile): - if callback is None: - self.actionManager(data, profile=profile) - else: - callback(data=data, cb_id=callback_id, profile=profile) - - def launchAction(self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE): - """Launch a dynamic action - - @param callback_id: id of the action to launch - @param data: data needed only for certain actions - @param callback(callable, None): will be called with the resut - if None, self.actionManager will be called - else the callable will be called with the following kw parameters: - - data: action_data - - cb_id: callback id - - profile: %(doc_profile)s - @param profile: %(doc_profile)s - - """ - if data is None: - data = dict() - action_cb = lambda data: self._actionCb(data, callback, callback_id, profile) - self.bridge.launchAction(callback_id, data, profile, callback=action_cb, errback=self.dialogFailure) - - def launchMenu(self, menu_type, path, data=None, callback=None, security_limit=C.SECURITY_LIMIT_MAX, profile=C.PROF_KEY_NONE): - """Launch a menu manually - - @param menu_type(unicode): type of the menu to launch - @param path(iterable[unicode]): path to the menu - @param data: data needed only for certain actions - @param callback(callable, None): will be called with the resut - if None, self.actionManager will be called - else the callable will be called with the following kw parameters: - - data: action_data - - cb_id: (menu_type, path) tuple - - profile: %(doc_profile)s - @param profile: %(doc_profile)s - - """ - if data is None: - data = dict() - action_cb = lambda data: self._actionCb(data, callback, (menu_type, path), profile) - self.bridge.menuLaunch(menu_type, path, data, security_limit, profile, callback=action_cb, errback=self.dialogFailure) - - def _avatarGetCb(self, avatar_path, entity, contact_list, profile): - path = avatar_path or self.getDefaultAvatar(entity) - contact_list.setCache(entity, "avatar", path) - self.callListeners('avatar', entity, path, profile=profile) - - def _avatarGetEb(self, failure, entity, contact_list): - log.warning(u"Can't get avatar: {}".format(failure)) - contact_list.setCache(entity, "avatar", self.getDefaultAvatar(entity)) - - def getAvatar(self, entity, cache_only=True, hash_only=False, ignore_cache=False, profile=C.PROF_KEY_NONE): - """return avatar path for an entity - - @param entity(jid.JID): entity to get avatar from - @param cache_only(bool): if False avatar will be requested if not in cache - with current vCard based implementation, it's better to keep True - except if we request avatars for roster items - @param hash_only(bool): if True avatar hash is returned, else full path - @param ignore_cache(bool): if False, won't check local cache and will request backend in every case - @return (unicode, None): avatar full path (None if no avatar found) - """ - contact_list = self.contact_lists[profile] - if ignore_cache: - avatar = None - else: - avatar = contact_list.getCache(entity, "avatar", bare_default=None) - if avatar is None: - self.bridge.avatarGet( - unicode(entity), - cache_only, - hash_only, - profile=profile, - callback=lambda path: self._avatarGetCb(path, entity, contact_list, profile), - errback=lambda failure: self._avatarGetEb(failure, entity, contact_list)) - # we set avatar to empty string to avoid requesting several time the same avatar - # while we are waiting for avatarGet result - contact_list.setCache(entity, "avatar", "") - return avatar - - def getDefaultAvatar(self, entity=None): - """return default avatar to use with given entity - - must be implemented by frontend - @param entity(jid.JID): entity for which a default avatar is needed - """ - raise NotImplementedError - - def disconnect(self, profile): - log.info("disconnecting") - self.callListeners('disconnect', profile=profile) - self.bridge.disconnect(profile) - - def onExit(self): - """Must be called when the frontend is terminating""" - to_unplug = [] - for profile, profile_manager in self.profiles.iteritems(): - if profile_manager.connected and profile_manager.autodisconnect: - #The user wants autodisconnection - self.disconnect(profile) - to_unplug.append(profile) - for profile in to_unplug: - self.unplug_profile(profile) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_blog.py --- a/frontends/src/quick_frontend/quick_blog.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,471 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# Copyright (C) 2011-2018 Jérôme Poisson - -# 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 . - -# from sat.core.i18n import _, D_ -from sat.core.log import getLogger -log = getLogger(__name__) - - -from sat_frontends.quick_frontend.constants import Const as C -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.tools import jid -from sat.tools.common import data_format - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - unicode = str - -ENTRY_CLS = None -COMMENTS_CLS = None - - -class Item(object): - """Manage all (meta)data of an item""" - - def __init__(self, data): - """ - @param data(dict): microblog data as return by bridge methods - if data values are not defined, set default values - """ - self.id = data['id'] - self.title = data.get('title') - self.title_rich = None - self.title_xhtml = data.get('title_xhtml') - self.tags = list(data_format.dict2iter('tag', data)) - self.content = data.get('content') - self.content_rich = None - self.content_xhtml = data.get('content_xhtml') - self.author = data['author'] - try: - author_jid = data['author_jid'] - self.author_jid = jid.JID(author_jid) if author_jid else None - except KeyError: - self.author_jid = None - - try: - self.author_verified = C.bool(data['author_jid_verified']) - except KeyError: - self.author_verified = False - - try: - self.updated = float(data['updated']) # XXX: int doesn't work here (pyjamas bug) - except KeyError: - self.updated = None - - try: - self.published = float(data['published']) # XXX: int doesn't work here (pyjamas bug) - except KeyError: - self.published = None - - self.comments = data.get('comments') - try: - self.comments_service = jid.JID(data['comments_service']) - except KeyError: - self.comments_service = None - self.comments_node = data.get('comments_node') - - # def loadComments(self): - # """Load all the comments""" - # index = str(main_entry.comments_count - main_entry.hidden_count) - # rsm = {'max': str(main_entry.hidden_count), 'index': index} - # self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm) - - -class EntriesManager(object): - """Class which manages list of (micro)blog entries""" - - def __init__(self, manager): - """ - @param manager (EntriesManager, None): parent EntriesManager - must be None for QuickBlog (and only for QuickBlog) - """ - self.manager = manager - if manager is None: - self.blog = self - else: - self.blog = manager.blog - self.entries = [] - self.edit_entry = None - - @property - def level(self): - """indicate how deep is this entry in the tree - - if level == -1, we have a QuickBlog - if level == 0, we have a main item - else we have a comment - """ - level = -1 - manager = self.manager - while manager is not None: - level += 1 - manager = manager.manager - return level - - def _addMBItems(self, items_tuple, service=None, node=None): - """Add Microblog items to this panel - update is NOT called after addition - - @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet - """ - items, metadata = items_tuple - for item in items: - self.addEntry(item, service=service, node=node, with_update=False) - - def _addMBItemsWithComments(self, items_tuple, service=None, node=None): - """Add Microblog items to this panel - update is NOT called after addition - - @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet - """ - items, metadata = items_tuple - for item, comments in items: - self.addEntry(item, comments, service=service, node=node, with_update=False) - - def addEntry(self, item=None, comments=None, service=None, node=None, with_update=True, editable=False, edit_entry=False): - """Add a microblog entry - - @param editable (bool): True if the entry can be modified - @param item (dict, None): blog item data, or None for an empty entry - @param comments (list, None): list of comments data if available - @param service (jid.JID, None): service where the entry is coming from - @param service (unicode, None): node hosting the entry - @param with_update (bool): if True, udpate is called with the new entry - @param edit_entry(bool): if True, will be in self.edit_entry instead of - self.entries, so it can be managed separately (e.g. first or last - entry regardless of sorting) - """ - new_entry = ENTRY_CLS(self, item, comments, service=service, node=node) - new_entry.setEditable(editable) - if edit_entry: - self.edit_entry = new_entry - else: - self.entries.append(new_entry) - if with_update: - self.update() - return new_entry - - def update(self, entry=None): - """Update the display with entries - - @param entry (Entry, None): if not None, must be the new entry. - If None, all the items will be checked to update the display - """ - # update is separated from addEntry to allow adding - # several entries at once, and updating at the end - raise NotImplementedError - - -class Entry(EntriesManager): - """Graphical representation of an Item - This class must be overriden by frontends""" - - def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None): - """ - @param blog(QuickBlog): the parent QuickBlog - @param manager(EntriesManager): the parent EntriesManager - @param item_data(dict, None): dict containing the blog item data, or None for an empty entry - @param comments_data(list, None): list of comments data - """ - assert manager is not None - EntriesManager.__init__(self, manager) - self.service = service - self.node = node - self.editable = False - self.reset(item_data) - self.blog.id2entries[self.item.id] = self - if self.item.comments: - node_tuple = (self.item.comments_service, self.item.comments_node) - self.blog.node2entries.setdefault(node_tuple,[]).append(self) - - def reset(self, item_data): - """Reset the entry with given data - - used during init (it's a set and not a reset then) - or later (e.g. message sent, or cancellation of an edition - @param idem_data(dict, None): data as in __init__ - """ - if item_data is None: - self.new = True - item_data = {'id': None, - # TODO: find a best author value - 'author': self.blog.host.whoami.node - } - else: - self.new = False - self.item = Item(item_data) - self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid - if self.author_jid is None and self.service and self.service.node: - self.author_jid = self.service - self.mode = C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML - - def refresh(self): - """Refresh the display when data have been modified""" - pass - - def setEditable(self, editable=True): - """tell if the entry can be edited or not - - @param editable(bool): True if the entry can be edited - """ - #XXX: we don't use @property as property setter doesn't play well with pyjamas - raise NotImplementedError - - def addComments(self, comments_data): - """Add comments to this entry by calling addEntry repeatidly - - @param comments_data(tuple): data as returned by mbGetFromMany*RTResults - """ - # TODO: manage seperator between comments of coming from different services/nodes - for data in comments_data: - service, node, failure, comments, metadata = data - for comment in comments: - if not failure: - self.addEntry(comment, service=jid.JID(service), node=node) - else: - log.warning("getting comment failed: {}".format(failure)) - self.update() - - def send(self): - """Send entry according to parent QuickBlog configuration and current level""" - - # keys to keep other than content*, title* and tag* - # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node) - keys_to_keep = ('id', 'comments', 'author', 'author_jid', 'published') - - mb_data = {} - for key in keys_to_keep: - value = getattr(self.item, key) - if value is not None: - mb_data[key] = unicode(value) - - for prefix in ('content', 'title'): - for suffix in ('', '_rich', '_xhtml'): - name = '{}{}'.format(prefix, suffix) - value = getattr(self.item, name) - if value is not None: - mb_data[name] = value - - data_format.iter2dict('tag', self.item.tags, mb_data) - - if self.blog.new_message_target not in (C.PUBLIC, C.GROUP): - raise NotImplementedError - - if self.level == 0: - mb_data["allow_comments"] = C.BOOL_TRUE - - if self.blog.new_message_target == C.GROUP: - data_format.iter2dict('group', self.blog.targets, mb_data) - - self.blog.host.bridge.mbSend( - unicode(self.service or ''), - self.node or '', - mb_data, - profile=self.blog.profile) - - def delete(self): - """Remove this Entry from parent manager - - This doesn't delete any entry in PubSub, just locally - all children entries will be recursively removed too - """ - # XXX: named delete and not remove to avoid conflict with pyjamas - log.debug(u"deleting entry {}".format('EDIT ENTRY' if self.new else self.item.id)) - for child in self.entries: - child.delete() - try: - self.manager.entries.remove(self) - except ValueError: - if self != self.manager.edit_entry: - log.error(u"Internal Error: entry not found in manager") - else: - self.manager.edit_entry = None - if not self.new: - # we must remove references to self - # in QuickBlog's dictionary - del self.blog.id2entries[self.item.id] - if self.item.comments: - comments_tuple = (self.item.comments_service, - self.item.comments_node) - other_entries = self.blog.node2entries[comments_tuple].remove(self) - if not other_entries: - del self.blog.node2entries[comments_tuple] - - def retract(self): - """Retract this item from microblog node - - if there is a comments node, it will be purged too - """ - # TODO: manage several comments nodes case. - if self.item.comments: - self.blog.host.bridge.psNodeDelete(unicode(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile) - self.blog.host.bridge.mbRetract(unicode(self.service or ""), self.node or "", self.item.id, profile=self.blog.profile) - - -class QuickBlog(EntriesManager, quick_widgets.QuickWidget): - - def __init__(self, host, targets, profiles=None): - """Panel used to show microblog - - @param targets (tuple(unicode)): contact groups displayed in this panel. - If empty, show all microblogs from all contacts. targets is also used - to know where to send new messages. - """ - EntriesManager.__init__(self, None) - self.id2entries = {} # used to find an entry with it's item id - # must be kept up-to-date by Entry - self.node2entries = {} # same as above, values are lists in case of - # two entries link to the same comments node - if not targets: - targets = () # XXX: we use empty tuple instead of None to workaround a pyjamas bug - quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE) - self._targets_type = C.ALL - else: - assert isinstance(targets[0], basestring) - quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE) - for target in targets[1:]: - assert isinstance(target, basestring) - self.addTarget(target) - self._targets_type = C.GROUP - - @property - def new_message_target(self): - if self._targets_type == C.ALL: - return C.PUBLIC - elif self._targets_type == C.GROUP: - return C.GROUP - else: - raise ValueError("Unkown targets type") - - def __str__(self): - return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.targets), self.profile) - - def _getResultsCb(self, data, rt_session): - remaining, results = data - log.debug("Got {got_len} results, {rem_len} remaining".format(got_len=len(results), rem_len=remaining)) - for result in results: - service, node, failure, items, metadata = result - if not failure: - self._addMBItemsWithComments((items, metadata), service=jid.JID(service)) - - self.update() - if remaining: - self._getResults(rt_session) - - def _getResultsEb(self, failure): - log.warning("microblog getFromMany error: {}".format(failure)) - - def _getResults(self, rt_session): - """Manage results from mbGetFromMany RT Session - - @param rt_session(str): session id as returned by mbGetFromMany - """ - self.host.bridge.mbGetFromManyWithCommentsRTResult(rt_session, profile=self.profile, - callback=lambda data:self._getResultsCb(data, rt_session), - errback=self._getResultsEb) - - def getAll(self): - """Get all (micro)blogs from self.targets""" - def gotSession(rt_session): - self._getResults(rt_session) - - if self._targets_type in (C.ALL, C.GROUP): - targets = tuple(self.targets) if self._targets_type is C.GROUP else () - self.host.bridge.mbGetFromManyWithComments(self._targets_type, targets, 10, 10, {}, {"subscribe":C.BOOL_TRUE}, profile=self.profile, callback=gotSession) - own_pep = self.host.whoami.bare - self.host.bridge.mbGetFromManyWithComments(C.JID, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession) - else: - raise NotImplementedError(u'{} target type is not managed'.format(self._targets_type)) - - def isJidAccepted(self, jid_): - """Tell if a jid is actepted and must be shown in this panel - - @param jid_(jid.JID): jid to check - @return: True if the jid is accepted - """ - if self._targets_type == C.ALL: - return True - assert self._targets_type is C.GROUP # we don't manage other types for now - for group in self.targets: - if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group): - return True - return False - - def addEntryIfAccepted(self, service, node, mb_data, groups, profile): - """add entry to this panel if it's acceptable - - This method check if the entry is new or an update, - if it below to a know node, or if it acceptable anyway - @param service(jid.JID): jid of the emitting pubsub service - @param node(unicode): node identifier - @param mb_data: microblog data - @param groups(list[unicode], None): groups which can receive this entry - None to accept everything - @param profile: %(doc_profile)s - """ - try: - entry = self.id2entries[mb_data['id']] - except KeyError: - # The entry is new - try: - parent_entries = self.node2entries[(service, node)] - except: - # The node is unknown, - # we need to check that we can accept the entry - if (self.isJidAccepted(service) - or (groups is None and service == self.host.profiles[self.profile].whoami.bare) - or (groups and groups.intersection(self.targets))): - self.addEntry(mb_data, service=service, node=node) - else: - # the entry is a comment in a known node - for parent_entry in parent_entries: - parent_entry.addEntry(mb_data, service=service, node=node) - else: - # The entry exist, it's an update - entry.reset(mb_data) - entry.refresh() - - def deleteEntryIfPresent(self, service, node, item_id, profile): - """Delete and entry if present in this QuickBlog - - @param sender(jid.JID): jid of the entry sender - @param mb_data: microblog data - @param service(jid.JID): sending service - @param node(unicode): hosting node - """ - try: - entry = self.id2entries[item_id] - except KeyError: - pass - else: - entry.delete() - - -def registerClass(type_, cls): - global ENTRY_CLS, COMMENTS_CLS - if type_ == "ENTRY": - ENTRY_CLS = cls - elif type == "COMMENT": - COMMENTS_CLS = cls - else: - raise ValueError("type_ should be ENTRY or COMMENT") - if COMMENTS_CLS is None: - COMMENTS_CLS = ENTRY_CLS diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_chat.py --- a/frontends/src/quick_frontend/quick_chat.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,610 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.i18n import _ -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core import exceptions -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend.constants import Const as C -from collections import OrderedDict -from sat_frontends.tools import jid -import time -try: - from locale import getlocale -except ImportError: - # FIXME: pyjamas workaround - getlocale = lambda x: (None, 'utf-8') - - -ROOM_USER_JOINED = 'ROOM_USER_JOINED' -ROOM_USER_LEFT = 'ROOM_USER_LEFT' -ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT) - -# from datetime import datetime - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - unicode = str - -# FIXME: day_format need to be settable (i18n) - -class Message(object): - """Message metadata""" - - def __init__(self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): - self.parent = parent - self.profile = profile - self.uid = uid - self.timestamp = timestamp - self.from_jid = from_jid - self.to_jid = to_jid - self.message = msg - self.subject = subject - self.type = type_ - self.extra = extra - self.nick = self.getNick(from_jid) - self._status = None - # own_mess is True if message was sent by profile's jid - self.own_mess = (from_jid.resource == self.parent.nick) if self.parent.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare) - # is user mentioned here ? - if self.parent.type == C.CHAT_GROUP and not self.own_mess: - for m in msg.itervalues(): - if self.parent.nick.lower() in m.lower(): - self._mention = True - break - self.handleMe() - self.widgets = set() # widgets linked to this message - - @property - def host(self): - return self.parent.host - - @property - def info_type(self): - return self.extra.get('info_type') - - @property - def mention(self): - try: - return self._mention - except AttributeError: - return False - - @property - def main_message(self): - """currently displayed message""" - if self.parent.lang in self.message: - self.selected_lang = self.parent.lang - return self.message[self.parent.lang] - try: - self.selected_lang = '' - return self.message[''] - except KeyError: - try: - lang, mess = self.message.iteritems().next() - self.selected_lang = lang - return mess - except StopIteration: - log.error(u"Can't find message for uid {}".format(self.uid)) - return '' - - @property - def main_message_xhtml(self): - """rich message""" - xhtml = {k:v for k,v in self.extra.iteritems() if 'html' in k} - if xhtml: - # FIXME: we only return first found value for now - return next(xhtml.itervalues()) - - - @property - def time_text(self): - """Return timestamp in a nicely formatted way""" - # if the message was sent before today, we print the full date - timestamp = time.localtime(self.timestamp) - time_format = u"%c" if timestamp < self.parent.day_change else u"%H:%M" - return time.strftime(time_format, timestamp).decode(getlocale()[1] or 'utf-8') - - @property - def avatar(self): - """avatar full path or None if no avatar is found""" - ret = self.host.getAvatar(self.from_jid, profile=self.profile) - return ret - - def getNick(self, entity): - """Return nick of an entity when possible""" - contact_list = self.host.contact_lists[self.profile] - if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED: - try: - return self.extra['user_nick'] - except KeyError: - log.error(u"extra data is missing user nick for uid {}".format(self.uid)) - return "" - # FIXME: converted getSpecials to list for pyjamas - if self.parent.type == C.CHAT_GROUP or entity in list(contact_list.getSpecials(C.CONTACT_SPECIAL_GROUP)): - return entity.resource or "" - if entity.bare in contact_list: - return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity - return entity.node or entity - - @property - def status(self): - return self._status - - @status.setter - def status(self, status): - if status != self._status: - self._status = status - for w in self.widgets: - w.update({"status": status}) - - def handleMe(self): - """Check if messages starts with "/me " and change them if it is the case - - if several messages (different languages) are presents, they all need to start with "/me " - """ - # TODO: XHTML-IM /me are not handled - me = False - # we need to check /me for every message - for m in self.message.itervalues(): - if m.startswith(u"/me "): - me = True - else: - me = False - break - if me: - self.type = C.MESS_TYPE_INFO - self.extra['info_type'] = 'me' - nick = self.nick - for lang, mess in self.message.iteritems(): - self.message[lang] = u"* " + nick + mess[3:] - - -class Occupant(object): - """Occupant metadata""" - - def __init__(self, parent, data, profile): - self.parent = parent - self.profile = profile - self.nick = data['nick'] - self._entity = data.get('entity') - self.affiliation = data['affiliation'] - self.role = data['role'] - self.widgets = set() # widgets linked to this occupant - self._state = None - - @property - def data(self): - """reconstruct data dict from attributes""" - data = {} - data['nick'] = self.nick - if self._entity is not None: - data['entity'] = self._entity - data['affiliation'] = self.affiliation - data['role'] = self.role - return data - - @property - def jid(self): - """jid in the room""" - return jid.JID(u"{}/{}".format(self.parent.target.bare, self.nick)) - - @property - def real_jid(self): - """real jid if known else None""" - return self._entity - - @property - def host(self): - return self.parent.host - - @property - def state(self): - return self._state - - @state.setter - def state(self, new_state): - if new_state != self._state: - self._state = new_state - for w in self.widgets: - w.update({"state": new_state}) - - def update(self, update_dict=None): - for w in self.widgets: - w.update(update_dict) - - -class QuickChat(quick_widgets.QuickWidget): - visible_states = ['chat_state'] # FIXME: to be removed, used only in quick_games - - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None): - """ - @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC - """ - self.lang = '' # default language to use for messages - quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles) - self._locked = True # True when we are waiting for history/search - # messageNew signals are cached when locked - self._cache = OrderedDict() - assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) - self.current_target = target - self.type = type_ - if type_ == C.CHAT_GROUP: - if target.resource: - raise exceptions.InternalError(u"a group chat entity can't have a resource") - if nick is None: - raise exceptions.InternalError(u"nick must not be None for group chat") - - self.nick = nick - self.occupants = {} - self.setOccupants(occupants) - else: - if occupants is not None or nick is not None: - raise exceptions.InternalError(u"only group chat can have occupants or nick") - self.messages = OrderedDict() # key: uid, value: Message instance - self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame - self.subject = subject - lt = time.localtime() - self.day_change = (lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, lt.tm_wday, lt.tm_yday, lt.tm_isdst) # struct_time of day changing time - if self.host.AVATARS_HANDLER: - self.host.addListener('avatar', self.onAvatar, profiles) - - def postInit(self): - """Method to be called by frontend after widget is initialised - - handle the display of history and subject - """ - self.historyPrint(profile=self.profile) - if self.subject is not None: - self.setSubject(self.subject) - - def onDelete(self): - if self.host.AVATARS_HANDLER: - self.host.removeListener('avatar', self.onAvatar) - - @property - def contact_list(self): - return self.host.contact_lists[self.profile] - - ## Widget management ## - - def __str__(self): - return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile) - - @staticmethod - def getWidgetHash(target, profiles): - profile = list(profiles)[0] - return profile + "\n" + unicode(target.bare) - - @staticmethod - def getPrivateHash(target, profile): - """Get unique hash for private conversations - - This method should be used with force_hash to get unique widget for private MUC conversations - """ - return (unicode(profile), target) - - def addTarget(self, target): - super(QuickChat, self).addTarget(target) - if target.resource: - self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead - - def recreateArgs(self, args, kwargs): - """copy important attribute for a new widget""" - kwargs['type_'] = self.type - if self.type == C.CHAT_GROUP: - kwargs['occupants'] = {o.nick: o.data for o in self.occupants.itervalues()} - kwargs['subject'] = self.subject - try: - kwargs['nick'] = self.nick - except AttributeError: - pass - - def onPrivateCreated(self, widget): - """Method called when a new widget for private conversation (MUC) is created""" - raise NotImplementedError - - def getOrCreatePrivateWidget(self, entity): - """Create a widget for private conversation, or get it if it already exists - - @param entity: full jid of the target - """ - return self.host.widgets.getOrCreateWidget(QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.getPrivateHash(self.profile, entity), on_new_widget=self.onPrivateCreated, profile=self.profile) # we force hash to have a new widget, not this one again - - @property - def target(self): - if self.type == C.CHAT_GROUP: - return self.current_target.bare - return self.current_target - - ## occupants ## - - def setOccupants(self, occupants): - """set the whole list of occupants""" - assert len(self.occupants) == 0 - for nick, data in occupants.iteritems(): - self.occupants[nick] = Occupant( - self, - data, - self.profile - ) - - def addUser(self, occupant_data): - """Add user if it is not in the group list""" - occupant = Occupant( - self, - occupant_data, - self.profile - ) - self.occupants[occupant.nick] = occupant - return occupant - - def removeUser(self, occupant_data): - """Remove a user from the group list""" - nick = occupant_data['nick'] - try: - occupant = self.occupants.pop(nick) - except KeyError: - log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) - else: - return occupant - - def setUserNick(self, nick): - """Set the nick of the user, usefull for e.g. change the color of the user""" - self.nick = nick - - def changeUserNick(self, old_nick, new_nick): - """Change nick of a user in group list""" - self.printInfo("%s is now known as %s" % (old_nick, new_nick)) - - ## Messages ## - - def manageMessage(self, entity, mess_type): - """Tell if this chat widget manage an entity and message type couple - - @param entity (jid.JID): (full) jid of the sending entity - @param mess_type (str): message type as given by messageNew - @return (bool): True if this Chat Widget manage this couple - """ - if self.type == C.CHAT_GROUP: - if mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) and self.target == entity.bare: - return True - else: - if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: - return True - return False - - def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'): - """Called when history need to be recreated - - Remove all message from history then call historyPrint - Must probably be overriden by frontend to clear widget - @param size (int): number of messages - @param filters (str): patterns to filter the history results - @param profile (str): %(doc_profile)s - """ - self._locked = True - self._cache = OrderedDict() - self.messages.clear() - self.historyPrint(size, filters, profile) - - def _onHistoryPrinted(self): - """Method called when history is printed (or failed) - - unlock the widget, and can be used to refresh or scroll down - the focus after the history is printed - """ - self._locked = False - for data in self._cache.itervalues(): - self.messageNew(*data) - del self._cache - - def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'): - """Print the current history - - @param size (int): number of messages - @param search (str): pattern to filter the history results - @param profile (str): %(doc_profile)s - """ - if filters is None: - filters = {} - if size == 0: - log.debug(u"Empty history requested, skipping") - self._onHistoryPrinted() - return - log_msg = _(u"now we print the history") - if size != C.HISTORY_LIMIT_DEFAULT: - log_msg += _(u" ({} messages)".format(size)) - log.debug(log_msg) - - if self.type == C.CHAT_ONE2ONE: - special = self.host.contact_lists[self.profile].getCache(self.target, C.CONTACT_SPECIAL) - if special == C.CONTACT_SPECIAL_GROUP: - # we have a private conversation - # so we need full jid for the history - # (else we would get history from group itself) - # and to filter out groupchat message - target = self.target - filters['not_types'] = C.MESS_TYPE_GROUPCHAT - else: - target = self.target.bare - else: - # groupchat - target = self.target.bare - # FIXME: info not handled correctly - filters['types'] = C.MESS_TYPE_GROUPCHAT - - def _historyGetCb(history): - # day_format = "%A, %d %b %Y" # to display the day change - # previous_day = datetime.now().strftime(day_format) - # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format) - # if previous_day != message_day: - # self.printDayChange(message_day) - # previous_day = message_day - for data in history: - uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data - # cached messages may already be in history - # so we check it to avoid duplicates, they'll be added later - if uid in self._cache: - continue - from_jid = jid.JID(from_jid) - to_jid = jid.JID(to_jid) - # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or - # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): - # continue - self.messages[uid] = Message(self, uid, timestamp, from_jid, to_jid, message, subject, type_, extra, profile) - self._onHistoryPrinted() - - def _historyGetEb(err): - log.error(_(u"Can't get history: {}").format(err)) - self._onHistoryPrinted() - - self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, filters, profile, callback=_historyGetCb, errback=_historyGetEb) - - def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): - if self._locked: - self._cache[uid] = (uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) - return - if self.type == C.CHAT_GROUP: - if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT: - # we have a private message, we forward it to a private conversation widget - chat_widget = self.getOrCreatePrivateWidget(to_jid) - chat_widget.messageNew(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) - return - if type_ == C.MESS_TYPE_INFO: - try: - info_type = extra['info_type'] - except KeyError: - pass - else: - user_data = {k[5:]:v for k,v in extra.iteritems() if k.startswith('user_')} - if info_type == ROOM_USER_JOINED: - self.addUser(user_data) - elif info_type == ROOM_USER_LEFT: - self.removeUser(user_data) - - message = Message(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) - self.messages[uid] = message - - if 'received_timestamp' in extra: - log.warning(u"Delayed message received after history, this should not happen") - self.createMessage(message) - - def createMessage(self, message, append=False): - """Must be implemented by frontend to create and show a new message widget - - This is only called on messageNew, not on history. - You need to override historyPrint to handle the later - @param message(Message): message data - """ - raise NotImplementedError - - def printDayChange(self, day): - """Display the day on a new line. - - @param day(unicode): day to display (or not if this method is not overwritten) - """ - # FIXME: not called anymore after refactoring - pass - - ## Room ## - - def setSubject(self, subject): - """Set title for a group chat""" - if self.type != C.CHAT_GROUP: - raise exceptions.InternalError("trying to set subject for a non group chat window") - self.subject = subject - - def changeSubject(self, new_subject): - """Change the subject of the room - - This change the subject on the room itself (i.e. via XMPP), - while setSubject change the subject of this widget - """ - self.host.bridge.mucSubject(unicode(self.target), new_subject, self.profile) - - def addGamePanel(self, widget): - """Insert a game panel to this Chat dialog. - - @param widget (Widget): the game panel - """ - raise NotImplementedError - - def removeGamePanel(self, widget): - """Remove the game panel from this Chat dialog. - - @param widget (Widget): the game panel - """ - raise NotImplementedError - - def update(self, entity=None): - """Update one or all entities. - - @param entity (jid.JID): entity to update - """ - # FIXME: to remove ? - raise NotImplementedError - - ## events ## - - def onChatState(self, from_jid, state, profile): - """A chat state has been received""" - if self.type == C.CHAT_GROUP: - nick = from_jid.resource - try: - self.occupants[nick].state = state - except KeyError: - log.warning(u"{nick} not found in {room}, ignoring new chat state".format( - nick=nick, room=self.target.bare)) - - def onMessageState(self, uid, status, profile): - try: - mess_data = self.messages[uid] - except KeyError: - pass - else: - mess_data.status = status - - def onAvatar(self, entity, filename, profile): - if self.type == C.CHAT_GROUP: - if entity.bare == self.target: - try: - self.occupants[entity.resource].update({'avatar': filename}) - except KeyError: - # can happen for a message in history where the - # entity is not here anymore - pass - - for m in self.messages.values(): - if m.nick == entity.resource: - for w in m.widgets: - w.update({'avatar': filename}) - else: - if entity.bare == self.target.bare or entity.bare == self.host.profiles[profile].whoami.bare: - log.info(u"avatar updated for {}".format(entity)) - for m in self.messages.values(): - if m.from_jid.bare == entity.bare: - for w in m.widgets: - w.update({'avatar': filename}) - - -quick_widgets.register(QuickChat) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_contact_list.py --- a/frontends/src/quick_frontend/quick_contact_list.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,954 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -"""Contact List handling multi profiles at once, should replace quick_contact_list module in the future""" - -from sat.core.i18n import _ -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core import exceptions -from sat_frontends.quick_frontend.quick_widgets import QuickWidget -from sat_frontends.quick_frontend.constants import Const as C -from sat_frontends.tools import jid -from collections import OrderedDict - - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - # XXX: pyjamas' max doesn't support key argument, so we implement it ourself - pyjamas_max = max - - def max(iterable, key): - iter_cpy = list(iterable) - iter_cpy.sort(key=key) - return pyjamas_max(iter_cpy) - - # next doesn't exist in pyjamas - def next(iterable, *args): - try: - return iterable.next() - except StopIteration as e: - if args: - return args[0] - raise e - - -handler = None - - -class ProfileContactList(object): - """Contact list data for a single profile""" - - def __init__(self, profile): - self.host = handler.host - self.profile = profile - # contain all jids in roster or not, - # bare jids as keys, resources are used in data - # XXX: we don't mutualise cache, as values may differ - # for different profiles (e.g. directed presence) - self._cache = {} - - # special entities (groupchat, gateways, etc) - # may be bare or full jid - self._specials = set() - - # group data contain jids in groups and misc frontend data - # None key is used for jids with not group - self._groups = {} # groups to group data map - - # contacts in roster (bare jids) - self._roster = set() - - # selected entities, full jid - self._selected = set() - - # we keep our own jid - self.whoami = self.host.profiles[profile].whoami - - # options - self.show_disconnected = False - self.show_empty_groups = True - self.show_resources = False - self.show_status = False - - self.host.bridge.asyncGetParamA(C.SHOW_EMPTY_GROUPS, "General", profile_key=profile, callback=self._showEmptyGroups) - self.host.bridge.asyncGetParamA(C.SHOW_OFFLINE_CONTACTS, "General", profile_key=profile, callback=self._showOfflineContacts) - - # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) - self.presenceListener = self.onPresenceUpdate - self.host.addListener('presence', self.presenceListener, [self.profile]) - self.nickListener = self.onNickUpdate - self.host.addListener('nick', self.nickListener, [self.profile]) - self.notifListener = self.onNotification - self.host.addListener('notification', self.notifListener, [self.profile]) - # notifListener only update the entity, so we can re-use it - self.host.addListener('notificationsClear', self.notifListener, [self.profile]) - - def _showEmptyGroups(self, show_str): - # Called only by __init__ - # self.update is not wanted here, as it is done by - # handler when all profiles are ready - self.showEmptyGroups(C.bool(show_str)) - - def _showOfflineContacts(self, show_str): - # same comments as for _showEmptyGroups - self.showOfflineContacts(C.bool(show_str)) - - def __contains__(self, entity): - """Check if entity is in contact list - - An entity can be in contact list even if not in roster - @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed) - """ - if entity.resource: - try: - return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES) - except KeyError: - return False - return entity in self._cache - - @property - def roster(self): - """Return all the bare JIDs of the roster entities. - - @return (set[jid.JID]) - """ - return self._roster - - @property - def roster_connected(self): - """Return all the bare JIDs of the roster entities that are connected. - - @return (set[jid.JID]) - """ - return set([entity for entity in self._roster if self.getCache(entity, C.PRESENCE_SHOW) is not None]) - - @property - def roster_entities_by_group(self): - """Return a dictionary binding the roster groups to their entities bare JIDs. - - This also includes the empty group (None key). - @return (dict[unicode,set(jid.JID)]) - """ - return {group: self._groups[group]['jids'] for group in self._groups} - - @property - def roster_groups_by_entities(self): - """Return a dictionary binding the entities bare JIDs to their roster groups - - @return (dict[jid.JID, set(unicode)]) - """ - result = {} - for group, data in self._groups.iteritems(): - for entity in data['jids']: - result.setdefault(entity, set()).add(group) - return result - - @property - def selected(self): - """Return contacts currently selected - - @return (set): set of selected entities - """ - return self._selected - - @property - def all_iter(self): - """return all know entities in cache as an iterator of tuples - - entities are not sorted - """ - return self._cache.iteritems() - - - @property - def items(self): - """Return item representation for all visible entities in cache - - entities are not sorted - key: bare jid, value: data - """ - return {jid_:cache for jid_, cache in self._cache.iteritems() if self.entityToShow(jid_)} - - - def getItem(self, entity): - """Return item representation of requested entity - - @param entity(jid.JID): bare jid of entity - @raise (KeyError): entity is unknown - """ - return self._cache[entity] - - def _gotContacts(self, contacts): - """Called during filling, add contacts and notice parent that contacts are filled""" - for contact in contacts: - self.host.newContactHandler(*contact, profile=self.profile) - handler._contactsFilled(self.profile) - - def _fill(self): - """Get all contacts from backend - - Contacts will be cleared before refilling them - """ - self.clearContacts(keep_cache=True) - self.host.bridge.getContacts(self.profile, callback=self._gotContacts) - - def fill(self): - handler.fill(self.profile) - - def getCache(self, entity, name=None, bare_default=True): - """Return a cache value for a contact - - @param entity(jid.JID): entity of the contact from who we want data (resource is used if given) - if a resource specific information is requested: - - if no resource is given (bare jid), the main resource is used, according to priority - - if resource is given, it is used - @param name(unicode): name the data to get, or None to get everything - @param bare_default(bool, None): if True and entity is a full jid, the value of bare jid - will be returned if not value is found for the requested resource. - If False, None is returned if no value is found for the requested resource. - If None, bare_default will be set to False if entity is in a room, True else - @return: full cache if no name is given, or value of "name", or None - """ - # FIXME: resource handling need to be reworked - # FIXME: bare_default work for requesting full jid to get bare jid, but not the other way - # e.g.: if we have set an avatar for user@server.tld/resource and we request user@server.tld - # we won't get the avatar set in the resource - try: - cache = self._cache[entity.bare] - except KeyError: - self.setContact(entity) - cache = self._cache[entity.bare] - - if name is None: - return cache - - if name in ('status', C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW): - # these data are related to the resource - if not entity.resource: - main_resource = cache[C.CONTACT_MAIN_RESOURCE] - if main_resource is None: - # we ignore presence info if we don't have any resource in cache - # FIXME: to be checked - return - cache = cache[C.CONTACT_RESOURCES].setdefault(main_resource, {}) - else: - cache = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {}) - - if name == 'status': # XXX: we get the first status for 'status' key - # TODO: manage main language for statuses - return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, '') - - elif entity.resource: - try: - return cache[C.CONTACT_RESOURCES][entity.resource][name] - except KeyError: - if bare_default is None: - bare_default = not self.isRoom(entity.bare) - if not bare_default: - return None - - try: - return cache[name] - except KeyError: - return None - - def setCache(self, entity, name, value): - """Set or update value for one data in cache - - @param entity(JID): entity to update - @param name(unicode): value to set or update - """ - self.setContact(entity, attributes={name: value}) - - def getFullJid(self, entity): - """Get full jid from a bare jid - - @param entity(jid.JID): must be a bare jid - @return (jid.JID): bare jid + main resource - @raise ValueError: the entity is not bare - """ - if entity.resource: - raise ValueError(u"getFullJid must be used with a bare jid") - main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE) - return jid.JID(u"{}/{}".format(entity, main_resource)) - - def setGroupData(self, group, name, value): - """Register a data for a group - - @param group: a valid (existing) group name - @param name: name of the data (can't be "jids") - @param value: value to set - """ - assert name is not 'jids' - self._groups[group][name] = value - - def getGroupData(self, group, name=None): - """Return value associated to group data - - @param group: a valid (existing) group name - @param name: name of the data or None to get the whole dict - @return: registered value - """ - if name is None: - return self._groups[group] - return self._groups[group][name] - - def isRoom(self, entity): - """Helper method to know if entity is a MUC room - - @param entity(jid.JID): jid of the entity - hint: use bare jid here, as room can't be full jid with MUC - @return (bool): True if entity is a room - """ - assert entity.resource is None # FIXME: this may change when MIX will be handled - return self.isSpecial(entity, C.CONTACT_SPECIAL_GROUP) - - def isSpecial(self, entity, special_type): - """Tell if an entity is of a specialy _type - - @param entity(jid.JID): jid of the special entity - if the jid is full, will be added to special extras - @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) - @return (bool): True if entity is from this special type - """ - return self.getCache(entity, C.CONTACT_SPECIAL) == special_type - - def setSpecial(self, entity, special_type): - """Set special flag on an entity - - @param entity(jid.JID): jid of the special entity - if the jid is full, will be added to special extras - @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to remove special flag - """ - assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,) - self.setCache(entity, C.CONTACT_SPECIAL, special_type) - - def getSpecials(self, special_type=None, bare=False): - """Return all the bare JIDs of the special roster entities of with given type. - - @param special_type(unicode, None): if not None, filter by special type (e.g. C.CONTACT_SPECIAL_GROUP) - @param bare(bool): return only bare jids if True - @return (iter[jid.JID]): found special entities - """ - for entity in self._specials: - if bare and entity.resource: - continue - if special_type is not None and self.getCache(entity, C.CONTACT_SPECIAL) != special_type: - continue - yield entity - - def disconnect(self): - # for now we just clear contacts on disconnect - self.clearContacts() - - def clearContacts(self, keep_cache=False): - """Clear all the contact list - - @param keep_cache: if True, don't reset the cache - """ - self.select(None) - if not keep_cache: - self._cache.clear() - self._groups.clear() - self._specials.clear() - self._roster.clear() - self.update() - - def setContact(self, entity, groups=None, attributes=None, in_roster=False): - """Add a contact to the list if doesn't exist, else update it. - - This method can be called with groups=None for the purpose of updating - the contact's attributes (e.g. nickname). In that case, the groups - attribute must not be set to the default group but ignored. If not, - you may move your contact from its actual group(s) to the default one. - - None value for 'groups' has a different meaning than [None] which is for the default group. - - @param entity (jid.JID): entity to add or replace - if entity is a full jid, attributes will be cached in for the full jid only - @param groups (list): list of groups or None to ignore the groups membership. - @param attributes (dict): attibutes of the added jid or to update - if attribute value is None, it will be removed - @param in_roster (bool): True if contact is from roster - """ - if attributes is None: - attributes = {} - - entity_bare = entity.bare - update_type = C.UPDATE_MODIFY if entity_bare in self._cache else C.UPDATE_ADD - - if in_roster: - self._roster.add(entity_bare) - - cache = self._cache.setdefault(entity_bare, {C.CONTACT_RESOURCES: {}, - C.CONTACT_MAIN_RESOURCE: None, - C.CONTACT_SELECTED: set()}) - - assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes) # we don't want forbidden data in attributes - - # we set groups and fill self._groups accordingly - if groups is not None: - if not groups: - groups = [None] # [None] is the default group - if C.CONTACT_GROUPS in cache: - # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS] - for group in [group for group in cache[C.CONTACT_GROUPS] if group not in groups]: - self._groups[group]['jids'].remove(entity_bare) - cache[C.CONTACT_GROUPS] = groups - for group in groups: - self._groups.setdefault(group, {}).setdefault('jids', set()).add(entity_bare) - - # special entities management - if C.CONTACT_SPECIAL in attributes: - if attributes[C.CONTACT_SPECIAL] is None: - del attributes[C.CONTACT_SPECIAL] - self._specials.remove(entity) - else: - self._specials.add(entity) - cache[C.CONTACT_MAIN_RESOURCE] = None - - # now the attributes we keep in cache - # XXX: if entity is a full jid, we store the value for the resource only - cache_attr = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {}) if entity.resource else cache - for attribute, value in attributes.iteritems(): - if value is None: - # XXX: pyjamas hack: we need to use pop instead of del - try: - cache_attr[attribute].pop(value) - except KeyError: - pass - else: - cache_attr[attribute] = value - - # we can update the display - self.update([entity], update_type, self.profile) - - def entityToShow(self, entity, check_resource=False): - """Tell if the contact should be showed or hidden. - - @param entity (jid.JID): jid of the contact - @param check_resource (bool): True if resource must be significant - @return (bool): True if that contact should be showed in the list - """ - show = self.getCache(entity, C.PRESENCE_SHOW) - - if check_resource: - selected = self._selected - else: - selected = {selected.bare for selected in self._selected} - return ((show is not None and show != C.PRESENCE_UNAVAILABLE) - or self.show_disconnected - or entity in selected - or next(self.host.getNotifs(entity.bare, profile=self.profile), None) - ) - - def anyEntityToShow(self, entities, check_resources=False): - """Tell if in a list of entities, at least one should be shown - - @param entities (list[jid.JID]): list of jids - @param check_resources (bool): True if resources must be significant - @return (bool): True if a least one entity need to be shown - """ - # FIXME: looks inefficient, really needed? - for entity in entities: - if self.entityToShow(entity, check_resources): - return True - return False - - def isEntityInGroup(self, entity, group): - """Tell if an entity is in a roster group - - @param entity(jid.JID): jid of the entity - @param group(unicode): group to check - @return (bool): True if the entity is in the group - """ - return entity in self.getGroupData(group, "jids") - - def removeContact(self, entity): - """remove a contact from the list - - @param entity(jid.JID): jid of the entity to remove (bare jid is used) - """ - entity_bare = entity.bare - try: - groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set()) - except KeyError: - log.error(_(u"Trying to delete an unknow entity [{}]").format(entity)) - try: - self._roster.remove(entity_bare) - except KeyError: - pass - del self._cache[entity_bare] - for group in groups: - self._groups[group]['jids'].remove(entity_bare) - if not self._groups[group]['jids']: - self._groups.pop(group) # FIXME: we use pop because of pyjamas: http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en - for iterable in (self._selected, self._specials): - to_remove = set() - for set_entity in iterable: - if set_entity.bare == entity.bare: - to_remove.add(set_entity) - iterable.difference_update(to_remove) - self.update([entity], C.UPDATE_DELETE, self.profile) - - def onPresenceUpdate(self, entity, show, priority, statuses, profile): - """Update entity's presence status - - @param entity(jid.JID): entity updated - @param show: availability - @parap priority: resource's priority - @param statuses: dict of statuses - @param profile: %(doc_profile)s - """ - cache = self.getCache(entity) - if show == C.PRESENCE_UNAVAILABLE: - if not entity.resource: - cache[C.CONTACT_RESOURCES].clear() - cache[C.CONTACT_MAIN_RESOURCE] = None - else: - try: - del cache[C.CONTACT_RESOURCES][entity.resource] - except KeyError: - log.error(u"Presence unavailable received for an unknown resource [{}]".format(entity)) - if not cache[C.CONTACT_RESOURCES]: - cache[C.CONTACT_MAIN_RESOURCE] = None - else: - if not entity.resource: - log.warning(_(u"received presence from entity without resource: {}".format(entity))) - resources_data = cache[C.CONTACT_RESOURCES] - resource_data = resources_data.setdefault(entity.resource, {}) - resource_data[C.PRESENCE_SHOW] = show - resource_data[C.PRESENCE_PRIORITY] = int(priority) - resource_data[C.PRESENCE_STATUSES] = statuses - - if entity.bare not in self._specials: - # we may have resources with no priority - # (when a cached value is added for a not connected resource) - priority_resource = max(resources_data, key=lambda res: resources_data[res].get(C.PRESENCE_PRIORITY, -2**32)) - cache[C.CONTACT_MAIN_RESOURCE] = priority_resource - self.update([entity], C.UPDATE_MODIFY, self.profile) - - def onNickUpdate(self, entity, new_nick, profile): - """Update entity's nick - - @param entity(jid.JID): entity updated - @param new_nick(unicode): new nick of the entity - @param profile: %(doc_profile)s - """ - assert profile == self.profile - self.setCache(entity, 'nick', new_nick) - self.update([entity], C.UPDATE_MODIFY, profile) - - def onNotification(self, entity, notif, profile): - """Update entity with notification - - @param entity(jid.JID): entity updated - @param notif(dict): notification data - @param profile: %(doc_profile)s - """ - assert profile == self.profile - if entity is not None: - self.update([entity], C.UPDATE_MODIFY, profile) - - def unselect(self, entity): - """Unselect an entity - - @param entity(jid.JID): entity to unselect - """ - try: - cache = self._cache[entity.bare] - except: - log.error(u"Try to unselect an entity not in cache") - else: - try: - cache[C.CONTACT_SELECTED].remove(entity.resource) - except KeyError: - log.error(u"Try to unselect a not selected entity") - else: - self._selected.remove(entity) - self.update([entity], C.UPDATE_SELECTION) - - def select(self, entity): - """Select an entity - - @param entity(jid.JID, None): entity to select (resource is significant) - None to unselect all entities - """ - if entity is None: - self._selected.clear() - for cache in self._cache.itervalues(): - cache[C.CONTACT_SELECTED].clear() - self.update(type_=C.UPDATE_SELECTION, profile=self.profile) - else: - log.debug(u"select %s" % entity) - try: - cache = self._cache[entity.bare] - except: - log.error(u"Try to select an entity not in cache") - else: - cache[C.CONTACT_SELECTED].add(entity.resource) - self._selected.add(entity) - self.update([entity], C.UPDATE_SELECTION, profile=self.profile) - - def showOfflineContacts(self, show): - """Tell if offline contacts should shown - - @param show(bool): True if offline contacts should be shown - """ - assert isinstance(show, bool) - if self.show_disconnected == show: - return - self.show_disconnected = show - self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) - - def showEmptyGroups(self, show): - assert isinstance(show, bool) - if self.show_empty_groups == show: - return - self.show_empty_groups = show - self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) - - def showResources(self, show): - assert isinstance(show, bool) - if self.show_resources == show: - return - self.show_resources = show - self.update(profile=self.profile) - - def plug(self): - handler.addProfile(self.profile) - - def unplug(self): - handler.removeProfile(self.profile) - - def update(self, entities=None, type_=None, profile=None): - handler.update(entities, type_, profile) - - -class QuickContactListHandler(object): - - def __init__(self, host): - super(QuickContactListHandler, self).__init__() - self.host = host - global handler - if handler is not None: - raise exceptions.InternalError(u"QuickContactListHandler must be instanciated only once") - handler = self - self._clist = {} # key: profile, value: ProfileContactList - self._widgets = set() - self._update_locked = False # se to True to ignore updates - - def __getitem__(self, profile): - """Return ProfileContactList instance for the requested profile""" - return self._clist[profile] - - def __contains__(self, entity): - """Check if entity is in contact list - - @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed) - """ - for contact_list in self._clist.itervalues(): - if entity in contact_list: - return True - return False - - @property - def roster(self): - """Return all the bare JIDs of the roster entities. - - @return (set[jid.JID]) - """ - entities = set() - for contact_list in self._clist.itervalues(): - entities.update(contact_list.roster) - return entities - - @property - def roster_connected(self): - """Return all the bare JIDs of the roster entities that are connected. - - @return (set[jid.JID]) - """ - entities = set() - for contact_list in self._clist.itervalues(): - entities.update(contact_list.roster_connected) - return entities - - @property - def roster_entities_by_group(self): - """Return a dictionary binding the roster groups to their entities bare - JIDs. This also includes the empty group (None key). - - @return (dict[unicode,set(jid.JID)]) - """ - groups = {} - for contact_list in self._clist.itervalues(): - groups.update(contact_list.roster_entities_by_group) - return groups - - @property - def roster_groups_by_entities(self): - """Return a dictionary binding the entities bare JIDs to their roster - groups. - - @return (dict[jid.JID, set(unicode)]) - """ - entities = {} - for contact_list in self._clist.itervalues(): - entities.update(contact_list.roster_groups_by_entities) - return entities - - @property - def selected(self): - """Return contacts currently selected - - @return (set): set of selected entities - """ - entities = set() - for contact_list in self._clist.itervalues(): - entities.update(contact_list.selected) - return entities - - @property - def all_iter(self): - """Return item representation for all entities in cache - - items are unordered - """ - for profile, contact_list in self._clist.iteritems(): - for bare_jid, cache in contact_list.all_iter: - data = cache.copy() - data[C.CONTACT_PROFILE] = profile - yield bare_jid, data - - @property - def items(self): - """Return item representation for visible entities in cache - - items are unordered - key: bare jid, value: data - """ - items = {} - for profile, contact_list in self._clist.iteritems(): - for bare_jid, cache in contact_list.items.iteritems(): - data = cache.copy() - items[bare_jid] = data - data[C.CONTACT_PROFILE] = profile - return items - - @property - def items_sorted(self): - """Return item representation for visible entities in cache - - items are ordered using self.items_sort - key: bare jid, value: data - """ - return self.items_sort(self.items) - - def items_sort(self, items): - """sort items - - @param items(dict): items to sort (will be emptied !) - @return (OrderedDict): sorted items - """ - ordered_items = OrderedDict() - bare_jids = sorted(items.keys()) - for jid_ in bare_jids: - ordered_items[jid_] = items.pop(jid_) - return ordered_items - - def register(self, widget): - """Register a QuickContactList widget - - This method should only be used in QuickContactList - """ - self._widgets.add(widget) - - def unregister(self, widget): - """Unregister a QuickContactList widget - - This method should only be used in QuickContactList - """ - self._widgets.remove(widget) - - def addProfiles(self, profiles): - """Add a contact list for plugged profiles - - @param profile(iterable[unicode]): plugged profiles - """ - for profile in profiles: - if profile not in self._clist: - self._clist[profile] = ProfileContactList(profile) - return [self._clist[profile] for profile in profiles] - - def addProfile(self, profile): - return self.addProfiles([profile])[0] - - def removeProfiles(self, profiles): - """Remove given unplugged profiles from contact list - - @param profile(iterable[unicode]): unplugged profiles - """ - for profile in profiles: - del self._clist[profile] - - def removeProfile(self, profile): - self.removeProfiles([profile]) - - def getSpecialExtras(self, special_type=None): - """Return special extras with given type - - If special_type is None, return all special extras. - - @param special_type(unicode, None): one of special type (e.g. C.CONTACT_SPECIAL_GROUP) - None to return all special extras. - @return (set[jid.JID]) - """ - entities = set() - for contact_list in self._clist.itervalues(): - entities.update(contact_list.getSpecialExtras(special_type)) - return entities - - def _contactsFilled(self, profile): - self._to_fill.remove(profile) - if not self._to_fill: - del self._to_fill - self.update() - - def fill(self, profile=None): - """Get all contacts from backend, and fill the widget - - Contacts will be cleared before refilling them - @param profile(unicode, None): profile to fill - None to fill all profiles - """ - try: - to_fill = self._to_fill - except AttributeError: - to_fill = self._to_fill = set() - - # if check if profiles have already been filled - # to void filling them several times - filled = to_fill.copy() - - if profile is not None: - assert profile in self._clist - to_fill.add(profile) - else: - to_fill.update(self._clist.items()) - - remaining = to_fill.difference(filled) - if remaining != to_fill: - log.debug(u"Not re-filling already filled contact list(s) for {}".format(u', '.join(to_fill.intersection(filled)))) - for profile in remaining: - self._clist[profile]._fill() - - def clearContacts(self, keep_cache=False): - """Clear all the contact list - - @param keep_cache: if True, don't reset the cache - """ - for contact_list in self._clist.itervalues(): - contact_list.clearContacts(keep_cache) - self.update() - - def select(self, entity): - for contact_list in self._clist.itervalues(): - contact_list.select(entity) - - def unselect(self, entity): - for contact_list in self._clist.itervalues(): - contact_list.select(entity) - - def lockUpdate(self, locked=True, do_update=True): - """Forbid contact list updates - - Used mainly while profiles are plugged, as many updates can occurs, causing - an impact on performances - @param locked(bool): updates are forbidden if True - @param do_update(bool): if True, a full update is done after unlocking - if set to False, widget state can be inconsistent, be sure to know - what youa re doing! - """ - log.debug(u"Contact lists updates are now {}".format(u"LOCKED" if locked else u"UNLOCKED")) - self._update_locked = locked - if not locked and do_update: - self.update() - - def update(self, entities=None, type_=None, profile=None): - if not self._update_locked: - for widget in self._widgets: - widget.update(entities, type_, profile) - - -class QuickContactList(QuickWidget): - """This class manage the visual representation of contacts""" - SINGLE=False - PROFILES_MULTIPLE=True - PROFILES_ALLOW_NONE=True # Can be linked to no profile (e.g. at the early forntend start) - - def __init__(self, host, profiles): - super(QuickContactList, self).__init__(host, None, profiles) - - # options - # for next values, None means use indivual value per profile - # True or False mean override these values for all profiles - self.show_disconnected = None # TODO - self.show_empty_groups = None # TODO - self.show_resources = None # TODO - self.show_status = None # TODO - - def postInit(self): - """Method to be called by frontend after widget is initialised""" - handler.register(self) - - @property - def all_iter(self): - return handler.all_iter - - @property - def items(self): - return handler.items - - @property - def items_sorted(self): - return handler.items - - def update(self, entities=None, type_=None, profile=None): - """Update the display when something changed - - @param entities(iterable[jid.JID], None): updated entities, - None to update the whole contact list - @param type_(unicode, None): update type, may be: - - C.UPDATE_DELETE: entity deleted - - C.UPDATE_MODIFY: entity updated - - C.UPDATE_ADD: entity added - - C.UPDATE_SELECTION: selection modified - or None for undefined update - @param profile(unicode, None): profile concerned with the update - None if unknown - """ - raise NotImplementedError - - def onDelete(self): - QuickWidget.onDelete(self) - handler.unregister(self) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_contact_management.py --- a/frontends/src/quick_frontend/quick_contact_management.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,103 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.i18n import _ -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.tools.jid import JID - - -class QuickContactManagement(object): - """This helper class manage the contacts and ease the use of nicknames and shortcuts""" - ### FIXME: is SàT a better place for all this stuff ??? ### - - def __init__(self): - self.__contactlist = {} - - def __contains__(self, entity): - return entity.bare in self.__contactlist - - def clear(self): - """Clear all the contact list""" - self.__contactlist.clear() - - def add(self, entity): - """Add contact to the list, update resources""" - if not self.__contactlist.has_key(entity.bare): - self.__contactlist[entity.bare] = {'resources':[]} - if not entity.resource: - return - if entity.resource in self.__contactlist[entity.bare]['resources']: - self.__contactlist[entity.bare]['resources'].remove(entity.resource) - self.__contactlist[entity.bare]['resources'].append(entity.resource) - - def getContFromGroup(self, group): - """Return all contacts which are in given group""" - result = [] - for contact in self.__contactlist: - if self.__contactlist[contact].has_key('groups'): - if group in self.__contactlist[contact]['groups']: - result.append(JID(contact)) - return result - - def getAttr(self, entity, name): - """Return a specific attribute of contact, or all attributes - @param entity: jid of the contact - @param name: name of the attribute - @return: asked attribute""" - if self.__contactlist.has_key(entity.bare): - if name == 'status': #FIXME: for the moment, we only use the first status - if self.__contactlist[entity.bare]['statuses']: - return self.__contactlist[entity.bare]['statuses'].values()[0] - if self.__contactlist[entity.bare].has_key(name): - return self.__contactlist[entity.bare][name] - else: - log.debug(_('Trying to get attribute for an unknown contact')) - return None - - def isConnected(self, entity): - """Tell if the contact is online""" - return self.__contactlist.has_key(entity.bare) - - def remove(self, entity): - """remove resource. If no more resource is online or is no resource is specified, contact is deleted""" - try: - if entity.resource: - self.__contactlist[entity.bare]['resources'].remove(entity.resource) - if not entity.resource or not self.__contactlist[entity.bare]['resources']: - #no more resource available: the contact seems really disconnected - del self.__contactlist[entity.bare] - except KeyError: - log.error(_('INTERNAL ERROR: Key log.error')) - raise - - def update(self, entity, key, value): - """Update attribute of contact - @param entity: jid of the contact - @param key: name of the attribute - @param value: value of the attribute - """ - if self.__contactlist.has_key(entity.bare): - self.__contactlist[entity.bare][key] = value - else: - log.debug (_('Trying to update an unknown contact: %s') % entity.bare) - - def get_full(self, entity): - return entity.bare+'/'+self.__contactlist[entity.bare]['resources'][-1] - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_game_tarot.py --- a/frontends/src/quick_frontend/quick_game_tarot.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,158 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.tools.jid import JID - - -class QuickTarotGame(object): - - def __init__(self, parent, referee, players): - self._autoplay = None #XXX: use 0 to activate fake play, None else - self.parent = parent - self.referee = referee - self.players = players - self.played = {} - for player in players: - self.played[player] = None - self.player_nick = parent.nick - self.bottom_nick = unicode(self.player_nick) - idx = self.players.index(self.player_nick) - idx = (idx + 1) % len(self.players) - self.right_nick = unicode(self.players[idx]) - idx = (idx + 1) % len(self.players) - self.top_nick = unicode(self.players[idx]) - idx = (idx + 1) % len(self.players) - self.left_nick = unicode(self.players[idx]) - self.bottom_nick = unicode(self.player_nick) - self.selected = [] #Card choosed by the player (e.g. during ecart) - self.hand_size = 13 #number of cards in a hand - self.hand = [] - self.to_show = [] - self.state = None - - def resetRound(self): - """Reset the game's variables to be reatty to start the next round""" - del self.selected[:] - del self.hand[:] - del self.to_show[:] - self.state = None - for pl in self.played: - self.played[pl] = None - - def getPlayerLocation(self, nick): - """return player location (top,bottom,left or right)""" - for location in ['top','left','bottom','right']: - if getattr(self,'%s_nick' % location) == nick: - return location - assert(False) - - def loadCards(self): - """Load all the cards in memory - @param dir: directory where the PNG files are""" - self.cards={} - self.deck=[] - self.cards["atout"]={} #As Tarot is a french game, it's more handy & logical to keep french names - self.cards["pique"]={} #spade - self.cards["coeur"]={} #heart - self.cards["carreau"]={} #diamond - self.cards["trefle"]={} #club - - def tarotGameNewHandler(self, hand): - """Start a new game, with given hand""" - assert (len(self.hand) == 0) - for suit, value in hand: - self.hand.append(self.cards[suit, value]) - self.hand.sort() - self.state = "init" - - def tarotGameChooseContratHandler(self, xml_data): - """Called when the player as to select his contrat - @param xml_data: SàT xml representation of the form""" - raise NotImplementedError - - def tarotGameShowCardsHandler(self, game_stage, cards, data): - """Display cards in the middle of the game (to show for e.g. chien ou poignée)""" - self.to_show = [] - for suit, value in cards: - self.to_show.append(self.cards[suit, value]) - if game_stage == "chien" and data['attaquant'] == self.player_nick: - self.state = "wait_for_ecart" - else: - self.state = "chien" - - def tarotGameYourTurnHandler(self): - """Called when we have to play :)""" - if self.state == "chien": - self.to_show = [] - self.state = "play" - self.__fakePlay() - - def __fakePlay(self): - """Convenience method for stupid autoplay - /!\ don't forgot to comment any interactive dialog for invalid card""" - if self._autoplay == None: - return - if self._autoplay >= len(self.hand): - self._autoplay = 0 - card = self.hand[self._autoplay] - self.parent.host.bridge.tarotGamePlayCards(self.player_nick, self.referee, [(card.suit, card.value)], self.parent.profile) - del self.hand[self._autoplay] - self.state = "wait" - self._autoplay+=1 - - def tarotGameScoreHandler(self, xml_data, winners, loosers): - """Called at the end of a game - @param xml_data: SàT xml representation of the scores - @param winners: list of winners' nicks - @param loosers: list of loosers' nicks""" - raise NotImplementedError - - def tarotGameCardsPlayedHandler(self, player, cards): - """A card has been played by player""" - if self.to_show: - self.to_show = [] - pl_cards = [] - if self.played[player] != None: #FIXME - for pl in self.played: - self.played[pl] = None - for suit, value in cards: - pl_cards.append(self.cards[suit, value]) - self.played[player] = pl_cards[0] - - def tarotGameInvalidCardsHandler(self, phase, played_cards, invalid_cards): - """Invalid cards have been played - @param phase: phase of the game - @param played_cards: all the cards played - @param invalid_cards: cards which are invalid""" - - if phase == "play": - self.state = "play" - elif phase == "ecart": - self.state = "ecart" - else: - log.error ('INTERNAL ERROR: unmanaged game phase') - - for suit, value in played_cards: - self.hand.append(self.cards[suit, value]) - - self.hand.sort() - self.__fakePlay() - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_games.py --- a/frontends/src/quick_frontend/quick_games.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,117 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) - -from sat.core.i18n import _ - -from sat_frontends.tools import jid -from sat_frontends.tools import games -from sat_frontends.quick_frontend.constants import Const as C - -import quick_chat - - -class RoomGame(object): - _game_name = None - _signal_prefix = None - _signal_suffixes = None - - @classmethod - def registerSignals(cls, host): - - def make_handler(suffix, signal): - def handler(*args): - if suffix in ("Started", "Players"): - return cls.startedHandler(host, suffix, *args) - return cls.genericHandler(host, signal, *args) - return handler - - for suffix in cls._signal_suffixes: - signal = cls._signal_prefix + suffix - host.registerSignal(signal, handler=make_handler(suffix, signal), iface="plugin") - - @classmethod - def startedHandler(cls, host, suffix, *args): - room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1] - referee, players, args = args[0], args[1], args[2:] - chat_widget = host.widgets.getOrCreateWidget(quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile) - - # update symbols - if cls._game_name not in chat_widget.visible_states: - chat_widget.visible_states.append(cls._game_name) - symbols = games.SYMBOLS[cls._game_name] - index = 0 - contact_list = host.contact_lists[profile] - for occupant in chat_widget.occupants: - occupant_jid = jid.newResource(room_jid, occupant) - contact_list.setCache(occupant_jid, cls._game_name, symbols[index % len(symbols)] if occupant in players else None) - chat_widget.update(occupant_jid) - - if suffix == "Players" or chat_widget.nick not in players: - return # waiting for other players to join, or not playing - if cls._game_name in chat_widget.games: - return # game panel is already there - real_class = host.widgets.getRealClass(cls) - if real_class == cls: - host.showDialog(_(u"A {game} activity between {players} has been started, but you couldn't take part because your client doesn't support it.").format(game=cls._game_name, players=', '.join(players)), - _(u"{game} Game").format(game=cls._game_name)) - return - panel = real_class(chat_widget, referee, players, *args) - chat_widget.games[cls._game_name] = panel - chat_widget.addGamePanel(panel) - - @classmethod - def genericHandler(cls, host, signal, *args): - room_jid, args, profile = jid.JID(args[0]), args[1:-1], args[-1] - chat_widget = host.widgets.getWidget(quick_chat.QuickChat, room_jid, profile) - if chat_widget: - try: - game_panel = chat_widget.games[cls._game_name] - except KeyError: - log.error("TODO: better game synchronisation - received signal %s but no panel is found" % signal) - return - else: - getattr(game_panel, "%sHandler" % signal)(*args) - - -class Tarot(RoomGame): - _game_name = "Tarot" - _signal_prefix = "tarotGame" - _signal_suffixes = ("Started", "Players", "New", "ChooseContrat", - "ShowCards", "YourTurn", "Score", "CardsPlayed", - "InvalidCards", - ) - - -class Quiz(RoomGame): - _game_name = "Quiz" - _signal_prefix = "quizGame" - _signal_suffixes = ("Started", "New", "Question", "PlayerBuzzed", - "PlayerSays", "AnswerResult", "TimerExpired", - "TimerRestarted", - ) - - -class Radiocol(RoomGame): - _game_name = "Radiocol" - _signal_prefix = "radiocol" - _signal_suffixes = ("Started", "Players", "SongRejected", "Preload", - "Play", "NoUpload", "UploadOk") diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_list_manager.py --- a/frontends/src/quick_frontend/quick_list_manager.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,68 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2013-2016 Adrien Cossa - -# 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 . - - -class QuickTagList(object): - """This class manages a sorted list of tagged items, and a complementary sorted list of suggested but non tagged items.""" - - def __init__(self, items=None): - """ - - @param items (list): the suggested list of non tagged items - """ - self.tagged = [] - self.original = items[:] if items else [] # XXX: copy the list! It will be modified - self.untagged = items[:] if items else [] # XXX: copy the list! It will be modified - self.untagged.sort() - - @property - def items(self): - """Return a sorted list of all items, tagged or untagged. - - @return list - """ - res = list(set(self.tagged).union(self.untagged)) - res.sort() - return res - - def tag(self, items): - """Tag some items. - - @param items (list): items to be tagged - """ - for item in items: - if item not in self.tagged: - self.tagged.append(item) - if item in self.untagged: - self.untagged.remove(item) - self.tagged.sort() - self.untagged.sort() - - def untag(self, items): - """Untag some items. - - @param items (list): items to be untagged - """ - for item in items: - if item not in self.untagged and item in self.original: - self.untagged.append(item) - if item in self.tagged: - self.tagged.remove(item) - self.tagged.sort() - self.untagged.sort() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_menus.py --- a/frontends/src/quick_frontend/quick_menus.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,448 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - unicode = str - -from sat.core.log import getLogger -from sat.core.i18n import _, languageSwitch -log = getLogger(__name__) -from sat_frontends.quick_frontend.constants import Const as C -from collections import OrderedDict - - -## items ## - -class MenuBase(object): - ACTIVE=True - - def __init__(self, name, extra=None): - """ - @param name(unicode): canonical name of the item - @param extra(dict[unicode, unicode], None): same as in [addMenus] - """ - self._name = name - self.setExtra(extra) - - @property - def canonical(self): - """Return the canonical name of the container, used to identify it""" - return self._name - - @property - def name(self): - """Return the name of the container, can be translated""" - return self._name - - def setExtra(self, extra): - if extra is None: - extra = {} - self.icon = extra.get("icon") - - -class MenuItem(MenuBase): - """A callable item in the menu""" - CALLABLE=False - - def __init__(self, name, name_i18n, extra=None, type_=None): - """ - @param name(unicode): canonical name of the item - @param name_i18n(unicode): translated name of the item - @param extra(dict[unicode, unicode], None): same as in [addMenus] - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - """ - MenuBase.__init__(self, name, extra) - self._name_i18n = name_i18n if name_i18n else name - self.type = type_ - - @property - def name(self): - return self._name_i18n - - def collectData(self, caller): - """Get data according to data_collector - - @param caller: Menu caller - """ - assert self.type is not None # if data collector are used, type must be set - data_collector = QuickMenusManager.getDataCollector(self.type) - - if data_collector is None: - return {} - - elif callable(data_collector): - return data_collector(caller, self.name) - - else: - if caller is None: - log.error(u"Caller can't be None with a dictionary as data_collector") - return {} - data = {} - for data_key, caller_attr in data_collector.iteritems(): - data[data_key] = unicode(getattr(caller, caller_attr)) - return data - - - def call(self, caller, profile=C.PROF_KEY_NONE): - """Execute the menu item - - @param caller: instance linked to the menu - @param profile: %(doc_profile)s - """ - raise NotImplementedError - - -class MenuItemDistant(MenuItem): - """A MenuItem with a distant callback""" - CALLABLE=True - - def __init__(self, host, type_, name, name_i18n, id_, extra=None): - """ - @param host: %(doc_host)s - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param name(unicode): canonical name of the item - @param name_i18n(unicode): translated name of the item - @param id_(unicode): id of the distant callback - @param extra(dict[unicode, unicode], None): same as in [addMenus] - """ - MenuItem.__init__(self, name, name_i18n, extra, type_) - self.host = host - self.id = id_ - - def call(self, caller, profile=C.PROF_KEY_NONE): - data = self.collectData(caller) - log.debug("data collected: %s" % data) - self.host.launchAction(self.id, data, profile=profile) - - -class MenuItemLocal(MenuItem): - """A MenuItem with a local callback""" - CALLABLE=True - - def __init__(self, type_, name, name_i18n, callback, extra=None): - """ - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param name(unicode): canonical name of the item - @param name_i18n(unicode): translated name of the item - @param callback(callable): local callback. - Will be called with no argument if data_collector is None - and with caller, profile, and requested data otherwise - @param extra(dict[unicode, unicode], None): same as in [addMenus] - """ - MenuItem.__init__(self, name, name_i18n, extra, type_) - self.callback = callback - - def call(self, caller, profile=C.PROF_KEY_NONE): - data_collector = QuickMenusManager.getDataCollector(self.type) - if data_collector is None: - # FIXME: would not it be better if caller and profile where used as arguments? - self.callback() - else: - self.callback(caller, self.collectData(caller), profile) - - -class MenuHook(MenuItemLocal): - """A MenuItem which replace an expected item from backend""" - pass - - -class MenuPlaceHolder(MenuItem): - """A non existant menu which is used to keep a position""" - ACTIVE=False - - def __init__(self, name): - MenuItem.__init__(self, name, name) - - -class MenuSeparator(MenuItem): - """A separation between items/categories""" - SEP_IDX=0 - - def __init__(self): - MenuSeparator.SEP_IDX +=1 - name = u"___separator_{}".format(MenuSeparator.SEP_IDX) - MenuItem.__init__(self, name, name) - - -## containers ## - - -class MenuContainer(MenuBase): - - def __init__(self, name, extra=None): - MenuBase.__init__(self, name, extra) - self._items = OrderedDict() - - def __len__(self): - return len(self._items) - - def __contains__(self, item): - return item.canonical in self._items - - def __iter__(self): - return self._items.itervalues() - - def __getitem__(self, item): - try: - return self._items[item.canonical] - except KeyError: - raise KeyError(item) - - def getOrCreate(self, item): - log.debug(u"MenuContainer getOrCreate: item=%s name=%s\nlist=%s" % (item, item.canonical, self._items.keys())) - try: - return self[item] - except KeyError: - self.append(item) - return item - - def getActiveMenus(self): - """Return an iterator on active children""" - for child in self._items.itervalues(): - if child.ACTIVE: - yield child - - def append(self, item): - """add an item at the end of current ones - - @param item: instance of MenuBase (must be unique in container) - """ - assert isinstance(item, MenuItem) or isinstance(item, MenuContainer) - assert item.canonical not in self._items - self._items[item.canonical] = item - - def replace(self, item): - """add an item at the end of current ones or replace an existing one""" - self._items[item.canonical] = item - - -class MenuCategory(MenuContainer): - """A category which can hold other menus or categories""" - - def __init__(self, name, name_i18n=None, extra=None): - """ - @param name(unicode): canonical name - @param name_i18n(unicode, None): translated name - @param icon(unicode, None): same as in MenuBase.__init__ - """ - log.debug("creating menuCategory %s with extra %s" % (name, extra)) - MenuContainer.__init__(self, name, extra) - self._name_i18n = name_i18n or name - - @property - def name(self): - return self._name_i18n - - -class MenuType(MenuContainer): - """A type which can hold other menus or categories""" - pass - - -## manager ## - - -class QuickMenusManager(object): - """Manage all the menus""" - _data_collectors={C.MENU_GLOBAL: None} # No data is associated with C.MENU_GLOBAL items - - def __init__(self, host, menus=None, language=None): - """ - @param host: %(doc_host)s - @param menus(iterable): menus as in [addMenus] - @param language: same as in [i18n.languageSwitch] - """ - self.host = host - MenuBase.host = host - self.language = language - self.menus = {} - if menus is not None: - self.addMenus(menus) - - def _getPathI18n(self, path): - """Return translated version of path""" - languageSwitch(self.language) - path_i18n = [_(elt) for elt in path] - languageSwitch() - return path_i18n - - def _createCategories(self, type_, path, path_i18n=None, top_extra=None): - """Create catogories of the path - - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param path(list[unicode]): same as in [sat.core.sat_main.SAT.importMenu] - @param path_i18n(list[unicode], None): translated menu path (same lenght as path) or None to get deferred translation of path - @param top_extra: extra data to use on the first element of path only. If the first element already exists and is reused, top_extra will be ignored (you'll have to manually change it if you really want to). - @return (MenuContainer): last category created, or MenuType if path is empty - """ - if path_i18n is None: - path_i18n = self._getPathI18n(path) - assert len(path) == len(path_i18n) - menu_container = self.menus.setdefault(type_, MenuType(type_)) - - for idx, category in enumerate(path): - menu_category = MenuCategory(category, path_i18n[idx], extra=top_extra) - menu_container = menu_container.getOrCreate(menu_category) - top_extra = None - - return menu_container - - @staticmethod - def addDataCollector(type_, data_collector): - """Associate a data collector to a menu type - - A data collector is a method or a map which allow to collect context data to construct the dictionnary which will be sent to the bridge method managing the menu item. - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param data_collector(dict[unicode,unicode], callable, None): can be: - - a dict which map data name to local name. - The attribute named after the dict values will be getted from caller, and put in data. - e.g.: if data_collector={'room_jid':'target'}, then the "room_jid" data will be the value of the "target" attribute of the caller. - - a callable which must return the data dictionnary. callable will have caller and item name as argument - - None: an empty dict will be used - """ - QuickMenusManager._data_collectors[type_] = data_collector - - @staticmethod - def getDataCollector(type_): - """Get data_collector associated to type_ - - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @return (callable, dict, None): data_collector - """ - try: - return QuickMenusManager._data_collectors[type_] - except KeyError: - log.error(u"No data collector registered for {}".format(type_)) - return None - - def addMenuItem(self, type_, path, item, path_i18n=None, top_extra=None): - """Add a MenuItemBase instance - - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param path(list[unicode]): same as in [sat.core.sat_main.SAT.importMenu], stop at the last parent category - @param item(MenuItem): a instancied item - @param path_i18n(list[unicode],None): translated menu path (same lenght as path) or None to use deferred translation of path - @param top_extra: same as in [_createCategories] - """ - if path_i18n is None: - path_i18n = self._getPathI18n(path) - assert path and len(path) == len(path_i18n) - - menu_container = self._createCategories(type_, path, path_i18n, top_extra) - - if item in menu_container: - if isinstance(item, MenuHook): - menu_container.replace(item) - else: - container_item = menu_container[item] - if isinstance(container_item, MenuPlaceHolder): - menu_container.replace(item) - elif isinstance(container_item, MenuHook): - # MenuHook must not be replaced - log.debug(u"ignoring menu at path [{}] because a hook is already in place".format(path)) - else: - log.error(u"Conflicting menus at path [{}]".format(path)) - else: - log.debug(u"Adding menu [{type_}] {path}".format(type_=type_, path=path)) - menu_container.append(item) - self.host.callListeners('menu', type_, path, path_i18n, item) - - def addMenu(self, type_, path, path_i18n=None, extra=None, top_extra=None, id_=None, callback=None): - """Add a menu item - - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param path(list[unicode]): same as in [sat.core.sat_main.SAT.importMenu] - @param path_i18n(list[unicode], None): translated menu path (same lenght as path), or None to get deferred translation - @param extra(dict[unicode, unicode], None): same as in [addMenus] - @param top_extra: same as in [_createCategories] - @param id_(unicode): callback id (mutually exclusive with callback) - @param callback(callable): local callback (mutually exclusive with id_) - """ - if path_i18n is None: - path_i18n = self._getPathI18n(path) - assert bool(id_) ^ bool(callback) # we must have id_ xor callback defined - if id_: - menu_item = MenuItemDistant(self.host, type_, path[-1], path_i18n[-1], id_=id_, extra=extra) - else: - menu_item = MenuItemLocal(type_, path[-1], path_i18n[-1], callback=callback, extra=extra) - self.addMenuItem(type_, path[:-1], menu_item, path_i18n[:-1], top_extra) - - def addMenus(self, menus, top_extra=None): - """Add several menus at once - - @param menus(iterable): iterable with: - id_(unicode,callable): id of distant callback or local callback - type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - path(iterable[unicode]): same as in [sat.core.sat_main.SAT.importMenu] - path_i18n(iterable[unicode]): translated menu path (same lenght as path) - extra(dict[unicode,unicode]): dictionary of extra data (used on the leaf menu), can be: - - "icon": icon name - @param top_extra: same as in [_createCategories] - """ - # TODO: manage icons - for id_, type_, path, path_i18n, extra in menus: - if callable(id_): - self.addMenu(type_, path, path_i18n, callback=id_, extra=extra, top_extra=top_extra) - else: - self.addMenu(type_, path, path_i18n, id_=id_, extra=extra, top_extra=top_extra) - - def addMenuHook(self, type_, path, path_i18n=None, extra=None, top_extra=None, callback=None): - """Helper method to add a menu hook - - Menu hooks are local menus which override menu given by backend - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param path(list[unicode]): same as in [sat.core.sat_main.SAT.importMenu] - @param path_i18n(list[unicode], None): translated menu path (same lenght as path), or None to get deferred translation - @param extra(dict[unicode, unicode], None): same as in [addMenus] - @param top_extra: same as in [_createCategories] - @param callback(callable): local callback (mutually exclusive with id_) - """ - if path_i18n is None: - path_i18n = self._getPathI18n(path) - menu_item = MenuHook(type_, path[-1], path_i18n[-1], callback=callback, extra=extra) - self.addMenuItem(type_, path[:-1], menu_item, path_i18n[:-1], top_extra) - log.info(u"Menu hook set on {path} ({type_})".format(path=path, type_=type_)) - - def addCategory(self, type_, path, path_i18n=None, extra=None, top_extra=None): - """Create a category with all parents, and set extra on the last one - - @param type_(unicode): same as in [sat.core.sat_main.SAT.importMenu] - @param path(list[unicode]): same as in [sat.core.sat_main.SAT.importMenu] - @param path_i18n(list[unicode], None): translated menu path (same lenght as path), or None to get deferred translation of path - @param extra(dict[unicode, unicode], None): same as in [addMenus] (added on the leaf category only) - @param top_extra: same as in [_createCategories] - @return (MenuCategory): last category add - """ - if path_i18n is None: - path_i18n = self._getPathI18n(path) - last_container = self._createCategories(type_, path, path_i18n, top_extra=top_extra) - last_container.setExtra(extra) - return last_container - - def getMainContainer(self, type_): - """Get a main MenuType container - - @param type_: a C.MENU_* constant - @return(MenuContainer): the main container - """ - menu_container = self.menus.setdefault(type_, MenuType(type_)) - return menu_container diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_profile_manager.py --- a/frontends/src/quick_frontend/quick_profile_manager.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,259 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.i18n import _ -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat_frontends.primitivus.constants import Const as C - - -class ProfileRecord(object): - """Class which manage data for one profile""" - - def __init__(self, profile=None, login=None, password=None): - self._profile = profile - self._login = login - self._password = password - - @property - def profile(self): - return self._profile - - @profile.setter - def profile(self, value): - self._profile = value - # if we change the profile, - # we must have no login/password until backend give them - self._login = self._password = None - - @property - def login(self): - return self._login - - @login.setter - def login(self, value): - self._login = value - - @property - def password(self): - return self._password - - @password.setter - def password(self, value): - self._password = value - - -class QuickProfileManager(object): - """Class with manage profiles creation/deletion/connection""" - - def __init__(self, host, autoconnect=None): - """Create the manager - - @param host: %(doc_host)s - @param autoconnect(iterable): list of profiles to connect automatically - """ - self.host = host - self._autoconnect = bool(autoconnect) - self.current = ProfileRecord() - - def go(self, autoconnect): - if self._autoconnect: - self.autoconnect(autoconnect) - - def autoconnect(self, profile_keys): - """Automatically connect profiles - - @param profile_keys(iterable): list of profile keys to connect - """ - if not profile_keys: - log.warning("No profile given to autoconnect") - return - self._autoconnect = True - self._autoconnect_profiles=[] - self._do_autoconnect(profile_keys) - - - def _do_autoconnect(self, profile_keys): - """Connect automatically given profiles - - @param profile_kes(iterable): profiles to connect - """ - assert self._autoconnect - - def authenticate_cb(data, cb_id, profile): - - if C.bool(data.pop('validated', C.BOOL_FALSE)): - self._autoconnect_profiles.append(profile) - if len(self._autoconnect_profiles) == len(profile_keys): - # all the profiles have been validated - self.host.plug_profiles(self._autoconnect_profiles) - else: - # a profile is not validated, we go to manual mode - self._autoconnect=False - self.host.actionManager(data, callback=authenticate_cb, profile=profile) - - def getProfileNameCb(profile): - if not profile: - # FIXME: this method is not handling manual mode correclty anymore - # must be thought to be handled asynchronously - self._autoconnect = False # manual mode - msg = _("Trying to plug an unknown profile key ({})".format(profile_key)) - log.warning(msg) - self.host.showDialog(_("Profile plugging in error"), msg, 'error') - else: - self.host.launchAction(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=profile) - - def getProfileNameEb(failure): - log.error(u"Can't retrieve profile name: {}".format(failure)) - - for profile_key in profile_keys: - self.host.bridge.profileNameGet(profile_key, callback=getProfileNameCb, errback=getProfileNameEb) - - - def getParamError(self, dummy): - self.host.showDialog(_(u"Error"), _("Can't get profile parameter"), 'error') - - ## Helping methods ## - - def _getErrorMessage(self, reason): - """Return an error message corresponding to profile creation error - - @param reason (str): reason as returned by profileCreate - @return (unicode): human readable error message - """ - if reason == "ConflictError": - message = _("A profile with this name already exists") - elif reason == "CancelError": - message = _("Profile creation cancelled by backend") - elif reason == "ValueError": - message = _("You profile name is not valid") # TODO: print a more informative message (empty name, name starting with '@') - else: - message = _("Can't create profile ({})").format(reason) - return message - - def _deleteProfile(self): - """Delete the currently selected profile""" - if self.current.profile: - self.host.bridge.asyncDeleteProfile(self.current.profile, callback=self.refillProfiles) - self.resetFields() - - ## workflow methods (events occuring during the profiles selection) ## - - # These methods must be called by the frontend at some point - - def _onConnectProfiles(self): - """Connect the profiles and start the main widget""" - if self._autoconnect: - self.host.showDialog(_('Internal error'), _("You can't connect manually and automatically at the same time"), 'error') - return - self.updateConnectionParams() - profiles = self.getProfiles() - if not profiles: - self.host.showDialog(_('No profile selected'), _('You need to create and select at least one profile before connecting'), 'error') - else: - # All profiles in the list are already validated, so we can plug them directly - self.host.plug_profiles(profiles) - - def getConnectionParams(self, profile): - """Get login and password and display them - - @param profile: %(doc_profile)s - """ - self.host.bridge.asyncGetParamA("JabberID", "Connection", profile_key=profile, callback=self.setJID, errback=self.getParamError) - self.host.bridge.asyncGetParamA("Password", "Connection", profile_key=profile, callback=self.setPassword, errback=self.getParamError) - - def updateConnectionParams(self): - """Check if connection parameters have changed, and update them if so""" - if self.current.profile: - login = self.getJID() - password = self.getPassword() - if login != self.current.login and self.current.login is not None: - self.current.login = login - self.host.bridge.setParam("JabberID", login, "Connection", profile_key=self.current.profile) - log.info(u"login updated for profile [{}]".format(self.current.profile)) - if password != self.current.password and self.current.password is not None: - self.current.password = password - self.host.bridge.setParam("Password", password, "Connection", profile_key=self.current.profile) - log.info(u"password updated for profile [{}]".format(self.current.profile)) - - ## graphic updates (should probably be overriden in frontends) ## - - def resetFields(self): - """Set profile to None, and reset fields""" - self.current.profile=None - self.setJID("") - self.setPassword("") - - def refillProfiles(self): - """Rebuild the list of profiles""" - profiles = self.host.bridge.profilesListGet() - profiles.sort() - self.setProfiles(profiles) - - ## Method which must be implemented by frontends ## - - # get/set data - - def getProfiles(self): - """Return list of selected profiles - - Must be implemented by frontends - @return (list): list of profiles - """ - raise NotImplementedError - - def setProfiles(self, profiles): - """Update the list of profiles""" - raise NotImplementedError - - - def getJID(self): - """Get current jid - - Must be implemented by frontends - @return (unicode): current jabber id - """ - raise NotImplementedError - - def getPassword(self): - """Get current password - - Must be implemented by frontends - @return (unicode): current password - """ - raise NotImplementedError - - def setJID(self, jid_): - """Set current jid - - Must be implemented by frontends - @param jid_(unicode): jabber id to set - """ - raise NotImplementedError - - def setPassword(self, password): - """Set current password - - Must be implemented by frontends - """ - raise NotImplementedError - - # dialogs - - # Note: a method which check profiles change must be implemented too diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_utils.py --- a/frontends/src/quick_frontend/quick_utils.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# 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 . - -from sat.core.i18n import _ -from os.path import exists, splitext -from optparse import OptionParser - -def getNewPath(path): - """ Check if path exists, and find a non existant path if needed """ - idx = 2 - if not exists(path): - return path - root, ext = splitext(path) - while True: - new_path = "%s_%d%s" % (root, idx, ext) - if not exists(new_path): - return new_path - idx+=1 - -def check_options(): - """Check command line options""" - usage = _(""" - %prog [options] - - %prog --help for options list - """) - parser = OptionParser(usage=usage) # TODO: use argparse - - parser.add_option("-p", "--profile", help=_("Select the profile to use")) - - (options, args) = parser.parse_args() - if options.profile: - options.profile = options.profile.decode('utf-8') - return options - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/quick_frontend/quick_widgets.py --- a/frontends/src/quick_frontend/quick_widgets.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,363 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# helper class for making a SAT frontend -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core import exceptions -from sat_frontends.quick_frontend.constants import Const as C - - -NEW_INSTANCE_SUFF = '_new_instance_' -classes_map = {} - - -try: - # FIXME: to be removed when an acceptable solution is here - unicode('') # XXX: unicode doesn't exist in pyjamas -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - unicode = str - - -def register(base_cls, child_cls=None): - """Register a child class to use by default when a base class is needed - - @param base_cls: "Quick..." base class (like QuickChat or QuickContact), must inherit from QuickWidget - @param child_cls: inherited class to use when Quick... class is requested, must inherit from base_cls. - Can be None if it's the base_cls itself which register - """ - # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas because - # in the second case - classes_map[base_cls.__name__] = child_cls - - -class WidgetAlreadyExistsError(Exception): - pass - - -class QuickWidgetsManager(object): - """This class is used to manage all the widgets of a frontend - A widget can be a window, a graphical thing, or someting else depending of the frontend""" - - def __init__(self, host): - self.host = host - self._widgets = {} - - def __iter__(self): - """Iterate throught all widgets""" - for widget_map in self._widgets.itervalues(): - for widget in widget_map.itervalues(): - yield widget - - def getRealClass(self, class_): - """Return class registered for given class_ - - @param class_: subclass of QuickWidget - @return: class actually used to create widget - """ - try: - # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas bugs - # in the second case - cls = classes_map[class_.__name__] - except KeyError: - cls = class_ - if cls is None: - raise exceptions.InternalError("There is not class registered for {}".format(class_)) - return cls - - def getRootHash(self, hash_): - """Return root hash (i.e. hash without new instance suffix for recreated widgets - - @param hash_(immutable): hash of a widget - @return (unicode): root hash (transtyped to unicode) - """ - return unicode(hash_).split(NEW_INSTANCE_SUFF)[0] - - def getWidgets(self, class_, target=None, profiles=None): - """Get all subclassed widgets instances - - @param class_: subclass of QuickWidget, same parameter as used in [getOrCreateWidget] - @param target: if not None, construct a hash with this target and filter corresponding widgets - recreated widgets (with new instance suffix) are handled - @param profiles(iterable, None): if not None, filter on instances linked to these profiles - @return: iterator on widgets - """ - class_ = self.getRealClass(class_) - try: - widgets_map = self._widgets[class_.__name__] - except KeyError: - return - else: - if target is not None: - filter_hash = unicode(class_.getWidgetHash(target, profiles)) - else: - filter_hash = None - for w_hash, w in widgets_map.iteritems(): - if profiles is None or w.profiles.intersection(profiles): - if filter_hash is not None and self.getRootHash(w_hash) != filter_hash: - continue - yield w - - def getWidget(self, class_, target=None, profiles=None): - """Get a widget without creating it if it doesn't exist. - - @param class_(class): class of the widget to create - @param target: target depending of the widget, usually a JID instance - @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be - used, depending of the widget class) - @return: a class_ instance or None if the widget doesn't exist - """ - assert (target is not None) or (profiles is not None) - if profiles is not None and isinstance(profiles, unicode): - profiles = [profiles] - class_ = self.getRealClass(class_) - hash_ = class_.getWidgetHash(target, profiles) - try: - return self._widgets[class_.__name__][hash_] - except KeyError: - return None - - def getOrCreateWidget(self, class_, target, *args, **kwargs): - """Get an existing widget or create a new one when necessary - - If the widget is new, self.host.newWidget will be called with it. - @param class_(class): class of the widget to create - @param target: target depending of the widget, usually a JID instance - @param args(list): optional args to create a new instance of class_ - @param kwargs(dict): optional kwargs to create a new instance of class_ - if 'profile' key is present, it will be popped and put in 'profiles' - if there is neither 'profile' nor 'profiles', None will be used for 'profiles' - if 'on_new_widget' is present it can have the following values: - C.WIDGET_NEW [default]: self.host.newWidget will be called on widget creation - [callable]: this method will be called instead of self.host.newWidget - None: do nothing - if 'on_existing_widget' is present it can have the following values: - C.WIDGET_KEEP [default]: return the existing widget - C.WIDGET_RAISE: raise WidgetAlreadyExistsError - C.WIDGET_RECREATE: create a new widget *WITH A NEW HASH* - if the existing widget has a "recreateArgs" method, it will be called with args list and kwargs dict - so the values can be completed to create correctly the new instance - [callable]: this method will be called with existing widget as argument - if 'force_hash' is present, the hash given in value will be used instead of the one returned by class_.getWidgetHash - other keys will be used to instanciate class_ if the case happen (e.g. if type_ is present and class_ is a QuickChat subclass, - it will be used to create a new QuickChat instance). - @return: a class_ instance, either new or already existing - """ - cls = self.getRealClass(class_) - - ## arguments management ## - _args = [self.host, target] + list(args) or [] # FIXME: check if it's really necessary to use optional args - _kwargs = kwargs or {} - if 'profiles' in _kwargs and 'profile' in _kwargs: - raise ValueError("You can't have 'profile' and 'profiles' keys at the same time") - try: - _kwargs['profiles'] = [_kwargs.pop('profile')] - except KeyError: - if not 'profiles' in _kwargs: - _kwargs['profiles'] = None - - #on_new_widget tell what to do for the new widget creation - try: - on_new_widget = _kwargs.pop('on_new_widget') - except KeyError: - on_new_widget = C.WIDGET_NEW - - #on_existing_widget tell what to do when the widget already exists - try: - on_existing_widget = _kwargs.pop('on_existing_widget') - except KeyError: - on_existing_widget = C.WIDGET_KEEP - - ## we get the hash ## - try: - hash_ = _kwargs.pop('force_hash') - except KeyError: - hash_ = cls.getWidgetHash(target, _kwargs['profiles']) - - ## widget creation or retrieval ## - - widgets_map = self._widgets.setdefault(cls.__name__, {}) # we sorts widgets by classes - if not cls.SINGLE: - widget = None # if the class is not SINGLE, we always create a new widget - else: - try: - widget = widgets_map[hash_] - widget.addTarget(target) - except KeyError: - widget = None - - if widget is None: - # we need to create a new widget - log.debug(u"Creating new widget for target {} {}".format(target, cls)) - widget = cls(*_args, **_kwargs) - widgets_map[hash_] = widget - - if on_new_widget == C.WIDGET_NEW: - self.host.newWidget(widget) - elif callable(on_new_widget): - on_new_widget(widget) - else: - assert on_new_widget is None - else: - # the widget already exists - if on_existing_widget == C.WIDGET_KEEP: - pass - elif on_existing_widget == C.WIDGET_RAISE: - raise WidgetAlreadyExistsError(hash_) - elif on_existing_widget == C.WIDGET_RECREATE: - # we use getOrCreateWidget to recreate the new widget - # /!\ we use args and kwargs and not _args and _kwargs because we need the original args - # we need to get rid of kwargs special options - new_kwargs = kwargs.copy() - try: - new_kwargs.pop('force_hash') # FIXME: we use pop instead of del here because pyjamas doesn't raise error on del - except KeyError: - pass - else: - raise ValueError("force_hash option can't be used with on_existing_widget=RECREATE") - - new_kwargs['on_new_widget'] = on_new_widget - - # XXX: keep up-to-date if new special kwargs are added (i.e.: delete these keys here) - new_kwargs['on_existing_widget'] = C.WIDGET_RAISE - try: - recreateArgs = widget.recreateArgs - except AttributeError: - pass - else: - recreateArgs(args, new_kwargs) - hash_idx = 1 - while True: - new_kwargs['force_hash'] = "{}{}{}".format(hash_, NEW_INSTANCE_SUFF, hash_idx) - try: - widget = self.getOrCreateWidget(class_, target, *args, **new_kwargs) - except WidgetAlreadyExistsError: - hash_idx += 1 - else: - log.debug(u"Widget already exists, a new one has been recreated with hash {}".format(new_kwargs['force_hash'])) - break - elif callable(on_existing_widget): - on_existing_widget(widget) - else: - raise exceptions.InternalError("Unexpected on_existing_widget value ({})".format(on_existing_widget)) - - return widget - - def deleteWidget(self, widget_to_delete, *args, **kwargs): - """Delete a widget - - this method must be called by frontends when a widget is deleted - widget's onDelete method will be called before deletion - @param widget_to_delete(QuickWidget): widget which need to deleted - @param *args: extra arguments to pass to onDelete - @param *kwargs: extra keywords arguments to pass to onDelete - the extra arguments are not use by QuickFrontend, it's is up to - the frontend to use them or not - """ - if widget_to_delete.onDelete(*args, **kwargs) == False: - return - - if self.host.selected_widget == widget_to_delete: - self.host.selected_widget = None - - for widget_map in self._widgets.itervalues(): - to_delete = set() - for hash_, widget in widget_map.iteritems(): - if widget_to_delete is widget: - to_delete.add(hash_) - for hash_ in to_delete: - del widget_map[hash_] - - -class QuickWidget(object): - """generic widget base""" - SINGLE=True # if True, there can be only one widget per target(s) - PROFILES_MULTIPLE=False # If True, this widget can handle several profiles at once - PROFILES_ALLOW_NONE=False # If True, this widget can be used without profile - - def __init__(self, host, target, profiles=None): - """ - @param host: %(doc_host)s - @param target: target specific for this widget class - @param profiles: can be either: - - (unicode): used when widget class manage a unique profile - - (iterable): some widget class can manage several profiles, several at once can be specified here - - None: no profile is managed by this widget class (rare) - @raise: ValueError when (iterable) or None is given to profiles for a widget class which manage one unique profile. - """ - self.host = host - self.targets = set() - self.addTarget(target) - self.profiles = set() - if isinstance(profiles, basestring): - self.addProfile(profiles) - elif profiles is None: - if not self.PROFILES_ALLOW_NONE: - raise ValueError("profiles can't have a value of None") - else: - for profile in profiles: - self.addProfile(profile) - if not self.profiles: - raise ValueError("no profile found, use None for no profile classes") - - @property - def profile(self): - assert len(self.profiles) == 1 and not self.PROFILES_MULTIPLE and not self.PROFILES_ALLOW_NONE - return list(self.profiles)[0] - - def addTarget(self, target): - """Add a target if it doesn't already exists - - @param target: target to add - """ - self.targets.add(target) - - def addProfile(self, profile): - """Add a profile is if doesn't already exists - - @param profile: profile to add - """ - if self.profiles and not self.PROFILES_MULTIPLE: - raise ValueError("multiple profiles are not allowed") - self.profiles.add(profile) - - @staticmethod - def getWidgetHash(target, profiles): - """Return the hash associated with this target for this widget class - - some widget classes can manage several target on the same instance - (e.g.: a chat widget with multiple resources on the same bare jid), - this method allow to return a hash associated to one or several targets - to retrieve the good instance. For example, a widget managing JID targets, - and all resource of the same bare jid would return the bare jid as hash. - - @param target: target to check - @param profiles: profile(s) associated to target, see __init__ docstring - @return: a hash (can correspond to one or many targets or profiles, depending of widget class) - """ - return unicode(target) # by defaut, there is one hash for one target - - def onDelete(self, *args, **kwargs): - """Called when a widget is being deleted - - @return (boot, None): False to cancel deletion - all other value continue deletion - """ - log.debug(u"widget {} deleted".format(self)) - return True - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/composition.py --- a/frontends/src/tools/composition.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,113 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -""" -Libervia: a Salut à Toi frontend -Copyright (C) 2013-2016 Adrien Cossa - -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 . -""" - -# Map the messages recipient types to their properties. -RECIPIENT_TYPES = {"To": {"desc": "Direct recipients", "optional": False}, - "Cc": {"desc": "Carbon copies", "optional": True}, - "Bcc": {"desc": "Blind carbon copies", "optional": True}} - -# Rich text buttons icons and descriptions -RICH_BUTTONS = { - "bold": {"tip": "Bold", "icon": "media/icons/dokuwiki/toolbar/16/bold.png"}, - "italic": {"tip": "Italic", "icon": "media/icons/dokuwiki/toolbar/16/italic.png"}, - "underline": {"tip": "Underline", "icon": "media/icons/dokuwiki/toolbar/16/underline.png"}, - "code": {"tip": "Code", "icon": "media/icons/dokuwiki/toolbar/16/mono.png"}, - "strikethrough": {"tip": "Strikethrough", "icon": "media/icons/dokuwiki/toolbar/16/strike.png"}, - "heading": {"tip": "Heading", "icon": "media/icons/dokuwiki/toolbar/16/hequal.png"}, - "numberedlist": {"tip": "Numbered List", "icon": "media/icons/dokuwiki/toolbar/16/ol.png"}, - "list": {"tip": "List", "icon": "media/icons/dokuwiki/toolbar/16/ul.png"}, - "link": {"tip": "Link", "icon": "media/icons/dokuwiki/toolbar/16/linkextern.png"}, - "horizontalrule": {"tip": "Horizontal rule", "icon": "media/icons/dokuwiki/toolbar/16/hr.png"}, - "image": {"tip": "Image", "icon": "media/icons/dokuwiki/toolbar/16/image.png"}, - } - -# Define here your rich text syntaxes, the key must match the ones used in button. -# Tupples values must have 3 elements : prefix to the selection or cursor -# position, sample text to write if the marker is not applied on a selection, -# suffix to the selection or cursor position. -# FIXME: must not be hard-coded like this -RICH_SYNTAXES = {"markdown": {"bold": ("**", "bold", "**"), - "italic": ("*", "italic", "*"), - "code": ("`", "code", "`"), - "heading": ("\n# ", "Heading 1", "\n## Heading 2\n"), - "link": ("[desc](", "link", ")"), - "list": ("\n* ", "item", "\n + subitem\n"), - "horizontalrule": ("\n***\n", "", ""), - "image": ("![desc](", "path", ")"), - }, - "bbcode": {"bold": ("[b]", "bold", "[/b]"), - "italic": ("[i]", "italic", "[/i]"), - "underline": ("[u]", "underline", "[/u]"), - "code": ("[code]", "code", "[/code]"), - "strikethrough": ("[s]", "strikethrough", "[/s]"), - "link": ("[url=", "link", "]desc[/url]"), - "list": ("\n[list] [*]", "item 1", " [*]item 2 [/list]\n"), - "image": ("[img alt=\"desc\]", "path", "[/img]"), - }, - "dokuwiki": {"bold": ("**", "bold", "**"), - "italic": ("//", "italic", "//"), - "underline": ("__", "underline", "__"), - "code": ("", "code", ""), - "strikethrough": ("", "strikethrough", ""), - "heading": ("\n==== ", "Heading 1", " ====\n=== Heading 2 ===\n"), - "link": ("[[", "link", "|desc]]"), - "list": ("\n * ", "item\n", "\n * subitem\n"), - "horizontalrule": ("\n----\n", "", ""), - "image": ("{{", "path", " |desc}}"), - }, - "XHTML": {"bold": ("", "bold", ""), - "italic": ("", "italic", ""), - "underline": ("", "underline", ""), - "code": ("
", "code", "
"), - "strikethrough": ("", "strikethrough", ""), - "heading": ("\n

", "Heading 1", "

\n

Heading 2

\n"), - "link": ("desc"), - "list": ("\n
  • ", "item 1", "
  • item 2
\n"), - "horizontalrule": ("\n
\n", "", ""), - "image": ("\"desc\"/"), - } - } - -# Define here the commands that are supported by the WYSIWYG edition. -# Keys must be the same than the ones used in RICH_SYNTAXES["XHTML"]. -# Values will be used to call execCommand(cmd, False, arg), they can be: -# - a string used for cmd and arg is assumed empty -# - a tuple (cmd, prompt, arg) with cmd the name of the command, -# prompt the text to display for asking a user input and arg is the -# value to use directly without asking the user if prompt is empty. -COMMANDS = {"bold": "bold", - "italic": "italic", - "underline": "underline", - "code": ("formatBlock", "", "pre"), - "strikethrough": "strikeThrough", - "heading": ("heading", "Please specify the heading level (h1, h2, h3...)", ""), - "link": ("createLink", "Please specify an URL", ""), - "list": "insertUnorderedList", - "horizontalrule": "insertHorizontalRule", - "image": ("insertImage", "Please specify an image path", ""), - } - -# These values should be equal to the ones in plugin_misc_text_syntaxes -# FIXME: should the plugin import them from here to avoid duplicity? Importing -# the plugin's values from here is not possible because Libervia would fail. -PARAM_KEY_COMPOSITION = "Composition" -PARAM_NAME_SYNTAX = "Syntax" - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/css_color.py --- a/frontends/src/tools/css_color.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,239 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# CSS color parsing -# Copyright (C) 2009-2018 Jérome-Poisson - -# 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 . - -from sat.core.log import getLogger -log = getLogger(__name__) - - -CSS_COLORS = { - u"black": u"000000", - u"silver": u"c0c0c0", - u"gray": u"808080", - u"white": u"ffffff", - u"maroon": u"800000", - u"red": u"ff0000", - u"purple": u"800080", - u"fuchsia": u"ff00ff", - u"green": u"008000", - u"lime": u"00ff00", - u"olive": u"808000", - u"yellow": u"ffff00", - u"navy": u"000080", - u"blue": u"0000ff", - u"teal": u"008080", - u"aqua": u"00ffff", - u"orange": u"ffa500", - u"aliceblue": u"f0f8ff", - u"antiquewhite": u"faebd7", - u"aquamarine": u"7fffd4", - u"azure": u"f0ffff", - u"beige": u"f5f5dc", - u"bisque": u"ffe4c4", - u"blanchedalmond": u"ffebcd", - u"blueviolet": u"8a2be2", - u"brown": u"a52a2a", - u"burlywood": u"deb887", - u"cadetblue": u"5f9ea0", - u"chartreuse": u"7fff00", - u"chocolate": u"d2691e", - u"coral": u"ff7f50", - u"cornflowerblue": u"6495ed", - u"cornsilk": u"fff8dc", - u"crimson": u"dc143c", - u"darkblue": u"00008b", - u"darkcyan": u"008b8b", - u"darkgoldenrod": u"b8860b", - u"darkgray": u"a9a9a9", - u"darkgreen": u"006400", - u"darkgrey": u"a9a9a9", - u"darkkhaki": u"bdb76b", - u"darkmagenta": u"8b008b", - u"darkolivegreen": u"556b2f", - u"darkorange": u"ff8c00", - u"darkorchid": u"9932cc", - u"darkred": u"8b0000", - u"darksalmon": u"e9967a", - u"darkseagreen": u"8fbc8f", - u"darkslateblue": u"483d8b", - u"darkslategray": u"2f4f4f", - u"darkslategrey": u"2f4f4f", - u"darkturquoise": u"00ced1", - u"darkviolet": u"9400d3", - u"deeppink": u"ff1493", - u"deepskyblue": u"00bfff", - u"dimgray": u"696969", - u"dimgrey": u"696969", - u"dodgerblue": u"1e90ff", - u"firebrick": u"b22222", - u"floralwhite": u"fffaf0", - u"forestgreen": u"228b22", - u"gainsboro": u"dcdcdc", - u"ghostwhite": u"f8f8ff", - u"gold": u"ffd700", - u"goldenrod": u"daa520", - u"greenyellow": u"adff2f", - u"grey": u"808080", - u"honeydew": u"f0fff0", - u"hotpink": u"ff69b4", - u"indianred": u"cd5c5c", - u"indigo": u"4b0082", - u"ivory": u"fffff0", - u"khaki": u"f0e68c", - u"lavender": u"e6e6fa", - u"lavenderblush": u"fff0f5", - u"lawngreen": u"7cfc00", - u"lemonchiffon": u"fffacd", - u"lightblue": u"add8e6", - u"lightcoral": u"f08080", - u"lightcyan": u"e0ffff", - u"lightgoldenrodyellow": u"fafad2", - u"lightgray": u"d3d3d3", - u"lightgreen": u"90ee90", - u"lightgrey": u"d3d3d3", - u"lightpink": u"ffb6c1", - u"lightsalmon": u"ffa07a", - u"lightseagreen": u"20b2aa", - u"lightskyblue": u"87cefa", - u"lightslategray": u"778899", - u"lightslategrey": u"778899", - u"lightsteelblue": u"b0c4de", - u"lightyellow": u"ffffe0", - u"limegreen": u"32cd32", - u"linen": u"faf0e6", - u"mediumaquamarine": u"66cdaa", - u"mediumblue": u"0000cd", - u"mediumorchid": u"ba55d3", - u"mediumpurple": u"9370db", - u"mediumseagreen": u"3cb371", - u"mediumslateblue": u"7b68ee", - u"mediumspringgreen": u"00fa9a", - u"mediumturquoise": u"48d1cc", - u"mediumvioletred": u"c71585", - u"midnightblue": u"191970", - u"mintcream": u"f5fffa", - u"mistyrose": u"ffe4e1", - u"moccasin": u"ffe4b5", - u"navajowhite": u"ffdead", - u"oldlace": u"fdf5e6", - u"olivedrab": u"6b8e23", - u"orangered": u"ff4500", - u"orchid": u"da70d6", - u"palegoldenrod": u"eee8aa", - u"palegreen": u"98fb98", - u"paleturquoise": u"afeeee", - u"palevioletred": u"db7093", - u"papayawhip": u"ffefd5", - u"peachpuff": u"ffdab9", - u"peru": u"cd853f", - u"pink": u"ffc0cb", - u"plum": u"dda0dd", - u"powderblue": u"b0e0e6", - u"rosybrown": u"bc8f8f", - u"royalblue": u"4169e1", - u"saddlebrown": u"8b4513", - u"salmon": u"fa8072", - u"sandybrown": u"f4a460", - u"seagreen": u"2e8b57", - u"seashell": u"fff5ee", - u"sienna": u"a0522d", - u"skyblue": u"87ceeb", - u"slateblue": u"6a5acd", - u"slategray": u"708090", - u"slategrey": u"708090", - u"snow": u"fffafa", - u"springgreen": u"00ff7f", - u"steelblue": u"4682b4", - u"tan": u"d2b48c", - u"thistle": u"d8bfd8", - u"tomato": u"ff6347", - u"turquoise": u"40e0d0", - u"violet": u"ee82ee", - u"wheat": u"f5deb3", - u"whitesmoke": u"f5f5f5", - u"yellowgreen": u"9acd32", - u"rebeccapurple": u"663399" -} -DEFAULT = u"000000" - - -def parse(raw_value, as_string=True): - """parse CSS color value and return normalised value - - @param raw_value(unicode): CSS value - @param as_string(bool): if True return a string, - else return a tuple of int - @return (unicode, tuple): normalised value - if as_string is True, value is 3 or 4 hex words (e.g. u"ff00aabb") - else value is a 3 or 4 tuple of int (e.g.: (255, 0, 170, 187)). - If present, the 4th value is the alpha channel - If value can't be parsed, a warning message is logged, and DEFAULT is returned - """ - raw_value = raw_value.strip().lower() - if raw_value.startswith(u'#'): - # we have a hexadecimal value - str_value = raw_value[1:] - if len(raw_value) in (3,4): - str_value = u''.join([2*v for v in str_value]) - elif raw_value.startswith(u'rgb'): - left_p = raw_value.find(u'(') - right_p = raw_value.find(u')') - rgb_values = [v.strip() for v in raw_value[left_p+1:right_p].split(',')] - expected_len = 4 if raw_value.startswith(u'rgba') else 3 - if len(rgb_values) != expected_len: - log.warning(u"incorrect value: {}".format(raw_value)) - str_value = DEFAULT - else: - int_values = [] - for rgb_v in rgb_values: - p_idx = rgb_v.find(u'%') - if p_idx == -1: - # base 10 value - try: - int_v = int(rgb_v) - if int_v > 255: - raise ValueError(u"value exceed 255") - int_values.append(int_v) - except ValueError: - log.warning(u"invalid int: {}".format(rgb_v)) - int_values.append(0) - else: - # percentage - try: - int_v = int(int(rgb_v[:p_idx]) / 100.0 * 255) - if int_v > 255: - raise ValueError(u"value exceed 255") - int_values.append(int_v) - except ValueError: - log.warning(u"invalid percent value: {}".format(rgb_v)) - int_values.append(0) - str_value = u''.join([u"{:02x}".format(v) for v in int_values]) - elif raw_value.startswith(u'hsl'): - log.warning(u"hue-saturation-lightness not handled yet") # TODO - str_value = DEFAULT - else: - try: - str_value = CSS_COLORS[raw_value] - except KeyError: - log.warning(u"unrecognised format: {}".format(raw_value)) - str_value = DEFAULT - - if as_string: - return str_value - else: - return tuple([int(str_value[i]+str_value[i+1], 16) for i in xrange(0, len(str_value), 2)]) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/games.py --- a/frontends/src/tools/games.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,79 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SAT: a jabber client -# 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 . - -"""This library help manage general games (e.g. card games) and it is shared by the frontends""" - -SUITS_ORDER = ['pique', 'coeur', 'trefle', 'carreau', 'atout'] # I have switched the usual order 'trefle' and 'carreau' because card are more easy to see if suit colour change (black, red, black, red) -VALUES_ORDER = [str(i) for i in xrange(1, 11)] + ["valet", "cavalier", "dame", "roi"] - - -class TarotCard(object): - """This class is used to represent a car logically""" - - def __init__(self, tuple_card): - """@param tuple_card: tuple (suit, value)""" - self.suit, self.value = tuple_card - self.bout = self.suit == "atout" and self.value in ["1", "21", "excuse"] - if self.bout or self.value == "roi": - self.points = 4.5 - elif self.value == "dame": - self.points = 3.5 - elif self.value == "cavalier": - self.points = 2.5 - elif self.value == "valet": - self.points = 1.5 - else: - self.points = 0.5 - - def get_tuple(self): - return (self.suit, self.value) - - @staticmethod - def from_tuples(tuple_list): - result = [] - for card_tuple in tuple_list: - result.append(TarotCard(card_tuple)) - return result - - def __cmp__(self, other): - if other is None: - return 1 - if self.suit != other.suit: - idx1 = SUITS_ORDER.index(self.suit) - idx2 = SUITS_ORDER.index(other.suit) - return idx1.__cmp__(idx2) - if self.suit == 'atout': - if self.value == other.value == 'excuse': - return 0 - if self.value == 'excuse': - return -1 - if other.value == 'excuse': - return 1 - return int(self.value).__cmp__(int(other.value)) - # at this point we have the same suit which is not 'atout' - idx1 = VALUES_ORDER.index(self.value) - idx2 = VALUES_ORDER.index(other.value) - return idx1.__cmp__(idx2) - - def __str__(self): - return "[%s,%s]" % (self.suit, self.value) - - -# These symbols are diplayed by Libervia next to the player's nicknames -SYMBOLS = {"Radiocol": [u"♬"], "Tarot": [u"♠", u"♣", u"♥", u"♦"]} diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/host_listener.py --- a/frontends/src/tools/host_listener.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,45 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SAT: a jabber client -# 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 . - -"""This module is only used launch callbacks when host is ready, used for early initialisation stuffs""" - - -listeners = [] - -def addListener(cb): - """Add a listener which will be called when host is ready - - @param cb: callback which will be called when host is ready with host as only argument - """ - listeners.append(cb) - -def callListeners(host): - """Must be called by frontend when host is ready. - - The call will launch all the callbacks, then remove the listeners list. - @param host(QuickApp): the instancied QuickApp subclass - """ - global listeners - while True: - try: - cb = listeners.pop(0) - cb(host) - except IndexError: - break - del listeners diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/jid.py --- a/frontends/src/tools/jid.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,126 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SAT: a jabber client -# 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 . - - -# hack to use this module with pyjamas -try: - unicode('') # XXX: unicode doesn't exist in pyjamas - - # normal version - class BaseJID(unicode): - def __new__(cls, jid_str): - self = unicode.__new__(cls, cls._normalize(jid_str)) - return self - - def __init__(self, jid_str): - pass - - def _parse(self): - """Find node domain and resource""" - node_end = self.find('@') - if node_end < 0: - node_end = 0 - domain_end = self.find('/') - if domain_end == 0: - raise ValueError("a jid can't start with '/'") - if domain_end == -1: - domain_end = len(self) - self.node = self[:node_end] or None - self.domain = self[(node_end + 1) if node_end else 0:domain_end] - self.resource = self[domain_end + 1:] or None - -except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options - - # pyjamas version - class BaseJID(object): - def __init__(self, jid_str): - self.__internal_str = JID._normalize(jid_str) - - def __str__(self): - return self.__internal_str - - def __getattr__(self, name): - return getattr(self.__internal_str, name) - - def __eq__(self, other): - if not isinstance(other, JID): - return False - return (self.node == other.node - and self.domain == other.domain - and self.resource == other.resource) - - def __hash__(self): - return hash('JID<{}>'.format(self.__internal_str)) - - def find(self, *args): - return self.__internal_str.find(*args) - - def _parse(self): - """Find node domain and resource""" - node_end = self.__internal_str.find('@') - if node_end < 0: - node_end = 0 - domain_end = self.__internal_str.find('/') - if domain_end == 0: - raise ValueError("a jid can't start with '/'") - if domain_end == -1: - domain_end = len(self.__internal_str) - self.node = self.__internal_str[:node_end] or None - self.domain = self.__internal_str[(node_end + 1) if node_end else 0:domain_end] - self.resource = self.__internal_str[domain_end + 1:] or None - - -class JID(BaseJID): - """This class help manage JID (Node@Domaine/Resource)""" - - def __init__(self, jid_str): - super(JID, self).__init__(jid_str) - self._parse() - - @staticmethod - def _normalize(jid_str): - """Naive normalization before instantiating and parsing the JID""" - if not jid_str: - return jid_str - tokens = jid_str.split('/') - tokens[0] = tokens[0].lower() # force node and domain to lower-case - return '/'.join(tokens) - - @property - def bare(self): - if not self.node: - return JID(self.domain) - return JID(u"{}@{}".format(self.node, self.domain)) - - def is_valid(self): - """ - @return: True if the JID is XMPP compliant - """ - # TODO: implement real check, according to the RFC http://tools.ietf.org/html/rfc6122 - return self.domain != "" - - -def newResource(entity, resource): - """Build a new JID from the given entity and resource. - - @param entity (JID): original JID - @param resource (unicode): new resource - @return: a new JID instance - """ - return JID(u"%s/%s" % (entity.bare, resource)) diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/misc.py --- a/frontends/src/tools/misc.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SAT helpers methods for plugins -# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . - - -class InputHistory(object): - - def _updateInputHistory(self, text=None, step=None, callback=None, mode=""): - """Update the lists of previously sent messages. Several lists can be - handled as they are stored in a dictionary, the argument "mode" being - used as the entry key. There's also a temporary list to allow you play - with previous entries before sending a new message. Parameters values - can be combined: text is None and step is None to initialize a main - list and the temporary one, step is None to update a list and - reinitialize the temporary one, step is not None to update - the temporary list between two messages. - @param text: text to be saved. - @param step: step to move the temporary index. - @param callback: method to display temporary entries. - @param mode: the dictionary key for main lists. - """ - if not hasattr(self, "input_histories"): - self.input_histories = {} - history = self.input_histories.setdefault(mode, []) - if step is None and text is not None: - # update the main list - if text in history: - history.remove(text) - history.append(text) - length = len(history) - if step is None or length == 0: - # prepare the temporary list and index - self.input_history_tmp = history[:] - self.input_history_tmp.append("") - self.input_history_index = length - if step is None: - return - # update the temporary list - if text is not None: - # save the current entry - self.input_history_tmp[self.input_history_index] = text - # move to another entry if possible - index_tmp = self.input_history_index + step - if index_tmp >= 0 and index_tmp < len(self.input_history_tmp): - if callback is not None: - callback(self.input_history_tmp[index_tmp]) - self.input_history_index = index_tmp - - -class FlagsHandler(object): - """Small class to handle easily option flags - - the instance is initialized with an iterable - then attribute return True if flag is set, False else. - WARNING: If a flag is checked, it is removed (i.e. unset) - this is done to use bool(flags_handler) to check if all flags - have been used - """ - - def __init__(self, flags): - self.flags = set(flags or []) - - def __getattr__(self, flag): - try: - self.flags.remove(flag) - except KeyError: - return False - else: - return True - - def __len__(self): - return len(self.flags) - - def __iter__(self): - return self.flags.__iter__() diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/strings.py --- a/frontends/src/tools/strings.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,99 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SAT helpers methods for plugins -# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . - -import re - -# Regexp from http://daringfireball.net/2010/07/improved_regex_for_matching_urls -RE_URL = re.compile(r"""(?i)\b((?:[a-z]{3,}://|(www|ftp)\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/|mailto:|xmpp:)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?]))""") - - -# TODO: merge this class with an other module or at least rename it (strings is not a good name) - - -def getURLParams(url): - """This comes from pyjamas.Location.makeUrlDict with a small change - to also parse full URLs, and parameters with no value specified - (in that case the default value "" is used). - @param url: any URL with or without parameters - @return: a dictionary of the parameters, if any was given, or {} - """ - dict_ = {} - if "/" in url: - # keep the part after the last "/" - url = url[url.rindex("/") + 1:] - if url.startswith("?"): - # remove the first "?" - url = url[1:] - pairs = url.split("&") - for pair in pairs: - if len(pair) < 3: - continue - kv = pair.split("=", 1) - dict_[kv[0]] = kv[1] if len(kv) > 1 else "" - return dict_ - - -def addURLToText(string, new_target=True): - """Check a text for what looks like an URL and make it clickable. - - @param string (unicode): text to process - @param new_target (bool): if True, make the link open in a new window - """ - # XXX: report any change to libervia.browser.strings.addURLToText - def repl(match): - url = match.group(0) - if not re.match(r"""[a-z]{3,}://|mailto:|xmpp:""", url): - url = "http://" + url - target = ' target="_blank"' if new_target else '' - return '%s' % (url, target, match.group(0)) - return RE_URL.sub(repl, string) - - -def addURLToImage(string): - """Check a XHTML text for what looks like an imageURL and make it clickable. - - @param string (unicode): text to process - """ - # XXX: report any change to libervia.browser.strings.addURLToImage - def repl(match): - url = match.group(1) - return '%s' % (url, match.group(0)) - pattern = r"""]* src="([^"]+)"[^>]*>""" - return re.sub(pattern, repl, string) - - -def fixXHTMLLinks(xhtml): - """Add http:// if the scheme is missing and force opening in a new window. - - @param string (unicode): XHTML Content - """ - subs = [] - for match in re.finditer(r'', xhtml): - tag = match.group(0) - url = re.search(r'href="([^"]*)"', tag) - if url and not url.group(1).startswith("#"): # skip internal anchor - if not re.search(r'target="([^"]*)"', tag): # no target - subs.append((tag, '. - -"""This library help manage XML used in SàT frontends """ - -# we don't import minidom as a different class can be used in frontends -# (e.g. NativeDOM in Libervia) - - -def inlineRoot(doc): - """ make the root attribute inline - @param root_node: minidom's Document compatible class - @return: plain XML - """ - root_elt = doc.documentElement - if root_elt.hasAttribute('style'): - styles_raw = root_elt.getAttribute('style') - styles = styles_raw.split(';') - new_styles = [] - for style in styles: - try: - key, value = style.split(':') - except ValueError: - continue - if key.strip().lower() == 'display': - value = 'inline' - new_styles.append('%s: %s' % (key.strip(), value.strip())) - root_elt.setAttribute('style', "; ".join(new_styles)) - else: - root_elt.setAttribute('style', 'display: inline') - return root_elt.toxml() - diff -r bd30dc3ffe5a -r 26edcf3a30eb frontends/src/tools/xmlui.py --- a/frontends/src/tools/xmlui.py Mon Apr 02 08:56:24 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,858 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8 -*- - -# SàT frontend tools -# 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 . - -from sat.core.i18n import _ -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.quick_frontend.constants import Const as C -from sat.core import exceptions - - -class_map = {} -CLASS_PANEL = 'panel' -CLASS_DIALOG = 'dialog' -CURRENT_LABEL = 'current_label' - -class InvalidXMLUI(Exception): - pass - - -class ClassNotRegistedError(Exception): - pass - - -# FIXME: this method is duplicated in frontends.tools.xmlui.getText -def getText(node): - """Get child text nodes - @param node: dom Node - @return: joined unicode text of all nodes - - """ - data = [] - for child in node.childNodes: - if child.nodeType == child.TEXT_NODE: - data.append(child.wholeText) - return u"".join(data) - - -class Widget(object): - """base Widget""" - pass - - -class EmptyWidget(Widget): - """Just a placeholder widget""" - pass - - -class TextWidget(Widget): - """Non interactive text""" - pass - - -class LabelWidget(Widget): - """Non interactive text""" - pass - - -class JidWidget(Widget): - """Jabber ID""" - pass - - -class DividerWidget(Widget): - """Separator""" - pass - - -class StringWidget(Widget): - """Input widget wich require a string - - often called Edit in toolkits - """ - pass - - -class JidInputWidget(Widget): - """Input widget wich require a string - - often called Edit in toolkits - """ - pass - - -class PasswordWidget(Widget): - """Input widget with require a masked string""" - pass - - -class TextBoxWidget(Widget): - """Input widget with require a long, possibly multilines string - often called TextArea in toolkits - """ - pass - - -class BoolWidget(Widget): - """Input widget with require a boolean value - often called CheckBox in toolkits - """ - pass - - -class IntWidget(Widget): - """Input widget with require an integer""" - pass - - -class ButtonWidget(Widget): - """A clickable widget""" - pass - - -class ListWidget(Widget): - """A widget able to show/choose one or several strings in a list""" - pass - - -class JidsListWidget(Widget): - """A widget able to show/choose one or several strings in a list""" - pass - - -class Container(Widget): - """Widget which can contain other ones with a specific layout""" - - @classmethod - def _xmluiAdapt(cls, instance): - """Make cls as instance.__class__ - - cls must inherit from original instance class - Usefull when you get a class from UI toolkit - """ - assert instance.__class__ in cls.__bases__ - instance.__class__ = type(cls.__name__, cls.__bases__, dict(cls.__dict__)) - - -class PairsContainer(Container): - """Widgets are disposed in rows of two (usually label/input)""" - pass - - -class LabelContainer(Container): - """Widgets are associated with label or empty widget""" - pass - - -class TabsContainer(Container): - """A container which several other containers in tabs - - Often called Notebook in toolkits - """ - pass - -class VerticalContainer(Container): - """Widgets are disposed vertically""" - pass - - -class AdvancedListContainer(Container): - """Widgets are disposed in rows with advaned features""" - pass - - -class Dialog(object): - """base dialog""" - - def __init__(self, _xmlui_parent): - self._xmlui_parent = _xmlui_parent - - def _xmluiValidated(self, data=None): - if data is None: - data = {} - self._xmluiSetData(C.XMLUI_STATUS_VALIDATED, data) - self._xmluiSubmit(data) - - def _xmluiCancelled(self): - data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE} - self._xmluiSetData(C.XMLUI_STATUS_CANCELLED, data) - self._xmluiSubmit(data) - - def _xmluiSubmit(self, data): - if self._xmlui_parent.submit_id is None: - log.debug(_("Nothing to submit")) - else: - self._xmlui_parent.submit(data) - - def _xmluiSetData(self, status, data): - pass - - -class MessageDialog(Dialog): - """Dialog with a OK/Cancel type configuration""" - pass - - -class NoteDialog(Dialog): - """Short message which doesn't need user confirmation to disappear""" - pass - - -class ConfirmDialog(Dialog): - """Dialog with a OK/Cancel type configuration""" - - def _xmluiSetData(self, status, data): - if status == C.XMLUI_STATUS_VALIDATED: - data[C.XMLUI_DATA_ANSWER] = C.BOOL_TRUE - elif status == C.XMLUI_STATUS_CANCELLED: - data[C.XMLUI_DATA_ANSWER] = C.BOOL_FALSE - - -class FileDialog(Dialog): - """Dialog with a OK/Cancel type configuration""" - pass - - -class XMLUIBase(object): - """Base class to construct SàT XML User Interface - - This class must not be instancied directly - """ - - def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, profile=C.PROF_KEY_NONE): - """Initialise the XMLUI instance - - @param host: %(doc_host)s - @param parsed_dom: main parsed dom - @param title: force the title, or use XMLUI one if None - @param flags: list of string which can be: - - NO_CANCEL: the UI can't be cancelled - - FROM_BACKEND: the UI come from backend (i.e. it's not the direct result of user operation) - @param callback(callable, None): if not None, will be used with launchAction: - - if None is used, default behaviour will be used (closing the dialog and calling host.actionManager) - - if a callback is provided, it will be used instead, so you'll have to manage - dialog closing or new xmlui to display, or other action (you can call host.actionManager) - """ - self.host = host - top=parsed_dom.documentElement - self.session_id = top.getAttribute("session_id") or None - self.submit_id = top.getAttribute("submit") or None - self.xmlui_title = title or top.getAttribute("title") or u"" - if flags is None: - flags = [] - self.flags = flags - self.callback = callback or self._defaultCb - self.profile = profile - - @property - def user_action(self): - return "FROM_BACKEND" not in self.flags - - def _defaultCb(self, data, cb_id, profile): - # TODO: when XMLUI updates will be managed, the _xmluiClose - # must be called only if there is not update - self._xmluiClose() - self.host.actionManager(data, profile=profile) - - def _isAttrSet(self, name, node): - """Return widget boolean attribute status - - @param name: name of the attribute (e.g. "read_only") - @param node: Node instance - @return (bool): True if widget's attribute is set (C.BOOL_TRUE) - """ - read_only = node.getAttribute(name) or C.BOOL_FALSE - return read_only.lower().strip() == C.BOOL_TRUE - - def _getChildNode(self, node, name): - """Return the first child node with the given name - - @param node: Node instance - @param name: name of the wanted node - - @return: The found element or None - """ - for child in node.childNodes: - if child.nodeName == name: - return child - return None - - def submit(self, data): - self._xmluiClose() - if self.submit_id is None: - raise ValueError("Can't submit is self.submit_id is not set") - if "session_id" in data: - raise ValueError("session_id must no be used in data, it is automaticaly filled with self.session_id if present") - if self.session_id is not None: - data["session_id"] = self.session_id - self._xmluiLaunchAction(self.submit_id, data) - - def _xmluiLaunchAction(self, action_id, data): - self.host.launchAction(action_id, data, callback=self.callback, profile=self.profile) - - def _xmluiClose(self): - """Close the window/popup/... where the constructor XMLUI is - - this method must be overrided - """ - raise NotImplementedError - - -class ValueGetter(object): - """dict like object which return values of widgets""" - - def __init__(self, widgets, attr='value'): - self.attr = attr - self.widgets = widgets - - def __getitem__(self, name): - return getattr(self.widgets[name], self.attr) - - def __getattr__(self, name): - return self.__getitem__(name) - - def keys(self): - return self.widgets.keys() - - -class XMLUIPanel(XMLUIBase): - """XMLUI Panel - - New frontends can inherit this class to easily implement XMLUI - @property widget_factory: factory to create frontend-specific widgets - @property dialog_factory: factory to create frontend-specific dialogs - """ - widget_factory = None - - def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, ignore=None, whitelist=None, profile=C.PROF_KEY_NONE): - """ - - @param title(unicode, None): title of the - @property widgets(dict): widget name => widget map - @property widget_value(ValueGetter): retrieve widget value from it's name - """ - super(XMLUIPanel, self).__init__(host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile) - self.ctrl_list = {} # input widget, used mainly for forms - self.widgets = {} # allow to access any named widgets - self.widget_value = ValueGetter(self.widgets) - self._main_cont = None - if ignore is None: - ignore = [] - self._ignore = ignore - if whitelist is not None: - if ignore: - raise exceptions.InternalError('ignore and whitelist must not be used at the same time') - self._whitelist = whitelist - else: - self._whitelist = None - self.constructUI(parsed_dom) - - def escape(self, name): - """Return escaped name for forms""" - return u"%s%s" % (C.SAT_FORM_PREFIX, name) - - @property - def main_cont(self): - return self._main_cont - - @main_cont.setter - def main_cont(self, value): - if self._main_cont is not None: - raise ValueError(_("XMLUI can have only one main container")) - self._main_cont = value - - def _parseChilds(self, _xmlui_parent, current_node, wanted = ('container',), data = None): - """Recursively parse childNodes of an element - - @param _xmlui_parent: widget container with '_xmluiAppend' method - @param current_node: element from which childs will be parsed - @param wanted: list of tag names that can be present in the childs to be SàT XMLUI compliant - @param data(None, dict): additionnal data which are needed in some cases - """ - for node in current_node.childNodes: - if data is None: - data = {} - if wanted and not node.nodeName in wanted: - raise InvalidXMLUI('Unexpected node: [%s]' % node.nodeName) - - if node.nodeName == "container": - type_ = node.getAttribute('type') - if _xmlui_parent is self and type_ != 'vertical': - # main container is not a VerticalContainer and we want one, so we create one to wrap it - _xmlui_parent = self.widget_factory.createVerticalContainer(self) - self.main_cont = _xmlui_parent - if type_ == "tabs": - cont = self.widget_factory.createTabsContainer(_xmlui_parent) - self._parseChilds(_xmlui_parent, node, ('tab',), {'tabs_cont': cont}) - elif type_ == "vertical": - cont = self.widget_factory.createVerticalContainer(_xmlui_parent) - self._parseChilds(cont, node, ('widget', 'container')) - elif type_ == "pairs": - cont = self.widget_factory.createPairsContainer(_xmlui_parent) - self._parseChilds(cont, node, ('widget', 'container')) - elif type_ == "label": - cont = self.widget_factory.createLabelContainer(_xmlui_parent) - self._parseChilds(cont, node, ('widget', 'container'), {CURRENT_LABEL: None}) - elif type_ == "advanced_list": - try: - columns = int(node.getAttribute('columns')) - except (TypeError, ValueError): - raise exceptions.DataError("Invalid columns") - selectable = node.getAttribute('selectable') or 'no' - auto_index = node.getAttribute('auto_index') == C.BOOL_TRUE - data = {'index': 0} if auto_index else None - cont = self.widget_factory.createAdvancedListContainer(_xmlui_parent, columns, selectable) - callback_id = node.getAttribute("callback") or None - if callback_id is not None: - if selectable == 'no': - raise ValueError("can't have selectable=='no' and callback_id at the same time") - cont._xmlui_callback_id = callback_id - cont._xmluiOnSelect(self.onAdvListSelect) - - self._parseChilds(cont, node, ('row',), data) - else: - log.warning(_("Unknown container [%s], using default one") % type_) - cont = self.widget_factory.createVerticalContainer(_xmlui_parent) - self._parseChilds(cont, node, ('widget', 'container')) - try: - xmluiAppend = _xmlui_parent._xmluiAppend - except (AttributeError, TypeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - if _xmlui_parent is self: - self.main_cont = cont - else: - raise Exception(_("Internal Error, container has not _xmluiAppend method")) - else: - xmluiAppend(cont) - - elif node.nodeName == 'tab': - name = node.getAttribute('name') - label = node.getAttribute('label') - selected = C.bool(node.getAttribute('selected') or C.BOOL_FALSE) - if not name or not 'tabs_cont' in data: - raise InvalidXMLUI - if self.type == 'param': - self._current_category = name #XXX: awful hack because params need category and we don't keep parent - tab_cont = data['tabs_cont'] - new_tab = tab_cont._xmluiAddTab(label or name, selected) - self._parseChilds(new_tab, node, ('widget', 'container')) - - elif node.nodeName == 'row': - try: - index = str(data['index']) - except KeyError: - index = node.getAttribute('index') or None - else: - data['index'] += 1 - _xmlui_parent._xmluiAddRow(index) - self._parseChilds(_xmlui_parent, node, ('widget', 'container')) - - elif node.nodeName == "widget": - name = node.getAttribute("name") - if name and (name in self._ignore or self._whitelist is not None and name not in self._whitelist): - # current widget is ignored, but there may be already a label - if CURRENT_LABEL in data: - # if so, we remove it from parent - _xmlui_parent._xmluiRemove(data.pop(CURRENT_LABEL)) - continue - type_ = node.getAttribute("type") - value_elt = self._getChildNode(node, "value") - if value_elt is not None: - value = getText(value_elt) - else: - value = node.getAttribute("value") if node.hasAttribute('value') else u'' - if type_=="empty": - ctrl = self.widget_factory.createEmptyWidget(_xmlui_parent) - if CURRENT_LABEL in data: - data[CURRENT_LABEL] = None - elif type_=="text": - ctrl = self.widget_factory.createTextWidget(_xmlui_parent, value) - elif type_=="label": - ctrl = self.widget_factory.createLabelWidget(_xmlui_parent, value) - data[CURRENT_LABEL] = ctrl - elif type_=="jid": - ctrl = self.widget_factory.createJidWidget(_xmlui_parent, value) - elif type_=="divider": - style = node.getAttribute("style") or 'line' - ctrl = self.widget_factory.createDividerWidget(_xmlui_parent, style) - elif type_=="string": - ctrl = self.widget_factory.createStringWidget(_xmlui_parent, value, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_=="jid_input": - ctrl = self.widget_factory.createJidInputWidget(_xmlui_parent, value, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_=="password": - ctrl = self.widget_factory.createPasswordWidget(_xmlui_parent, value, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_=="textbox": - ctrl = self.widget_factory.createTextBoxWidget(_xmlui_parent, value, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_=="bool": - ctrl = self.widget_factory.createBoolWidget(_xmlui_parent, value==C.BOOL_TRUE, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_ == "int": - ctrl = self.widget_factory.createIntWidget(_xmlui_parent, value, self._isAttrSet("read_only", node)) - self.ctrl_list[name] = ({'type':type_, 'control':ctrl}) - elif type_ == "list": - style = [] if node.getAttribute("multi") == 'yes' else ['single'] - for attr in (u'noselect', u'extensible', u'reducible', u'inline'): - if node.getAttribute(attr) == 'yes': - style.append(attr) - _options = [(option.getAttribute("value"), option.getAttribute("label")) for option in node.getElementsByTagName("option")] - _selected = [option.getAttribute("value") for option in node.getElementsByTagName("option") if option.getAttribute('selected') == C.BOOL_TRUE] - ctrl = self.widget_factory.createListWidget(_xmlui_parent, _options, _selected, style) - self.ctrl_list[name] = ({'type': type_, 'control': ctrl}) - elif type_ == "jids_list": - style = [] - jids = [getText(jid_) for jid_ in node.getElementsByTagName("jid")] - ctrl = self.widget_factory.createJidsListWidget(_xmlui_parent, jids, style) - self.ctrl_list[name] = ({'type': type_, 'control': ctrl}) - elif type_=="button": - callback_id = node.getAttribute("callback") - ctrl = self.widget_factory.createButtonWidget(_xmlui_parent, value, self.onButtonPress) - ctrl._xmlui_param_id = (callback_id, [field.getAttribute('name') for field in node.getElementsByTagName("field_back")]) - else: - log.error(_("FIXME FIXME FIXME: widget type [%s] is not implemented") % type_) - raise NotImplementedError(_("FIXME FIXME FIXME: type [%s] is not implemented") % type_) - - if name: - self.widgets[name] = ctrl - - if self.type == 'param' and type_ not in ('text', 'button'): - try: - ctrl._xmluiOnChange(self.onParamChange) - ctrl._param_category = self._current_category - except (AttributeError, TypeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - if not isinstance(ctrl, (EmptyWidget, TextWidget, LabelWidget, JidWidget)): - log.warning(_("No change listener on [%s]") % ctrl) - - elif type_ != 'text': - callback = node.getAttribute("internal_callback") or None - if callback: - fields = [field.getAttribute('name') for field in node.getElementsByTagName("internal_field")] - cb_data = self.getInternalCallbackData(callback, node) - ctrl._xmlui_param_internal = (callback, fields, cb_data) - if type_ == 'button': - ctrl._xmluiOnClick(self.onChangeInternal) - else: - ctrl._xmluiOnChange(self.onChangeInternal) - - ctrl._xmlui_name = name - _xmlui_parent._xmluiAppend(ctrl) - if CURRENT_LABEL in data and not isinstance(ctrl, LabelWidget): - # this key is set in LabelContainer, when present - # we can associate the label with the widget it is labelling - data.pop(CURRENT_LABEL)._xmlui_for_name = name - - else: - raise NotImplementedError(_('Unknown tag [%s]') % node.nodeName) - - def constructUI(self, parsed_dom, post_treat=None): - """Actually construct the UI - - @param parsed_dom: main parsed dom - @param post_treat: frontend specific treatments to do once the UI is constructed - @return: constructed widget - """ - top=parsed_dom.documentElement - self.type = top.getAttribute("type") - if top.nodeName != "sat_xmlui" or not self.type in ['form', 'param', 'window', 'popup']: - raise InvalidXMLUI - - if self.type == 'param': - self.param_changed = set() - - self._parseChilds(self, parsed_dom.documentElement) - - if post_treat is not None: - post_treat() - - def _xmluiSetParam(self, name, value, category): - self.host.bridge.setParam(name, value, category, profile_key=self.profile) - - ##EVENTS## - - def onParamChange(self, ctrl): - """Called when type is param and a widget to save is modified - - @param ctrl: widget modified - """ - assert self.type == "param" - self.param_changed.add(ctrl) - - def onAdvListSelect(self, ctrl): - data = {} - widgets = ctrl._xmluiGetSelectedWidgets() - for wid in widgets: - try: - name = self.escape(wid._xmlui_name) - value = wid._xmluiGetValue() - data[name] = value - except (AttributeError, TypeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - pass - idx = ctrl._xmluiGetSelectedIndex() - if idx is not None: - data['index'] = idx - callback_id = ctrl._xmlui_callback_id - if callback_id is None: - log.info(_("No callback_id found")) - return - self._xmluiLaunchAction(callback_id, data) - - def onButtonPress(self, button): - """Called when an XMLUI button is clicked - - Launch the action associated to the button - @param button: the button clicked - """ - callback_id, fields = button._xmlui_param_id - if not callback_id: # the button is probably bound to an internal action - return - data = {} - for field in fields: - escaped = self.escape(field) - ctrl = self.ctrl_list[field] - if isinstance(ctrl['control'], ListWidget): - data[escaped] = u'\t'.join(ctrl['control']._xmluiGetSelectedValues()) - else: - data[escaped] = ctrl['control']._xmluiGetValue() - self._xmluiLaunchAction(callback_id, data) - - def onChangeInternal(self, ctrl): - """Called when a widget that has been bound to an internal callback is changed. - - This is used to perform some UI actions without communicating with the backend. - See sat.tools.xml_tools.Widget.setInternalCallback for more details. - @param ctrl: widget modified - """ - action, fields, data = ctrl._xmlui_param_internal - if action not in ('copy', 'move', 'groups_of_contact'): - raise NotImplementedError(_("FIXME: XMLUI internal action [%s] is not implemented") % action) - - def copy_move(source, target): - """Depending of 'action' value, copy or move from source to target.""" - if isinstance(target, ListWidget): - if isinstance(source, ListWidget): - values = source._xmluiGetSelectedValues() - else: - values = [source._xmluiGetValue()] - if action == 'move': - source._xmluiSetValue('') - values = [value for value in values if value] - if values: - target._xmluiAddValues(values, select=True) - else: - if isinstance(source, ListWidget): - value = u', '.join(source._xmluiGetSelectedValues()) - else: - value = source._xmluiGetValue() - if action == 'move': - source._xmluiSetValue('') - target._xmluiSetValue(value) - - def groups_of_contact(source, target): - """Select in target the groups of the contact which is selected in source.""" - assert isinstance(source, ListWidget) - assert isinstance(target, ListWidget) - try: - contact_jid_s = source._xmluiGetSelectedValues()[0] - except IndexError: - return - target._xmluiSelectValues(data[contact_jid_s]) - pass - - source = None - for field in fields: - widget = self.ctrl_list[field]['control'] - if not source: - source = widget - continue - if action in ('copy', 'move'): - copy_move(source, widget) - elif action == 'groups_of_contact': - groups_of_contact(source, widget) - source = None - - def getInternalCallbackData(self, action, node): - """Retrieve from node the data needed to perform given action. - - @param action (string): a value from the one that can be passed to the - 'callback' parameter of sat.tools.xml_tools.Widget.setInternalCallback - @param node (DOM Element): the node of the widget that triggers the callback - """ - # TODO: it would be better to not have a specific way to retrieve - # data for each action, but instead to have a generic method to - # extract any kind of data structure from the 'internal_data' element. - - try: # data is stored in the first 'internal_data' element of the node - data_elts = node.getElementsByTagName('internal_data')[0].childNodes - except IndexError: - return None - data = {} - if action == 'groups_of_contact': # return a dict(key: string, value: list[string]) - for elt in data_elts: - jid_s = elt.getAttribute('name') - data[jid_s] = [] - for value_elt in elt.childNodes: - data[jid_s].append(value_elt.getAttribute('name')) - return data - - def onFormSubmitted(self, ignore=None): - """An XMLUI form has been submited - - call the submit action associated with this form - """ - selected_values = [] - for ctrl_name in self.ctrl_list: - escaped = self.escape(ctrl_name) - ctrl = self.ctrl_list[ctrl_name] - if isinstance(ctrl['control'], ListWidget): - selected_values.append((escaped, u'\t'.join(ctrl['control']._xmluiGetSelectedValues()))) - else: - selected_values.append((escaped, ctrl['control']._xmluiGetValue())) - if self.submit_id is not None: - data = dict(selected_values) - self.submit(data) - else: - log.warning(_("The form data is not sent back, the type is not managed properly")) - self._xmluiClose() - - def onFormCancelled(self, ignore=None): - """Called when a form is cancelled""" - log.debug(_("Cancelling form")) - if self.submit_id is not None: - data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE} - self.submit(data) - else: - log.warning(_("The form data is not sent back, the type is not managed properly")) - self._xmluiClose() - - def onSaveParams(self, ignore=None): - """Params are saved, we send them to backend - - self.type must be param - """ - assert self.type == 'param' - for ctrl in self.param_changed: - if isinstance(ctrl, ListWidget): - value = u'\t'.join(ctrl._xmluiGetSelectedValues()) - else: - value = ctrl._xmluiGetValue() - param_name = ctrl._xmlui_name.split(C.SAT_PARAM_SEPARATOR)[1] - self._xmluiSetParam(param_name, value, ctrl._param_category) - - self._xmluiClose() - - def show(self, *args, **kwargs): - pass - - -class XMLUIDialog(XMLUIBase): - dialog_factory = None - - def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, ignore=None, profile=C.PROF_KEY_NONE): - super(XMLUIDialog, self).__init__(host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile) - top=parsed_dom.documentElement - dlg_elt = self._getChildNode(top, "dialog") - if dlg_elt is None: - raise ValueError("Invalid XMLUI: no Dialog element found !") - dlg_type = dlg_elt.getAttribute("type") or C.XMLUI_DIALOG_MESSAGE - try: - mess_elt = self._getChildNode(dlg_elt, C.XMLUI_DATA_MESS) - message = getText(mess_elt) - except (TypeError, AttributeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - message = "" - level = dlg_elt.getAttribute(C.XMLUI_DATA_LVL) or C.XMLUI_DATA_LVL_INFO - - if dlg_type == C.XMLUI_DIALOG_MESSAGE: - self.dlg = self.dialog_factory.createMessageDialog(self, self.xmlui_title, message, level) - elif dlg_type == C.XMLUI_DIALOG_NOTE: - self.dlg = self.dialog_factory.createNoteDialog(self, self.xmlui_title, message, level) - elif dlg_type == C.XMLUI_DIALOG_CONFIRM: - try: - buttons_elt = self._getChildNode(dlg_elt, "buttons") - buttons_set = buttons_elt.getAttribute("set") or C.XMLUI_DATA_BTNS_SET_DEFAULT - except (TypeError, AttributeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - buttons_set = C.XMLUI_DATA_BTNS_SET_DEFAULT - self.dlg = self.dialog_factory.createConfirmDialog(self, self.xmlui_title, message, level, buttons_set) - elif dlg_type == C.XMLUI_DIALOG_FILE: - try: - file_elt = self._getChildNode(dlg_elt, "file") - filetype = file_elt.getAttribute("type") or C.XMLUI_DATA_FILETYPE_DEFAULT - except (TypeError, AttributeError): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError - filetype = C.XMLUI_DATA_FILETYPE_DEFAULT - self.dlg = self.dialog_factory.createFileDialog(self, self.xmlui_title, message, level, filetype) - else: - raise ValueError("Unknown dialog type [%s]" % dlg_type) - - def show(self): - self.dlg._xmluiShow() - - def _xmluiClose(self): - self.dlg._xmluiClose() - - -def registerClass(type_, class_): - """Register the class to use with the factory - - @param type_: one of: - CLASS_PANEL: classical XMLUI interface - CLASS_DIALOG: XMLUI dialog - @param class_: the class to use to instanciate given type - """ - assert type_ in (CLASS_PANEL, CLASS_DIALOG) - class_map[type_] = class_ - - -def create(host, xml_data, title=None, flags=None, dom_parse=None, dom_free=None, callback=None, ignore=None, whitelist=None, profile=C.PROF_KEY_NONE): - """ - @param dom_parse: methode equivalent to minidom.parseString (but which must manage unicode), or None to use default one - @param dom_free: method used to free the parsed DOM - @param ignore(list[unicode], None): name of widgets to ignore - widgets with name in this list and their label will be ignored - @param whitelist(list[unicode], None): name of widgets to keep - when not None, only widgets in this list and their label will be kept - mutually exclusive with ignore - """ - if dom_parse is None: - from xml.dom import minidom - dom_parse = lambda xml_data: minidom.parseString(xml_data.encode('utf-8')) - dom_free = lambda parsed_dom: parsed_dom.unlink() - else: - dom_parse = dom_parse - dom_free = dom_free or (lambda parsed_dom: None) - parsed_dom = dom_parse(xml_data) - top=parsed_dom.documentElement - ui_type = top.getAttribute("type") - try: - if ui_type != C.XMLUI_DIALOG: - cls = class_map[CLASS_PANEL] - else: - cls = class_map[CLASS_DIALOG] - except KeyError: - raise ClassNotRegistedError(_("You must register classes with registerClass before creating a XMLUI")) - - xmlui = cls(host, parsed_dom, - title = title, - flags = flags, - callback = callback, - ignore = ignore, - whitelist = whitelist, - profile = profile) - dom_free(parsed_dom) - return xmlui diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/__init__.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,3 @@ +from .core.constants import Const as C + +__version__ = C.APP_VERSION diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/base_constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/base_constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,332 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +"""base constructor class""" + +from sat.bridge.bridge_constructor.constants import Const as C +from ConfigParser import NoOptionError +import sys +import os +import os.path +import re +from importlib import import_module + + +class ParseError(Exception): + #Used when the signature parsing is going wrong (invalid signature ?) + pass + + +class Constructor(object): + NAME = None # used in arguments parsing, filename will be used if not set + # following attribute are used by default generation method + # they can be set to dict of strings using python formatting syntax + # dict keys will be used to select part to replace (e.g. "signals" key will + # replace ##SIGNALS_PART## in template), while the value is the format + # keys starting with "signal" will be used for signals, while ones starting with + # "method" will be used for methods + # check D-Bus constructor for an example + CORE_FORMATS = None + CORE_TEMPLATE = None + CORE_DEST = None + FRONTEND_FORMATS = None + FRONTEND_TEMPLATE = None + FRONTEND_DEST = None + + # set to False if your bridge need only core + FRONTEND_ACTIVATE = True + + def __init__(self, bridge_template, options): + self.bridge_template = bridge_template + self.args = options + + @property + def constructor_dir(self): + constructor_mod = import_module(self.__module__) + return os.path.dirname(constructor_mod.__file__) + + def getValues(self, name): + """Return values of a function in a dict + @param name: Name of the function to get + @return: dict, each key has the config value or None if the value is not set""" + function = {} + for option in ['type', 'category', 'sig_in', 'sig_out', 'doc']: + try: + value = self.bridge_template.get(name, option) + except NoOptionError: + value = None + function[option] = value + return function + + def getDefault(self, name): + """Return default values of a function in a dict + @param name: Name of the function to get + @return: dict, each key is the integer param number (no key if no default value)""" + default_dict = {} + def_re = re.compile(r"param_(\d+)_default") + + for option in self.bridge_template.options(name): + match = def_re.match(option) + if match: + try: + idx = int(match.group(1)) + except ValueError: + raise ParseError("Invalid value [%s] for parameter number" % match.group(1)) + default_dict[idx] = self.bridge_template.get(name, option) + + return default_dict + + def getFlags(self, name): + """Return list of flags set for this function + + @param name: Name of the function to get + @return: List of flags (string) + """ + flags = [] + for option in self.bridge_template.options(name): + if option in C.DECLARATION_FLAGS: + flags.append(option) + return flags + + def getArgumentsDoc(self, name): + """Return documentation of arguments + @param name: Name of the function to get + @return: dict, each key is the integer param number (no key if no argument doc), value is a tuple (name, doc)""" + doc_dict = {} + option_re = re.compile(r"doc_param_(\d+)") + value_re = re.compile(r"^(\w+): (.*)$", re.MULTILINE | re.DOTALL) + for option in self.bridge_template.options(name): + if option == 'doc_return': + doc_dict['return'] = self.bridge_template.get(name, option) + continue + match = option_re.match(option) + if match: + try: + idx = int(match.group(1)) + except ValueError: + raise ParseError("Invalid value [%s] for parameter number" % match.group(1)) + value_match = value_re.match(self.bridge_template.get(name, option)) + if not value_match: + raise ParseError("Invalid value for parameter doc [%i]" % idx) + doc_dict[idx] = (value_match.group(1), value_match.group(2)) + return doc_dict + + def getDoc(self, name): + """Return documentation of the method + @param name: Name of the function to get + @return: string documentation, or None""" + if self.bridge_template.has_option(name, "doc"): + return self.bridge_template.get(name, "doc") + return None + + def argumentsParser(self, signature): + """Generator which return individual arguments signatures from a global signature""" + start = 0 + i = 0 + + while i < len(signature): + if signature[i] not in ['b', 'y', 'n', 'i', 'x', 'q', 'u', 't', 'd', 's', 'a']: + raise ParseError("Unmanaged attribute type [%c]" % signature[i]) + + if signature[i] == 'a': + i += 1 + if signature[i] != '{' and signature[i] != '(': # FIXME: must manage tuples out of arrays + i += 1 + yield signature[start:i] + start = i + continue # we have a simple type for the array + opening_car = signature[i] + assert(opening_car in ['{', '(']) + closing_car = '}' if opening_car == '{' else ')' + opening_count = 1 + while (True): # we have a dict or a list of tuples + i += 1 + if i >= len(signature): + raise ParseError("missing }") + if signature[i] == opening_car: + opening_count += 1 + if signature[i] == closing_car: + opening_count -= 1 + if opening_count == 0: + break + i += 1 + yield signature[start:i] + start = i + + def getArguments(self, signature, name=None, default=None, unicode_protect=False): + """Return arguments to user given a signature + + @param signature: signature in the short form (using s,a,i,b etc) + @param name: dictionary of arguments name like given by getArgumentsDoc + @param default: dictionary of default values, like given by getDefault + @param unicode_protect: activate unicode protection on strings (return strings as unicode(str)) + @return (str): arguments that correspond to a signature (e.g.: "sss" return "arg1, arg2, arg3") + """ + idx = 0 + attr_string = [] + + for arg in self.argumentsParser(signature): + attr_string.append(("unicode(%(name)s)%(default)s" if (unicode_protect and arg == 's') else "%(name)s%(default)s") % { + 'name': name[idx][0] if (name and idx in name) else "arg_%i" % idx, + 'default': "=" + default[idx] if (default and idx in default) else ''}) + # give arg_1, arg2, etc or name1, name2=default, etc. + #give unicode(arg_1), unicode(arg_2), etc. if unicode_protect is set and arg is a string + idx += 1 + + return ", ".join(attr_string) + + def getTemplatePath(self, template_file): + """return template path corresponding to file name + + @param template_file(str): name of template file + """ + return os.path.join(self.constructor_dir, template_file) + + def core_completion_method(self, completion, function, default, arg_doc, async_): + """override this method to extend completion""" + pass + + def core_completion_signal(self, completion, function, default, arg_doc, async_): + """override this method to extend completion""" + pass + + def frontend_completion_method(self, completion, function, default, arg_doc, async_): + """override this method to extend completion""" + pass + + def frontend_completion_signal(self, completion, function, default, arg_doc, async_): + """override this method to extend completion""" + pass + + + def generate(self, side): + """generate bridge + + call generateCoreSide or generateFrontendSide if they exists + else call generic self._generate method + """ + try: + if side == "core": + method = self.generateCoreSide + elif side == "frontend": + if not self.FRONTEND_ACTIVATE: + print(u"This constructor only handle core, please use core side") + sys.exit(1) + method = self.generateFrontendSide + except AttributeError: + self._generate(side) + else: + method() + + def _generate(self, side): + """generate the backend + + this is a generic method which will use formats found in self.CORE_SIGNAL_FORMAT + and self.CORE_METHOD_FORMAT (standard format method will be used) + @param side(str): core or frontend + """ + side_vars = [] + for var in ('FORMATS', 'TEMPLATE', 'DEST'): + attr = "{}_{}".format(side.upper(), var) + value = getattr(self, attr) + if value is None: + raise NotImplementedError + side_vars.append(value) + + FORMATS, TEMPLATE, DEST = side_vars + del side_vars + + parts = {part.upper():[] for part in FORMATS} + sections = self.bridge_template.sections() + sections.sort() + for section in sections: + function = self.getValues(section) + print ("Adding %s %s" % (section, function["type"])) + default = self.getDefault(section) + arg_doc = self.getArgumentsDoc(section) + async_ = "async" in self.getFlags(section) + completion = { + 'sig_in': function['sig_in'] or '', + 'sig_out': function['sig_out'] or '', + 'category': 'plugin' if function['category'] == 'plugin' else 'core', + 'name': section, + # arguments with default values + 'args': self.getArguments(function['sig_in'], name=arg_doc, default=default), + } + + extend_method = getattr(self, "{}_completion_{}".format(side, function["type"])) + extend_method(completion, function, default, arg_doc, async_) + + for part, fmt in FORMATS.iteritems(): + if part.startswith(function["type"]): + parts[part.upper()].append(fmt.format(**completion)) + + + #at this point, signals_part, methods_part and direct_calls should be filled, + #we just have to place them in the right part of the template + bridge = [] + const_override = {env[len(C.ENV_OVERRIDE):]:v for env,v in os.environ.iteritems() if env.startswith(C.ENV_OVERRIDE)} + template_path = self.getTemplatePath(TEMPLATE) + try: + with open(template_path) as template: + for line in template: + + for part, extend_list in parts.iteritems(): + if line.startswith('##{}_PART##'.format(part)): + bridge.extend(extend_list) + break + else: + # the line is not a magic part replacement + if line.startswith('const_'): + const_name = line[len('const_'):line.find(' = ')].strip() + if const_name in const_override: + print("const {} overriden".format(const_name)) + bridge.append('const_{} = {}'.format(const_name, const_override[const_name])) + continue + bridge.append(line.replace('\n', '')) + except IOError: + print ("can't open template file [{}]".format(template_path)) + sys.exit(1) + + #now we write to final file + self.finalWrite(DEST, bridge) + + def finalWrite(self, filename, file_buf): + """Write the final generated file in [dest dir]/filename + + @param filename: name of the file to generate + @param file_buf: list of lines (stings) of the file + """ + if os.path.exists(self.args.dest_dir) and not os.path.isdir(self.args.dest_dir): + print ("The destination dir [%s] can't be created: a file with this name already exists !") + sys.exit(1) + try: + if not os.path.exists(self.args.dest_dir): + os.mkdir(self.args.dest_dir) + full_path = os.path.join(self.args.dest_dir, filename) + if os.path.exists(full_path) and not self.args.force: + print ("The destination file [%s] already exists ! Use --force to overwrite it" % full_path) + try: + with open(full_path, 'w') as dest_file: + dest_file.write('\n'.join(file_buf)) + except IOError: + print ("Can't open destination file [%s]" % full_path) + except OSError: + print("It's not possible to generate the file, check your permissions") + exit(1) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/bridge_constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/bridge_constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,97 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + + +from sat.bridge import bridge_constructor +from sat.bridge.bridge_constructor.constants import Const as C +from sat.bridge.bridge_constructor import constructors, base_constructor +import argparse +from ConfigParser import SafeConfigParser as Parser +from importlib import import_module +import os +import os.path + +#consts +__version__ = C.APP_VERSION + + +class BridgeConstructor(object): + + def importConstructors(self): + constructors_dir = os.path.dirname(constructors.__file__) + self.protocoles = {} + for dir_ in os.listdir(constructors_dir): + init_path = os.path.join(constructors_dir, dir_, '__init__.py') + constructor_path = os.path.join(constructors_dir, dir_, 'constructor.py') + module_path = "sat.bridge.bridge_constructor.constructors.{}.constructor".format(dir_) + if os.path.isfile(init_path) and os.path.isfile(constructor_path): + mod = import_module(module_path) + for attr in dir(mod): + obj = getattr(mod, attr) + if not isinstance(obj, type): + continue + if issubclass(obj, base_constructor.Constructor): + name = obj.NAME or dir_ + self.protocoles[name] = obj + break + if not self.protocoles: + raise ValueError("no protocole constructor found") + + def parse_args(self): + """Check command line options""" + parser = argparse.ArgumentParser(description=C.DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter) + + parser.add_argument("--version", action="version", version= __version__) + default_protocole = C.DEFAULT_PROTOCOLE if C.DEFAULT_PROTOCOLE in self.protocoles else self.protocoles[0] + parser.add_argument("-p", "--protocole", choices=sorted(self.protocoles), default=default_protocole, + help="generate bridge using PROTOCOLE (default: %(default)s)") # (default: %s, possible values: [%s])" % (DEFAULT_PROTOCOLE, ", ".join(MANAGED_PROTOCOLES))) + parser.add_argument("-s", "--side", choices=("core", "frontend"), default="core", + help="which side of the bridge do you want to make ?") # (default: %default, possible values: [core, frontend])") + default_template = os.path.join(os.path.dirname(bridge_constructor.__file__), 'bridge_template.ini') + parser.add_argument("-t", "--template", type=file, default=default_template, + help="use TEMPLATE to generate bridge (default: %(default)s)") + parser.add_argument("-f", "--force", action="store_true", + help=("force overwritting of existing files")) + parser.add_argument("-d", "--debug", action="store_true", + help=("add debug information printing")) + parser.add_argument("--no-unicode", action="store_false", dest="unicode", + help=("remove unicode type protection from string results")) + parser.add_argument("--flags", nargs='+', default=[], + help=("constructors' specific flags")) + parser.add_argument("--dest-dir", default=C.DEST_DIR_DEFAULT, + help=("directory when the generated files will be written (default: %(default)s)")) + + return parser.parse_args() + + def go(self): + self.importConstructors() + args = self.parse_args() + template_parser = Parser() + try: + template_parser.readfp(args.template) + except IOError: + print ("The template file doesn't exist or is not accessible") + exit(1) + constructor = self.protocoles[args.protocole](template_parser, args) + constructor.generate(args.side) + + +if __name__ == "__main__": + bc = BridgeConstructor() + bc.go() diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/bridge_template.ini --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/bridge_template.ini Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,798 @@ +[DEFAULT] +doc_profile=profile: Name of the profile. +doc_profile_key=profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile. +doc_security_limit=security_limit: -1 means no security, 0 is the maximum security then the higher the less secure + +;signals + +[connected] +type=signal +category=core +sig_in=ss +doc=Connection is done +doc_param_0=%(doc_profile)s +doc_param_1=jid_s: the JID that we were assigned by the server, as the resource might differ from the JID we asked for. + +[disconnected] +type=signal +category=core +sig_in=s +doc=Connection is finished or lost +doc_param_0=%(doc_profile)s + +[newContact] +type=signal +category=core +sig_in=sa{ss}ass +doc=New contact received in roster +doc_param_0=contact_jid: JID which has just been added +doc_param_1=attributes: Dictionary of attributes where keys are: + - name: name of the contact + - to: "True" if the contact give its presence information to us + - from: "True" if contact is registred to our presence information + - ask: "True" is subscription is pending +doc_param_2=groups: Roster's groups where the contact is +doc_param_3=%(doc_profile)s + +[messageNew] +type=signal +category=core +sig_in=sdssa{ss}a{ss}sa{ss}s +doc=A message has been received +doc_param_0=uid: unique ID of the message (id specific to SàT, this it *NOT* an XMPP id) +doc_param_1=timestamp: when the message was sent (or declared sent for delayed messages) +doc_param_2=from_jid: JID where the message is comming from +doc_param_3=to_jid: JID where the message must be sent +doc_param_4=message: message itself, can be in several languages (key is language code or '' for default) +doc_param_5=subject: subject of the message, can be in several languages (key is language code or '' for default) +doc_param_6=mess_type: Type of the message (cf RFC 6121 §5.2.2) + C.MESS_TYPE_INFO (system info) +doc_param_7=extra: extra message information, can have data added by plugins and/or: + - thread: id of the thread + - thread_parent: id of the parent of the current thread + - received_timestamp: date of receiption for delayed messages + - delay_sender: entity which has originally sent or which has delayed the message + - info_type: subtype for info messages +doc_param_8=%(doc_profile)s + +[presenceUpdate] +type=signal +category=core +sig_in=ssia{ss}s +doc=Somebody changed his presence information. +doc_param_0=entity_jid: JID from which we have presence informatios +doc_param_1=show: availability status (see RFC 6121 §4.7.2.1) +doc_param_2=priority: Priority level of the ressource (see RFC 6121 §4.7.2.3) +doc_param_3=statuses: Natural language description of the availability status (see RFC 6121 §4.7.2.2) +doc_param_4=%(doc_profile)s + +[subscribe] +type=signal +category=core +sig_in=sss +doc=Somebody wants to be added in roster +doc_param_0=sub_type: Subscription states (see RFC 6121 §3) +doc_param_1=entity_jid: JID from which the subscription is coming +doc_param_2=%(doc_profile)s + +[paramUpdate] +type=signal +category=core +sig_in=ssss +doc=A parameter has been changed +doc_param_0=name: Name of the updated parameter +doc_param_1=value: New value of the parameter +doc_param_2=category: Category of the updated parameter +doc_param_3=%(doc_profile)s + +[contactDeleted] +type=signal +category=core +sig_in=ss +doc=A contact has been supressed from roster +doc_param_0=entity_jid: JID of the contact removed from roster +doc_param_1=%(doc_profile)s + +[actionNew] +type=signal +category=core +sig_in=a{ss}sis +doc=A frontend action is requested +doc_param_0=action_data: a dict where key can be: + - xmlui: a XMLUI need to be displayed + - progress: a progress id + - meta_*: meta information on the action, used to make automation more easy, + some are defined below + - meta_from_jid: origin of the request + - meta_type: type of the request, can be one of: + - C.META_TYPE_FILE: a file transfer request validation + - C.META_TYPE_OVERWRITE: a file overwriting confirmation + - meta_progress_id: progress id linked to this action +doc_param_1=id: action id + This id can be used later by frontends to announce to other ones that the action is managed and can now be ignored. +doc_param_2=%(doc_security_limit)s +doc_param_3=%(doc_profile)s + +[entityDataUpdated] +type=signal +category=core +sig_in=ssss +doc=An entity's data has been updated +doc_param_0=jid: entity's bare jid +doc_param_1=name: Name of the updated value +doc_param_2=value: New value +doc_param_3=%(doc_profile)s + +[progressStarted] +type=signal +category=core +sig_in=sa{ss}s +doc=A progressing operation has just started +doc_param_0=id: id of the progression operation +doc_param_1=metadata: dict of progress metadata, key can be: + - name: name of the progression, full path for a file + - direction: "in" for incoming data, "out" else + - type: type of the progression: + C.META_TYPE_FILE: file transfer +doc_param_2=%(doc_profile)s + +[progressFinished] +type=signal +category=core +sig_in=sa{ss}s +doc=A progressing operation is finished +doc_param_0=id: id of the progression operation +doc_param_1=metadata: dict of progress status metadata, key can be: + - hash: value of the computed hash + - hash_algo: alrorithm used to compute hash + - hash_verified: C.BOOL_TRUE if hash is verified and OK + C.BOOL_FALSE if hash was not received ([progressError] will be used if there is a mismatch) + - url: url linked to the progression (e.g. download url after a file upload) +doc_param_2=%(doc_profile)s + +[progressError] +type=signal +category=core +sig_in=sss +doc=There was an error during progressing operation +doc_param_0=id: id of the progression operation +doc_param_1=error: error message +doc_param_2=%(doc_profile)s + +;methods + +[getReady] +async= +type=method +category=core +sig_in= +sig_out= +doc=Return when backend is initialised + +[getVersion] +type=method +category=core +sig_in= +sig_out=s +doc=Get "Salut à Toi" full version + +[getFeatures] +type=method +category=core +sig_in=s +sig_out=a{sa{ss}} +doc=Get available features and plugins + features can changes for differents profiles, e.g. because of differents server capabilities +doc_param_0=%(doc_profile_key)s +doc_return=dictionary of available features: + plugin import name is used as key, data is an other dict managed by the plugin +async= + +[profileNameGet] +type=method +category=core +sig_in=s +sig_out=s +param_0_default="@DEFAULT@" +doc=Get real profile name from profile key +doc_param_0=%(doc_profile_key)s +doc_return=Real profile name + +[profilesListGet] +type=method +category=core +sig_in=bb +sig_out=as +param_0_default=True +param_1_default=False +doc_param_0=clients: get clients profiles +doc_param_1=components: get components profiles +doc=Get list of profiles + +[profileSetDefault] +type=method +category=core +sig_in=s +sig_out= +doc_param_0=%(doc_profile)s +doc=Set default profile + +[getEntityData] +type=method +category=core +sig_in=sass +sig_out=a{ss} +doc=Get data in cache for an entity +doc_param_0=jid: entity's bare jid +doc_param_1=keys: list of keys to get +doc_param_2=%(doc_profile)s +doc_return=dictionary of asked key, + if key doesn't exist, the resulting dictionary will not have the key + +[getEntitiesData] +type=method +category=core +sig_in=asass +sig_out=a{sa{ss}} +doc=Get data in cache for several entities at once +doc_param_0=jids: list of entities bare jid, or empty list to have all jids in cache +doc_param_1=keys: list of keys to get +doc_param_2=%(doc_profile)s +doc_return=dictionary with jids as keys and dictionary of asked key as values + if key doesn't exist for a jid, the resulting dictionary will not have it + +[profileCreate] +async= +type=method +category=core +sig_in=sss +sig_out= +param_1_default='' +param_2_default='' +doc=Create a new profile +doc_param_0=%(doc_profile)s +doc_param_1=password: password of the profile +doc_param_2=component: set to component entry point if it is a component, else use empty string +doc_return=callback is called when profile actually exists in database and memory +errback is called with error constant as parameter: + - ConflictError: the profile name already exists + - CancelError: profile creation canceled + - NotFound: component entry point is not available + +[asyncDeleteProfile] +async= +type=method +category=core +sig_in=s +sig_out= +doc=Delete a profile +doc_param_0=%(doc_profile)s +doc_return=callback is called when profile has been deleted from database and memory +errback is called with error constant as parameter: + - ProfileUnknownError: the profile name is unknown + - ConnectedProfileError: a connected profile would not be deleted + +[connect] +async= +type=method +category=core +sig_in=ssa{ss} +sig_out=b +param_0_default="@DEFAULT@" +param_1_default='' +param_2_default={} +doc=Connect a profile +doc_param_0=%(doc_profile_key)s +doc_param_1=password: the SàT profile password +doc_param_2=options: connection options +doc_return=a deferred boolean or failure: + - boolean if the profile authentication succeed: + - True if the XMPP connection was already established + - False if the XMPP connection has been initiated (it may still fail) + - failure if the profile authentication failed + +[profileStartSession] +async= +type=method +category=core +sig_in=ss +sig_out=b +param_0_default='' +param_1_default="@DEFAULT@" +doc=Start a profile session without connecting it (if it's not already the case) +doc_param_0=password: the SàT profile password +doc_param_1=%(doc_profile_key)s +doc_return=D(bool): + - True if the profile session was already started + - False else + +[profileIsSessionStarted] +type=method +category=core +sig_in=s +sig_out=b +param_0_default="@DEFAULT@" +doc=Tell if a profile session is loaded +doc_param_0=%(doc_profile_key)s + +[disconnect] +async= +type=method +category=core +sig_in=s +sig_out= +param_0_default="@DEFAULT@" +doc=Disconnect a profile +doc_param_0=%(doc_profile_key)s + +[isConnected] +type=method +category=core +sig_in=s +sig_out=b +param_0_default="@DEFAULT@" +doc=Tell if a profile is connected +doc_param_0=%(doc_profile_key)s + +[getContacts] +async= +type=method +category=core +sig_in=s +sig_out=a(sa{ss}as) +param_0_default="@DEFAULT@" +doc=Return information about all contacts (the roster) +doc_param_0=%(doc_profile_key)s +doc_return=array of tuples with the following values: + - JID of the contact + - list of attributes as in [newContact] + - groups where the contact is + +[getContactsFromGroup] +type=method +category=core +sig_in=ss +sig_out=as +param_1_default="@DEFAULT@" +doc=Return information about all contacts +doc_param_0=group: name of the group to check +doc_param_1=%(doc_profile_key)s +doc_return=array of jids + +[getMainResource] +type=method +category=core +sig_in=ss +sig_out=s +param_1_default="@DEFAULT@" +doc=Return the last resource connected for a contact +doc_param_0=contact_jid: jid of the contact +doc_param_1=%(doc_profile_key)s +doc_return=the resource connected of the contact with highest priority, or "" + +[getPresenceStatuses] +type=method +category=core +sig_in=s +sig_out=a{sa{s(sia{ss})}} +param_0_default="@DEFAULT@" +doc=Return presence information of all contacts +doc_param_0=%(doc_profile_key)s +doc_return=Dict of presence with bare JID of contact as key, and value as follow: + A dict where key is the resource and the value is a tuple with (show, priority, statuses) as for [presenceUpdate] + +[getWaitingSub] +type=method +category=core +sig_in=s +sig_out=a{ss} +param_0_default="@DEFAULT@" +doc=Get subscription requests in queue +doc_param_0=%(doc_profile_key)s +doc_return=Dict where contact JID is the key, and value is the subscription type + +[messageSend] +async= +type=method +category=core +sig_in=sa{ss}a{ss}sa{ss}s +sig_out= +param_2_default={} +param_3_default="auto" +param_4_default={} +param_5_default="@NONE@" +doc=Send a message +doc_param_0=to_jid: JID of the recipient +doc_param_1=message: body of the message: + key is the language of the body, use '' when unknown +doc_param_2=subject: Subject of the message + key is the language of the subject, use '' when unknown +doc_param_3=mess_type: Type of the message (cf RFC 6121 §5.2.2) or "auto" for automatic type detection +doc_param_4=extra: optional data that can be used by a plugin to build more specific messages +doc_param_5=%(doc_profile_key)s + +[setPresence] +type=method +category=core +sig_in=ssa{ss}s +sig_out= +param_0_default='' +param_1_default='' +param_2_default={} +param_3_default="@DEFAULT@" +doc=Set presence information for the profile +doc_param_0=to_jid: the JID to who we send the presence data (emtpy string for broadcast) +doc_param_1=show: as for [presenceUpdate] +doc_param_2=statuses: as for [presenceUpdate] +doc_param_3=%(doc_profile_key)s + +[subscription] +type=method +category=core +sig_in=sss +sig_out= +param_2_default="@DEFAULT@" +doc=Send subscription request/answer to a contact +doc_param_0=sub_type: as for [subscribe] +doc_param_1=entity: as for [subscribe] +doc_param_2=%(doc_profile_key)s + +[getConfig] +type=method +category=core +sig_in=ss +sig_out=s +doc=get main configuration option +doc_param_0=section: section of the configuration file (empty string for DEFAULT) +doc_param_1=name: name of the option + +[setParam] +type=method +category=core +sig_in=sssis +sig_out= +param_3_default=-1 +param_4_default="@DEFAULT@" +doc=Change a parameter +doc_param_0=name: Name of the parameter to change +doc_param_1=value: New Value of the parameter +doc_param_2=category: Category of the parameter to change +doc_param_3=%(doc_security_limit)s +doc_param_4=%(doc_profile_key)s + +[getParamA] +type=method +category=core +sig_in=ssss +sig_out=s +param_2_default="value" +param_3_default="@DEFAULT@" +doc=Helper method to get a parameter's attribute *when profile is connected* +doc_param_0=name: as for [setParam] +doc_param_1=category: as for [setParam] +doc_param_2=attribute: Name of the attribute +doc_param_3=%(doc_profile_key)s + +[asyncGetParamA] +async= +type=method +category=core +sig_in=sssis +sig_out=s +param_2_default="value" +param_3_default=-1 +param_4_default="@DEFAULT@" +doc=Helper method to get a parameter's attribute +doc_param_0=name: as for [setParam] +doc_param_1=category: as for [setParam] +doc_param_2=attribute: Name of the attribute +doc_param_3=%(doc_security_limit)s +doc_param_4=%(doc_profile_key)s + +[asyncGetParamsValuesFromCategory] +async= +type=method +category=code +sig_in=sis +sig_out=a{ss} +param_1_default=-1 +param_2_default="@DEFAULT@" +doc=Get "attribute" for all params of a category +doc_param_0=category: as for [setParam] +doc_param_1=%(doc_security_limit)s +doc_param_2=%(doc_profile_key)s + +[getParamsUI] +async= +type=method +category=core +sig_in=iss +sig_out=s +param_0_default=-1 +param_1_default='' +param_2_default="@DEFAULT@" +doc=Return a SàT XMLUI for parameters, eventually restrict the result to the parameters concerning a given frontend +doc_param_0=%(doc_security_limit)s +doc_param_1=app: name of the frontend requesting the parameters, or '' to get all parameters +doc_param_2=%(doc_profile_key)s + +[getParamsCategories] +type=method +category=core +sig_in= +sig_out=as +doc=Get all categories currently existing in parameters +doc_return=list of categories + +[paramsRegisterApp] +type=method +category=core +sig_in=sis +sig_out= +param_1_default=-1 +param_2_default='' +doc=Register frontend's specific parameters +doc_param_0=xml: XML definition of the parameters to be added +doc_param_1=%(doc_security_limit)s +doc_param_2=app: name of the frontend registering the parameters + +[historyGet] +async= +type=method +category=core +sig_in=ssiba{ss}s +sig_out=a(sdssa{ss}a{ss}sa{ss}) +param_3_default=True +param_4_default='' +param_5_default="@NONE@" +doc=Get history of a communication between two entities +doc_param_0=from_jid: source JID (bare jid for catch all, full jid else) +doc_param_1=to_jid: dest JID (bare jid for catch all, full jid else) +doc_param_2=limit: max number of history elements to get (0 for the whole history) +doc_param_3=between: True if we want history between the two jids (in both direction), False if we only want messages from from_jid to to_jid +doc_param_4=filters: patterns to filter the history results, can be: + - body: pattern must be in message body + - search: pattern must be in message body or source resource + - types: type must one of those, values are separated by spaces + - not_types: type must not be one of those, values are separated by spaces +doc_param_5=%(doc_profile)s +doc_return=Ordered list (by timestamp) of data as in [messageNew] (without final profile) + +[addContact] +type=method +category=core +sig_in=ss +sig_out= +param_1_default="@DEFAULT@" +doc=Add a contact to profile's roster +doc_param_0=entity_jid: JID to add to roster +doc_param_1=%(doc_profile_key)s + +[updateContact] +async= +type=method +category=core +sig_in=ssass +sig_out= +param_3_default="@DEFAULT@" +doc=update a contact in profile's roster +doc_param_0=entity_jid: JID update in roster +doc_param_1=name: roster's name for the entity +doc_param_2=groups: list of group where the entity is +doc_param_3=%(doc_profile_key)s + +[delContact] +async= +type=method +category=core +sig_in=ss +sig_out= +param_1_default="@DEFAULT@" +doc=Remove a contact from profile's roster +doc_param_0=entity_jid: JID to remove from roster +doc_param_1=%(doc_profile_key)s + +[launchAction] +async= +type=method +category=core +sig_in=sa{ss}s +sig_out=a{ss} +param_2_default="@DEFAULT@" +doc=Launch a registred action +doc_param_0=callback_id: id of the registred callback +doc_param_1=data: optional data +doc_param_2=%(doc_profile_key)s +doc_return=dict where key can be: + - xmlui: a XMLUI need to be displayed + +[actionsGet] +type=method +category=core +sig_in=s +sig_out=a(a{ss}si) +param_0_default="@DEFAULT@" +doc=Get all not yet answered actions +doc_param_0=%(doc_profile_key)s +doc_return=list of data as for [actionNew] (without the profile) + +[progressGet] +type=method +category=core +sig_in=ss +sig_out=a{ss} +doc=Get progress information for an action +doc_param_0=id: id of the progression status +doc_param_1=%(doc_profile)s +doc_return=dict with progress informations: + - position: current position + - size: end position (optional if not known) + other metadata may be present + +[progressGetAllMetadata] +type=method +category=core +sig_in=s +sig_out=a{sa{sa{ss}}} +doc=Get all active progress informations +doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles +doc_return= a dict which map profile to progress_dict + progress_dict map progress_id to progress_metadata + progress_metadata is the same dict as sent by [progressStarted] + +[progressGetAll] +type=method +category=core +sig_in=s +sig_out=a{sa{sa{ss}}} +doc=Get all active progress informations +doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles +doc_return= a dict which map profile to progress_dict + progress_dict map progress_id to progress_data + progress_data is the same dict as returned by [progressGet] + +[menusGet] +type=method +category=core +sig_in=si +sig_out=a(ssasasa{ss}) +doc=Get all additional menus +doc_param_0=language: language in which the menu should be translated (empty string for default) +doc_param_1=security_limit: %(doc_security_limit)s +doc_return=list of tuple with the following value: + - menu_id: menu id (same as callback id) + - menu_type: Type which can be: + * NORMAL: Classical application menu + - menu_path: raw path of the menu + - menu_path_i18n: translated path of the menu + - extra: extra data, like icon name + +[menuLaunch] +async= +type=method +category=core +sig_in=sasa{ss}is +sig_out=a{ss} +doc=Launch a registred menu +doc_param_0=menu_type: type of the menu (C.MENU_*) +doc_param_1=path: canonical (untranslated) path of the menu +doc_param_2=data: optional data +doc_param_3=%(doc_security_limit)s +doc_param_4=%(doc_profile_key)s +doc_return=dict where key can be: + - xmlui: a XMLUI need to be displayed + +[menuHelpGet] +type=method +category=core +sig_in=ss +sig_out=s +param_2="NORMAL" +doc=Get help information for a menu +doc_param_0=menu_id: id of the menu (same as callback_id) +doc_param_1=language: language in which the menu should be translated (empty string for default) +doc_return=Translated help string + +[discoInfos] +async= +type=method +category=core +sig_in=ssbs +sig_out=(asa(sss)a{sa(a{ss}as)}) +param_1_default=u'' +param_2_default=True +param_3_default=u"@DEFAULT@" +doc=Discover infos on an entity +doc_param_0=entity_jid: JID to discover +doc_param_1=node: node to use +doc_param_2=use_cache: use cached data if available +doc_param_3=%(doc_profile_key)s +doc_return=discovery data: + - list of features + - list of identities (category, type, name) + - dictionary of extensions (FORM_TYPE as key), with value of: + - list of field which are: + - dictionary key/value where key can be: + * var + * label + * type + * desc + - list of values + +[discoItems] +async= +type=method +category=core +sig_in=ssbs +sig_out=a(sss) +param_1_default=u'' +param_2_default=True +param_3_default=u"@DEFAULT@" +doc=Discover items of an entity +doc_param_0=entity_jid: JID to discover +doc_param_1=node: node to use +doc_param_2=use_cache: use cached data if available +doc_param_3=%(doc_profile_key)s +doc_return=array of tuple (entity, node identifier, name) + +[discoFindByFeatures] +async= +type=method +category=core +sig_in=asa(ss)bbbbs +sig_out=(a{sa(sss)}a{sa(sss)}a{sa(sss)}) +param_2_default=False +param_3_default=True +param_4_default=True +param_5_default=True +param_6_default=u"@DEFAULT@" +doc=Discover items of an entity +doc_param_0=namespaces: namespaces of the features to check +doc_param_1=identities: identities to filter +doc_param_2=bare_jid: if True only retrieve bare jids + if False, retrieve full jids of connected resources +doc_param_3=service: True to check server's services +doc_param_4=roster: True to check connected devices from people in roster +doc_param_5=own_jid: True to check profile's jid +doc_param_6=%(doc_profile_key)s +doc_return=tuple of maps of found entities full jids to their identities. Maps are in this order: + - services entities + - own entities (i.e. entities linked to profile's jid) + - roster entities + +[saveParamsTemplate] +type=method +category=core +sig_in=s +sig_out=b +doc=Save parameters template to xml file +doc_param_0=filename: output filename +doc_return=boolean (True in case of success) + +[loadParamsTemplate] +type=method +category=core +sig_in=s +sig_out=b +doc=Load parameters template from xml file +doc_param_0=filename: input filename +doc_return=boolean (True in case of success) + +[sessionInfosGet] +async= +type=method +category=core +sig_in=s +sig_out=a{ss} +doc=Get various informations on current profile session +doc_param_0=%(doc_profile_key)s +doc_return=session informations, with at least the following keys: + jid: current full jid + started: date of creation of the session (Epoch time) + +[namespacesGet] +type=method +category=core +sig_in= +sig_out=a{ss} +doc=Get a dict to short name => whole namespaces +doc_return=namespaces mapping diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constants.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constants.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,41 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.core import constants + + +class Const(constants.Const): + + NAME = u"bridge_constructor" + DEST_DIR_DEFAULT = "generated" + DESCRIPTION = u"""{name} Copyright (C) 2009-2018 Jérôme Poisson (aka Goffi) + + This script construct a SàT bridge using the given protocol + + This program comes with ABSOLUTELY NO WARRANTY; + This is free software, and you are welcome to redistribute it + under certain conditions. + """.format(name=NAME, version=constants.Const.APP_VERSION) +# TODO: move protocoles in separate files (plugins?) + DEFAULT_PROTOCOLE = 'dbus' + +# flags used method/signal declaration (not to be confused with constructor flags) + DECLARATION_FLAGS = ['deprecated', 'async'] + + ENV_OVERRIDE = "SAT_BRIDGE_CONST_" # Prefix used to override a constant diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus-xml/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus-xml/constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/dbus-xml/constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,91 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.bridge_constructor import base_constructor +from xml.dom import minidom +import sys + + +class DbusXmlConstructor(base_constructor.Constructor): + """Constructor for DBus XML syntaxt (used by Qt frontend)""" + + def __init__(self, bridge_template, options): + base_constructor.Constructor.__init__(self, bridge_template, options) + + self.template = "dbus_xml_template.xml" + self.core_dest = "org.goffi.sat.xml" + self.default_annotation = {'a{ss}': 'StringDict', + 'a(sa{ss}as)': 'QList', + 'a{i(ss)}': 'HistoryT', + 'a(sss)': 'QList', + 'a{sa{s(sia{ss})}}': 'PresenceStatusT', + } + + def generateCoreSide(self): + try: + doc = minidom.parse(self.getTemplatePath(self.template)) + interface_elt = doc.getElementsByTagName('interface')[0] + except IOError: + print ("Can't access template") + sys.exit(1) + except IndexError: + print ("Template error") + sys.exit(1) + + sections = self.bridge_template.sections() + sections.sort() + for section in sections: + function = self.getValues(section) + print ("Adding %s %s" % (section, function["type"])) + new_elt = doc.createElement('method' if function["type"] == 'method' else 'signal') + new_elt.setAttribute('name', section) + + idx = 0 + args_doc = self.getArgumentsDoc(section) + for arg in self.argumentsParser(function['sig_in'] or ''): + arg_elt = doc.createElement('arg') + arg_elt.setAttribute('name', args_doc[idx][0] if idx in args_doc else "arg_%i" % idx) + arg_elt.setAttribute('type', arg) + _direction = 'in' if function["type"] == 'method' else 'out' + arg_elt.setAttribute('direction', _direction) + new_elt.appendChild(arg_elt) + if "annotation" in self.args.flags: + if arg in self.default_annotation: + annot_elt = doc.createElement("annotation") + annot_elt.setAttribute('name', "com.trolltech.QtDBus.QtTypeName.In%d" % idx) + annot_elt.setAttribute('value', self.default_annotation[arg]) + new_elt.appendChild(annot_elt) + idx += 1 + + if function['sig_out']: + arg_elt = doc.createElement('arg') + arg_elt.setAttribute('type', function['sig_out']) + arg_elt.setAttribute('direction', 'out') + new_elt.appendChild(arg_elt) + if "annotation" in self.args.flags: + if function['sig_out'] in self.default_annotation: + annot_elt = doc.createElement("annotation") + annot_elt.setAttribute('name', "com.trolltech.QtDBus.QtTypeName.Out0") + annot_elt.setAttribute('value', self.default_annotation[function['sig_out']]) + new_elt.appendChild(annot_elt) + + interface_elt.appendChild(new_elt) + + #now we write to final file + self.finalWrite(self.core_dest, [doc.toprettyxml()]) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,4 @@ + + + + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus/constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/dbus/constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,97 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.bridge_constructor import base_constructor + + +class DbusConstructor(base_constructor.Constructor): + NAME = "dbus" + CORE_TEMPLATE = "dbus_core_template.py" + CORE_DEST = "dbus_bridge.py" + CORE_FORMATS = { + 'signals': """\ + @dbus.service.signal(const_INT_PREFIX+const_{category}_SUFFIX, + signature='{sig_in}') + def {name}(self, {args}): + {body}\n""", + + 'methods': """\ + @dbus.service.method(const_INT_PREFIX+const_{category}_SUFFIX, + in_signature='{sig_in}', out_signature='{sig_out}', + async_callbacks={async_callbacks}) + def {name}(self, {args}{async_comma}{async_args_def}): + {debug}return self._callback("{name}", {args_result}{async_comma}{async_args_call})\n""", + + 'signal_direct_calls': """\ + def {name}(self, {args}): + self.dbus_bridge.{name}({args})\n""", + } + + FRONTEND_TEMPLATE = "dbus_frontend_template.py" + FRONTEND_DEST = CORE_DEST + FRONTEND_FORMATS = { + 'methods': """\ + def {name}(self, {args}{async_comma}{async_args}): + {error_handler}{blocking_call}{debug}return {result}\n""", + } + + def core_completion_signal(self, completion, function, default, arg_doc, async_): + completion['category'] = completion['category'].upper() + completion['body'] = "pass" if not self.args.debug else 'log.debug ("{}")'.format(completion['name']) + + def core_completion_method(self, completion, function, default, arg_doc, async_): + completion.update({ + 'debug': "" if not self.args.debug else 'log.debug ("%s")\n%s' % (completion['name'], 8 * ' '), + 'args_result': self.getArguments(function['sig_in'], name=arg_doc, unicode_protect=self.args.unicode), + 'async_comma': ', ' if async_ and function['sig_in'] else '', + 'async_args_def': 'callback=None, errback=None' if async_ else '', + 'async_args_call': 'callback=callback, errback=errback' if async_ else '', + 'async_callbacks': "('callback', 'errback')" if async_ else "None", + 'category': completion['category'].upper(), + }) + + def frontend_completion_method(self, completion, function, default, arg_doc, async_): + completion.update({ + # XXX: we can manage blocking call in the same way as async one: if callback is None the call will be blocking + 'debug': "" if not self.args.debug else 'log.debug ("%s")\n%s' % (completion['name'], 8 * ' '), + 'args_result': self.getArguments(function['sig_in'], name=arg_doc), + 'async_args': 'callback=None, errback=None', + 'async_comma': ', ' if function['sig_in'] else '', + 'error_handler': """if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err:errback(dbus_to_bridge_exception(err)) + """, + }) + if async_: + completion['blocking_call'] = '' + completion['async_args_result'] = 'timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler' + else: + # XXX: To have a blocking call, we must have not reply_handler, so we test if callback exists, and add reply_handler only in this case + completion['blocking_call'] = """kwargs={} + if callback is not None: + kwargs['timeout'] = const_TIMEOUT + kwargs['reply_handler'] = callback + kwargs['error_handler'] = error_handler + """ + completion['async_args_result'] = '**kwargs' + result = "self.db_%(category)s_iface.%(name)s(%(args_result)s%(async_comma)s%(async_args_result)s)" % completion + completion['result'] = ("unicode(%s)" if self.args.unicode and function['sig_out'] == 's' else "%s") % result diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,246 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +import dbus +import dbus.service +import dbus.mainloop.glib +import inspect +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet.defer import Deferred +from sat.core.exceptions import BridgeInitError + +const_INT_PREFIX = "org.goffi.SAT" # Interface prefix +const_ERROR_PREFIX = const_INT_PREFIX + ".error" +const_OBJ_PATH = '/org/goffi/SAT/bridge' +const_CORE_SUFFIX = ".core" +const_PLUGIN_SUFFIX = ".plugin" + + +class ParseError(Exception): + pass + + +class MethodNotRegistered(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".MethodNotRegistered" + + +class InternalError(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".InternalError" + + +class AsyncNotDeferred(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".AsyncNotDeferred" + + +class DeferredNotAsync(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".DeferredNotAsync" + + +class GenericException(dbus.DBusException): + def __init__(self, twisted_error): + """ + + @param twisted_error (Failure): instance of twisted Failure + @return: DBusException + """ + super(GenericException, self).__init__() + try: + # twisted_error.value is a class + class_ = twisted_error.value().__class__ + except TypeError: + # twisted_error.value is an instance + class_ = twisted_error.value.__class__ + message = twisted_error.getErrorMessage() + try: + self.args = (message, twisted_error.value.condition) + except AttributeError: + self.args = (message,) + self._dbus_error_name = '.'.join([const_ERROR_PREFIX, class_.__module__, class_.__name__]) + + +class DbusObject(dbus.service.Object): + + def __init__(self, bus, path): + dbus.service.Object.__init__(self, bus, path) + log.debug("Init DbusObject...") + self.cb = {} + + def register_method(self, name, cb): + self.cb[name] = cb + + def _callback(self, name, *args, **kwargs): + """call the callback if it exists, raise an exception else + if the callback return a deferred, use async methods""" + if not name in self.cb: + raise MethodNotRegistered + + if "callback" in kwargs: + #we must have errback too + if not "errback" in kwargs: + log.error("errback is missing in method call [%s]" % name) + raise InternalError + callback = kwargs.pop("callback") + errback = kwargs.pop("errback") + async = True + else: + async = False + result = self.cb[name](*args, **kwargs) + if async: + if not isinstance(result, Deferred): + log.error("Asynchronous method [%s] does not return a Deferred." % name) + raise AsyncNotDeferred + result.addCallback(lambda result: callback() if result is None else callback(result)) + result.addErrback(lambda err: errback(GenericException(err))) + else: + if isinstance(result, Deferred): + log.error("Synchronous method [%s] return a Deferred." % name) + raise DeferredNotAsync + return result + ### signals ### + + @dbus.service.signal(const_INT_PREFIX + const_PLUGIN_SUFFIX, + signature='') + def dummySignal(self): + #FIXME: workaround for addSignal (doesn't work if one method doensn't + # already exist for plugins), probably missing some initialisation, need + # further investigations + pass + +##SIGNALS_PART## + ### methods ### + +##METHODS_PART## + def __attributes(self, in_sign): + """Return arguments to user given a in_sign + @param in_sign: in_sign in the short form (using s,a,i,b etc) + @return: list of arguments that correspond to a in_sign (e.g.: "sss" return "arg1, arg2, arg3")""" + i = 0 + idx = 0 + attr = [] + while i < len(in_sign): + if in_sign[i] not in ['b', 'y', 'n', 'i', 'x', 'q', 'u', 't', 'd', 's', 'a']: + raise ParseError("Unmanaged attribute type [%c]" % in_sign[i]) + + attr.append("arg_%i" % idx) + idx += 1 + + if in_sign[i] == 'a': + i += 1 + if in_sign[i] != '{' and in_sign[i] != '(': # FIXME: must manage tuples out of arrays + i += 1 + continue # we have a simple type for the array + opening_car = in_sign[i] + assert(opening_car in ['{', '(']) + closing_car = '}' if opening_car == '{' else ')' + opening_count = 1 + while (True): # we have a dict or a list of tuples + i += 1 + if i >= len(in_sign): + raise ParseError("missing }") + if in_sign[i] == opening_car: + opening_count += 1 + if in_sign[i] == closing_car: + opening_count -= 1 + if opening_count == 0: + break + i += 1 + return attr + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False): + """Dynamically add a method to Dbus Bridge""" + inspect_args = inspect.getargspec(method) + + _arguments = inspect_args.args + _defaults = list(inspect_args.defaults or []) + + if inspect.ismethod(method): + #if we have a method, we don't want the first argument (usually 'self') + del(_arguments[0]) + + #first arguments are for the _callback method + arguments_callback = ', '.join([repr(name)] + ((_arguments + ['callback=callback', 'errback=errback']) if async else _arguments)) + + if async: + _arguments.extend(['callback', 'errback']) + _defaults.extend([None, None]) + + #now we create a second list with default values + for i in range(1, len(_defaults) + 1): + _arguments[-i] = "%s = %s" % (_arguments[-i], repr(_defaults[-i])) + + arguments_defaults = ', '.join(_arguments) + + code = compile('def %(name)s (self,%(arguments_defaults)s): return self._callback(%(arguments_callback)s)' % + {'name': name, 'arguments_defaults': arguments_defaults, 'arguments_callback': arguments_callback}, '', 'exec') + exec (code) # FIXME: to the same thing in a cleaner way, without compile/exec + method = locals()[name] + async_callbacks = ('callback', 'errback') if async else None + setattr(DbusObject, name, dbus.service.method( + const_INT_PREFIX + int_suffix, in_signature=in_sign, out_signature=out_sign, + async_callbacks=async_callbacks)(method)) + function = getattr(self, name) + func_table = self._dbus_class_table[self.__class__.__module__ + '.' + self.__class__.__name__][function._dbus_interface] + func_table[function.__name__] = function # Needed for introspection + + def addSignal(self, name, int_suffix, signature, doc={}): + """Dynamically add a signal to Dbus Bridge""" + attributes = ', '.join(self.__attributes(signature)) + #TODO: use doc parameter to name attributes + + #code = compile ('def '+name+' (self,'+attributes+'): log.debug ("'+name+' signal")', '','exec') #XXX: the log.debug is too annoying with xmllog + code = compile('def ' + name + ' (self,' + attributes + '): pass', '', 'exec') + exec (code) + signal = locals()[name] + setattr(DbusObject, name, dbus.service.signal( + const_INT_PREFIX + int_suffix, signature=signature)(signal)) + function = getattr(self, name) + func_table = self._dbus_class_table[self.__class__.__module__ + '.' + self.__class__.__name__][function._dbus_interface] + func_table[function.__name__] = function # Needed for introspection + + +class Bridge(object): + def __init__(self): + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + log.info("Init DBus...") + try: + self.session_bus = dbus.SessionBus() + except dbus.DBusException as e: + if e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported': + log.error(_(u"D-Bus is not launched, please see README to see instructions on how to launch it")) + raise BridgeInitError + self.dbus_name = dbus.service.BusName(const_INT_PREFIX, self.session_bus) + self.dbus_bridge = DbusObject(self.session_bus, const_OBJ_PATH) + +##SIGNAL_DIRECT_CALLS_PART## + def register_method(self, name, callback): + log.debug("registering DBus bridge method [%s]" % name) + self.dbus_bridge.register_method(name, callback) + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False, doc={}): + """Dynamically add a method to Dbus Bridge""" + #FIXME: doc parameter is kept only temporary, the time to remove it from calls + log.debug("Adding method [%s] to DBus bridge" % name) + self.dbus_bridge.addMethod(name, int_suffix, in_sign, out_sign, method, async) + self.register_method(name, method) + + def addSignal(self, name, int_suffix, signature, doc={}): + self.dbus_bridge.addSignal(name, int_suffix, signature, doc) + setattr(Bridge, name, getattr(self.dbus_bridge, name)) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,139 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT communication bridge +# 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 . + +from sat.core.i18n import _ +from bridge_frontend import BridgeException +import dbus +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.exceptions import BridgeExceptionNoService, BridgeInitError + +from dbus.mainloop.glib import DBusGMainLoop +DBusGMainLoop(set_as_default=True) + +import ast + +const_INT_PREFIX = "org.goffi.SAT" # Interface prefix +const_ERROR_PREFIX = const_INT_PREFIX + ".error" +const_OBJ_PATH = '/org/goffi/SAT/bridge' +const_CORE_SUFFIX = ".core" +const_PLUGIN_SUFFIX = ".plugin" +const_TIMEOUT = 120 + + +def dbus_to_bridge_exception(dbus_e): + """Convert a DBusException to a BridgeException. + + @param dbus_e (DBusException) + @return: BridgeException + """ + full_name = dbus_e.get_dbus_name() + if full_name.startswith(const_ERROR_PREFIX): + name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:] + else: + name = full_name + # XXX: dbus_e.args doesn't contain the original DBusException args, but we + # receive its serialized form in dbus_e.args[0]. From that we can rebuild + # the original arguments list thanks to ast.literal_eval (secure eval). + message = dbus_e.get_dbus_message() # similar to dbus_e.args[0] + try: + message, condition = ast.literal_eval(message) + except (SyntaxError, ValueError, TypeError): + condition = '' + return BridgeException(name, message, condition) + + +class Bridge(object): + + def bridgeConnect(self, callback, errback): + try: + self.sessions_bus = dbus.SessionBus() + self.db_object = self.sessions_bus.get_object(const_INT_PREFIX, + const_OBJ_PATH) + self.db_core_iface = dbus.Interface(self.db_object, + dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX) + self.db_plugin_iface = dbus.Interface(self.db_object, + dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX) + except dbus.exceptions.DBusException, e: + if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown', + 'org.freedesktop.DBus.Error.Spawn.ExecFailed'): + errback(BridgeExceptionNoService()) + elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported': + log.error(_(u"D-Bus is not launched, please see README to see instructions on how to launch it")) + errback(BridgeInitError) + else: + errback(e) + callback() + #props = self.db_core_iface.getProperties() + + def register_signal(self, functionName, handler, iface="core"): + if iface == "core": + self.db_core_iface.connect_to_signal(functionName, handler) + elif iface == "plugin": + self.db_plugin_iface.connect_to_signal(functionName, handler) + else: + log.error(_('Unknown interface')) + + def __getattribute__(self, name): + """ usual __getattribute__ if the method exists, else try to find a plugin method """ + try: + return object.__getattribute__(self, name) + except AttributeError: + # The attribute is not found, we try the plugin proxy to find the requested method + + def getPluginMethod(*args, **kwargs): + # We first check if we have an async call. We detect this in two ways: + # - if we have the 'callback' and 'errback' keyword arguments + # - or if the last two arguments are callable + + async = False + args = list(args) + + if kwargs: + if 'callback' in kwargs: + async = True + _callback = kwargs.pop('callback') + _errback = kwargs.pop('errback', lambda failure: log.error(unicode(failure))) + try: + args.append(kwargs.pop('profile')) + except KeyError: + try: + args.append(kwargs.pop('profile_key')) + except KeyError: + pass + # at this point, kwargs should be empty + if kwargs: + log.warnings(u"unexpected keyword arguments, they will be ignored: {}".format(kwargs)) + elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]): + async = True + _errback = args.pop() + _callback = args.pop() + + method = getattr(self.db_plugin_iface, name) + + if async: + kwargs['timeout'] = const_TIMEOUT + kwargs['reply_handler'] = _callback + kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err)) + + return method(*args, **kwargs) + + return getPluginMethod + +##METHODS_PART## diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/embedded/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/embedded/constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/embedded/constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,85 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.bridge_constructor import base_constructor +# from textwraps import dedent + + +class EmbeddedConstructor(base_constructor.Constructor): + NAME = "embedded" + CORE_TEMPLATE = "embedded_template.py" + CORE_DEST = "embedded.py" + CORE_FORMATS = { + 'methods': """\ + def {name}(self, {args}{args_comma}callback=None, errback=None): +{ret_routine} +""", + 'signals': """\ + def {name}(self, {args}): + try: + cb = self._signals_cbs["{category}"]["{name}"] + except KeyError: + log.warning(u"ignoring signal {name}: no callback registered") + else: + cb({args_result}) +""" + } + FRONTEND_TEMPLATE = "embedded_frontend_template.py" + FRONTEND_DEST = CORE_DEST + FRONTEND_FORMATS = {} + + def core_completion_method(self, completion, function, default, arg_doc, async_): + completion.update({ + 'debug': "" if not self.args.debug else 'log.debug ("%s")\n%s' % (completion['name'], 8 * ' '), + 'args_result': self.getArguments(function['sig_in'], name=arg_doc), + 'args_comma': ', ' if function['sig_in'] else '', + }) + + if async_: + completion["cb_or_lambda"] = "callback" if function['sig_out'] else "lambda dummy: callback()" + completion["ret_routine"] = """\ + d = self._methods_cbs["{name}"]({args_result}) + if callback is not None: + d.addCallback({cb_or_lambda}) + if errback is None: + d.addErrback(lambda failure_: log.error(failure_)) + else: + d.addErrback(errback) + return d + """.format(**completion) + else: + completion['ret_or_nothing'] = 'ret' if function['sig_out'] else '' + completion["ret_routine"] = """\ + try: + ret = self._methods_cbs["{name}"]({args_result}) + except Exception as e: + if errback is not None: + errback(e) + else: + raise e + else: + if callback is None: + return ret + else: + callback({ret_or_nothing})""".format(**completion) + + def core_completion_signal(self, completion, function, default, arg_doc, async_): + completion.update({ + 'args_result': self.getArguments(function['sig_in'], name=arg_doc), + }) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,20 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.embedded import Bridge diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/embedded/embedded_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/embedded/embedded_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,108 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions + + +class _Bridge(object): + def __init__(self): + log.debug(u"Init embedded bridge...") + self._methods_cbs = {} + self._signals_cbs = { + "core": {}, + "plugin": {} + } + + def bridgeConnect(self, callback, errback): + callback() + + def register_method(self, name, callback): + log.debug(u"registering embedded bridge method [{}]".format(name)) + if name in self._methods_cbs: + raise exceptions.ConflictError(u"method {} is already regitered".format(name)) + self._methods_cbs[name] = callback + + def register_signal(self, functionName, handler, iface="core"): + iface_dict = self._signals_cbs[iface] + if functionName in iface_dict: + raise exceptions.ConflictError(u"signal {name} is already regitered for interface {iface}".format(name=functionName, iface=iface)) + iface_dict[functionName] = handler + + def call_method(self, name, out_sign, async_, args, kwargs): + callback = kwargs.pop("callback", None) + errback = kwargs.pop("errback", None) + if async_: + d = self._methods_cbs[name](*args, **kwargs) + if callback is not None: + d.addCallback(callback if out_sign else lambda dummy: callback()) + if errback is None: + d.addErrback(lambda failure_: log.error(failure_)) + else: + d.addErrback(errback) + return d + else: + try: + ret = self._methods_cbs[name](*args, **kwargs) + except Exception as e: + if errback is not None: + errback(e) + else: + raise e + else: + if callback is None: + return ret + else: + if out_sign: + callback(ret) + else: + callback() + + def send_signal(self, name, args, kwargs): + try: + cb = self._signals_cbs["plugin"][name] + except KeyError: + log.debug(u"ignoring signal {}: no callback registered".format(name)) + else: + cb(*args, **kwargs) + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False, doc={}): + #FIXME: doc parameter is kept only temporary, the time to remove it from calls + log.debug("Adding method [{}] to embedded bridge".format(name)) + self.register_method(name, method) + setattr(self.__class__, name, lambda self_, *args, **kwargs: self.call_method(name, out_sign, async, args, kwargs)) + + def addSignal(self, name, int_suffix, signature, doc={}): + setattr(self.__class__, name, lambda self_, *args, **kwargs: self.send_signal(name, args, kwargs)) + + ## signals ## + +##SIGNALS_PART## + ## methods ## + +##METHODS_PART## + +# we want the same instance for both core and frontend +bridge = None +def Bridge(): + global bridge + if bridge is None: + bridge = _Bridge() + return bridge diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/mediawiki/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/mediawiki/constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/mediawiki/constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,153 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.bridge_constructor import base_constructor +import sys +from datetime import datetime +import re + + +class MediawikiConstructor(base_constructor.Constructor): + + def __init__(self, bridge_template, options): + base_constructor.Constructor.__init__(self, bridge_template, options) + self.core_template = "mediawiki_template.tpl" + self.core_dest = "mediawiki.wiki" + + def _addTextDecorations(self, text): + """Add text decorations like coloration or shortcuts""" + + def anchor_link(match): + link = match.group(1) + #we add anchor_link for [method_name] syntax: + if link in self.bridge_template.sections(): + return "[[#%s|%s]]" % (link, link) + print ("WARNING: found an anchor link to an unknown method") + return link + + return re.sub(r"\[(\w+)\]", anchor_link, text) + + def _wikiParameter(self, name, sig_in): + """Format parameters with the wiki syntax + @param name: name of the function + @param sig_in: signature in + @return: string of the formated parameters""" + arg_doc = self.getArgumentsDoc(name) + arg_default = self.getDefault(name) + args_str = self.getArguments(sig_in) + args = args_str.split(', ') if args_str else [] # ugly but it works :) + wiki = [] + for i in range(len(args)): + if i in arg_doc: + name, doc = arg_doc[i] + doc = '\n:'.join(doc.rstrip('\n').split('\n')) + wiki.append("; %s: %s" % (name, self._addTextDecorations(doc))) + else: + wiki.append("; arg_%d: " % i) + if i in arg_default: + wiki.append(":''DEFAULT: %s''" % arg_default[i]) + return "\n".join(wiki) + + def _wikiReturn(self, name): + """Format return doc with the wiki syntax + @param name: name of the function + """ + arg_doc = self.getArgumentsDoc(name) + wiki = [] + if 'return' in arg_doc: + wiki.append('\n|-\n! scope=row | return value\n|') + wiki.append('
\n'.join(self._addTextDecorations(arg_doc['return']).rstrip('\n').split('\n'))) + return "\n".join(wiki) + + def generateCoreSide(self): + signals_part = [] + methods_part = [] + sections = self.bridge_template.sections() + sections.sort() + for section in sections: + function = self.getValues(section) + print ("Adding %s %s" % (section, function["type"])) + async_msg = """
'''This method is asynchronous'''""" + deprecated_msg = """
'''/!\ WARNING /!\ : This method is deprecated, please don't use it !'''""" + signature_signal = \ + """\ +! scope=row | signature +| %s +|-\ +""" % function['sig_in'] + signature_method = \ + """\ +! scope=row | signature in +| %s +|- +! scope=row | signature out +| %s +|-\ +""" % (function['sig_in'], function['sig_out']) + completion = { + 'signature': signature_signal if function['type'] == "signal" else signature_method, + 'sig_out': function['sig_out'] or '', + 'category': function['category'], + 'name': section, + 'doc': self.getDoc(section) or "FIXME: No description available", + 'async': async_msg if "async" in self.getFlags(section) else "", + 'deprecated': deprecated_msg if "deprecated" in self.getFlags(section) else "", + 'parameters': self._wikiParameter(section, function['sig_in']), + 'return': self._wikiReturn(section) if function['type'] == 'method' else ''} + + dest = signals_part if function['type'] == "signal" else methods_part + dest.append("""\ +== %(name)s == +''%(doc)s'' +%(deprecated)s +%(async)s +{| class="wikitable" style="text-align:left; width:80%%;" +! scope=row | category +| %(category)s +|- +%(signature)s +! scope=row | parameters +| +%(parameters)s%(return)s +|} +""" % completion) + + #at this point, signals_part, and methods_part should be filled, + #we just have to place them in the right part of the template + core_bridge = [] + template_path = self.getTemplatePath(self.core_template) + try: + with open(template_path) as core_template: + for line in core_template: + if line.startswith('##SIGNALS_PART##'): + core_bridge.extend(signals_part) + elif line.startswith('##METHODS_PART##'): + core_bridge.extend(methods_part) + elif line.startswith('##TIMESTAMP##'): + core_bridge.append('Generated on %s' % datetime.now()) + else: + core_bridge.append(line.replace('\n', '')) + except IOError: + print ("Can't open template file [%s]" % template_path) + sys.exit(1) + + #now we write to final file + self.finalWrite(self.core_dest, core_bridge) + + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,11 @@ +[[Catégorie:Salut à Toi]] +[[Catégorie:documentation développeur]] + += Overview = +This is an autogenerated doc for SàT bridge's API += Signals = +##SIGNALS_PART## += Methods = +##METHODS_PART## +---- +##TIMESTAMP## diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/pb/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/pb/constructor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/pb/constructor.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,56 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +from sat.bridge.bridge_constructor import base_constructor + + +class pbConstructor(base_constructor.Constructor): + NAME = "pb" + CORE_TEMPLATE = "pb_core_template.py" + CORE_DEST = "pb.py" + CORE_FORMATS = { + 'signals': """\ + def {name}(self, {args}): + {debug}self.sendSignal("{name}", {args_no_def})\n""", + } + + FRONTEND_TEMPLATE = "pb_frontend_template.py" + FRONTEND_DEST = CORE_DEST + FRONTEND_FORMATS = { + 'methods': """\ + def {name}(self{args_comma}{args}, callback=None, errback=None): + {debug}d = self.root.callRemote("{name}"{args_comma}{args_no_def}) + if callback is not None: + d.addCallback({callback}) + if errback is None: + errback = self._generic_errback + d.addErrback(errback)\n""", + } + + def core_completion_signal(self, completion, function, default, arg_doc, async_): + completion['args_no_def'] = self.getArguments(function['sig_in'], name=arg_doc) + completion['debug'] = "" if not self.args.debug else 'log.debug ("%s")\n%s' % (completion['name'], 8 * ' ') + + def frontend_completion_method(self, completion, function, default, arg_doc, async_): + completion.update({ + 'args_comma': ', ' if function['sig_in'] else '', + 'args_no_def': self.getArguments(function['sig_in'], name=arg_doc), + 'callback': 'callback' if function['sig_out'] else 'lambda dummy: callback()', + 'debug': "" if not self.args.debug else 'log.debug ("%s")\n%s' % (completion['name'], 8 * ' '), + }) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/pb/pb_core_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/pb/pb_core_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,101 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + + +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.spread import jelly, pb +from twisted.internet import reactor + + +## jelly hack +# we monkey patch jelly to handle namedtuple +ori_jelly = jelly._Jellier.jelly + +def fixed_jelly(self, obj): + """this method fix handling of namedtuple""" + if isinstance(obj, tuple) and not obj is tuple: + obj = tuple(obj) + return ori_jelly(self, obj) + +jelly._Jellier.jelly = fixed_jelly + + +class PBRoot(pb.Root): + + def __init__(self): + self.signals_handlers = [] + + def remote_initBridge(self, signals_handler): + self.signals_handlers.append(signals_handler) + log.info(u"registered signal handler") + + def sendSignalEb(self, failure, signal_name): + log.error(u"Error while sending signal {name}: {msg}".format( + name = signal_name, + msg = failure, + )) + + def sendSignal(self, name, args, kwargs): + to_remove = [] + for handler in self.signals_handlers: + try: + d = handler.callRemote(name, *args, **kwargs) + except pb.DeadReferenceError: + to_remove.append(handler) + else: + d.addErrback(self.sendSignalEb, name) + if to_remove: + for handler in to_remove: + log.debug(u"Removing signal handler for dead frontend") + self.signals_handlers.remove(handler) + +##METHODS_PART## + + +class Bridge(object): + + def __init__(self): + log.info("Init Perspective Broker...") + self.root = PBRoot() + reactor.listenTCP(8789, pb.PBServerFactory(self.root)) + + def sendSignal(self, name, *args, **kwargs): + self.root.sendSignal(name, args, kwargs) + + def remote_initBridge(self, signals_handler): + self.signals_handlers.append(signals_handler) + log.info(u"registered signal handler") + + def register_method(self, name, callback): + log.debug("registering PB bridge method [%s]" % name) + setattr(self.root, "remote_"+name, callback) + # self.root.register_method(name, callback) + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False, doc={}): + """Dynamically add a method to PB Bridge""" + #FIXME: doc parameter is kept only temporary, the time to remove it from calls + log.debug("Adding method {name} to PB bridge".format(name=name)) + self.register_method(name, method) + + def addSignal(self, name, int_suffix, signature, doc={}): + log.debug("Adding signal {name} to PB bridge".format(name=name)) + setattr(self, name, lambda *args, **kwargs: self.sendSignal(name, *args, **kwargs)) + +##SIGNALS_PART## diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,127 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT communication bridge +# 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 . + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.spread import pb +from twisted.internet import reactor + + +class SignalsHandler(pb.Referenceable): + + def __getattr__(self, name): + if name.startswith("remote_"): + log.debug(u"calling an unregistered signal: {name}".format( + name = name[7:])) + return lambda *args, **kwargs: None + + else: + raise AttributeError(name) + + def register_signal(self, name, handler, iface="core"): + log.debug("registering signal {name}".format(name=name)) + method_name = "remote_" + name + try: + self.__getattribute__(self, method_name) + except AttributeError: + pass + else: + raise exceptions.InternalError(u"{name} signal handler has been registered twice".format( + name = method_name)) + setattr(self, method_name, handler) + + +class Bridge(object): + + def __init__(self): + self.signals_handler = SignalsHandler() + + def __getattr__(self, name): + return lambda *args, **kwargs: self.call(name, args, kwargs) + + def remoteCallback(self, result, callback): + """call callback with argument or None + + if result is not None not argument is used, + else result is used as argument + @param result: remote call result + @param callback(callable): method to call on result + """ + if result is None: + callback() + else: + callback(result) + + def call(self, name, args, kwargs): + """call a remote method + + @param name(str): name of the bridge method + @param args(list): arguments + may contain callback and errback as last 2 items + @param kwargs(dict): keyword arguments + may contain callback and errback + """ + callback = errback = None + if kwargs: + try: + callback = kwargs.pop('callback') + except KeyError: + pass + try: + errback = kwargs.pop('errback') + except KeyError: + pass + elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]): + errback = args.pop() + callback = args.pop() + d = self.root.callRemote(name, *args, **kwargs) + if callback is not None: + d.addCallback(self.remoteCallback, callback) + if errback is not None: + d.addErrback(errback) + + def _initBridgeEb(self, failure): + log.error(u"Can't init bridge: {msg}".format(msg=failure)) + + def _set_root(self, root): + """set remote root object + + bridge will then be initialised + """ + self.root = root + d = root.callRemote("initBridge", self.signals_handler) + d.addErrback(self._initBridgeEb) + return d + + def _generic_errback(self, failure): + log.error(u"bridge failure: {}".format(failure)) + + def bridgeConnect(self, callback, errback): + factory = pb.PBClientFactory() + reactor.connectTCP("localhost", 8789, factory) + d = factory.getRootObject() + d.addCallback(self._set_root) + d.addCallback(lambda dummy: callback()) + d.addErrback(errback) + + def register_signal(self, functionName, handler, iface="core"): + self.signals_handler.register_signal(functionName, handler, iface) + +##METHODS_PART## diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/bridge/dbus_bridge.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/bridge/dbus_bridge.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,647 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +import dbus +import dbus.service +import dbus.mainloop.glib +import inspect +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet.defer import Deferred +from sat.core.exceptions import BridgeInitError + +const_INT_PREFIX = "org.goffi.SAT" # Interface prefix +const_ERROR_PREFIX = const_INT_PREFIX + ".error" +const_OBJ_PATH = '/org/goffi/SAT/bridge' +const_CORE_SUFFIX = ".core" +const_PLUGIN_SUFFIX = ".plugin" + + +class ParseError(Exception): + pass + + +class MethodNotRegistered(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".MethodNotRegistered" + + +class InternalError(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".InternalError" + + +class AsyncNotDeferred(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".AsyncNotDeferred" + + +class DeferredNotAsync(dbus.DBusException): + _dbus_error_name = const_ERROR_PREFIX + ".DeferredNotAsync" + + +class GenericException(dbus.DBusException): + def __init__(self, twisted_error): + """ + + @param twisted_error (Failure): instance of twisted Failure + @return: DBusException + """ + super(GenericException, self).__init__() + try: + # twisted_error.value is a class + class_ = twisted_error.value().__class__ + except TypeError: + # twisted_error.value is an instance + class_ = twisted_error.value.__class__ + message = twisted_error.getErrorMessage() + try: + self.args = (message, twisted_error.value.condition) + except AttributeError: + self.args = (message,) + self._dbus_error_name = '.'.join([const_ERROR_PREFIX, class_.__module__, class_.__name__]) + + +class DbusObject(dbus.service.Object): + + def __init__(self, bus, path): + dbus.service.Object.__init__(self, bus, path) + log.debug("Init DbusObject...") + self.cb = {} + + def register_method(self, name, cb): + self.cb[name] = cb + + def _callback(self, name, *args, **kwargs): + """call the callback if it exists, raise an exception else + if the callback return a deferred, use async methods""" + if not name in self.cb: + raise MethodNotRegistered + + if "callback" in kwargs: + #we must have errback too + if not "errback" in kwargs: + log.error("errback is missing in method call [%s]" % name) + raise InternalError + callback = kwargs.pop("callback") + errback = kwargs.pop("errback") + async = True + else: + async = False + result = self.cb[name](*args, **kwargs) + if async: + if not isinstance(result, Deferred): + log.error("Asynchronous method [%s] does not return a Deferred." % name) + raise AsyncNotDeferred + result.addCallback(lambda result: callback() if result is None else callback(result)) + result.addErrback(lambda err: errback(GenericException(err))) + else: + if isinstance(result, Deferred): + log.error("Synchronous method [%s] return a Deferred." % name) + raise DeferredNotAsync + return result + ### signals ### + + @dbus.service.signal(const_INT_PREFIX + const_PLUGIN_SUFFIX, + signature='') + def dummySignal(self): + #FIXME: workaround for addSignal (doesn't work if one method doensn't + # already exist for plugins), probably missing some initialisation, need + # further investigations + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='a{ss}sis') + def actionNew(self, action_data, id, security_limit, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='ss') + def connected(self, profile, jid_s): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='ss') + def contactDeleted(self, entity_jid, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='s') + def disconnected(self, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='ssss') + def entityDataUpdated(self, jid, name, value, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sdssa{ss}a{ss}sa{ss}s') + def messageNew(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sa{ss}ass') + def newContact(self, contact_jid, attributes, groups, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='ssss') + def paramUpdate(self, name, value, category, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='ssia{ss}s') + def presenceUpdate(self, entity_jid, show, priority, statuses, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sss') + def progressError(self, id, error, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sa{ss}s') + def progressFinished(self, id, metadata, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sa{ss}s') + def progressStarted(self, id, metadata, profile): + pass + + @dbus.service.signal(const_INT_PREFIX+const_CORE_SUFFIX, + signature='sss') + def subscribe(self, sub_type, entity_jid, profile): + pass + + ### methods ### + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a(a{ss}si)', + async_callbacks=None) + def actionsGet(self, profile_key="@DEFAULT@"): + return self._callback("actionsGet", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='', + async_callbacks=None) + def addContact(self, entity_jid, profile_key="@DEFAULT@"): + return self._callback("addContact", unicode(entity_jid), unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='', + async_callbacks=('callback', 'errback')) + def asyncDeleteProfile(self, profile, callback=None, errback=None): + return self._callback("asyncDeleteProfile", unicode(profile), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sssis', out_signature='s', + async_callbacks=('callback', 'errback')) + def asyncGetParamA(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("asyncGetParamA", unicode(name), unicode(category), unicode(attribute), security_limit, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sis', out_signature='a{ss}', + async_callbacks=('callback', 'errback')) + def asyncGetParamsValuesFromCategory(self, category, security_limit=-1, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("asyncGetParamsValuesFromCategory", unicode(category), security_limit, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssa{ss}', out_signature='b', + async_callbacks=('callback', 'errback')) + def connect(self, profile_key="@DEFAULT@", password='', options={}, callback=None, errback=None): + return self._callback("connect", unicode(profile_key), unicode(password), options, callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='', + async_callbacks=('callback', 'errback')) + def delContact(self, entity_jid, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("delContact", unicode(entity_jid), unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='asa(ss)bbbbs', out_signature='(a{sa(sss)}a{sa(sss)}a{sa(sss)})', + async_callbacks=('callback', 'errback')) + def discoFindByFeatures(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, profile_key=u"@DEFAULT@", callback=None, errback=None): + return self._callback("discoFindByFeatures", namespaces, identities, bare_jid, service, roster, own_jid, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssbs', out_signature='(asa(sss)a{sa(a{ss}as)})', + async_callbacks=('callback', 'errback')) + def discoInfos(self, entity_jid, node=u'', use_cache=True, profile_key=u"@DEFAULT@", callback=None, errback=None): + return self._callback("discoInfos", unicode(entity_jid), unicode(node), use_cache, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssbs', out_signature='a(sss)', + async_callbacks=('callback', 'errback')) + def discoItems(self, entity_jid, node=u'', use_cache=True, profile_key=u"@DEFAULT@", callback=None, errback=None): + return self._callback("discoItems", unicode(entity_jid), unicode(node), use_cache, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='', + async_callbacks=('callback', 'errback')) + def disconnect(self, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("disconnect", unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='s', + async_callbacks=None) + def getConfig(self, section, name): + return self._callback("getConfig", unicode(section), unicode(name)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a(sa{ss}as)', + async_callbacks=('callback', 'errback')) + def getContacts(self, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("getContacts", unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='as', + async_callbacks=None) + def getContactsFromGroup(self, group, profile_key="@DEFAULT@"): + return self._callback("getContactsFromGroup", unicode(group), unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='asass', out_signature='a{sa{ss}}', + async_callbacks=None) + def getEntitiesData(self, jids, keys, profile): + return self._callback("getEntitiesData", jids, keys, unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sass', out_signature='a{ss}', + async_callbacks=None) + def getEntityData(self, jid, keys, profile): + return self._callback("getEntityData", unicode(jid), keys, unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{sa{ss}}', + async_callbacks=('callback', 'errback')) + def getFeatures(self, profile_key, callback=None, errback=None): + return self._callback("getFeatures", unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='s', + async_callbacks=None) + def getMainResource(self, contact_jid, profile_key="@DEFAULT@"): + return self._callback("getMainResource", unicode(contact_jid), unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssss', out_signature='s', + async_callbacks=None) + def getParamA(self, name, category, attribute="value", profile_key="@DEFAULT@"): + return self._callback("getParamA", unicode(name), unicode(category), unicode(attribute), unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='', out_signature='as', + async_callbacks=None) + def getParamsCategories(self, ): + return self._callback("getParamsCategories", ) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='iss', out_signature='s', + async_callbacks=('callback', 'errback')) + def getParamsUI(self, security_limit=-1, app='', profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("getParamsUI", security_limit, unicode(app), unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{sa{s(sia{ss})}}', + async_callbacks=None) + def getPresenceStatuses(self, profile_key="@DEFAULT@"): + return self._callback("getPresenceStatuses", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='', out_signature='', + async_callbacks=('callback', 'errback')) + def getReady(self, callback=None, errback=None): + return self._callback("getReady", callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='', out_signature='s', + async_callbacks=None) + def getVersion(self, ): + return self._callback("getVersion", ) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{ss}', + async_callbacks=None) + def getWaitingSub(self, profile_key="@DEFAULT@"): + return self._callback("getWaitingSub", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssiba{ss}s', out_signature='a(sdssa{ss}a{ss}sa{ss})', + async_callbacks=('callback', 'errback')) + def historyGet(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@", callback=None, errback=None): + return self._callback("historyGet", unicode(from_jid), unicode(to_jid), limit, between, filters, unicode(profile), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='b', + async_callbacks=None) + def isConnected(self, profile_key="@DEFAULT@"): + return self._callback("isConnected", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sa{ss}s', out_signature='a{ss}', + async_callbacks=('callback', 'errback')) + def launchAction(self, callback_id, data, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("launchAction", unicode(callback_id), data, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='b', + async_callbacks=None) + def loadParamsTemplate(self, filename): + return self._callback("loadParamsTemplate", unicode(filename)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='s', + async_callbacks=None) + def menuHelpGet(self, menu_id, language): + return self._callback("menuHelpGet", unicode(menu_id), unicode(language)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sasa{ss}is', out_signature='a{ss}', + async_callbacks=('callback', 'errback')) + def menuLaunch(self, menu_type, path, data, security_limit, profile_key, callback=None, errback=None): + return self._callback("menuLaunch", unicode(menu_type), path, data, security_limit, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='si', out_signature='a(ssasasa{ss})', + async_callbacks=None) + def menusGet(self, language, security_limit): + return self._callback("menusGet", unicode(language), security_limit) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sa{ss}a{ss}sa{ss}s', out_signature='', + async_callbacks=('callback', 'errback')) + def messageSend(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@", callback=None, errback=None): + return self._callback("messageSend", unicode(to_jid), message, subject, unicode(mess_type), extra, unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='', out_signature='a{ss}', + async_callbacks=None) + def namespacesGet(self, ): + return self._callback("namespacesGet", ) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sis', out_signature='', + async_callbacks=None) + def paramsRegisterApp(self, xml, security_limit=-1, app=''): + return self._callback("paramsRegisterApp", unicode(xml), security_limit, unicode(app)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sss', out_signature='', + async_callbacks=('callback', 'errback')) + def profileCreate(self, profile, password='', component='', callback=None, errback=None): + return self._callback("profileCreate", unicode(profile), unicode(password), unicode(component), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='b', + async_callbacks=None) + def profileIsSessionStarted(self, profile_key="@DEFAULT@"): + return self._callback("profileIsSessionStarted", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='s', + async_callbacks=None) + def profileNameGet(self, profile_key="@DEFAULT@"): + return self._callback("profileNameGet", unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='', + async_callbacks=None) + def profileSetDefault(self, profile): + return self._callback("profileSetDefault", unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='b', + async_callbacks=('callback', 'errback')) + def profileStartSession(self, password='', profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("profileStartSession", unicode(password), unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='bb', out_signature='as', + async_callbacks=None) + def profilesListGet(self, clients=True, components=False): + return self._callback("profilesListGet", clients, components) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ss', out_signature='a{ss}', + async_callbacks=None) + def progressGet(self, id, profile): + return self._callback("progressGet", unicode(id), unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{sa{sa{ss}}}', + async_callbacks=None) + def progressGetAll(self, profile): + return self._callback("progressGetAll", unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{sa{sa{ss}}}', + async_callbacks=None) + def progressGetAllMetadata(self, profile): + return self._callback("progressGetAllMetadata", unicode(profile)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='b', + async_callbacks=None) + def saveParamsTemplate(self, filename): + return self._callback("saveParamsTemplate", unicode(filename)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='s', out_signature='a{ss}', + async_callbacks=('callback', 'errback')) + def sessionInfosGet(self, profile_key, callback=None, errback=None): + return self._callback("sessionInfosGet", unicode(profile_key), callback=callback, errback=errback) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sssis', out_signature='', + async_callbacks=None) + def setParam(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"): + return self._callback("setParam", unicode(name), unicode(value), unicode(category), security_limit, unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssa{ss}s', out_signature='', + async_callbacks=None) + def setPresence(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"): + return self._callback("setPresence", unicode(to_jid), unicode(show), statuses, unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='sss', out_signature='', + async_callbacks=None) + def subscription(self, sub_type, entity, profile_key="@DEFAULT@"): + return self._callback("subscription", unicode(sub_type), unicode(entity), unicode(profile_key)) + + @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, + in_signature='ssass', out_signature='', + async_callbacks=('callback', 'errback')) + def updateContact(self, entity_jid, name, groups, profile_key="@DEFAULT@", callback=None, errback=None): + return self._callback("updateContact", unicode(entity_jid), unicode(name), groups, unicode(profile_key), callback=callback, errback=errback) + + def __attributes(self, in_sign): + """Return arguments to user given a in_sign + @param in_sign: in_sign in the short form (using s,a,i,b etc) + @return: list of arguments that correspond to a in_sign (e.g.: "sss" return "arg1, arg2, arg3")""" + i = 0 + idx = 0 + attr = [] + while i < len(in_sign): + if in_sign[i] not in ['b', 'y', 'n', 'i', 'x', 'q', 'u', 't', 'd', 's', 'a']: + raise ParseError("Unmanaged attribute type [%c]" % in_sign[i]) + + attr.append("arg_%i" % idx) + idx += 1 + + if in_sign[i] == 'a': + i += 1 + if in_sign[i] != '{' and in_sign[i] != '(': # FIXME: must manage tuples out of arrays + i += 1 + continue # we have a simple type for the array + opening_car = in_sign[i] + assert(opening_car in ['{', '(']) + closing_car = '}' if opening_car == '{' else ')' + opening_count = 1 + while (True): # we have a dict or a list of tuples + i += 1 + if i >= len(in_sign): + raise ParseError("missing }") + if in_sign[i] == opening_car: + opening_count += 1 + if in_sign[i] == closing_car: + opening_count -= 1 + if opening_count == 0: + break + i += 1 + return attr + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False): + """Dynamically add a method to Dbus Bridge""" + inspect_args = inspect.getargspec(method) + + _arguments = inspect_args.args + _defaults = list(inspect_args.defaults or []) + + if inspect.ismethod(method): + #if we have a method, we don't want the first argument (usually 'self') + del(_arguments[0]) + + #first arguments are for the _callback method + arguments_callback = ', '.join([repr(name)] + ((_arguments + ['callback=callback', 'errback=errback']) if async else _arguments)) + + if async: + _arguments.extend(['callback', 'errback']) + _defaults.extend([None, None]) + + #now we create a second list with default values + for i in range(1, len(_defaults) + 1): + _arguments[-i] = "%s = %s" % (_arguments[-i], repr(_defaults[-i])) + + arguments_defaults = ', '.join(_arguments) + + code = compile('def %(name)s (self,%(arguments_defaults)s): return self._callback(%(arguments_callback)s)' % + {'name': name, 'arguments_defaults': arguments_defaults, 'arguments_callback': arguments_callback}, '', 'exec') + exec (code) # FIXME: to the same thing in a cleaner way, without compile/exec + method = locals()[name] + async_callbacks = ('callback', 'errback') if async else None + setattr(DbusObject, name, dbus.service.method( + const_INT_PREFIX + int_suffix, in_signature=in_sign, out_signature=out_sign, + async_callbacks=async_callbacks)(method)) + function = getattr(self, name) + func_table = self._dbus_class_table[self.__class__.__module__ + '.' + self.__class__.__name__][function._dbus_interface] + func_table[function.__name__] = function # Needed for introspection + + def addSignal(self, name, int_suffix, signature, doc={}): + """Dynamically add a signal to Dbus Bridge""" + attributes = ', '.join(self.__attributes(signature)) + #TODO: use doc parameter to name attributes + + #code = compile ('def '+name+' (self,'+attributes+'): log.debug ("'+name+' signal")', '','exec') #XXX: the log.debug is too annoying with xmllog + code = compile('def ' + name + ' (self,' + attributes + '): pass', '', 'exec') + exec (code) + signal = locals()[name] + setattr(DbusObject, name, dbus.service.signal( + const_INT_PREFIX + int_suffix, signature=signature)(signal)) + function = getattr(self, name) + func_table = self._dbus_class_table[self.__class__.__module__ + '.' + self.__class__.__name__][function._dbus_interface] + func_table[function.__name__] = function # Needed for introspection + + +class Bridge(object): + def __init__(self): + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + log.info("Init DBus...") + try: + self.session_bus = dbus.SessionBus() + except dbus.DBusException as e: + if e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported': + log.error(_(u"D-Bus is not launched, please see README to see instructions on how to launch it")) + raise BridgeInitError + self.dbus_name = dbus.service.BusName(const_INT_PREFIX, self.session_bus) + self.dbus_bridge = DbusObject(self.session_bus, const_OBJ_PATH) + + def actionNew(self, action_data, id, security_limit, profile): + self.dbus_bridge.actionNew(action_data, id, security_limit, profile) + + def connected(self, profile, jid_s): + self.dbus_bridge.connected(profile, jid_s) + + def contactDeleted(self, entity_jid, profile): + self.dbus_bridge.contactDeleted(entity_jid, profile) + + def disconnected(self, profile): + self.dbus_bridge.disconnected(profile) + + def entityDataUpdated(self, jid, name, value, profile): + self.dbus_bridge.entityDataUpdated(jid, name, value, profile) + + def messageNew(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile): + self.dbus_bridge.messageNew(uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile) + + def newContact(self, contact_jid, attributes, groups, profile): + self.dbus_bridge.newContact(contact_jid, attributes, groups, profile) + + def paramUpdate(self, name, value, category, profile): + self.dbus_bridge.paramUpdate(name, value, category, profile) + + def presenceUpdate(self, entity_jid, show, priority, statuses, profile): + self.dbus_bridge.presenceUpdate(entity_jid, show, priority, statuses, profile) + + def progressError(self, id, error, profile): + self.dbus_bridge.progressError(id, error, profile) + + def progressFinished(self, id, metadata, profile): + self.dbus_bridge.progressFinished(id, metadata, profile) + + def progressStarted(self, id, metadata, profile): + self.dbus_bridge.progressStarted(id, metadata, profile) + + def subscribe(self, sub_type, entity_jid, profile): + self.dbus_bridge.subscribe(sub_type, entity_jid, profile) + + def register_method(self, name, callback): + log.debug("registering DBus bridge method [%s]" % name) + self.dbus_bridge.register_method(name, callback) + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False, doc={}): + """Dynamically add a method to Dbus Bridge""" + #FIXME: doc parameter is kept only temporary, the time to remove it from calls + log.debug("Adding method [%s] to DBus bridge" % name) + self.dbus_bridge.addMethod(name, int_suffix, in_sign, out_sign, method, async) + self.register_method(name, method) + + def addSignal(self, name, int_suffix, signature, doc={}): + self.dbus_bridge.addSignal(name, int_suffix, signature, doc) + setattr(Bridge, name, getattr(self.dbus_bridge, name)) \ No newline at end of file diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/constants.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/constants.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,385 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +try: + from xdg import BaseDirectory + from os.path import expanduser, realpath +except ImportError: + BaseDirectory = None + + +class Const(object): + + ## Application ## + APP_NAME = u'Salut à Toi' + APP_NAME_SHORT = u'SàT' + APP_NAME_FILE = u'sat' + APP_NAME_FULL = u'%s (%s)' % (APP_NAME_SHORT, APP_NAME) + APP_VERSION = u'0.7.0D' # Please add 'D' at the end for dev versions + APP_RELEASE_NAME = u'La Commune' + APP_URL = u'http://salut-a-toi.org' + + + ## Runtime ## + PLUGIN_EXT = "py" + HISTORY_SKIP = u'skip' + + ## Main config ## + DEFAULT_BRIDGE = 'dbus' + + + ## Protocol ## + XMPP_C2S_PORT = 5222 + XMPP_KEEP_ALIFE = 180 + XMPP_MAX_RETRIES = 2 + # default port used on Prosody, may differ on other servers + XMPP_COMPONENT_PORT = 5347 + + + ## Parameters ## + NO_SECURITY_LIMIT = -1 # FIXME: to rename + SECURITY_LIMIT_MAX = 0 + INDIVIDUAL = "individual" + GENERAL = "general" + # General parameters + HISTORY_LIMIT = "History" + SHOW_OFFLINE_CONTACTS = "Offline contacts" + SHOW_EMPTY_GROUPS = "Empty groups" + # Parameters related to connection + FORCE_SERVER_PARAM = "Force server" + FORCE_PORT_PARAM = "Force port" + # Parameters related to encryption + PROFILE_PASS_PATH = ('General', 'Password') + MEMORY_CRYPTO_NAMESPACE = 'crypto' # for the private persistent binary dict + MEMORY_CRYPTO_KEY = 'personal_key' + # Parameters for static blog pages + # FIXME: blog constants should not be in core constants + STATIC_BLOG_KEY = "Blog page" + STATIC_BLOG_PARAM_TITLE = "Title" + STATIC_BLOG_PARAM_BANNER = "Banner" + STATIC_BLOG_PARAM_KEYWORDS = "Keywords" + STATIC_BLOG_PARAM_DESCRIPTION = "Description" + + + ## Menus ## + MENU_GLOBAL = "GLOBAL" + MENU_ROOM = "ROOM" + MENU_SINGLE = "SINGLE" + MENU_JID_CONTEXT = "JID_CONTEXT" + MENU_ROSTER_JID_CONTEXT = "ROSTER_JID_CONTEXT" + MENU_ROSTER_GROUP_CONTEXT = "MENU_ROSTER_GROUP_CONTEXT" + MENU_ROOM_OCCUPANT_CONTEXT = "MENU_ROOM_OCCUPANT_CONTEXT" + + + ## Profile and entities ## + PROF_KEY_NONE = '@NONE@' + PROF_KEY_DEFAULT = '@DEFAULT@' + PROF_KEY_ALL = '@ALL@' + ENTITY_ALL = '@ALL@' + ENTITY_ALL_RESOURCES = '@ALL_RESOURCES@' + ENTITY_MAIN_RESOURCE = '@MAIN_RESOURCE@' + ENTITY_CAP_HASH = 'CAP_HASH' + ENTITY_TYPE = 'TYPE' + + + ## Roster jids selection ## + PUBLIC = 'PUBLIC' + ALL = 'ALL' # ALL means all known contacts, while PUBLIC means everybody, known or not + GROUP = 'GROUP' + JID = 'JID' + + + ## Messages ## + MESS_TYPE_INFO = 'info' + MESS_TYPE_CHAT = 'chat' + MESS_TYPE_ERROR = 'error' + MESS_TYPE_GROUPCHAT = 'groupchat' + MESS_TYPE_HEADLINE = 'headline' + MESS_TYPE_NORMAL = 'normal' + MESS_TYPE_AUTO = 'auto' # magic value to let the backend guess the type + MESS_TYPE_STANDARD = (MESS_TYPE_CHAT, MESS_TYPE_ERROR, MESS_TYPE_GROUPCHAT, MESS_TYPE_HEADLINE, MESS_TYPE_NORMAL) + MESS_TYPE_ALL = MESS_TYPE_STANDARD + (MESS_TYPE_INFO, MESS_TYPE_AUTO) + + MESS_EXTRA_INFO = "info_type" + + + ## Chat ## + CHAT_ONE2ONE = 'one2one' + CHAT_GROUP = 'group' + + + ## Presence ## + PRESENCE_UNAVAILABLE = 'unavailable' + PRESENCE_SHOW_AWAY = 'away' + PRESENCE_SHOW_CHAT = 'chat' + PRESENCE_SHOW_DND = 'dnd' + PRESENCE_SHOW_XA = 'xa' + PRESENCE_SHOW = 'show' + PRESENCE_STATUSES = 'statuses' + PRESENCE_STATUSES_DEFAULT = 'default' + PRESENCE_PRIORITY = 'priority' + + + ## Common namespaces ## + NS_XML = 'http://www.w3.org/XML/1998/namespace' + NS_CLIENT = 'jabber:client' + NS_FORWARD = 'urn:xmpp:forward:0' + NS_DELAY = 'urn:xmpp:delay' + NS_XHTML = 'http://www.w3.org/1999/xhtml' + + ## Common XPath ## + + IQ_GET = '/iq[@type="get"]' + IQ_SET = '/iq[@type="set"]' + + ## Directories ## + + # directory for components specific data + COMPONENTS_DIR = u'components' + CACHE_DIR = u'cache' + # files in file dir are stored for long term + # files dir is global, i.e. for all profiles + FILES_DIR = u'files' + # FILES_LINKS_DIR is a directory where files owned by a specific profile + # are linked to the global files directory. This way the directory can be + # shared per profiles while keeping global directory where identical files + # shared between different profiles are not duplicated. + FILES_LINKS_DIR = u'files_links' + # FILES_TMP_DIR is where profile's partially transfered files are put. + # Once transfer is completed, they are moved to FILES_DIR + FILES_TMP_DIR = u'files_tmp' + + + ## Configuration ## + if BaseDirectory: # skipped when xdg module is not available (should not happen in backend) + if "org.goffi.cagou.cagou" in BaseDirectory.__file__: + # FIXME: hack to make config read from the right location on Android + # TODO: fix it in a more proper way + BaseDirectory = None + DEFAULT_CONFIG = { + 'local_dir': '/data/data/org.goffi.cagou.cagou/', + 'media_dir': '/data/data/org.goffi.cagou.cagou/files/media', + 'pid_dir': '%(local_dir)s', + 'log_dir': '%(local_dir)s', + } + CONFIG_FILES = ['/data/data/org.goffi.cagou.cagou/files/platform/android/' + APP_NAME_FILE + '.conf'] + else: + + ## Configuration ## + DEFAULT_CONFIG = { + 'media_dir': '/usr/share/' + APP_NAME_FILE + '/media', + 'local_dir': BaseDirectory.save_data_path(APP_NAME_FILE), + 'pid_dir': '%(local_dir)s', + 'log_dir': '%(local_dir)s', + } + + # List of the configuration filenames sorted by ascending priority + CONFIG_FILES = [realpath(expanduser(path) + APP_NAME_FILE + '.conf') for path in + ['/etc/', '~/', '~/.', '', '.'] + + ['%s/' % path for path in list(BaseDirectory.load_config_paths(APP_NAME_FILE))] + ] + + ## Templates ## + TEMPLATE_THEME_DEFAULT = u'default' + TEMPLATE_STATIC_DIR = u'static' + + + ## Plugins ## + + # PLUGIN_INFO keys + # XXX: we use PI instead of PLUG_INFO which would normally be used + # to make the header more readable + PI_NAME = u'name' + PI_IMPORT_NAME = u'import_name' + PI_MAIN = u'main' + PI_HANDLER = u'handler' + PI_TYPE = u'type' # FIXME: should be types, and should handle single unicode type or tuple of types (e.g. "blog" and "import") + PI_MODES = u'modes' + PI_PROTOCOLS = u'protocols' + PI_DEPENDENCIES = u'dependencies' + PI_RECOMMENDATIONS = u'recommendations' + PI_DESCRIPTION = u'description' + PI_USAGE = u'usage' + + # Types + PLUG_TYPE_XEP = "XEP" + PLUG_TYPE_MISC = "MISC" + PLUG_TYPE_EXP = "EXP" + PLUG_TYPE_SEC = "SEC" + PLUG_TYPE_SYNTAXE = "SYNTAXE" + PLUG_TYPE_BLOG = "BLOG" + PLUG_TYPE_IMPORT = "IMPORT" + PLUG_TYPE_ENTRY_POINT = "ENTRY_POINT" + + # Modes + PLUG_MODE_CLIENT = "client" + PLUG_MODE_COMPONENT = "component" + PLUG_MODE_DEFAULT = (PLUG_MODE_CLIENT,) + PLUG_MODE_BOTH = (PLUG_MODE_CLIENT, PLUG_MODE_COMPONENT) + + # names of widely used plugins + TEXT_CMDS = 'TEXT-COMMANDS' + + # PubSub event categories + PS_PEP = "PEP" + PS_MICROBLOG = "MICROBLOG" + + # PubSub + PS_PUBLISH = "publish" + PS_RETRACT = "retract" # used for items + PS_DELETE = "delete" # used for nodes + PS_ITEM = "item" + PS_ITEMS = "items" # Can contain publish and retract items + PS_EVENTS = (PS_ITEMS, PS_DELETE) + + + ## XMLUI ## + XMLUI_WINDOW = 'window' + XMLUI_POPUP = 'popup' + XMLUI_FORM = 'form' + XMLUI_PARAM = 'param' + XMLUI_DIALOG = 'dialog' + XMLUI_DIALOG_CONFIRM = "confirm" + XMLUI_DIALOG_MESSAGE = "message" + XMLUI_DIALOG_NOTE = "note" + XMLUI_DIALOG_FILE = "file" + XMLUI_DATA_ANSWER = "answer" + XMLUI_DATA_CANCELLED = "cancelled" + XMLUI_DATA_TYPE = "type" + XMLUI_DATA_MESS = "message" + XMLUI_DATA_LVL = "level" + XMLUI_DATA_LVL_INFO = "info" + XMLUI_DATA_LVL_WARNING = "warning" + XMLUI_DATA_LVL_ERROR = "error" + XMLUI_DATA_LVL_DEFAULT = XMLUI_DATA_LVL_INFO + XMLUI_DATA_LVLS = (XMLUI_DATA_LVL_INFO, XMLUI_DATA_LVL_WARNING, XMLUI_DATA_LVL_ERROR) + XMLUI_DATA_BTNS_SET = "buttons_set" + XMLUI_DATA_BTNS_SET_OKCANCEL = "ok/cancel" + XMLUI_DATA_BTNS_SET_YESNO = "yes/no" + XMLUI_DATA_BTNS_SET_DEFAULT = XMLUI_DATA_BTNS_SET_OKCANCEL + XMLUI_DATA_FILETYPE = 'filetype' + XMLUI_DATA_FILETYPE_FILE = "file" + XMLUI_DATA_FILETYPE_DIR = "dir" + XMLUI_DATA_FILETYPE_DEFAULT = XMLUI_DATA_FILETYPE_FILE + + + ## Logging ## + LOG_LVL_DEBUG = 'DEBUG' + LOG_LVL_INFO = 'INFO' + LOG_LVL_WARNING = 'WARNING' + LOG_LVL_ERROR = 'ERROR' + LOG_LVL_CRITICAL = 'CRITICAL' + LOG_LEVELS = (LOG_LVL_DEBUG, LOG_LVL_INFO, LOG_LVL_WARNING, LOG_LVL_ERROR, LOG_LVL_CRITICAL) + LOG_BACKEND_STANDARD = 'standard' + LOG_BACKEND_TWISTED = 'twisted' + LOG_BACKEND_BASIC = 'basic' + LOG_BACKEND_CUSTOM = 'custom' + LOG_BASE_LOGGER = 'root' + LOG_TWISTED_LOGGER = 'twisted' + LOG_OPT_SECTION = 'DEFAULT' # section of sat.conf where log options should be + LOG_OPT_PREFIX = 'log_' + # (option_name, default_value) tuples + LOG_OPT_COLORS = ('colors', 'true') # true for auto colors, force to have colors even if stdout is not a tty, false for no color + LOG_OPT_TAINTS_DICT = ('levels_taints_dict', { + LOG_LVL_DEBUG: ('cyan',), + LOG_LVL_INFO: (), + LOG_LVL_WARNING: ('yellow',), + LOG_LVL_ERROR: ('red', 'blink', r'/!\ ', 'blink_off'), + LOG_LVL_CRITICAL: ('bold', 'red', 'Guru Meditation ', 'normal_weight') + }) + LOG_OPT_LEVEL = ('level', 'info') + LOG_OPT_FORMAT = ('fmt', '%(message)s') # similar to logging format. + LOG_OPT_LOGGER = ('logger', '') # regex to filter logger name + LOG_OPT_OUTPUT_SEP = '//' + LOG_OPT_OUTPUT_DEFAULT = 'default' + LOG_OPT_OUTPUT_MEMORY = 'memory' + LOG_OPT_OUTPUT_MEMORY_LIMIT = 50 + LOG_OPT_OUTPUT_FILE = 'file' # file is implicit if only output + LOG_OPT_OUTPUT = ('output', LOG_OPT_OUTPUT_SEP + LOG_OPT_OUTPUT_DEFAULT) # //default = normal output (stderr or a file with twistd), path/to/file for a file (must be the first if used), //memory for memory (options can be put in parenthesis, e.g.: //memory(500) for a 500 lines memory) + + + ## action constants ## + META_TYPE_FILE = "file" + META_TYPE_OVERWRITE = "overwrite" + + + ## HARD-CODED ACTIONS IDS (generated with uuid.uuid4) ## + AUTHENTICATE_PROFILE_ID = u'b03bbfa8-a4ae-4734-a248-06ce6c7cf562' + CHANGE_XMPP_PASSWD_ID = u'878b9387-de2b-413b-950f-e424a147bcd0' + + + ## Text values ## + BOOL_TRUE = "true" + BOOL_FALSE = "false" + + + ## Special values used in bridge methods calls ## + HISTORY_LIMIT_DEFAULT = -1 + HISTORY_LIMIT_NONE = -2 + + + ## Progress error special values ## + PROGRESS_ERROR_DECLINED = u'declined' # session has been declined by peer user + + + ## Files ## + FILE_TYPE_DIRECTORY = 'directory' + FILE_TYPE_FILE = 'file' + + + ## Permissions management ## + ACCESS_PERM_READ = u'read' + ACCESS_PERM_WRITE = u'write' + ACCESS_PERMS = {ACCESS_PERM_READ, ACCESS_PERM_WRITE} + ACCESS_TYPE_PUBLIC = u'public' + ACCESS_TYPE_WHITELIST = u'whitelist' + ACCESS_TYPES = (ACCESS_TYPE_PUBLIC, ACCESS_TYPE_WHITELIST) + + + ## Common data keys ## + KEY_THUMBNAILS = u'thumbnails' + KEY_PROGRESS_ID = u'progress_id' + + + ## Misc ## + SAVEFILE_DATABASE = APP_NAME_FILE + ".db" + IQ_SET = '/iq[@type="set"]' + ENV_PREFIX = 'SAT_' # Prefix used for environment variables + IGNORE = 'ignore' + NO_LIMIT = -1 # used in bridge when a integer value is expected + DEFAULT_MAX_AGE = 1209600 # default max age of cached files, in seconds + HASH_SHA1_EMPTY = 'da39a3ee5e6b4b0d3255bfef95601890afd80709' + + @classmethod + def LOG_OPTIONS(cls): + """Return options checked for logs""" + # XXX: we use a classmethod so we can use Const inheritance to change default options + return(cls.LOG_OPT_COLORS, cls.LOG_OPT_TAINTS_DICT, cls.LOG_OPT_LEVEL, cls.LOG_OPT_FORMAT, cls.LOG_OPT_LOGGER, cls.LOG_OPT_OUTPUT) + + @classmethod + def bool(cls, value): + """@return (bool): bool value for associated constant""" + assert isinstance(value, basestring) + return value.lower() in (cls.BOOL_TRUE, "1", "yes") + + @classmethod + def boolConst(cls, value): + """@return (str): constant associated to bool value""" + assert isinstance(value, bool) + return cls.BOOL_TRUE if value else cls.BOOL_FALSE diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/exceptions.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/exceptions.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,117 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT Exceptions +# Copyright (C) 2011 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 . + + +class ProfileUnknownError(Exception): + pass + + +class ProfileNotInCacheError(Exception): + pass + + +class ProfileNotSetError(Exception): + """This error raises when no profile has been set (value @NONE@ is found, but it should have been replaced)""" + + +class ProfileConnected(Exception): + """This error is raised when trying to delete a connected profile.""" + + +class ProfileNotConnected(Exception): + pass + + +class ProfileKeyUnknown(Exception): + pass + + +class ClientTypeError(Exception): + """This code is not allowed for this type of client (i.e. component or not)""" + + +class UnknownEntityError(Exception): + pass + + +class UnknownGroupError(Exception): + pass + + +class MissingModule(Exception): + # Used to indicate when a plugin dependence is not found + # it's nice to indicate when to find the dependence in argument string + pass + + +class NotFound(Exception): + pass + + +class DataError(Exception): + pass + + +class ConflictError(Exception): + pass + + +class TimeOutError(Exception): + pass + + +class CancelError(Exception): + pass + + +class InternalError(Exception): + pass + + +class FeatureNotFound(Exception): # a disco feature/identity which is needed is not present + pass + + +class BridgeInitError(Exception): + pass + + +class BridgeExceptionNoService(Exception): + pass + + +class DatabaseError(Exception): + pass + + +class PasswordError(Exception): + pass + + +class PermissionError(Exception): + pass + + +class ParsingError(Exception): + pass + + +# Something which need to be done is not available yet +class NotReady(Exception): + pass diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/i18n.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/i18n.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,45 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + + +from sat.core.log import getLogger +log = getLogger(__name__) + +try: + + import gettext + + _ = gettext.translation('sat', 'i18n', fallback=True).ugettext + _translators = {None: gettext.NullTranslations()} + + def languageSwitch(lang=None): + if not lang in _translators: + _translators[lang] = gettext.translation('sat', languages=[lang], fallback=True) + _translators[lang].install(unicode=True) + +except ImportError: + + log.warning("gettext support disabled") + _ = lambda msg: msg # Libervia doesn't support gettext + def languageSwitch(lang=None): + pass + + +D_ = lambda msg: msg # used for deferred translations + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/log.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/log.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,392 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +"""High level logging functions""" +# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed. +# TODO: change formatting from "%s" style to "{}" when moved to Python 3 + +from sat.core.constants import Const as C +from sat.tools.common.ansi import ANSI as A +from sat.core import exceptions + +backend = None +_loggers = {} +handlers = {} +COLOR_START = '%(color_start)s' +COLOR_END = '%(color_end)s' + + +class Filtered(Exception): + pass + + +class Logger(object): + """High level logging class""" + fmt = None # format option as given by user (e.g. SAT_LOG_LOGGER) + filter_name = None # filter to call + post_treat = None + + def __init__(self, name): + if isinstance(name, Logger): + self.copy(name) + else: + self._name = name + + def copy(self, other): + """Copy values from other Logger""" + self.fmt = other.fmt + self.Filter_name = other.fmt + self.post_treat = other.post_treat + self._name = other._name + + def out(self, message, level=None): + """Actually log the message + + @param message: formatted message + """ + print message + + def log(self, level, message): + """Print message + + @param level: one of C.LOG_LEVELS + @param message: message to format and print + """ + try: + formatted = self.format(level, message) + if self.post_treat is None: + self.out(formatted, level) + else: + self.out(self.post_treat(level, formatted), level) + except Filtered: + pass + + def format(self, level, message): + """Format message according to Logger.fmt + + @param level: one of C.LOG_LEVELS + @param message: message to format + @return: formatted message + + @raise: Filtered when the message must not be logged + """ + if self.fmt is None and self.filter_name is None: + return message + record = {'name': self._name, + 'message': message, + 'levelname': level, + } + try: + if not self.filter_name.dictFilter(record): + raise Filtered + except (AttributeError, TypeError): # XXX: TypeError is here because of a pyjamas bug which need to be fixed (TypeError is raised instead of AttributeError) + if self.filter_name is not None: + raise ValueError("Bad filter: filters must have a .filter method") + try: + return self.fmt % record + except TypeError: + return message + except KeyError as e: + if e.args[0] == 'profile': + # XXX: %(profile)s use some magic with introspection, for debugging purpose only *DO NOT* use in production + record['profile'] = configure_cls[backend].getProfile() + return self.fmt % record + else: + raise e + + def debug(self, msg): + self.log(C.LOG_LVL_DEBUG, msg) + + def info(self, msg): + self.log(C.LOG_LVL_INFO, msg) + + def warning(self, msg): + self.log(C.LOG_LVL_WARNING, msg) + + def error(self, msg): + self.log(C.LOG_LVL_ERROR, msg) + + def critical(self, msg): + self.log(C.LOG_LVL_CRITICAL, msg) + + +class FilterName(object): + """Filter on logger name according to a regex""" + + def __init__(self, name_re): + """Initialise name filter + + @param name_re: regular expression used to filter names (using search and not match) + """ + assert name_re + import re + self.name_re = re.compile(name_re) + + def filter(self, record): + if self.name_re.search(record.name) is not None: + return 1 + return 0 + + def dictFilter(self, dict_record): + """Filter using a dictionary record + + @param dict_record: dictionary with at list a key "name" with logger name + @return: True if message should be logged + """ + class LogRecord(object): + pass + log_record = LogRecord() + log_record.name = dict_record['name'] + return self.filter(log_record) == 1 + + +class ConfigureBase(object): + LOGGER_CLASS = Logger + _color_location = False # True if color location is specified in fmt (with COLOR_START) + + def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, levels_taints_dict=None, force_colors=False, backend_data=None): + """Configure a backend + + @param level: one of C.LOG_LEVELS + @param fmt: format string, pretty much as in std logging. Accept the following keywords (maybe more depending on backend): + - "message" + - "levelname" + - "name" (logger name) + @param logger: if set, use it as a regular expression to filter on logger name. + Use search to match expression, so ^ or $ can be necessary. + @param colors: if True use ANSI colors to show log levels + @param force_colors: if True ANSI colors are used even if stdout is not a tty + """ + self.backend_data = backend_data + self.preTreatment() + self.configureLevel(level) + self.configureFormat(fmt) + self.configureOutput(output) + self.configureLogger(logger) + self.configureColors(colors, force_colors, levels_taints_dict) + self.postTreatment() + self.updateCurrentLogger() + + def updateCurrentLogger(self): + """update existing logger to the class needed for this backend""" + if self.LOGGER_CLASS is None: + return + for name, logger in _loggers.items(): + _loggers[name] = self.LOGGER_CLASS(logger) + + def preTreatment(self): + pass + + def configureLevel(self, level): + if level is not None: + # we deactivate methods below level + level_idx = C.LOG_LEVELS.index(level) + def dev_null(self, msg): + pass + for _level in C.LOG_LEVELS[:level_idx]: + setattr(Logger, _level.lower(), dev_null) + + def configureFormat(self, fmt): + if fmt is not None: + if fmt != '%(message)s': # %(message)s is the same as None + Logger.fmt = fmt + if COLOR_START in fmt: + ConfigureBase._color_location = True + if fmt.find(COLOR_END,fmt.rfind(COLOR_START))<0: + # color_start not followed by an end, we add it + Logger.fmt += COLOR_END + + def configureOutput(self, output): + if output is not None: + if output != C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT: + # TODO: manage other outputs + raise NotImplementedError("Basic backend only manage default output yet") + + def configureLogger(self, logger): + if logger: + Logger.filter_name = FilterName(logger) + + def configureColors(self, colors, force_colors, levels_taints_dict): + if colors: + # if color are used, we need to handle levels_taints_dict + for level in levels_taints_dict.keys(): + # we wants levels in uppercase to correspond to contstants + levels_taints_dict[level.upper()] = levels_taints_dict[level] + taints = self.__class__.taints = {} + for level in C.LOG_LEVELS: + # we want use values and use constant value as default + taint_list = levels_taints_dict.get(level, C.LOG_OPT_TAINTS_DICT[1][level]) + ansi_list = [] + for elt in taint_list: + elt = elt.upper() + try: + ansi = getattr(A, 'FG_{}'.format(elt)) + except AttributeError: + try: + ansi = getattr(A, elt) + except AttributeError: + # we use raw string if element is unknown + ansi = elt + ansi_list.append(ansi) + taints[level] = ''.join(ansi_list) + + def postTreatment(self): + pass + + def manageOutputs(self, outputs_raw): + """ Parse output option in a backend agnostic way, and fill handlers consequently + + @param outputs_raw: output option as enterred in environment variable or in configuration + """ + if not outputs_raw: + return + outputs = outputs_raw.split(C.LOG_OPT_OUTPUT_SEP) + global handlers + if len(outputs) == 1: + handlers[C.LOG_OPT_OUTPUT_FILE] = [outputs.pop()] + + for output in outputs: + if not output: + continue + if output[-1] == ')': + # we have options + opt_begin = output.rfind('(') + options = output[opt_begin+1:-1] + output = output[:opt_begin] + else: + options = None + + if output not in (C.LOG_OPT_OUTPUT_DEFAULT, C.LOG_OPT_OUTPUT_FILE, C.LOG_OPT_OUTPUT_MEMORY): + raise ValueError(u"Invalid output [%s]" % output) + + if output == C.LOG_OPT_OUTPUT_DEFAULT: + # no option for defaut handler + handlers[output] = None + elif output == C.LOG_OPT_OUTPUT_FILE: + if not options: + ValueError("{handler} output need a path as option" .format(handle=output)) + handlers.setdefault(output, []).append(options) + options = None # option are parsed, we can empty them + elif output == C.LOG_OPT_OUTPUT_MEMORY: + # we have memory handler, option can be the len limit or None + try: + limit = int(options) + options = None # option are parsed, we can empty them + except (TypeError, ValueError): + limit = C.LOG_OPT_OUTPUT_MEMORY_LIMIT + handlers[output] = limit + + if options: # we should not have unparsed options + raise ValueError(u"options [{options}] are not supported for {handler} output".format(options=options, handler=output)) + + @staticmethod + def memoryGet(size=None): + """Return buffered logs + + @param size: number of logs to return + """ + raise NotImplementedError + + @classmethod + def ansiColors(cls, level, message): + """Colorise message depending on level for terminals + + @param level: one of C.LOG_LEVELS + @param message: formatted message to log + @return: message with ANSI escape codes for coloration + """ + + try: + start = cls.taints[level] + except KeyError: + start = '' + + if cls._color_location: + return message % {'color_start': start, + 'color_end': A.RESET} + else: + return '%s%s%s' % (start, message, A.RESET) + + @staticmethod + def getProfile(): + """Try to find profile value using introspection""" + raise NotImplementedError + + +class ConfigureCustom(ConfigureBase): + LOGGER_CLASS = None + + def __init__(self, logger_class, *args, **kwargs): + ConfigureCustom.LOGGER_CLASS = logger_class + + +configure_cls = { None: ConfigureBase, + C.LOG_BACKEND_CUSTOM: ConfigureCustom + } # XXX: (key: backend, value: Configure subclass) must be filled when new backend are added + + +def configure(backend_, **options): + """Configure logging behaviour + @param backend: can be: + C.LOG_BACKEND_BASIC: use a basic print based logging + C.LOG_BACKEND_CUSTOM: use a given Logger subclass + """ + global backend + if backend is not None: + raise exceptions.InternalError("Logging can only be configured once") + backend = backend_ + + try: + configure_class = configure_cls[backend] + except KeyError: + raise ValueError("unknown backend [{}]".format(backend)) + if backend == C.LOG_BACKEND_CUSTOM: + logger_class = options.pop('logger_class') + configure_class(logger_class, **options) + else: + configure_class(**options) + +def memoryGet(size=None): + if not C.LOG_OPT_OUTPUT_MEMORY in handlers: + raise ValueError('memory output is not used') + return configure_cls[backend].memoryGet(size) + +def getLogger(name=C.LOG_BASE_LOGGER): + try: + logger_class = configure_cls[backend].LOGGER_CLASS + except KeyError: + raise ValueError("This method should not be called with backend [{}]".format(backend)) + return _loggers.setdefault(name, logger_class(name)) + +_root_logger = getLogger() + +def debug(msg): + _root_logger.debug(msg) + +def info(msg): + _root_logger.info(msg) + +def warning(msg): + _root_logger.warning(msg) + +def error(msg): + _root_logger.error(msg) + +def critical(msg): + _root_logger.critical(msg) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/log_config.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/log_config.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,425 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +"""High level logging functions""" +# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed. + +from sat.core.constants import Const as C +from sat.core import log + + +class TwistedLogger(log.Logger): + colors = True + force_colors = False + + def __init__(self, *args, **kwargs): + super(TwistedLogger, self).__init__(*args, **kwargs) + from twisted.python import log as twisted_log + self.twisted_log = twisted_log + + def out(self, message, level=None): + """Actually log the message + + @param message: formatted message + """ + self.twisted_log.msg(message.encode('utf-8', 'ignore'), sat_logged=True, level=level) + + +class ConfigureBasic(log.ConfigureBase): + + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureBasic, self).configureColors(colors, force_colors, levels_taints_dict) + if colors: + import sys + try: + isatty = sys.stdout.isatty() + except AttributeError: + isatty = False + if force_colors or isatty: # FIXME: isatty should be tested on each handler, not globaly + # we need colors + log.Logger.post_treat = lambda logger, level, message: self.ansiColors(level, message) + elif force_colors: + raise ValueError("force_colors can't be used if colors is False") + + @staticmethod + def getProfile(): + """Try to find profile value using introspection""" + import inspect + stack = inspect.stack() + current_path = stack[0][1] + for frame_data in stack[:-1]: + if frame_data[1] != current_path: + if log.backend == C.LOG_BACKEND_STANDARD and "/logging/__init__.py" in frame_data[1]: + continue + break + + frame = frame_data[0] + args = inspect.getargvalues(frame) + try: + profile = args.locals.get('profile') or args.locals['profile_key'] + except (TypeError, KeyError): + try: + try: + profile = args.locals['self'].profile + except AttributeError: + try: + profile = args.locals['self'].parent.profile + except AttributeError: + profile = args.locals['self'].host.profile # used in quick_frontend for single profile configuration + except Exception: + # we can't find profile, we return an empty value + profile = '' + return profile + + +class ConfigureTwisted(ConfigureBasic): + LOGGER_CLASS = TwistedLogger + + def changeObserver(self, observer, can_colors=False): + """Install a hook on observer to manage SàT specificities + + @param observer: original observer to hook + @param can_colors: True if observer can display ansi colors + """ + def observer_hook(event): + """redirect non SàT log to twisted_logger, and add colors when possible""" + if 'sat_logged' in event: # we only want our own logs, other are managed by twistedObserver + # we add colors if possible + if (can_colors and self.LOGGER_CLASS.colors) or self.LOGGER_CLASS.force_colors: + message = event.get('message', tuple()) + level = event.get('level', C.LOG_LVL_INFO) + if message: + event['message'] = (self.ansiColors(level, ''.join(message)),) # must be a tuple + observer(event) # we can now call the original observer + + return observer_hook + + def changeFileLogObserver(self, observer): + """Install SàT hook for FileLogObserver + + if the output is a tty, we allow colors, else we don't + @param observer: original observer to hook + """ + log_obs = observer.__self__ + log_file = log_obs.write.__self__ + try: + can_colors = log_file.isatty() + except AttributeError: + can_colors = False + return self.changeObserver(observer, can_colors=can_colors) + + def installObserverHook(self, observer): + """Check observer type and install SàT hook when possible + + @param observer: observer to hook + @return: hooked observer or original one + """ + if hasattr(observer, '__self__'): + ori = observer + if isinstance(observer.__self__, self.twisted_log.FileLogObserver): + observer = self.changeFileLogObserver(observer) + elif isinstance(observer.__self__, self.twisted_log.DefaultObserver): + observer = self.changeObserver(observer, can_colors=True) + else: + # we use print because log system is not fully initialized + print("Unmanaged observer [%s]" % observer) + return observer + self.observers[ori] = observer + return observer + + def preTreatment(self): + """initialise needed attributes, and install observers hooks""" + self.observers = {} + from twisted.python import log as twisted_log + self.twisted_log = twisted_log + self.log_publisher = twisted_log.msg.__self__ + def addObserverObserver(self_logpub, other): + """Install hook so we know when a new observer is added""" + other = self.installObserverHook(other) + return self_logpub._originalAddObserver(other) + def removeObserverObserver(self_logpub, ori): + """removeObserver hook fix + + As we wrap the original observer, the original removeObserver may want to remove the original object instead of the wrapper, this method fix this + """ + if ori in self.observers: + self_logpub._originalRemoveObserver(self.observers[ori]) + else: + try: + self_logpub._originalRemoveObserver(ori) + except ValueError: + try: + ori in self.cleared_observers + except AttributeError: + raise ValueError("Unknown observer") + + # we replace addObserver/removeObserver by our own + twisted_log.LogPublisher._originalAddObserver = twisted_log.LogPublisher.addObserver + twisted_log.LogPublisher._originalRemoveObserver = twisted_log.LogPublisher.removeObserver + import types # see https://stackoverflow.com/a/4267590 (thx Chris Morgan/aaronasterling) + twisted_log.addObserver = types.MethodType(addObserverObserver, self.log_publisher, twisted_log.LogPublisher) + twisted_log.removeObserver = types.MethodType(removeObserverObserver, self.log_publisher, twisted_log.LogPublisher) + + # we now change existing observers + for idx, observer in enumerate(self.log_publisher.observers): + self.log_publisher.observers[idx] = self.installObserverHook(observer) + + def configureLevel(self, level): + self.LOGGER_CLASS.level = level + super(ConfigureTwisted, self).configureLevel(level) + + def configureOutput(self, output): + import sys + if output is None: + output = C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT + self.manageOutputs(output) + addObserver = self.twisted_log.addObserver + + if C.LOG_OPT_OUTPUT_DEFAULT in log.handlers: + # default output is already managed, we just add output to stdout if we are in debug or nodaemon mode + if self.backend_data is None: + raise ValueError("You must pass options as backend_data with Twisted backend") + options = self.backend_data + if options.get('nodaemon', False) or options.get('debug', False): + addObserver(self.twisted_log.FileLogObserver(sys.stdout).emit) + else: + # \\default is not in the output, so we remove current observers + self.cleared_observers = self.log_publisher.observers + self.observers.clear() + del self.log_publisher.observers[:] + # and we forbid twistd to add any observer + self.twisted_log.addObserver = lambda other: None + + if C.LOG_OPT_OUTPUT_FILE in log.handlers: + from twisted.python import logfile + for path in log.handlers[C.LOG_OPT_OUTPUT_FILE]: + log_file = sys.stdout if path == '-' else logfile.LogFile.fromFullPath(path) + addObserver(self.twisted_log.FileLogObserver(log_file).emit) + + if C.LOG_OPT_OUTPUT_MEMORY in log.handlers: + raise NotImplementedError("Memory observer is not implemented in Twisted backend") + + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureTwisted, self).configureColors(colors, force_colors, levels_taints_dict) + self.LOGGER_CLASS.colors = colors + self.LOGGER_CLASS.force_colors = force_colors + if force_colors and not colors: + raise ValueError('colors must be True if force_colors is True') + + def postTreatment(self): + """Install twistedObserver which manage non SàT logs""" + def twistedObserver(event): + """Observer which redirect log message not produced by SàT to SàT logging system""" + if not 'sat_logged' in event: + # this log was not produced by SàT + from twisted.python import log as twisted_log + text = twisted_log.textFromEventDict(event) + if text is None: + return + twisted_logger = log.getLogger(C.LOG_TWISTED_LOGGER) + log_method = twisted_logger.error if event.get('isError', False) else twisted_logger.info + log_method(text.decode('utf-8')) + + self.log_publisher._originalAddObserver(twistedObserver) + + +class ConfigureStandard(ConfigureBasic): + + def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, levels_taints_dict=None, force_colors=False, backend_data=None): + if fmt is None: + fmt = C.LOG_OPT_FORMAT[1] + if output is None: + output = C.LOG_OPT_OUTPUT[1] + super(ConfigureStandard, self).__init__(level, fmt, output, logger, colors, levels_taints_dict, force_colors, backend_data) + + def preTreatment(self): + """We use logging methods directly, instead of using Logger""" + import logging + log.getLogger = logging.getLogger + log.debug = logging.debug + log.info = logging.info + log.warning = logging.warning + log.error = logging.error + log.critical = logging.critical + + def configureLevel(self, level): + if level is None: + level = C.LOG_LVL_DEBUG + self.level = level + + def configureFormat(self, fmt): + super(ConfigureStandard, self).configureFormat(fmt) + import logging + import sys + + class SatFormatter(logging.Formatter): + u"""Formatter which manage SàT specificities""" + _format = fmt + _with_profile = '%(profile)s' in fmt + + def __init__(self, can_colors=False): + super(SatFormatter, self).__init__(self._format) + self.can_colors = can_colors + + def format(self, record): + if self._with_profile: + record.profile = ConfigureStandard.getProfile() + do_color = self.with_colors and (self.can_colors or self.force_colors) + if ConfigureStandard._color_location: + # we copy raw formatting strings for color_* + # as formatting is handled in ansiColors in this case + if do_color: + record.color_start = log.COLOR_START + record.color_end = log.COLOR_END + else: + record.color_start = record.color_end = '' + s = super(SatFormatter, self).format(record) + if do_color: + s = ConfigureStandard.ansiColors(record.levelname, s) + if sys.platform == "android": + # FIXME: dirty hack to workaround android encoding issue on log + # need to be fixed properly + return s.encode('ascii', 'ignore') + else: + return s + + self.formatterClass = SatFormatter + + def configureOutput(self, output): + self.manageOutputs(output) + + def configureLogger(self, logger): + self.name_filter = log.FilterName(logger) if logger else None + + def configureColors(self, colors, force_colors, levels_taints_dict): + super(ConfigureStandard, self).configureColors(colors, force_colors, levels_taints_dict) + self.formatterClass.with_colors = colors + self.formatterClass.force_colors = force_colors + if not colors and force_colors: + raise ValueError("force_colors can't be used if colors is False") + + def _addHandler(self, root_logger, hdlr, can_colors=False): + hdlr.setFormatter(self.formatterClass(can_colors)) + root_logger.addHandler(hdlr) + root_logger.setLevel(self.level) + if self.name_filter is not None: + hdlr.addFilter(self.name_filter) + + def postTreatment(self): + import logging + root_logger = logging.getLogger() + if len(root_logger.handlers) == 0: + for handler, options in log.handlers.items(): + if handler == C.LOG_OPT_OUTPUT_DEFAULT: + hdlr = logging.StreamHandler() + try: + can_colors = hdlr.stream.isatty() + except AttributeError: + can_colors = False + self._addHandler(root_logger, hdlr, can_colors=can_colors) + elif handler == C.LOG_OPT_OUTPUT_MEMORY: + from logging.handlers import BufferingHandler + class SatMemoryHandler(BufferingHandler): + def emit(self, record): + super(SatMemoryHandler, self).emit(self.format(record)) + hdlr = SatMemoryHandler(options) + log.handlers[handler] = hdlr # we keep a reference to the handler to read the buffer later + self._addHandler(root_logger, hdlr, can_colors=False) + elif handler == C.LOG_OPT_OUTPUT_FILE: + import os.path + for path in options: + hdlr = logging.FileHandler(os.path.expanduser(path)) + self._addHandler(root_logger, hdlr, can_colors=False) + else: + raise ValueError("Unknown handler type") + else: + root_logger.warning(u"Handlers already set on root logger") + + @staticmethod + def memoryGet(size=None): + """Return buffered logs + + @param size: number of logs to return + """ + mem_handler = log.handlers[C.LOG_OPT_OUTPUT_MEMORY] + return (log_msg for log_msg in mem_handler.buffer[size if size is None else -size:]) + + +log.configure_cls[C.LOG_BACKEND_BASIC] = ConfigureBasic +log.configure_cls[C.LOG_BACKEND_TWISTED] = ConfigureTwisted +log.configure_cls[C.LOG_BACKEND_STANDARD] = ConfigureStandard + +def configure(backend, **options): + """Configure logging behaviour + @param backend: can be: + C.LOG_BACKEND_STANDARD: use standard logging module + C.LOG_BACKEND_TWISTED: use twisted logging module (with standard logging observer) + C.LOG_BACKEND_BASIC: use a basic print based logging + C.LOG_BACKEND_CUSTOM: use a given Logger subclass + """ + return log.configure(backend, **options) + +def _parseOptions(options): + """Parse string options as given in conf or environment variable, and return expected python value + + @param options (dict): options with (key: name, value: string value) + """ + COLORS = C.LOG_OPT_COLORS[0] + LEVEL = C.LOG_OPT_LEVEL[0] + + if COLORS in options: + if options[COLORS].lower() in ('1', 'true'): + options[COLORS] = True + elif options[COLORS] == 'force': + options[COLORS] = True + options['force_colors'] = True + else: + options[COLORS] = False + if LEVEL in options: + level = options[LEVEL].upper() + if level not in C.LOG_LEVELS: + level = C.LOG_LVL_INFO + options[LEVEL] = level + +def satConfigure(backend=C.LOG_BACKEND_STANDARD, const=None, backend_data=None): + """Configure logging system for SàT, can be used by frontends + + logs conf is read in SàT conf, then in environment variables. It must be done before Memory init + @param backend: backend to use, it can be: + - C.LOG_BACKEND_BASIC: print based backend + - C.LOG_BACKEND_TWISTED: Twisted logging backend + - C.LOG_BACKEND_STANDARD: standard logging backend + @param const: Const class to use instead of sat.core.constants.Const (mainly used to change default values) + """ + if const is not None: + global C + C = const + log.C = const + from sat.tools import config + import os + log_conf = {} + sat_conf = config.parseMainConf() + for opt_name, opt_default in C.LOG_OPTIONS(): + try: + log_conf[opt_name] = os.environ[''.join((C.ENV_PREFIX, C.LOG_OPT_PREFIX.upper(), opt_name.upper()))] + except KeyError: + log_conf[opt_name] = config.getConfig(sat_conf, C.LOG_OPT_SECTION, C.LOG_OPT_PREFIX + opt_name, opt_default) + + _parseOptions(log_conf) + configure(backend, backend_data=backend_data, **log_conf) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/sat_main.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/sat_main.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1083 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +import sat +from sat.core.i18n import _, languageSwitch +from twisted.application import service +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.internet import reactor +from wokkel.xmppim import RosterItem +from sat.core import xmpp +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C +from sat.memory import memory +from sat.memory import cache +from sat.tools import trigger +from sat.tools import utils +from sat.tools.common import dynamic_import +from sat.tools.common import regex +from sat.stdui import ui_contact_list, ui_profile_manager +import sat.plugins +from glob import glob +import sys +import os.path +import uuid + +try: + from collections import OrderedDict # only available from python 2.7 +except ImportError: + from ordereddict import OrderedDict + + +class SAT(service.Service): + + def __init__(self): + self._cb_map = {} # map from callback_id to callbacks + self._menus = OrderedDict() # dynamic menus. key: callback_id, value: menu data (dictionnary) + self._menus_paths = {} # path to id. key: (menu_type, lower case tuple of path), value: menu id + self.initialised = defer.Deferred() + self.profiles = {} + self.plugins = {} + self.ns_map = {u'x-data': u'jabber:x:data'} # map for short name to whole namespace, + # extended by plugins with registerNamespace + self.memory = memory.Memory(self) + self.trigger = trigger.TriggerManager() # trigger are used to change SàT behaviour + + bridge_name = self.memory.getConfig('', 'bridge', 'dbus') + + bridge_module = dynamic_import.bridge(bridge_name) + if bridge_module is None: + log.error(u"Can't find bridge module of name {}".format(bridge_name)) + sys.exit(1) + log.info(u"using {} bridge".format(bridge_name)) + try: + self.bridge = bridge_module.Bridge() + except exceptions.BridgeInitError: + log.error(u"Bridge can't be initialised, can't start SàT core") + sys.exit(1) + self.bridge.register_method("getReady", lambda: self.initialised) + self.bridge.register_method("getVersion", lambda: self.full_version) + self.bridge.register_method("getFeatures", self.getFeatures) + self.bridge.register_method("profileNameGet", self.memory.getProfileName) + self.bridge.register_method("profilesListGet", self.memory.getProfilesList) + self.bridge.register_method("getEntityData", lambda jid_, keys, profile: self.memory.getEntityData(jid.JID(jid_), keys, profile)) + self.bridge.register_method("getEntitiesData", self.memory._getEntitiesData) + self.bridge.register_method("profileCreate", self.memory.createProfile) + self.bridge.register_method("asyncDeleteProfile", self.memory.asyncDeleteProfile) + self.bridge.register_method("profileStartSession", self.memory.startSession) + self.bridge.register_method("profileIsSessionStarted", self.memory._isSessionStarted) + self.bridge.register_method("profileSetDefault", self.memory.profileSetDefault) + self.bridge.register_method("connect", self._connect) + self.bridge.register_method("disconnect", self.disconnect) + self.bridge.register_method("getContacts", self.getContacts) + self.bridge.register_method("getContactsFromGroup", self.getContactsFromGroup) + self.bridge.register_method("getMainResource", self.memory._getMainResource) + self.bridge.register_method("getPresenceStatuses", self.memory._getPresenceStatuses) + self.bridge.register_method("getWaitingSub", self.memory.getWaitingSub) + self.bridge.register_method("messageSend", self._messageSend) + self.bridge.register_method("getConfig", self._getConfig) + self.bridge.register_method("setParam", self.setParam) + self.bridge.register_method("getParamA", self.memory.getStringParamA) + self.bridge.register_method("asyncGetParamA", self.memory.asyncGetStringParamA) + self.bridge.register_method("asyncGetParamsValuesFromCategory", self.memory.asyncGetParamsValuesFromCategory) + self.bridge.register_method("getParamsUI", self.memory.getParamsUI) + self.bridge.register_method("getParamsCategories", self.memory.getParamsCategories) + self.bridge.register_method("paramsRegisterApp", self.memory.paramsRegisterApp) + self.bridge.register_method("historyGet", self.memory._historyGet) + self.bridge.register_method("setPresence", self._setPresence) + self.bridge.register_method("subscription", self.subscription) + self.bridge.register_method("addContact", self._addContact) + self.bridge.register_method("updateContact", self._updateContact) + self.bridge.register_method("delContact", self._delContact) + self.bridge.register_method("isConnected", self.isConnected) + self.bridge.register_method("launchAction", self.launchCallback) + self.bridge.register_method("actionsGet", self.actionsGet) + self.bridge.register_method("progressGet", self._progressGet) + self.bridge.register_method("progressGetAll", self._progressGetAll) + self.bridge.register_method("menusGet", self.getMenus) + self.bridge.register_method("menuHelpGet", self.getMenuHelp) + self.bridge.register_method("menuLaunch", self._launchMenu) + self.bridge.register_method("discoInfos", self.memory.disco._discoInfos) + self.bridge.register_method("discoItems", self.memory.disco._discoItems) + self.bridge.register_method("discoFindByFeatures", self._findByFeatures) + self.bridge.register_method("saveParamsTemplate", self.memory.save_xml) + self.bridge.register_method("loadParamsTemplate", self.memory.load_xml) + self.bridge.register_method("sessionInfosGet", self.getSessionInfos) + self.bridge.register_method("namespacesGet", self.getNamespaces) + + self.memory.initialized.addCallback(self._postMemoryInit) + + @property + def version(self): + """Return the short version of SàT""" + return C.APP_VERSION + + @property + def full_version(self): + """Return the full version of SàT (with release name and extra data when in development mode)""" + version = self.version + if version[-1] == 'D': + # we are in debug version, we add extra data + try: + return self._version_cache + except AttributeError: + self._version_cache = u"{} « {} » ({})".format(version, C.APP_RELEASE_NAME, utils.getRepositoryData(sat)) + return self._version_cache + else: + return version + + @property + def bridge_name(self): + return os.path.splitext(os.path.basename(self.bridge.__file__))[0] + + def _postMemoryInit(self, ignore): + """Method called after memory initialization is done""" + self.common_cache = cache.Cache(self, None) + log.info(_("Memory initialised")) + try: + self._import_plugins() + ui_contact_list.ContactList(self) + ui_profile_manager.ProfileManager(self) + except Exception as e: + log.error(_(u"Could not initialize backend: {reason}").format( + reason = str(e).decode('utf-8', 'ignore'))) + sys.exit(1) + self.initialised.callback(None) + log.info(_(u"Backend is ready")) + + def _unimport_plugin(self, plugin_path): + """remove a plugin from sys.modules if it is there""" + try: + del sys.modules[plugin_path] + except KeyError: + pass + + def _import_plugins(self): + """Import all plugins found in plugins directory""" + # FIXME: module imported but cancelled should be deleted + # TODO: make this more generic and reusable in tools.common + # FIXME: should use imp + # TODO: do not import all plugins if no needed: component plugins are not needed if we + # just use a client, and plugin blacklisting should be possible in sat.conf + plugins_path = os.path.dirname(sat.plugins.__file__) + plugin_glob = "plugin*." + C.PLUGIN_EXT + plug_lst = [os.path.splitext(plugin)[0] for plugin in map(os.path.basename, glob(os.path.join(plugins_path, plugin_glob)))] + plugins_to_import = {} # plugins we still have to import + for plug in plug_lst: + plugin_path = 'sat.plugins.' + plug + try: + __import__(plugin_path) + except exceptions.MissingModule as e: + self._unimport_plugin(plugin_path) + log.warning(u"Can't import plugin [{path}] because of an unavailale third party module:\n{msg}".format( + path=plugin_path, msg=e)) + continue + except exceptions.CancelError as e: + log.info(u"Plugin [{path}] cancelled its own import: {msg}".format(path=plugin_path, msg=e)) + self._unimport_plugin(plugin_path) + continue + except Exception as e: + import traceback + log.error(_(u"Can't import plugin [{path}]:\n{error}").format(path=plugin_path, error=traceback.format_exc())) + self._unimport_plugin(plugin_path) + continue + mod = sys.modules[plugin_path] + plugin_info = mod.PLUGIN_INFO + import_name = plugin_info['import_name'] + + plugin_modes = plugin_info[u'modes'] = set(plugin_info.setdefault(u"modes", C.PLUG_MODE_DEFAULT)) + + # if the plugin is an entry point, it must work in component mode + if plugin_info[u'type'] == C.PLUG_TYPE_ENTRY_POINT: + # if plugin is an entrypoint, we cache it + if C.PLUG_MODE_COMPONENT not in plugin_modes: + log.error(_(u"{type} type must be used with {mode} mode, ignoring plugin").format( + type = C.PLUG_TYPE_ENTRY_POINT, mode = C.PLUG_MODE_COMPONENT)) + self._unimport_plugin(plugin_path) + continue + + if import_name in plugins_to_import: + log.error(_(u"Name conflict for import name [{import_name}], can't import plugin [{name}]").format(**plugin_info)) + continue + plugins_to_import[import_name] = (plugin_path, mod, plugin_info) + while True: + try: + self._import_plugins_from_dict(plugins_to_import) + except ImportError: + pass + if not plugins_to_import: + break + + def _import_plugins_from_dict(self, plugins_to_import, import_name=None, optional=False): + """Recursively import and their dependencies in the right order + + @param plugins_to_import(dict): key=import_name and values=(plugin_path, module, plugin_info) + @param import_name(unicode, None): name of the plugin to import as found in PLUGIN_INFO['import_name'] + @param optional(bool): if False and plugin is not found, an ImportError exception is raised + """ + if import_name in self.plugins: + log.debug(u'Plugin {} already imported, passing'.format(import_name)) + return + if not import_name: + import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem() + else: + if not import_name in plugins_to_import: + if optional: + log.warning(_(u"Recommended plugin not found: {}").format(import_name)) + return + msg = u"Dependency not found: {}".format(import_name) + log.error(msg) + raise ImportError(msg) + plugin_path, mod, plugin_info = plugins_to_import.pop(import_name) + dependencies = plugin_info.setdefault("dependencies", []) + recommendations = plugin_info.setdefault("recommendations", []) + for to_import in dependencies + recommendations: + if to_import not in self.plugins: + log.debug(u'Recursively import dependency of [%s]: [%s]' % (import_name, to_import)) + try: + self._import_plugins_from_dict(plugins_to_import, to_import, to_import not in dependencies) + except ImportError as e: + log.warning(_(u"Can't import plugin {name}: {error}").format(name=plugin_info['name'], error=e)) + if optional: + return + raise e + log.info("importing plugin: {}".format(plugin_info['name'])) + # we instanciate the plugin here + try: + self.plugins[import_name] = getattr(mod, plugin_info['main'])(self) + except Exception as e: + log.warning(u'Error while loading plugin "{name}", ignoring it: {error}' + .format(name=plugin_info['name'], error=e)) + if optional: + return + raise ImportError(u"Error during initiation") + if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)): + self.plugins[import_name].is_handler = True + else: + self.plugins[import_name].is_handler = False + # we keep metadata as a Class attribute + self.plugins[import_name]._info = plugin_info + #TODO: test xmppclient presence and register handler parent + + def pluginsUnload(self): + """Call unload method on every loaded plugin, if exists + + @return (D): A deferred which return None when all method have been called + """ + # TODO: in the futur, it should be possible to hot unload a plugin + # pluging depending on the unloaded one should be unloaded too + # for now, just a basic call on plugin.unload is done + defers_list = [] + for plugin in self.plugins.itervalues(): + try: + unload = plugin.unload + except AttributeError: + continue + else: + defers_list.append(defer.maybeDeferred(unload)) + return defers_list + + def _connect(self, profile_key, password='', options=None): + profile = self.memory.getProfileName(profile_key) + return self.connect(profile, password, options) + + def connect(self, profile, password='', options=None, max_retries=C.XMPP_MAX_RETRIES): + """Connect a profile (i.e. connect client.component to XMPP server) + + Retrieve the individual parameters, authenticate the profile + and initiate the connection to the associated XMPP server. + @param profile: %(doc_profile)s + @param password (string): the SàT profile password + @param options (dict): connection options. Key can be: + - + @param max_retries (int): max number of connection retries + @return (D(bool)): + - True if the XMPP connection was already established + - False if the XMPP connection has been initiated (it may still fail) + @raise exceptions.PasswordError: Profile password is wrong + """ + if options is None: + options={} + def connectProfile(dummy=None): + if self.isConnected(profile): + log.info(_("already connected !")) + return True + + if self.memory.isComponent(profile): + d = xmpp.SatXMPPComponent.startConnection(self, profile, max_retries) + else: + d = xmpp.SatXMPPClient.startConnection(self, profile, max_retries) + return d.addCallback(lambda dummy: False) + + d = self.memory.startSession(password, profile) + d.addCallback(connectProfile) + return d + + def disconnect(self, profile_key): + """disconnect from jabber server""" + # FIXME: client should not be deleted if only disconnected + # it shoud be deleted only when session is finished + if not self.isConnected(profile_key): + # isConnected is checked here and not on client + # because client is deleted when session is ended + log.info(_(u"not connected !")) + return defer.succeed(None) + client = self.getClient(profile_key) + return client.entityDisconnect() + + def getFeatures(self, profile_key=C.PROF_KEY_NONE): + """Get available features + + Return list of activated plugins and plugin specific data + @param profile_key: %(doc_profile_key)s + C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile dependent) + @return (dict)[Deferred]: features data where: + - key is plugin import name, present only for activated plugins + - value is a an other dict, when meaning is specific to each plugin. + this dict is return by plugin's getFeature method. + If this method doesn't exists, an empty dict is returned. + """ + try: + # FIXME: there is no method yet to check profile session + # as soon as one is implemented, it should be used here + self.getClient(profile_key) + except KeyError: + log.warning("Requesting features for a profile outside a session") + profile_key = C.PROF_KEY_NONE + except exceptions.ProfileNotSetError: + pass + + features = [] + for import_name, plugin in self.plugins.iteritems(): + try: + features_d = defer.maybeDeferred(plugin.getFeatures, profile_key) + except AttributeError: + features_d = defer.succeed({}) + features.append(features_d) + + d_list = defer.DeferredList(features) + def buildFeatures(result, import_names): + assert len(result) == len(import_names) + ret = {} + for name, (success, data) in zip (import_names, result): + if success: + ret[name] = data + else: + log.warning(u"Error while getting features for {name}: {failure}".format( + name=name, failure=data)) + ret[name] = {} + return ret + + d_list.addCallback(buildFeatures, self.plugins.keys()) + return d_list + + def getContacts(self, profile_key): + client = self.getClient(profile_key) + def got_roster(dummy): + ret = [] + for item in client.roster.getItems(): # we get all items for client's roster + # and convert them to expected format + attr = client.roster.getAttributes(item) + ret.append([item.jid.userhost(), attr, item.groups]) + return ret + + return client.roster.got_roster.addCallback(got_roster) + + def getContactsFromGroup(self, group, profile_key): + client = self.getClient(profile_key) + return [jid_.full() for jid_ in client.roster.getJidsFromGroup(group)] + + def purgeEntity(self, profile): + """Remove reference to a profile client/component and purge cache + + the garbage collector can then free the memory + """ + try: + del self.profiles[profile] + except KeyError: + log.error(_("Trying to remove reference to a client not referenced")) + else: + self.memory.purgeProfileSession(profile) + + def startService(self): + log.info(u"Salut à toi ô mon frère !") + + def stopService(self): + log.info(u"Salut aussi à Rantanplan") + return self.pluginsUnload() + + def run(self): + log.debug(_("running app")) + reactor.run() + + def stop(self): + log.debug(_("stopping app")) + reactor.stop() + + ## Misc methods ## + + def getJidNStream(self, profile_key): + """Convenient method to get jid and stream from profile key + @return: tuple (jid, xmlstream) from profile, can be None""" + # TODO: deprecate this method (getClient is enough) + profile = self.memory.getProfileName(profile_key) + if not profile or not self.profiles[profile].isConnected(): + return (None, None) + return (self.profiles[profile].jid, self.profiles[profile].xmlstream) + + def getClient(self, profile_key): + """Convenient method to get client from profile key + + @return: client or None if it doesn't exist + @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist + @raise exceptions.NotFound: client is not available + This happen if profile has not been used yet + """ + profile = self.memory.getProfileName(profile_key) + if not profile: + raise exceptions.ProfileKeyUnknown + try: + return self.profiles[profile] + except KeyError: + raise exceptions.NotFound(profile_key) + + def getClients(self, profile_key): + """Convenient method to get list of clients from profile key (manage list through profile_key like C.PROF_KEY_ALL) + + @param profile_key: %(doc_profile_key)s + @return: list of clients + """ + if not profile_key: + raise exceptions.DataError(_(u'profile_key must not be empty')) + try: + profile = self.memory.getProfileName(profile_key, True) + except exceptions.ProfileUnknownError: + return [] + if profile == C.PROF_KEY_ALL: + return self.profiles.values() + elif profile[0] == '@': # only profile keys can start with "@" + raise exceptions.ProfileKeyUnknown + return [self.profiles[profile]] + + def _getConfig(self, section, name): + """Get the main configuration option + + @param section: section of the config file (None or '' for DEFAULT) + @param name: name of the option + @return: unicode representation of the option + """ + return unicode(self.memory.getConfig(section, name, '')) + + def logErrback(self, failure_): + """generic errback logging + + can be used as last errback to show unexpected error + """ + log.error(_(u"Unexpected error: {}".format(failure_))) + return failure_ + + # namespaces + + def registerNamespace(self, short_name, namespace): + """associate a namespace to a short name""" + if short_name in self.ns_map: + raise exceptions.ConflictError(u'this short name is already used') + self.ns_map[short_name] = namespace + + def getNamespaces(self): + return self.ns_map + + def getSessionInfos(self, profile_key): + """compile interesting data on current profile session""" + client = self.getClient(profile_key) + data = { + "jid": client.jid.full(), + "started": unicode(int(client.started)), + } + return defer.succeed(data) + + # local dirs + + def getLocalPath(self, client, dir_name, *extra_path, **kwargs): + """retrieve path for local data + + if path doesn't exist, it will be created + @param client(SatXMPPClient, None): client instance + used when profile is set, can be None if profile is False + @param dir_name(unicode): name of the main path directory + @param component(bool): if True, path will be prefixed with C.COMPONENTS_DIR + @param profile(bool): if True, path will be suffixed by profile name + @param *extra_path: extra path element(s) to use + @return (unicode): path + """ + # FIXME: component and profile are parsed with **kwargs because of python 2 limitations + # once moved to python 3, this can be fixed + component = kwargs.pop('component', False) + profile = kwargs.pop('profile', True) + assert not kwargs + + path_elts = [self.memory.getConfig('', 'local_dir')] + if component: + path_elts.append(C.COMPONENTS_DIR) + path_elts.append(regex.pathEscape(dir_name)) + if extra_path: + path_elts.extend([regex.pathEscape(p) for p in extra_path]) + if profile: + regex.pathEscape(client.profile) + path = os.path.join(*path_elts) + if not os.path.exists(path): + os.makedirs(path) + return path + + ## Client management ## + + def setParam(self, name, value, category, security_limit, profile_key): + """set wanted paramater and notice observers""" + self.memory.setParam(name, value, category, security_limit, profile_key) + + def isConnected(self, profile_key): + """Return connection status of profile + @param profile_key: key_word or profile name to determine profile name + @return: True if connected + """ + profile = self.memory.getProfileName(profile_key) + if not profile: + log.error(_('asking connection status for a non-existant profile')) + raise exceptions.ProfileUnknownError(profile_key) + if profile not in self.profiles: + return False + return self.profiles[profile].isConnected() + + ## XMPP methods ## + + def _messageSend(self, to_jid_s, message, subject=None, mess_type='auto', extra=None, profile_key=C.PROF_KEY_NONE): + client = self.getClient(profile_key) + to_jid = jid.JID(to_jid_s) + #XXX: we need to use the dictionary comprehension because D-Bus return its own types, and pickle can't manage them. TODO: Need to find a better way + return client.sendMessage(to_jid, message, subject, mess_type, {unicode(key): unicode(value) for key, value in extra.items()}) + + def _setPresence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE): + return self.setPresence(jid.JID(to) if to else None, show, statuses, profile_key) + + def setPresence(self, to_jid=None, show="", statuses=None, profile_key=C.PROF_KEY_NONE): + """Send our presence information""" + if statuses is None: + statuses = {} + profile = self.memory.getProfileName(profile_key) + assert profile + priority = int(self.memory.getParamA("Priority", "Connection", profile_key=profile)) + self.profiles[profile].presence.available(to_jid, show, statuses, priority) + #XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not broadcasted to generating resource) + if '' in statuses: + statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop('') + self.bridge.presenceUpdate(self.profiles[profile].jid.full(), show, + int(priority), statuses, profile) + + def subscription(self, subs_type, raw_jid, profile_key): + """Called to manage subscription + @param subs_type: subsciption type (cf RFC 3921) + @param raw_jid: unicode entity's jid + @param profile_key: profile""" + profile = self.memory.getProfileName(profile_key) + assert profile + to_jid = jid.JID(raw_jid) + log.debug(_(u'subsciption request [%(subs_type)s] for %(jid)s') % {'subs_type': subs_type, 'jid': to_jid.full()}) + if subs_type == "subscribe": + self.profiles[profile].presence.subscribe(to_jid) + elif subs_type == "subscribed": + self.profiles[profile].presence.subscribed(to_jid) + elif subs_type == "unsubscribe": + self.profiles[profile].presence.unsubscribe(to_jid) + elif subs_type == "unsubscribed": + self.profiles[profile].presence.unsubscribed(to_jid) + + def _addContact(self, to_jid_s, profile_key): + return self.addContact(jid.JID(to_jid_s), profile_key) + + def addContact(self, to_jid, profile_key): + """Add a contact in roster list""" + profile = self.memory.getProfileName(profile_key) + assert profile + # presence is sufficient, as a roster push will be sent according to RFC 6121 §3.1.2 + self.profiles[profile].presence.subscribe(to_jid) + + def _updateContact(self, to_jid_s, name, groups, profile_key): + return self.updateContact(jid.JID(to_jid_s), name, groups, profile_key) + + def updateContact(self, to_jid, name, groups, profile_key): + """update a contact in roster list""" + profile = self.memory.getProfileName(profile_key) + assert profile + groups = set(groups) + roster_item = RosterItem(to_jid) + roster_item.name = name or None + roster_item.groups = set(groups) + return self.profiles[profile].roster.setItem(roster_item) + + def _delContact(self, to_jid_s, profile_key): + return self.delContact(jid.JID(to_jid_s), profile_key) + + def delContact(self, to_jid, profile_key): + """Remove contact from roster list""" + profile = self.memory.getProfileName(profile_key) + assert profile + self.profiles[profile].presence.unsubscribe(to_jid) # is not asynchronous + return self.profiles[profile].roster.removeItem(to_jid) + + ## Discovery ## + # discovery methods are shortcuts to self.memory.disco + # the main difference with client.disco is that self.memory.disco manage cache + + def hasFeature(self, *args, **kwargs): + return self.memory.disco.hasFeature(*args, **kwargs) + + def checkFeature(self, *args, **kwargs): + return self.memory.disco.checkFeature(*args, **kwargs) + + def checkFeatures(self, *args, **kwargs): + return self.memory.disco.checkFeatures(*args, **kwargs) + + def getDiscoInfos(self, *args, **kwargs): + return self.memory.disco.getInfos(*args, **kwargs) + + def getDiscoItems(self, *args, **kwargs): + return self.memory.disco.getItems(*args, **kwargs) + + def findServiceEntity(self, *args, **kwargs): + return self.memory.disco.findServiceEntity(*args, **kwargs) + + def findServiceEntities(self, *args, **kwargs): + return self.memory.disco.findServiceEntities(*args, **kwargs) + + def findFeaturesSet(self, *args, **kwargs): + return self.memory.disco.findFeaturesSet(*args, **kwargs) + + def _findByFeatures(self, namespaces, identities, bare_jids, service, roster, own_jid, profile_key): + client = self.getClient(profile_key) + return self.findByFeatures(client, namespaces, identities, bare_jids, service, roster, own_jid) + + @defer.inlineCallbacks + def findByFeatures(self, client, namespaces, identities=None, bare_jids=False, service=True, roster=True, own_jid=True): + """retrieve all services or contacts managing a set a features + + @param namespaces(list[unicode]): features which must be handled + @param identities(list[tuple[unicode,unicode]], None): if not None or empty, only keep those identities + tuple must by (category, type) + @param bare_jids(bool): retrieve only bare_jids if True + if False, retrieve full jid of connected devices + @param service(bool): if True return service from our roster + @param roster(bool): if True, return entities in roster + full jid of all matching resources available will be returned + @param own_jid(bool): if True, return profile's jid resources + @return (tuple(dict[jid.JID(), tuple[unicode, unicode, unicode]]*3)): found entities in a tuple with: + - service entities + - own entities + - roster entities + """ + if not identities: + identities = None + if not namespaces and not identities: + raise exceptions.DataError("at least one namespace or one identity must be set") + found_service = {} + found_own = {} + found_roster = {} + if service: + services_jids = yield self.findFeaturesSet(client, namespaces) + for service_jid in services_jids: + infos = yield self.getDiscoInfos(client, service_jid) + if identities is not None and not set(infos.identities.keys()).issuperset(identities): + continue + found_identities = [(cat, type_, name or u'') for (cat, type_), name in infos.identities.iteritems()] + found_service[service_jid.full()] = found_identities + + jids = [] + if roster: + jids.extend(client.roster.getJids()) + if own_jid: + jids.append(client.jid.userhostJID()) + + for found, jids in ((found_own, [client.jid.userhostJID()]), + (found_roster, client.roster.getJids())): + for jid_ in jids: + if jid_.resource: + if bare_jids: + continue + resources = [jid_.resource] + else: + if bare_jids: + resources = [None] + else: + try: + resources = self.memory.getAllResources(client, jid_) + except exceptions.UnknownEntityError: + continue + for resource in resources: + full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource)) + infos = yield self.getDiscoInfos(client, full_jid) + if infos.features.issuperset(namespaces): + if identities is not None and not set(infos.identities.keys()).issuperset(identities): + continue + found_identities = [(cat, type_, name or u'') for (cat, type_), name in infos.identities.iteritems()] + found[full_jid.full()] = found_identities + + defer.returnValue((found_service, found_own, found_roster)) + + ## Generic HMI ## + + def _killAction(self, keep_id, client): + log.debug(u"Killing action {} for timeout".format(keep_id)) + client.actions[keep_id] + + def actionNew(self, action_data, security_limit=C.NO_SECURITY_LIMIT, keep_id=None, profile=C.PROF_KEY_NONE): + """Shortcut to bridge.actionNew which generate and id and keep for retrieval + + @param action_data(dict): action data (see bridge documentation) + @param security_limit: %(doc_security_limit)s + @param keep_id(None, unicode): if not None, used to keep action for differed retrieval + must be set to the callback_id + action will be deleted after 30 min. + @param profile: %(doc_profile)s + """ + id_ = unicode(uuid.uuid4()) + if keep_id is not None: + client = self.getClient(profile) + action_timer = reactor.callLater(60*30, self._killAction, keep_id, client) + client.actions[keep_id] = (action_data, id_, security_limit, action_timer) + + self.bridge.actionNew(action_data, id_, security_limit, profile) + + def actionsGet(self, profile): + """Return current non answered actions + + @param profile: %(doc_profile)s + """ + client = self.getClient(profile) + return [action_tuple[:-1] for action_tuple in client.actions.itervalues()] + + def registerProgressCb(self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE): + """Register a callback called when progress is requested for id""" + if metadata is None: + metadata = {} + client = self.getClient(profile) + if progress_id in client._progress_cb: + raise exceptions.ConflictError(u"Progress ID is not unique !") + client._progress_cb[progress_id] = (callback, metadata) + + def removeProgressCb(self, progress_id, profile): + """Remove a progress callback""" + client = self.getClient(profile) + try: + del client._progress_cb[progress_id] + except KeyError: + log.error(_(u"Trying to remove an unknow progress callback")) + + def _progressGet(self, progress_id, profile): + data = self.progressGet(progress_id, profile) + return {k: unicode(v) for k,v in data.iteritems()} + + def progressGet(self, progress_id, profile): + """Return a dict with progress information + + @param progress_id(unicode): unique id of the progressing element + @param profile: %(doc_profile)s + @return (dict): data with the following keys: + 'position' (int): current possition + 'size' (int): end_position + if id doesn't exists (may be a finished progression), and empty dict is returned + """ + client = self.getClient(profile) + try: + data = client._progress_cb[progress_id][0](progress_id, profile) + except KeyError: + data = {} + return data + + def _progressGetAll(self, profile_key): + progress_all = self.progressGetAll(profile_key) + for profile, progress_dict in progress_all.iteritems(): + for progress_id, data in progress_dict.iteritems(): + for key, value in data.iteritems(): + data[key] = unicode(value) + return progress_all + + def progressGetAllMetadata(self, profile_key): + """Return all progress metadata at once + + @param profile_key: %(doc_profile)s + if C.PROF_KEY_ALL is used, all progress metadata from all profiles are returned + @return (dict[dict[dict]]): a dict which map profile to progress_dict + progress_dict map progress_id to progress_data + progress_metadata is the same dict as sent by [progressStarted] + """ + clients = self.getClients(profile_key) + progress_all = {} + for client in clients: + profile = client.profile + progress_dict = {} + progress_all[profile] = progress_dict + for progress_id, (dummy, progress_metadata) in client._progress_cb.iteritems(): + progress_dict[progress_id] = progress_metadata + return progress_all + + def progressGetAll(self, profile_key): + """Return all progress status at once + + @param profile_key: %(doc_profile)s + if C.PROF_KEY_ALL is used, all progress status from all profiles are returned + @return (dict[dict[dict]]): a dict which map profile to progress_dict + progress_dict map progress_id to progress_data + progress_data is the same dict as returned by [progressGet] + """ + clients = self.getClients(profile_key) + progress_all = {} + for client in clients: + profile = client.profile + progress_dict = {} + progress_all[profile] = progress_dict + for progress_id, (progress_cb, dummy) in client._progress_cb.iteritems(): + progress_dict[progress_id] = progress_cb(progress_id, profile) + return progress_all + + def registerCallback(self, callback, *args, **kwargs): + """Register a callback. + + @param callback(callable): method to call + @param kwargs: can contain: + with_data(bool): True if the callback use the optional data dict + force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid if possible + one_shot(bool): True to delete callback once it have been called + @return: id of the registered callback + """ + callback_id = kwargs.pop('force_id', None) + if callback_id is None: + callback_id = str(uuid.uuid4()) + else: + if callback_id in self._cb_map: + raise exceptions.ConflictError(_(u"id already registered")) + self._cb_map[callback_id] = (callback, args, kwargs) + + if "one_shot" in kwargs: # One Shot callback are removed after 30 min + def purgeCallback(): + try: + self.removeCallback(callback_id) + except KeyError: + pass + reactor.callLater(1800, purgeCallback) + + return callback_id + + def removeCallback(self, callback_id): + """ Remove a previously registered callback + @param callback_id: id returned by [registerCallback] """ + log.debug("Removing callback [%s]" % callback_id) + del self._cb_map[callback_id] + + def launchCallback(self, callback_id, data=None, profile_key=C.PROF_KEY_NONE): + """Launch a specific callback + + @param callback_id: id of the action (callback) to launch + @param data: optional data + @profile_key: %(doc_profile_key)s + @return: a deferred which fire a dict where key can be: + - xmlui: a XMLUI need to be displayed + - validated: if present, can be used to launch a callback, it can have the values + - C.BOOL_TRUE + - C.BOOL_FALSE + """ + # FIXME: security limit need to be checked here + try: + client = self.getClient(profile_key) + except exceptions.NotFound: + # client is not available yet + profile = self.memory.getProfileName(profile_key) + if not profile: + raise exceptions.ProfileUnknownError(_(u'trying to launch action with a non-existant profile')) + else: + profile = client.profile + # we check if the action is kept, and remove it + try: + action_tuple = client.actions[callback_id] + except KeyError: + pass + else: + action_tuple[-1].cancel() # the last item is the action timer + del client.actions[callback_id] + + try: + callback, args, kwargs = self._cb_map[callback_id] + except KeyError: + raise exceptions.DataError(u"Unknown callback id {}".format(callback_id)) + + if kwargs.get("with_data", False): + if data is None: + raise exceptions.DataError("Required data for this callback is missing") + args,kwargs=list(args)[:],kwargs.copy() # we don't want to modify the original (kw)args + args.insert(0, data) + kwargs["profile"] = profile + del kwargs["with_data"] + + if kwargs.pop('one_shot', False): + self.removeCallback(callback_id) + + return defer.maybeDeferred(callback, *args, **kwargs) + + #Menus management + + def _getMenuCanonicalPath(self, path): + """give canonical form of path + + canonical form is a tuple of the path were every element is stripped and lowercase + @param path(iterable[unicode]): untranslated path to menu + @return (tuple[unicode]): canonical form of path + """ + return tuple((p.lower().strip() for p in path)) + + def importMenu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT, help_string="", type_=C.MENU_GLOBAL): + """register a new menu for frontends + + @param path(iterable[unicode]): path to go to the menu (category/subcategory/.../item) (e.g.: ("File", "Open")) + /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open"))) + untranslated/lower case path can be used to identity a menu, for this reason it must be unique independently of case. + @param callback(callable): method to be called when menuitem is selected, callable or a callback id (string) as returned by [registerCallback] + @param security_limit(int): %(doc_security_limit)s + /!\ security_limit MUST be added to data in launchCallback if used #TODO + @param help_string(unicode): string used to indicate what the menu do (can be show as a tooltip). + /!\ use D_() instead of _() for translations + @param type(unicode): one of: + - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g. something like File/Open) + - C.MENU_ROOM: like a global menu, but only shown in multi-user chat + menu_data must contain a "room_jid" data + - C.MENU_SINGLE: like a global menu, but only shown in one2one chat + menu_data must contain a "jid" data + - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc commands, jid is already filled) + menu_data must contain a "jid" data + - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in roster. + menu_data must contain a "room_jid" data + - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish microblog, group is already filled) + menu_data must contain a "group" data + @return (unicode): menu_id (same as callback_id) + """ + + if callable(callback): + callback_id = self.registerCallback(callback, with_data=True) + elif isinstance(callback, basestring): + # The callback is already registered + callback_id = callback + try: + callback, args, kwargs = self._cb_map[callback_id] + except KeyError: + raise exceptions.DataError("Unknown callback id") + kwargs["with_data"] = True # we have to be sure that we use extra data + else: + raise exceptions.DataError("Unknown callback type") + + for menu_data in self._menus.itervalues(): + if menu_data['path'] == path and menu_data['type'] == type_: + raise exceptions.ConflictError(_("A menu with the same path and type already exists")) + + path_canonical = self._getMenuCanonicalPath(path) + menu_key = (type_, path_canonical) + + if menu_key in self._menus_paths: + raise exceptions.ConflictError(u"this menu path is already used: {path} ({menu_key})".format( + path=path_canonical, menu_key=menu_key)) + + menu_data = {'path': tuple(path), + 'path_canonical': path_canonical, + 'security_limit': security_limit, + 'help_string': help_string, + 'type': type_ + } + + self._menus[callback_id] = menu_data + self._menus_paths[menu_key] = callback_id + + return callback_id + + def getMenus(self, language='', security_limit=C.NO_SECURITY_LIMIT): + """Return all menus registered + + @param language: language used for translation, or empty string for default + @param security_limit: %(doc_security_limit)s + @return: array of tuple with: + - menu id (same as callback_id) + - menu type + - raw menu path (array of strings) + - translated menu path + - extra (dict(unicode, unicode)): extra data where key can be: + - icon: name of the icon to use (TODO) + - help_url: link to a page with more complete documentation (TODO) + """ + ret = [] + for menu_id, menu_data in self._menus.iteritems(): + type_ = menu_data['type'] + path = menu_data['path'] + menu_security_limit = menu_data['security_limit'] + if security_limit!=C.NO_SECURITY_LIMIT and (menu_security_limit==C.NO_SECURITY_LIMIT or menu_security_limit>security_limit): + continue + languageSwitch(language) + path_i18n = [_(elt) for elt in path] + languageSwitch() + extra = {} # TODO: manage extra data like icon + ret.append((menu_id, type_, path, path_i18n, extra)) + + return ret + + def _launchMenu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + client = self.getClient(profile_key) + return self.launchMenu(client, menu_type, path, data, security_limit) + + def launchMenu(self, client, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT): + """launch action a menu action + + @param menu_type(unicode): type of menu to launch + @param path(iterable[unicode]): canonical path of the menu + @params data(dict): menu data + @raise NotFound: this path is not known + """ + # FIXME: manage security_limit here + # defaut security limit should be high instead of C.NO_SECURITY_LIMIT + canonical_path = self._getMenuCanonicalPath(path) + menu_key = (menu_type, canonical_path) + try: + callback_id = self._menus_paths[menu_key] + except KeyError: + raise exceptions.NotFound(u"Can't find menu {path} ({menu_type})".format( + path=canonical_path, menu_type=menu_type)) + return self.launchCallback(callback_id, data, client.profile) + + def getMenuHelp(self, menu_id, language=''): + """return the help string of the menu + + @param menu_id: id of the menu (same as callback_id) + @param language: language used for translation, or empty string for default + @param return: translated help + + """ + try: + menu_data = self._menus[menu_id] + except KeyError: + raise exceptions.DataError("Trying to access an unknown menu") + languageSwitch(language) + help_string = _(menu_data['help_string']) + languageSwitch() + return help_string diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/core/xmpp.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/core/xmpp.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1138 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.memory import cache +from twisted.internet import task, defer +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import error +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from twisted.python import failure +from wokkel import client as wokkel_client, disco, xmppim, generic, iwokkel +from wokkel import component +from wokkel import delay +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from zope.interface import implements +import time +import calendar +import uuid +import sys + + +class SatXMPPEntity(object): + """Common code for Client and Component""" + + def __init__(self, host_app, profile, max_retries): + + self.factory.clientConnectionLost = self.connectionLost + self.factory.maxRetries = max_retries + # when self._connected is None, we are not connected + # else, it's a deferred which fire on disconnection + self._connected = None + self.profile = profile + self.host_app = host_app + self.cache = cache.Cache(host_app, profile) + self._mess_id_uid = {} # map from message id to uid used in history. Key: (full_jid,message_id) Value: uid + self.conn_deferred = defer.Deferred() + self._progress_cb = {} # callback called when a progress is requested (key = progress id) + self.actions = {} # used to keep track of actions for retrieval (key = action_id) + + ## initialisation ## + + @defer.inlineCallbacks + def _callConnectionTriggers(self): + """Call conneting trigger prepare connected trigger + + @param plugins(iterable): plugins to use + @return (list[object, callable]): plugin to trigger tuples with: + - plugin instance + - profileConnected* triggers (to call after connection) + """ + plugin_conn_cb = [] + for plugin in self._getPluginsList(): + # we check if plugin handle client mode + if plugin.is_handler: + plugin.getHandler(self).setHandlerParent(self) + + # profileConnecting/profileConnected methods handling + + # profile connecting is called right now (before actually starting client) + connecting_cb = getattr(plugin, "profileConnecting", None) + if connecting_cb is not None: + yield connecting_cb(self) + + # profile connected is called after client is ready and roster is got + connected_cb = getattr(plugin, "profileConnected", None) + if connected_cb is not None: + plugin_conn_cb.append((plugin, connected_cb)) + + defer.returnValue(plugin_conn_cb) + + def _getPluginsList(self): + """Return list of plugin to use + + need to be implemented by subclasses + this list is used to call profileConnect* triggers + @return(iterable[object]): plugins to use + """ + raise NotImplementedError + + def _createSubProtocols(self): + return + + def entityConnected(self): + """Called once connection is done + + may return a Deferred, to perform initialisation tasks + """ + return + + @classmethod + @defer.inlineCallbacks + def startConnection(cls, host, profile, max_retries): + """instantiate the entity and start the connection""" + # FIXME: reconnection doesn't seems to be handled correclty (client is deleted then recreated from scrash + # most of methods called here should be called once on first connection (e.g. adding subprotocols) + # but client should not be deleted except if session is finished (independently of connection/deconnection + # + try: + port = int(host.memory.getParamA(C.FORCE_PORT_PARAM, "Connection", profile_key=profile)) + except ValueError: + log.debug(_("Can't parse port value, using default value")) + port = None # will use default value 5222 or be retrieved from a DNS SRV record + + password = yield host.memory.asyncGetParamA("Password", "Connection", profile_key=profile) + entity = host.profiles[profile] = cls(host, profile, + jid.JID(host.memory.getParamA("JabberID", "Connection", profile_key=profile)), + password, host.memory.getParamA(C.FORCE_SERVER_PARAM, "Connection", profile_key=profile) or None, + port, max_retries) + + entity._createSubProtocols() + + entity.fallBack = SatFallbackHandler(host) + entity.fallBack.setHandlerParent(entity) + + entity.versionHandler = SatVersionHandler(C.APP_NAME_FULL, + host.full_version) + entity.versionHandler.setHandlerParent(entity) + + entity.identityHandler = SatIdentityHandler() + entity.identityHandler.setHandlerParent(entity) + + log.debug(_("setting plugins parents")) + + plugin_conn_cb = yield entity._callConnectionTriggers() + + entity.startService() + + yield entity.getConnectionDeferred() + + yield defer.maybeDeferred(entity.entityConnected) + + # Call profileConnected callback for all plugins, and print error message if any of them fails + conn_cb_list = [] + for dummy, callback in plugin_conn_cb: + conn_cb_list.append(defer.maybeDeferred(callback, entity)) + list_d = defer.DeferredList(conn_cb_list) + + def logPluginResults(results): + all_succeed = all([success for success, result in results]) + if not all_succeed: + log.error(_(u"Plugins initialisation error")) + for idx, (success, result) in enumerate(results): + if not success: + log.error(u"error (plugin %(name)s): %(failure)s" % + {'name': plugin_conn_cb[idx][0]._info['import_name'], 'failure': result}) + + yield list_d.addCallback(logPluginResults) # FIXME: we should have a timeout here, and a way to know if a plugin freeze + # TODO: mesure launch time of each plugin + + def getConnectionDeferred(self): + """Return a deferred which fire when the client is connected""" + return self.conn_deferred + + def _disconnectionCb(self, dummy): + self._connected = None + + def _disconnectionEb(self, failure_): + log.error(_(u"Error while disconnecting: {}".format(failure_))) + + def _authd(self, xmlstream): + if not self.host_app.trigger.point("XML Initialized", xmlstream, self.profile): + return + super(SatXMPPEntity, self)._authd(xmlstream) + + # the following Deferred is used to know when we are connected + # so we need to be set it to None when connection is lost + self._connected = defer.Deferred() + self._connected.addCallback(self._cleanConnection) + self._connected.addCallback(self._disconnectionCb) + self._connected.addErrback(self._disconnectionEb) + + log.info(_(u"********** [{profile}] CONNECTED **********").format(profile=self.profile)) + self.streamInitialized() + self.host_app.bridge.connected(self.profile, unicode(self.jid)) # we send the signal to the clients + + def _finish_connection(self, dummy): + self.conn_deferred.callback(None) + + def streamInitialized(self): + """Called after _authd""" + log.debug(_(u"XML stream is initialized")) + self.keep_alife = task.LoopingCall(self.xmlstream.send, " ") # Needed to avoid disconnection (specially with openfire) + self.keep_alife.start(C.XMPP_KEEP_ALIFE) + + self.disco = SatDiscoProtocol(self) + self.disco.setHandlerParent(self) + self.discoHandler = disco.DiscoHandler() + self.discoHandler.setHandlerParent(self) + disco_d = defer.succeed(None) + + if not self.host_app.trigger.point("Disco handled", disco_d, self.profile): + return + + disco_d.addCallback(self._finish_connection) + + def initializationFailed(self, reason): + log.error(_(u"ERROR: XMPP connection failed for profile '%(profile)s': %(reason)s" % {'profile': self.profile, 'reason': reason})) + self.conn_deferred.errback(reason.value) + try: + super(SatXMPPEntity, self).initializationFailed(reason) + except: + # we already chained an errback, no need to raise an exception + pass + + ## connection ## + + def connectionLost(self, connector, reason): + try: + self.keep_alife.stop() + except AttributeError: + log.debug(_("No keep_alife")) + if self._connected is not None: + self.host_app.bridge.disconnected(self.profile) # we send the signal to the clients + self._connected.callback(None) + self.host_app.purgeEntity(self.profile) # and we remove references to this client + log.info(_(u"********** [{profile}] DISCONNECTED **********").format(profile=self.profile)) + if not self.conn_deferred.called: + # FIXME: real error is not gotten here (e.g. if jid is not know by Prosody, + # we should have the real error) + self.conn_deferred.errback(error.StreamError(u"Server unexpectedly closed the connection")) + + @defer.inlineCallbacks + def _cleanConnection(self, dummy): + """method called on disconnection + + used to call profileDisconnected* triggers + """ + trigger_name = "profileDisconnected" + for plugin in self._getPluginsList(): + disconnected_cb = getattr(plugin, trigger_name, None) + if disconnected_cb is not None: + yield disconnected_cb(self) + + def isConnected(self): + return self._connected is not None + + def entityDisconnect(self): + log.info(_(u"Disconnecting...")) + self.stopService() + if self._connected is not None: + return self._connected + else: + return defer.succeed(None) + + ## sending ## + + def IQ(self, type_=u'set', timeout=60): + """shortcut to create an IQ element managing deferred + + @param type_(unicode): IQ type ('set' or 'get') + @param timeout(None, int): timeout in seconds + @return((D)domish.Element: result stanza + errback is called if and error stanza is returned + """ + iq_elt = xmlstream.IQ(self.xmlstream, type_) + iq_elt.timeout = timeout + return iq_elt + + def sendError(self, iq_elt, condition): + """Send error stanza build from iq_elt + + @param iq_elt(domish.Element): initial IQ element + @param condition(unicode): error condition + """ + iq_error_elt = error.StanzaError(condition).toResponse(iq_elt) + self.xmlstream.send(iq_error_elt) + + def generateMessageXML(self, data): + """Generate stanza from message data + + @param data(dict): message data + domish element will be put in data['xml'] + following keys are needed: + - from + - to + - uid: can be set to '' if uid attribute is not wanted + - message + - type + - subject + - extra + @return (dict) message data + """ + data['xml'] = message_elt = domish.Element((None, 'message')) + message_elt["to"] = data["to"].full() + message_elt["from"] = data['from'].full() + message_elt["type"] = data["type"] + if data['uid']: # key must be present but can be set to '' + # by a plugin to avoid id on purpose + message_elt['id'] = data['uid'] + for lang, subject in data["subject"].iteritems(): + subject_elt = message_elt.addElement("subject", content=subject) + if lang: + subject_elt[(C.NS_XML, 'lang')] = lang + for lang, message in data["message"].iteritems(): + body_elt = message_elt.addElement("body", content=message) + if lang: + body_elt[(C.NS_XML, 'lang')] = lang + try: + thread = data['extra']['thread'] + except KeyError: + if 'thread_parent' in data['extra']: + raise exceptions.InternalError(u"thread_parent found while there is not associated thread") + else: + thread_elt = message_elt.addElement("thread", content=thread) + try: + thread_elt["parent"] = data["extra"]["thread_parent"] + except KeyError: + pass + return data + + def addPostXmlCallbacks(self, post_xml_treatments): + """Used to add class level callbacks at the end of the workflow + + @param post_xml_treatments(D): the same Deferred as in sendMessage trigger + """ + raise NotImplementedError + + def sendMessage(self, to_jid, message, subject=None, mess_type='auto', extra=None, uid=None, no_trigger=False): + """Send a message to an entity + + @param to_jid(jid.JID): destinee of the message + @param message(dict): message body, key is the language (use '' when unknown) + @param subject(dict): message subject, key is the language (use '' when unknown) + @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or: + - auto: for automatic type detection + - info: for information ("info_type" can be specified in extra) + @param extra(dict, None): extra data. Key can be: + - info_type: information type, can be + TODO + @param uid(unicode, None): unique id: + should be unique at least in this XMPP session + if None, an uuid will be generated + @param no_trigger (bool): if True, sendMessage[suffix] trigger will no be used + useful when a message need to be sent without any modification + """ + if subject is None: + subject = {} + if extra is None: + extra = {} + + assert mess_type in C.MESS_TYPE_ALL + + data = { # dict is similar to the one used in client.onMessage + "from": self.jid, + "to": to_jid, + "uid": uid or unicode(uuid.uuid4()), + "message": message, + "subject": subject, + "type": mess_type, + "extra": extra, + "timestamp": time.time(), + } + pre_xml_treatments = defer.Deferred() # XXX: plugin can add their pre XML treatments to this deferred + post_xml_treatments = defer.Deferred() # XXX: plugin can add their post XML treatments to this deferred + + if data["type"] == C.MESS_TYPE_AUTO: + # we try to guess the type + if data["subject"]: + data["type"] = C.MESS_TYPE_NORMAL + elif not data["to"].resource: # if to JID has a resource, the type is not 'groupchat' + # we may have a groupchat message, we check if the we know this jid + try: + entity_type = self.host_app.memory.getEntityData(data["to"], ['type'], self.profile)["type"] + #FIXME: should entity_type manage resources ? + except (exceptions.UnknownEntityError, KeyError): + entity_type = "contact" + + if entity_type == "chatroom": + data["type"] = C.MESS_TYPE_GROUPCHAT + else: + data["type"] = C.MESS_TYPE_CHAT + else: + data["type"] == C.MESS_TYPE_CHAT + data["type"] == C.MESS_TYPE_CHAT if data["subject"] else C.MESS_TYPE_NORMAL + + # FIXME: send_only is used by libervia's OTR plugin to avoid + # the triggers from frontend, and no_trigger do the same + # thing internally, this could be unified + send_only = data['extra'].get('send_only', False) + + if not no_trigger and not send_only: + if not self.host_app.trigger.point("sendMessage" + self.trigger_suffix, self, data, pre_xml_treatments, post_xml_treatments): + return defer.succeed(None) + + log.debug(_(u"Sending message (type {type}, to {to})").format(type=data["type"], to=to_jid.full())) + + pre_xml_treatments.addCallback(lambda dummy: self.generateMessageXML(data)) + pre_xml_treatments.chainDeferred(post_xml_treatments) + post_xml_treatments.addCallback(self.sendMessageData) + if send_only: + log.debug(_("Triggers, storage and echo have been inhibited by the 'send_only' parameter")) + else: + self.addPostXmlCallbacks(post_xml_treatments) + post_xml_treatments.addErrback(self._cancelErrorTrap) + post_xml_treatments.addErrback(self.host_app.logErrback) + pre_xml_treatments.callback(data) + return pre_xml_treatments + + def _cancelErrorTrap(self, failure): + """A message sending can be cancelled by a plugin treatment""" + failure.trap(exceptions.CancelError) + + def messageAddToHistory(self, data): + """Store message into database (for local history) + + @param data: message data dictionnary + @param client: profile's client + """ + if data[u"type"] != C.MESS_TYPE_GROUPCHAT: + # we don't add groupchat message to history, as we get them back + # and they will be added then + if data[u'message'] or data[u'subject']: # we need a message to store + self.host_app.memory.addToHistory(self, data) + else: + log.warning(u"No message found") # empty body should be managed by plugins before this point + return data + + def messageSendToBridge(self, data): + """Send message to bridge, so frontends can display it + + @param data: message data dictionnary + @param client: profile's client + """ + if data[u"type"] != C.MESS_TYPE_GROUPCHAT: + # we don't send groupchat message to bridge, as we get them back + # and they will be added the + if data[u'message'] or data[u'subject']: # we need a message to send something + # We send back the message, so all frontends are aware of it + self.host_app.bridge.messageNew(data[u'uid'], data[u'timestamp'], data[u'from'].full(), data[u'to'].full(), data[u'message'], data[u'subject'], data[u'type'], data[u'extra'], profile=self.profile) + else: + log.warning(_(u"No message found")) + return data + + +class SatXMPPClient(SatXMPPEntity, wokkel_client.XMPPClient): + implements(iwokkel.IDisco) + trigger_suffix = "" + is_component = False + + def __init__(self, host_app, profile, user_jid, password, host=None, port=C.XMPP_C2S_PORT, max_retries=C.XMPP_MAX_RETRIES): + # XXX: DNS SRV records are checked when the host is not specified. + # If no SRV record is found, the host is directly extracted from the JID. + self.started = time.time() + + # Currently, we use "client/pc/Salut à Toi", but as + # SàT is multi-frontends and can be used on mobile devices, as a bot, with a web frontend, + # etc., we should implement a way to dynamically update identities through the bridge + self.identities = [disco.DiscoIdentity(u"client", u"pc", C.APP_NAME)] + if sys.platform == "android": + # FIXME: temporary hack as SRV is not working on android + # TODO: remove this hack and fix SRV + log.info(u"FIXME: Android hack, ignoring SRV") + host = user_jid.host + + hosts_map = host_app.memory.getConfig(None, "hosts_dict", {}) + if host is None and user_jid.host in hosts_map: + host_data = hosts_map[user_jid.host] + if isinstance(host_data, basestring): + host = host_data + elif isinstance(host_data, dict): + if u'host' in host_data: + host = host_data[u'host'] + if u'port' in host_data: + port = host_data[u'port'] + else: + log.warning(_(u"invalid data used for host: {data}").format(data=host_data)) + host_data = None + if host_data is not None: + log.info(u"using {host}:{port} for host {host_ori} as requested in config".format( + host_ori = user_jid.host, + host = host, + port = port)) + + wokkel_client.XMPPClient.__init__(self, user_jid, password, host or None, port or C.XMPP_C2S_PORT) + SatXMPPEntity.__init__(self, host_app, profile, max_retries) + + def _getPluginsList(self): + for p in self.host_app.plugins.itervalues(): + if C.PLUG_MODE_CLIENT in p._info[u'modes']: + yield p + + def _createSubProtocols(self): + self.messageProt = SatMessageProtocol(self.host_app) + self.messageProt.setHandlerParent(self) + + self.roster = SatRosterProtocol(self.host_app) + self.roster.setHandlerParent(self) + + self.presence = SatPresenceProtocol(self.host_app) + self.presence.setHandlerParent(self) + + def entityConnected(self): + # we want to be sure that we got the roster + return self.roster.got_roster + + def addPostXmlCallbacks(self, post_xml_treatments): + post_xml_treatments.addCallback(self.messageAddToHistory) + post_xml_treatments.addCallback(self.messageSendToBridge) + + def send(self, obj): + # original send method accept string + # but we restrict to domish.Element to make trigger treatments easier + assert isinstance(obj, domish.Element) + # XXX: this trigger is the last one before sending stanza on wire + # it is intended for things like end 2 end encryption. + # *DO NOT* cancel (i.e. return False) without very good reason + # (out of band transmission for instance). + # e2e should have a priority of 0 here, and out of band transmission + # a lower priority + # FIXME: trigger not used yet, can be uncommented when e2e full stanza encryption is implemented + # if not self.host_app.trigger.point("send", self, obj): + #  return + super(SatXMPPClient, self).send(obj) + + def sendMessageData(self, mess_data): + """Convenient method to send message data to stream + + This method will send mess_data[u'xml'] to stream, but a trigger is there + The trigger can't be cancelled, it's a good place for e2e encryption which + don't handle full stanza encryption + @param mess_data(dict): message data as constructed by onMessage workflow + @return (dict): mess_data (so it can be used in a deferred chain) + """ + # XXX: This is the last trigger before u"send" (last but one globally) for sending message. + # This is intented for e2e encryption which doesn't do full stanza encryption (e.g. OTR) + # This trigger point can't cancel the method + self.host_app.trigger.point("sendMessageData", self, mess_data) + self.send(mess_data[u'xml']) + return mess_data + + def feedback(self, to_jid, message): + """Send message to frontends + + This message will be an info message, not recorded in history. + It can be used to give feedback of a command + @param to_jid(jid.JID): destinee jid + @param message(unicode): message to send to frontends + """ + self.host_app.bridge.messageNew(uid=unicode(uuid.uuid4()), + timestamp=time.time(), + from_jid=self.jid.full(), + to_jid=to_jid.full(), + message={u'': message}, + subject={}, + mess_type=C.MESS_TYPE_INFO, + extra={}, + profile=self.profile) + + def _finish_connection(self, dummy): + self.roster.requestRoster() + self.presence.available() + super(SatXMPPClient, self)._finish_connection(dummy) + + +class SatXMPPComponent(SatXMPPEntity, component.Component): + """XMPP component + + This component are similar but not identical to clients. + An entry point plugin is launched after component is connected. + Component need to instantiate MessageProtocol itself + """ + implements(iwokkel.IDisco) + trigger_suffix = "Component" # used for to distinguish some trigger points set in SatXMPPEntity + is_component = True + sendHistory = False # XXX: set to True from entry plugin to keep messages in history for received messages + + def __init__(self, host_app, profile, component_jid, password, host=None, port=None, max_retries=C.XMPP_MAX_RETRIES): + self.started = time.time() + if port is None: + port = C.XMPP_COMPONENT_PORT + + ## entry point ## + entry_point = host_app.memory.getEntryPoint(profile) + try: + self.entry_plugin = host_app.plugins[entry_point] + except KeyError: + raise exceptions.NotFound(_(u"The requested entry point ({entry_point}) is not available").format( + entry_point = entry_point)) + + self.identities = [disco.DiscoIdentity(u"component", u"generic", C.APP_NAME)] + # jid is set automatically on bind by Twisted for Client, but not for Component + self.jid = component_jid + if host is None: + try: + host = component_jid.host.split(u'.', 1)[1] + except IndexError: + raise ValueError(u"Can't guess host from jid, please specify a host") + # XXX: component.Component expect unicode jid, while Client expect jid.JID. + # this is not consistent, so we use jid.JID for SatXMPP* + component.Component.__init__(self, host, port, component_jid.full(), password) + SatXMPPEntity.__init__(self, host_app, profile, max_retries) + + def _buildDependencies(self, current, plugins, required=True): + """build recursively dependencies needed for a plugin + + this method build list of plugin needed for a component and raises + errors if they are not available or not allowed for components + @param current(object): parent plugin to check + use entry_point for first call + @param plugins(list): list of validated plugins, will be filled by the method + give an empty list for first call + @param required(bool): True if plugin is mandatory + for recursive calls only, should not be modified by inital caller + @raise InternalError: one of the plugin is not handling components + @raise KeyError: one plugin should be present in self.host_app.plugins but it is not + """ + if C.PLUG_MODE_COMPONENT not in current._info[u'modes']: + if not required: + return + else: + log.error(_(u"Plugin {current_name} is needed for {entry_name}, but it doesn't handle component mode").format( + current_name = current._info[u'import_name'], + entry_name = self.entry_plugin._info[u'import_name'] + )) + raise exceptions.InternalError(_(u"invalid plugin mode")) + + for import_name in current._info.get(C.PI_DEPENDENCIES, []): + # plugins are already loaded as dependencies + # so we know they are in self.host_app.plugins + dep = self.host_app.plugins[import_name] + self._buildDependencies(dep, plugins) + + for import_name in current._info.get(C.PI_RECOMMENDATIONS, []): + # here plugins are only recommendations, + # so they may not exist in self.host_app.plugins + try: + dep = self.host_app.plugins[import_name] + except KeyError: + continue + self._buildDependencies(dep, plugins, required = False) + + if current not in plugins: + # current can be required for several plugins and so + # it can already be present in the list + plugins.append(current) + + def _getPluginsList(self): + # XXX: for component we don't launch all plugins triggers + # but only the ones from which there is a dependency + plugins = [] + self._buildDependencies(self.entry_plugin, plugins) + return plugins + + def entityConnected(self): + # we can now launch entry point + try: + start_cb = self.entry_plugin.componentStart + except AttributeError: + return + else: + return start_cb(self) + + def addPostXmlCallbacks(self, post_xml_treatments): + if self.sendHistory: + post_xml_treatments.addCallback(self.messageAddToHistory) + + +class SatMessageProtocol(xmppim.MessageProtocol): + + def __init__(self, host): + xmppim.MessageProtocol.__init__(self) + self.host = host + + @staticmethod + def parseMessage(message_elt, client=None): + """parse a message XML and return message_data + + @param message_elt(domish.Element): raw xml + @param client(SatXMPPClient, None): client to map message id to uid + if None, mapping will not be done + @return(dict): message data + """ + message = {} + subject = {} + extra = {} + data = {"from": jid.JID(message_elt['from']), + "to": jid.JID(message_elt['to']), + "uid": message_elt.getAttribute('uid', unicode(uuid.uuid4())), # XXX: uid is not a standard attribute but may be added by plugins + "message": message, + "subject": subject, + "type": message_elt.getAttribute('type', 'normal'), + "extra": extra} + + if client is not None: + try: + data['stanza_id'] = message_elt['id'] + except KeyError: + pass + else: + client._mess_id_uid[(data['from'], data['stanza_id'])] = data['uid'] + + # message + for e in message_elt.elements(C.NS_CLIENT, 'body'): + message[e.getAttribute((C.NS_XML,'lang'),'')] = unicode(e) + + # subject + for e in message_elt.elements(C.NS_CLIENT, 'subject'): + subject[e.getAttribute((C.NS_XML, 'lang'),'')] = unicode(e) + + # delay and timestamp + try: + delay_elt = message_elt.elements(delay.NS_DELAY, 'delay').next() + except StopIteration: + data['timestamp'] = time.time() + else: + parsed_delay = delay.Delay.fromElement(delay_elt) + data['timestamp'] = calendar.timegm(parsed_delay.stamp.utctimetuple()) + data['received_timestamp'] = unicode(time.time()) + if parsed_delay.sender: + data['delay_sender'] = parsed_delay.sender.full() + return data + + def onMessage(self, message_elt): + # TODO: handle threads + client = self.parent + if not 'from' in message_elt.attributes: + message_elt['from'] = client.jid.host + log.debug(_(u"got message from: {from_}").format(from_=message_elt['from'])) + post_treat = defer.Deferred() # XXX: plugin can add their treatments to this deferred + + if not self.host.trigger.point("MessageReceived", client, message_elt, post_treat): + return + + data = self.parseMessage(message_elt, client) + + post_treat.addCallback(self.skipEmptyMessage) + post_treat.addCallback(self.addToHistory, client) + post_treat.addCallback(self.bridgeSignal, client, data) + post_treat.addErrback(self.cancelErrorTrap) + post_treat.callback(data) + + def skipEmptyMessage(self, data): + if not data['message'] and not data['extra'] and not data['subject']: + raise failure.Failure(exceptions.CancelError("Cancelled empty message")) + return data + + def addToHistory(self, data, client): + if data.pop(u'history', None) == C.HISTORY_SKIP: + log.info(u'history is skipped as requested') + data[u'extra'][u'history'] = C.HISTORY_SKIP + else: + return self.host.memory.addToHistory(client, data) + + def bridgeSignal(self, dummy, client, data): + try: + data['extra']['received_timestamp'] = data['received_timestamp'] + data['extra']['delay_sender'] = data['delay_sender'] + except KeyError: + pass + if data is not None: + self.host.bridge.messageNew(data['uid'], data['timestamp'], data['from'].full(), data['to'].full(), data['message'], data['subject'], data['type'], data['extra'], profile=client.profile) + return data + + def cancelErrorTrap(self, failure_): + """A message sending can be cancelled by a plugin treatment""" + failure_.trap(exceptions.CancelError) + + +class SatRosterProtocol(xmppim.RosterClientProtocol): + + def __init__(self, host): + xmppim.RosterClientProtocol.__init__(self) + self.host = host + self.got_roster = defer.Deferred() # called when roster is received and ready + #XXX: the two following dicts keep a local copy of the roster + self._groups = {} # map from groups to jids: key=group value=set of jids + self._jids = None # map from jids to RosterItem: key=jid value=RosterItem + + def rosterCb(self, roster): + assert roster is not None # FIXME: must be managed with roster versioning + self._groups.clear() + self._jids = roster + for item in roster.itervalues(): + if not item.subscriptionTo and not item.subscriptionFrom and not item.ask: + #XXX: current behaviour: we don't want contact in our roster list + # if there is no presence subscription + # may change in the future + log.info(u"Removing contact {} from roster because there is no presence subscription".format(item.jid)) + self.removeItem(item.entity) # FIXME: to be checked + else: + self._registerItem(item) + + def _registerItem(self, item): + """Register item in local cache + + item must be already registered in self._jids before this method is called + @param item (RosterIem): item added + """ + log.debug(u"registering item: {}".format(item.entity.full())) + if item.entity.resource: + log.warning(u"Received a roster item with a resource, this is not common but not restricted by RFC 6121, this case may be not well tested.") + if not item.subscriptionTo: + if not item.subscriptionFrom: + log.info(_(u"There's no subscription between you and [{}]!").format(item.entity.full())) + else: + log.info(_(u"You are not subscribed to [{}]!").format(item.entity.full())) + if not item.subscriptionFrom: + log.info(_(u"[{}] is not subscribed to you!").format(item.entity.full())) + + for group in item.groups: + self._groups.setdefault(group, set()).add(item.entity) + + def requestRoster(self): + """ ask the server for Roster list """ + log.debug("requestRoster") + d = self.getRoster().addCallback(self.rosterCb) + d.chainDeferred(self.got_roster) + + def removeItem(self, to_jid): + """Remove a contact from roster list + @param to_jid: a JID instance + @return: Deferred + """ + return xmppim.RosterClientProtocol.removeItem(self, to_jid) + + def getAttributes(self, item): + """Return dictionary of attributes as used in bridge from a RosterItem + + @param item: RosterItem + @return: dictionary of attributes + """ + item_attr = {'to': unicode(item.subscriptionTo), + 'from': unicode(item.subscriptionFrom), + 'ask': unicode(item.ask) + } + if item.name: + item_attr['name'] = item.name + return item_attr + + def setReceived(self, request): + #TODO: implement roster versioning (cf RFC 6121 §2.6) + item = request.item + try: # update the cache for the groups the contact has been removed from + left_groups = set(self._jids[item.entity].groups).difference(item.groups) + for group in left_groups: + jids_set = self._groups[group] + jids_set.remove(item.entity) + if not jids_set: + del self._groups[group] + except KeyError: + pass # no previous item registration (or it's been cleared) + self._jids[item.entity] = item + self._registerItem(item) + self.host.bridge.newContact(item.entity.full(), self.getAttributes(item), item.groups, self.parent.profile) + + def removeReceived(self, request): + entity = request.item.entity + log.info(u"removing %s from roster list" % entity.full()) + + # we first remove item from local cache (self._groups and self._jids) + try: + item = self._jids.pop(entity) + except KeyError: + log.error(u"Received a roster remove event for an item not in cache ({})".format(entity)) + return + for group in item.groups: + try: + jids_set = self._groups[group] + jids_set.remove(entity) + if not jids_set: + del self._groups[group] + except KeyError: + log.warning(u"there is no cache for the group [%(group)s] of the removed roster item [%(jid)s]" % + {"group": group, "jid": entity}) + + # then we send the bridge signal + self.host.bridge.contactDeleted(entity.full(), self.parent.profile) + + def getGroups(self): + """Return a list of groups""" + return self._groups.keys() + + def getItem(self, entity_jid): + """Return RosterItem for a given jid + + @param entity_jid(jid.JID): jid of the contact + @return(RosterItem, None): RosterItem instance + None if contact is not in cache + """ + return self._jids.get(entity_jid, None) + + def getJids(self): + """Return all jids of the roster""" + return self._jids.keys() + + def isJidInRoster(self, entity_jid): + """Return True if jid is in roster""" + return entity_jid in self._jids + + def isPresenceAuthorised(self, entity_jid): + """Return True if entity is authorised to see our presence""" + try: + item = self._jids[entity_jid.userhostJID()] + except KeyError: + return False + return item.subscriptionFrom + + def getItems(self): + """Return all items of the roster""" + return self._jids.values() + + def getJidsFromGroup(self, group): + try: + return self._groups[group] + except KeyError: + raise exceptions.UnknownGroupError(group) + + def getJidsSet(self, type_, groups=None): + """Helper method to get a set of jids + + @param type_(unicode): one of: + C.ALL: get all jids from roster + C.GROUP: get jids from groups (listed in "groups") + @groups(list[unicode]): list of groups used if type_==C.GROUP + @return (set(jid.JID)): set of selected jids + """ + if type_ == C.ALL and groups is not None: + raise ValueError('groups must not be set for {} type'.format(C.ALL)) + + if type_ == C.ALL: + return set(self.getJids()) + elif type_ == C.GROUP: + jids = set() + for group in groups: + jids.update(self.getJidsFromGroup(group)) + return jids + else: + raise ValueError(u'Unexpected type_ {}'.format(type_)) + + def getNick(self, entity_jid): + """Return a nick name for an entity + + return nick choosed by user if available + else return user part of entity_jid + """ + item = self.getItem(entity_jid) + if item is None: + return entity_jid.user + else: + return item.name or entity_jid.user + + +class SatPresenceProtocol(xmppim.PresenceClientProtocol): + + def __init__(self, host): + xmppim.PresenceClientProtocol.__init__(self) + self.host = host + + def send(self, obj): + presence_d = defer.succeed(None) + if not self.host.trigger.point("Presence send", self.parent, obj, presence_d): + return + presence_d.addCallback(lambda __: super(SatPresenceProtocol, self).send(obj)) + + def availableReceived(self, entity, show=None, statuses=None, priority=0): + log.debug(_(u"presence update for [%(entity)s] (available, show=%(show)s statuses=%(statuses)s priority=%(priority)d)") % {'entity': entity, C.PRESENCE_SHOW: show, C.PRESENCE_STATUSES: statuses, C.PRESENCE_PRIORITY: priority}) + + if not statuses: + statuses = {} + + if None in statuses: # we only want string keys + statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None) + + if not self.host.trigger.point("presenceReceived", entity, show, priority, statuses, self.parent.profile): + return + + self.host.memory.setPresenceStatus(entity, show or "", + int(priority), statuses, + self.parent.profile) + + # now it's time to notify frontends + self.host.bridge.presenceUpdate(entity.full(), show or "", + int(priority), statuses, + self.parent.profile) + + def unavailableReceived(self, entity, statuses=None): + log.debug(_(u"presence update for [%(entity)s] (unavailable, statuses=%(statuses)s)") % {'entity': entity, C.PRESENCE_STATUSES: statuses}) + + if not statuses: + statuses = {} + + if None in statuses: # we only want string keys + statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None) + + if not self.host.trigger.point("presenceReceived", entity, "unavailable", 0, statuses, self.parent.profile): + return + + # now it's time to notify frontends + # if the entity is not known yet in this session or is already unavailable, there is no need to send an unavailable signal + try: + presence = self.host.memory.getEntityDatum(entity, "presence", self.parent.profile) + except (KeyError, exceptions.UnknownEntityError): + # the entity has not been seen yet in this session + pass + else: + if presence.show != C.PRESENCE_UNAVAILABLE: + self.host.bridge.presenceUpdate(entity.full(), C.PRESENCE_UNAVAILABLE, 0, statuses, self.parent.profile) + + self.host.memory.setPresenceStatus(entity, C.PRESENCE_UNAVAILABLE, 0, statuses, self.parent.profile) + + def available(self, entity=None, show=None, statuses=None, priority=None): + """Set a presence and statuses. + + @param entity (jid.JID): entity + @param show (unicode): value in ('unavailable', '', 'away', 'xa', 'chat', 'dnd') + @param statuses (dict{unicode: unicode}): multilingual statuses with + the entry key beeing a language code on 2 characters or "default". + """ + if priority is None: + try: + priority = int(self.host.memory.getParamA("Priority", "Connection", profile_key=self.parent.profile)) + except ValueError: + priority = 0 + + if statuses is None: + statuses = {} + + # default for us is None for wokkel + # so we must temporarily switch to wokkel's convention... + if C.PRESENCE_STATUSES_DEFAULT in statuses: + statuses[None] = statuses.pop(C.PRESENCE_STATUSES_DEFAULT) + + presence_elt = xmppim.AvailablePresence(entity, show, statuses, priority) + + # ... before switching back + if None in statuses: + statuses['default'] = statuses.pop(None) + + if not self.host.trigger.point("presence_available", presence_elt, self.parent): + return + self.send(presence_elt) + + @defer.inlineCallbacks + def subscribed(self, entity): + yield self.parent.roster.got_roster + xmppim.PresenceClientProtocol.subscribed(self, entity) + self.host.memory.delWaitingSub(entity.userhost(), self.parent.profile) + item = self.parent.roster.getItem(entity) + if not item or not item.subscriptionTo: # we automatically subscribe to 'to' presence + log.debug(_('sending automatic "from" subscription request')) + self.subscribe(entity) + + def unsubscribed(self, entity): + xmppim.PresenceClientProtocol.unsubscribed(self, entity) + self.host.memory.delWaitingSub(entity.userhost(), self.parent.profile) + + def subscribedReceived(self, entity): + log.debug(_(u"subscription approved for [%s]") % entity.userhost()) + self.host.bridge.subscribe('subscribed', entity.userhost(), self.parent.profile) + + def unsubscribedReceived(self, entity): + log.debug(_(u"unsubscription confirmed for [%s]") % entity.userhost()) + self.host.bridge.subscribe('unsubscribed', entity.userhost(), self.parent.profile) + + @defer.inlineCallbacks + def subscribeReceived(self, entity): + log.debug(_(u"subscription request from [%s]") % entity.userhost()) + yield self.parent.roster.got_roster + item = self.parent.roster.getItem(entity) + if item and item.subscriptionTo: + # We automatically accept subscription if we are already subscribed to contact presence + log.debug(_('sending automatic subscription acceptance')) + self.subscribed(entity) + else: + self.host.memory.addWaitingSub('subscribe', entity.userhost(), self.parent.profile) + self.host.bridge.subscribe('subscribe', entity.userhost(), self.parent.profile) + + @defer.inlineCallbacks + def unsubscribeReceived(self, entity): + log.debug(_(u"unsubscription asked for [%s]") % entity.userhost()) + yield self.parent.roster.got_roster + item = self.parent.roster.getItem(entity) + if item and item.subscriptionFrom: # we automatically remove contact + log.debug(_('automatic contact deletion')) + self.host.delContact(entity, self.parent.profile) + self.host.bridge.subscribe('unsubscribe', entity.userhost(), self.parent.profile) + + +class SatDiscoProtocol(disco.DiscoClientProtocol): + def __init__(self, host): + disco.DiscoClientProtocol.__init__(self) + + +class SatFallbackHandler(generic.FallbackHandler): + def __init__(self, host): + generic.FallbackHandler.__init__(self) + + def iqFallback(self, iq): + if iq.handled is True: + return + log.debug(u"iqFallback: xml = [%s]" % (iq.toXml())) + generic.FallbackHandler.iqFallback(self, iq) + + +class SatVersionHandler(generic.VersionHandler): + + def getDiscoInfo(self, requestor, target, node): + #XXX: We need to work around wokkel's behaviour (namespace not added if there is a + # node) as it cause issues with XEP-0115 & PEP (XEP-0163): there is a node when server + # ask for disco info, and not when we generate the key, so the hash is used with different + # disco features, and when the server (seen on ejabberd) generate its own hash for security check + # it reject our features (resulting in e.g. no notification on PEP) + return generic.VersionHandler.getDiscoInfo(self, requestor, target, None) + + +class SatIdentityHandler(XMPPHandler): + """ Manage disco Identity of SàT. + + """ + #TODO: dynamic identity update (see docstring). Note that a XMPP entity can have several identities + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return self.parent.identities + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/cache.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/cache.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,153 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools.common import regex +from sat.core import exceptions +from sat.core.constants import Const as C +import cPickle as pickle +import mimetypes +import os.path +import time + +DEFAULT_EXT = '.raw' + + +class Cache(object): + """generic file caching""" + + def __init__(self, host, profile): + """ + @param profile(unicode, None): ame of the profile to set the cache for + if None, the cache will be common for all profiles + """ + self.profile = profile + path_elts = [host.memory.getConfig('', 'local_dir'), C.CACHE_DIR] + if profile: + path_elts.extend([u'profiles',regex.pathEscape(profile)]) + else: + path_elts.append(u'common') + self.cache_dir = os.path.join(*path_elts) + + if not os.path.exists(self.cache_dir): + os.makedirs(self.cache_dir) + + def getPath(self, filename): + """return cached file URL + + @param filename(unicode): cached file name (cache data or actual file) + """ + if not filename or u'/' in filename: + log.error(u"invalid char found in file name, hack attempt? name:{}".format(filename)) + raise exceptions.DataError(u"Invalid char found") + return os.path.join(self.cache_dir, filename) + + def getMetadata(self, uid): + """retrieve metadata for cached data + + @param uid(unicode): unique identifier of file + @return (dict, None): metadata with following keys: + see [cacheData] for data details, an additional "path" key is the full path to cached file. + None if file is not in cache (or cache is invalid) + """ + + uid = uid.strip() + if not uid: + raise exceptions.InternalError(u"uid must not be empty") + cache_url = self.getPath(uid) + if not os.path.exists(cache_url): + return None + + try: + with open(cache_url, 'rb') as f: + cache_data = pickle.load(f) + except IOError: + log.warning(u"can't read cache at {}".format(cache_url)) + return None + except pickle.UnpicklingError: + log.warning(u'invalid cache found at {}'.format(cache_url)) + return None + + try: + eol = cache_data['eol'] + except KeyError: + log.warning(u'no End Of Life found for cached file {}'.format(uid)) + eol = 0 + if eol < time.time(): + log.debug(u"removing expired cache (expired for {}s)".format( + time.time() - eol)) + return None + + cache_data['path'] = self.getPath(cache_data['filename']) + return cache_data + + def getFilePath(self, uid): + """retrieve absolute path to file + + @param uid(unicode): unique identifier of file + @return (unicode, None): absolute path to cached file + None if file is not in cache (or cache is invalid) + """ + metadata = self.getMetadata(uid) + if metadata is not None: + return metadata['path'] + + def cacheData(self, source, uid, mime_type=None, max_age=None, filename=None): + """create cache metadata and file object to use for actual data + + @param source(unicode): source of the cache (should be plugin's import_name) + @param uid(unicode): an identifier of the file which must be unique + @param mime_type(unicode): MIME type of the file to cache + it will be used notably to guess file extension + @param max_age(int, None): maximum age in seconds + the cache metadata will have an "eol" (end of life) + None to use default value + 0 to ignore cache (file will be re-downloaded on each access) + @param filename: if not None, will be used as filename + else one will be generated from uid and guessed extension + @return(file): file object opened in write mode + you have to close it yourself (hint: use with statement) + """ + cache_url = self.getPath(uid) + if filename is None: + if mime_type: + ext = mimetypes.guess_extension(mime_type, strict=False) + if ext is None: + log.warning(u"can't find extension for MIME type {}".format(mime_type)) + ext = DEFAULT_EXT + elif ext == u'.jpe': + ext = u'.jpg' + else: + ext = DEFAULT_EXT + mime_type = None + filename = uid + ext + if max_age is None: + max_age = C.DEFAULT_MAX_AGE + cache_data = {u'source': source, + u'filename': filename, + u'eol': int(time.time()) + max_age, + u'mime_type': mime_type, + } + file_path = self.getPath(filename) + + with open(cache_url, 'wb') as f: + pickle.dump(cache_data, f, protocol=2) + + return open(file_path, 'wb') diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/crypto.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/crypto.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,145 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +try: + from Crypto.Cipher import AES + from Crypto.Protocol.KDF import PBKDF2 +except ImportError: + raise Exception("PyCrypto is not installed.") + +from os import urandom +from base64 import b64encode, b64decode +from twisted.internet.threads import deferToThread +from twisted.internet.defer import succeed + + +class BlockCipher(object): + + BLOCK_SIZE = AES.block_size # 16 bits + MAX_KEY_SIZE = AES.key_size[-1] # 32 bits = AES-256 + IV_SIZE = BLOCK_SIZE # initialization vector size, 16 bits + + @classmethod + def encrypt(cls, key, text, leave_empty=True): + """Encrypt a message. + + Based on http://stackoverflow.com/a/12525165 + + @param key (unicode): the encryption key + @param text (unicode): the text to encrypt + @param leave_empty (bool): if True, empty text will be returned "as is" + @return: Deferred: base-64 encoded str + """ + if leave_empty and text == '': + return succeed(text) + iv = BlockCipher.getRandomKey() + key = key.encode('utf-8') + key = key[:BlockCipher.MAX_KEY_SIZE] if len(key) >= BlockCipher.MAX_KEY_SIZE else BlockCipher.pad(key) + cipher = AES.new(key, AES.MODE_CFB, iv) + d = deferToThread(cipher.encrypt, BlockCipher.pad(text.encode('utf-8'))) + d.addCallback(lambda ciphertext: b64encode(iv + ciphertext)) + return d + + @classmethod + def decrypt(cls, key, ciphertext, leave_empty=True): + """Decrypt a message. + + Based on http://stackoverflow.com/a/12525165 + + @param key (unicode): the decryption key + @param ciphertext (base-64 encoded str): the text to decrypt + @param leave_empty (bool): if True, empty ciphertext will be returned "as is" + @return: Deferred: str or None if the password could not be decrypted + """ + if leave_empty and ciphertext == '': + return succeed('') + ciphertext = b64decode(ciphertext) + iv, ciphertext = ciphertext[:BlockCipher.IV_SIZE], ciphertext[BlockCipher.IV_SIZE:] + key = key.encode('utf-8') + key = key[:BlockCipher.MAX_KEY_SIZE] if len(key) >= BlockCipher.MAX_KEY_SIZE else BlockCipher.pad(key) + cipher = AES.new(key, AES.MODE_CFB, iv) + d = deferToThread(cipher.decrypt, ciphertext) + d.addCallback(lambda text: BlockCipher.unpad(text)) + # XXX: cipher.decrypt gives no way to make the distinction between + # a decrypted empty value and a decryption failure... both return + # the empty value. Fortunately, we detect empty passwords beforehand + # thanks to the "leave_empty" parameter which is used by default. + d.addCallback(lambda text: text.decode('utf-8') if text else None) + return d + + @classmethod + def getRandomKey(cls, size=None, base64=False): + """Return a random key suitable for block cipher encryption. + + Note: a good value for the key length is to make it as long as the block size. + + @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE) + @param base64: if True, encode the result to base-64 + @return: str (eventually base-64 encoded) + """ + if size is None or size < 0: + size = BlockCipher.IV_SIZE + key = urandom(size) + return b64encode(key) if base64 else key + + @classmethod + def pad(self, s): + """Method from http://stackoverflow.com/a/12525165""" + bs = BlockCipher.BLOCK_SIZE + return s + (bs - len(s) % bs) * chr(bs - len(s) % bs) + + @classmethod + def unpad(self, s): + """Method from http://stackoverflow.com/a/12525165""" + return s[0:-ord(s[-1])] + + +class PasswordHasher(object): + + SALT_LEN = 16 # 128 bits + + @classmethod + def hash(cls, password, salt=None, leave_empty=True): + """Hash a password. + + @param password (str): the password to hash + @param salt (base-64 encoded str): if not None, use the given salt instead of a random value + @param leave_empty (bool): if True, empty password will be returned "as is" + @return: Deferred: base-64 encoded str + """ + if leave_empty and password == '': + return succeed(password) + salt = b64decode(salt)[:PasswordHasher.SALT_LEN] if salt else urandom(PasswordHasher.SALT_LEN) + d = deferToThread(PBKDF2, password, salt) + d.addCallback(lambda hashed: b64encode(salt + hashed)) + return d + + @classmethod + def verify(cls, attempt, hashed): + """Verify a password attempt. + + @param attempt (str): the attempt to check + @param hashed (str): the hash of the password + @return: Deferred: boolean + """ + leave_empty = hashed == '' + d = PasswordHasher.hash(attempt, hashed, leave_empty) + d.addCallback(lambda hashed_attempt: hashed_attempt == hashed) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/disco.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/disco.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,389 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber.error import StanzaError +from twisted.internet import defer +from twisted.internet import reactor +from twisted.python import failure +from sat.core.constants import Const as C +from sat.tools import xml_tools +from sat.memory import persistent +from wokkel import disco +from base64 import b64encode +from hashlib import sha1 + + +TIMEOUT = 15 +CAP_HASH_ERROR = 'ERROR' + +class HashGenerationError(Exception): + pass + + +class ByteIdentity(object): + """This class manage identity as bytes (needed for i;octet sort), it is used for the hash generation""" + + def __init__(self, identity, lang=None): + assert isinstance(identity, disco.DiscoIdentity) + self.category = identity.category.encode('utf-8') + self.idType = identity.type.encode('utf-8') + self.name = identity.name.encode('utf-8') if identity.name else '' + self.lang = lang.encode('utf-8') if lang is not None else '' + + def __str__(self): + return "%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name) + + +class HashManager(object): + """map object which manage hashes + + persistent storage is update when a new hash is added + """ + + def __init__(self, persistent): + self.hashes = { + CAP_HASH_ERROR: disco.DiscoInfo(), # used when we can't get disco infos + } + self.persistent = persistent + + def __getitem__(self, key): + return self.hashes[key] + + def __setitem__(self, hash_, disco_info): + if hash_ in self.hashes: + log.debug(u"ignoring hash set: it is already known") + return + self.hashes[hash_] = disco_info + self.persistent[hash_] = disco_info.toElement().toXml() + + def __contains__(self, hash_): + return self.hashes.__contains__(hash_) + + def load(self): + def fillHashes(hashes): + for hash_, xml in hashes.iteritems(): + element = xml_tools.ElementParser()(xml) + self.hashes[hash_] = disco.DiscoInfo.fromElement(element) + log.info(u"Disco hashes loaded") + d = self.persistent.load() + d.addCallback(fillHashes) + return d + + +class Discovery(object): + """ Manage capabilities of entities """ + + def __init__(self, host): + self.host = host + # TODO: remove legacy hashes + + def load(self): + """Load persistent hashes""" + self.hashes = HashManager(persistent.PersistentDict("disco")) + return self.hashes.load() + + @defer.inlineCallbacks + def hasFeature(self, client, feature, jid_=None, node=u''): + """Tell if an entity has the required feature + + @param feature: feature namespace + @param jid_: jid of the target, or None for profile's server + @param node(unicode): optional node to use for disco request + @return: a Deferred which fire a boolean (True if feature is available) + """ + disco_infos = yield self.getInfos(client, jid_, node) + defer.returnValue(feature in disco_infos.features) + + @defer.inlineCallbacks + def checkFeature(self, client, feature, jid_=None, node=u''): + """Like hasFeature, but raise an exception is feature is not Found + + @param feature: feature namespace + @param jid_: jid of the target, or None for profile's server + @param node(unicode): optional node to use for disco request + + @raise: exceptions.FeatureNotFound + """ + disco_infos = yield self.getInfos(client, jid_, node) + if not feature in disco_infos.features: + raise failure.Failure(exceptions.FeatureNotFound) + + @defer.inlineCallbacks + def checkFeatures(self, client, features, jid_=None, identity=None, node=u''): + """Like checkFeature, but check several features at once, and check also identity + + @param features(iterable[unicode]): features to check + @param jid_(jid.JID): jid of the target, or None for profile's server + @param node(unicode): optional node to use for disco request + @param identity(None, tuple(unicode, unicode): if not None, the entity must have an identity with this (category, type) tuple + + @raise: exceptions.FeatureNotFound + """ + disco_infos = yield self.getInfos(client, jid_, node) + if not set(features).issubset(disco_infos.features): + raise failure.Failure(exceptions.FeatureNotFound()) + + if identity is not None and identity not in disco_infos.identities: + raise failure.Failure(exceptions.FeatureNotFound()) + + def getInfos(self, client, jid_=None, node=u'', use_cache=True): + """get disco infos from jid_, filling capability hash if needed + + @param jid_: jid of the target, or None for profile's server + @param node(unicode): optional node to use for disco request + @param use_cache(bool): if True, use cached data if available + @return: a Deferred which fire disco.DiscoInfo + """ + if jid_ is None: + jid_ = jid.JID(client.jid.host) + try: + cap_hash = self.host.memory.getEntityData(jid_, [C.ENTITY_CAP_HASH], client.profile)[C.ENTITY_CAP_HASH] + if not use_cache: + # we ignore cache, so we pretend we haven't found it + raise KeyError + except (KeyError, exceptions.UnknownEntityError): + # capability hash is not available, we'll compute one + def infosCb(disco_infos): + cap_hash = self.generateHash(disco_infos) + self.hashes[cap_hash] = disco_infos + self.host.memory.updateEntityData(jid_, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile) + return disco_infos + def infosEb(fail): + if fail.check(defer.CancelledError): + reason = u"request time-out" + else: + try: + reason = unicode(fail.value) + except AttributeError: + reason = unicode(fail) + log.warning(u"Error while requesting disco infos from {jid}: {reason}".format(jid=jid_.full(), reason=reason)) + self.host.memory.updateEntityData(jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR, profile_key=client.profile) + disco_infos = self.hashes[CAP_HASH_ERROR] + return disco_infos + d = client.disco.requestInfo(jid_, nodeIdentifier=node) + d.addCallback(infosCb) + d.addErrback(infosEb) + return d + else: + disco_infos = self.hashes[cap_hash] + return defer.succeed(disco_infos) + + @defer.inlineCallbacks + def getItems(self, client, jid_=None, node=u'', use_cache=True): + """get disco items from jid_, cache them for our own server + + @param jid_(jid.JID): jid of the target, or None for profile's server + @param node(unicode): optional node to use for disco request + @param use_cache(bool): if True, use cached data if available + @return: a Deferred which fire disco.DiscoItems + """ + server_jid = jid.JID(client.jid.host) + if jid_ is None: + jid_ = server_jid + + if jid_ == server_jid and not node: + # we cache items only for our own server and if node is not set + try: + items = self.host.memory.getEntityData(jid_, ["DISCO_ITEMS"], client.profile)["DISCO_ITEMS"] + log.debug(u"[%s] disco items are in cache" % jid_.full()) + if not use_cache: + # we ignore cache, so we pretend we haven't found it + raise KeyError + except (KeyError, exceptions.UnknownEntityError): + log.debug(u"Caching [%s] disco items" % jid_.full()) + items = yield client.disco.requestItems(jid_, nodeIdentifier=node) + self.host.memory.updateEntityData(jid_, "DISCO_ITEMS", items, profile_key=client.profile) + else: + try: + items = yield client.disco.requestItems(jid_, nodeIdentifier=node) + except StanzaError as e: + log.warning(u"Error while requesting items for {jid}: {reason}" + .format(jid=jid_.full(), reason=e.condition)) + items = disco.DiscoItems() + + defer.returnValue(items) + + + def _infosEb(self, failure_, entity_jid): + failure_.trap(StanzaError) + log.warning(_(u"Error while requesting [%(jid)s]: %(error)s") % {'jid': entity_jid.full(), + 'error': failure_.getErrorMessage()}) + + def findServiceEntity(self, client, category, type_, jid_=None): + """Helper method to find first available entity from findServiceEntities + + args are the same as for [findServiceEntities] + @return (jid.JID, None): found entity + """ + d = self.host.findServiceEntities(client, "pubsub", "service") + d.addCallback(lambda entities: entities.pop() if entities else None) + return d + + def findServiceEntities(self, client, category, type_, jid_=None): + """Return all available items of an entity which correspond to (category, type_) + + @param category: identity's category + @param type_: identitiy's type + @param jid_: the jid of the target server (None for profile's server) + @return: a set of found entities + @raise defer.CancelledError: the request timed out + """ + found_entities = set() + + def infosCb(infos, entity_jid): + if (category, type_) in infos.identities: + found_entities.add(entity_jid) + + def gotItems(items): + defers_list = [] + for item in items: + info_d = self.getInfos(client, item.entity) + info_d.addCallbacks(infosCb, self._infosEb, [item.entity], None, [item.entity]) + defers_list.append(info_d) + return defer.DeferredList(defers_list) + + d = self.getItems(client, jid_) + d.addCallback(gotItems) + d.addCallback(lambda dummy: found_entities) + reactor.callLater(TIMEOUT, d.cancel) # FIXME: one bad service make a general timeout + return d + + def findFeaturesSet(self, client, features, identity=None, jid_=None): + """Return entities (including jid_ and its items) offering features + + @param features: iterable of features which must be present + @param identity(None, tuple(unicode, unicode)): if not None, accept only this (category/type) identity + @param jid_: the jid of the target server (None for profile's server) + @param profile: %(doc_profile)s + @return: a set of found entities + """ + if jid_ is None: + jid_ = jid.JID(client.jid.host) + features = set(features) + found_entities = set() + + def infosCb(infos, entity): + if entity is None: + log.warning(_(u'received an item without jid')) + return + if identity is not None and identity not in infos.identities: + return + if features.issubset(infos.features): + found_entities.add(entity) + + def gotItems(items): + defer_list = [] + for entity in [jid_] + [item.entity for item in items]: + infos_d = self.getInfos(client, entity) + infos_d.addCallbacks(infosCb, self._infosEb, [entity], None, [entity]) + defer_list.append(infos_d) + return defer.DeferredList(defer_list) + + d = self.getItems(client, jid_) + d.addCallback(gotItems) + d.addCallback(lambda dummy: found_entities) + reactor.callLater(TIMEOUT, d.cancel) # FIXME: one bad service make a general timeout + return d + + def generateHash(self, services): + """ Generate a unique hash for given service + + hash algorithm is the one described in XEP-0115 + @param services: iterable of disco.DiscoIdentity/disco.DiscoFeature, as returned by discoHandler.info + + """ + s = [] + byte_identities = [ByteIdentity(service) for service in services if isinstance(service, disco.DiscoIdentity)] # FIXME: lang must be managed here + byte_identities.sort(key=lambda i: i.lang) + byte_identities.sort(key=lambda i: i.idType) + byte_identities.sort(key=lambda i: i.category) + for identity in byte_identities: + s.append(str(identity)) + s.append('<') + byte_features = [service.encode('utf-8') for service in services if isinstance(service, disco.DiscoFeature)] + byte_features.sort() # XXX: the default sort has the same behaviour as the requested RFC 4790 i;octet sort + for feature in byte_features: + s.append(feature) + s.append('<') + #TODO: manage XEP-0128 data form here + cap_hash = b64encode(sha1(''.join(s)).digest()) + log.debug(_(u'Capability hash generated: [%s]') % cap_hash) + return cap_hash + + @defer.inlineCallbacks + def _discoInfos(self, entity_jid_s, node=u'', use_cache=True, profile_key=C.PROF_KEY_NONE): + """ Discovery method for the bridge + @param entity_jid_s: entity we want to discover + @param use_cache(bool): if True, use cached data if available + @param node(unicode): optional node to use + + @return: list of tuples + """ + client = self.host.getClient(profile_key) + entity = jid.JID(entity_jid_s) + disco_infos = yield self.getInfos(client, entity, node, use_cache) + extensions = {} + for form_type, form in disco_infos.extensions.items(): + fields = [] + for field in form.fieldList: + data = {'type': field.fieldType} + for attr in ('var', 'label', 'desc'): + value = getattr(field, attr) + if value is not None: + data[attr] = value + + values = [field.value] if field.value is not None else field.values + fields.append((data, values)) + + extensions[form_type or ""] = fields + + defer.returnValue((disco_infos.features, + [(cat, type_, name or '') for (cat, type_), name in disco_infos.identities.items()], + extensions)) + + def items2tuples(self, disco_items): + """convert disco items to tuple of strings + + @param disco_items(iterable[disco.DiscoItem]): items + @return G(tuple[unicode,unicode,unicode]): serialised items + """ + for item in disco_items: + if not item.entity: + log.warning(_(u"invalid item (no jid)")) + continue + yield (item.entity.full(), item.nodeIdentifier or '', item.name or '') + + @defer.inlineCallbacks + def _discoItems(self, entity_jid_s, node=u'', use_cache=True, profile_key=C.PROF_KEY_NONE): + """ Discovery method for the bridge + + @param entity_jid_s: entity we want to discover + @param node(unicode): optional node to use + @param use_cache(bool): if True, use cached data if available + @return: list of tuples""" + client = self.host.getClient(profile_key) + entity = jid.JID(entity_jid_s) + disco_items = yield self.getItems(client, entity, node, use_cache) + ret = list(self.items2tuples(disco_items)) + defer.returnValue(ret) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/memory.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/memory.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1326 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ + +from sat.core.log import getLogger +log = getLogger(__name__) + +import os.path +import copy +from collections import namedtuple +from ConfigParser import SafeConfigParser, NoOptionError, NoSectionError +from uuid import uuid4 +from twisted.python import failure +from twisted.internet import defer, reactor, error +from twisted.words.protocols.jabber import jid +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.memory.sqlite import SqliteStorage +from sat.memory.persistent import PersistentDict +from sat.memory.params import Params +from sat.memory.disco import Discovery +from sat.memory.crypto import BlockCipher +from sat.memory.crypto import PasswordHasher +from sat.tools import config as tools_config +import shortuuid +import mimetypes +import time + + +PresenceTuple = namedtuple("PresenceTuple", ('show', 'priority', 'statuses')) +MSG_NO_SESSION = "Session id doesn't exist or is finished" + +class Sessions(object): + """Sessions are data associated to key used for a temporary moment, with optional profile checking.""" + DEFAULT_TIMEOUT = 600 + + def __init__(self, timeout=None, resettable_timeout=True): + """ + @param timeout (int): nb of seconds before session destruction + @param resettable_timeout (bool): if True, the timeout is reset on each access + """ + self._sessions = dict() + self.timeout = timeout or Sessions.DEFAULT_TIMEOUT + self.resettable_timeout = resettable_timeout + + def newSession(self, session_data=None, session_id=None, profile=None): + """Create a new session + + @param session_data: mutable data to use, default to a dict + @param session_id (str): force the session_id to the given string + @param profile: if set, the session is owned by the profile, + and profileGet must be used instead of __getitem__ + @return: session_id, session_data + """ + if session_id is None: + session_id = str(uuid4()) + elif session_id in self._sessions: + raise exceptions.ConflictError(u"Session id {} is already used".format(session_id)) + timer = reactor.callLater(self.timeout, self._purgeSession, session_id) + if session_data is None: + session_data = {} + self._sessions[session_id] = (timer, session_data) if profile is None else (timer, session_data, profile) + return session_id, session_data + + def _purgeSession(self, session_id): + try: + timer, session_data, profile = self._sessions[session_id] + except ValueError: + timer, session_data = self._sessions[session_id] + profile = None + try: + timer.cancel() + except error.AlreadyCalled: + # if the session is time-outed, the timer has been called + pass + del self._sessions[session_id] + log.debug(u"Session {} purged{}".format(session_id, u' (profile {})'.format(profile) if profile is not None else u'')) + + def __len__(self): + return len(self._sessions) + + def __contains__(self, session_id): + return session_id in self._sessions + + def profileGet(self, session_id, profile): + try: + timer, session_data, profile_set = self._sessions[session_id] + except ValueError: + raise exceptions.InternalError("You need to use __getitem__ when profile is not set") + except KeyError: + raise failure.Failure(KeyError(MSG_NO_SESSION)) + if profile_set != profile: + raise exceptions.InternalError("current profile differ from set profile !") + if self.resettable_timeout: + timer.reset(self.timeout) + return session_data + + def __getitem__(self, session_id): + try: + timer, session_data = self._sessions[session_id] + except ValueError: + raise exceptions.InternalError("You need to use profileGet instead of __getitem__ when profile is set") + except KeyError: + raise failure.Failure(KeyError(MSG_NO_SESSION)) + if self.resettable_timeout: + timer.reset(self.timeout) + return session_data + + def __setitem__(self, key, value): + raise NotImplementedError("You need do use newSession to create a session") + + def __delitem__(self, session_id): + """ delete the session data """ + self._purgeSession(session_id) + + def keys(self): + return self._sessions.keys() + + def iterkeys(self): + return self._sessions.iterkeys() + + +class ProfileSessions(Sessions): + """ProfileSessions extends the Sessions class, but here the profile can be + used as the key to retrieve data or delete a session (instead of session id). + """ + + def _profileGetAllIds(self, profile): + """Return a list of the sessions ids that are associated to the given profile. + + @param profile: %(doc_profile)s + @return: a list containing the sessions ids + """ + ret = [] + for session_id in self._sessions.iterkeys(): + try: + timer, session_data, profile_set = self._sessions[session_id] + except ValueError: + continue + if profile == profile_set: + ret.append(session_id) + return ret + + def profileGetUnique(self, profile): + """Return the data of the unique session that is associated to the given profile. + + @param profile: %(doc_profile)s + @return: + - mutable data (default: dict) of the unique session + - None if no session is associated to the profile + - raise an error if more than one session are found + """ + ids = self._profileGetAllIds(profile) + if len(ids) > 1: + raise exceptions.InternalError('profileGetUnique has been used but more than one session has been found!') + return self.profileGet(ids[0], profile) if len(ids) == 1 else None # XXX: timeout might be reset + + def profileDelUnique(self, profile): + """Delete the unique session that is associated to the given profile. + + @param profile: %(doc_profile)s + @return: None, but raise an error if more than one session are found + """ + ids = self._profileGetAllIds(profile) + if len(ids) > 1: + raise exceptions.InternalError('profileDelUnique has been used but more than one session has been found!') + if len(ids) == 1: + del self._sessions[ids[0]] + + +class PasswordSessions(ProfileSessions): + + # FIXME: temporary hack for the user personal key not to be lost. The session + # must actually be purged and later, when the personal key is needed, the + # profile password should be asked again in order to decrypt it. + def __init__(self, timeout=None): + ProfileSessions.__init__(self, timeout, resettable_timeout=False) + + def _purgeSession(self, session_id): + log.debug("FIXME: PasswordSessions should ask for the profile password after the session expired") + + +# XXX: tmp update code, will be removed in the future +# When you remove this, please add the default value for +# 'local_dir' in sat.core.constants.Const.DEFAULT_CONFIG +def fixLocalDir(silent=True): + """Retro-compatibility with the previous local_dir default value. + + @param silent (boolean): toggle logging output (must be True when called from sat.sh) + """ + user_config = SafeConfigParser() + try: + user_config.read(C.CONFIG_FILES) + except: + pass # file is readable but its structure if wrong + try: + current_value = user_config.get('DEFAULT', 'local_dir') + except (NoOptionError, NoSectionError): + current_value = '' + if current_value: + return # nothing to do + old_default = '~/.sat' + if os.path.isfile(os.path.expanduser(old_default) + '/' + C.SAVEFILE_DATABASE): + if not silent: + log.warning(_(u"A database has been found in the default local_dir for previous versions (< 0.5)")) + tools_config.fixConfigOption('', 'local_dir', old_default, silent) + + +class Memory(object): + """This class manage all the persistent information""" + + def __init__(self, host): + log.info(_("Memory manager init")) + self.initialized = defer.Deferred() + self.host = host + self._entities_cache = {} # XXX: keep presence/last resource/other data in cache + # /!\ an entity is not necessarily in roster + # main key is bare jid, value is a dict + # where main key is resource, or None for bare jid + self._key_signals = set() # key which need a signal to frontends when updated + self.subscriptions = {} + self.auth_sessions = PasswordSessions() # remember the authenticated profiles + self.disco = Discovery(host) + fixLocalDir(False) # XXX: tmp update code, will be removed in the future + self.config = tools_config.parseMainConf() + database_file = os.path.expanduser(os.path.join(self.getConfig('', 'local_dir'), C.SAVEFILE_DATABASE)) + self.storage = SqliteStorage(database_file, host.version) + PersistentDict.storage = self.storage + self.params = Params(host, self.storage) + log.info(_("Loading default params template")) + self.params.load_default_params() + d = self.storage.initialized.addCallback(lambda ignore: self.load()) + self.memory_data = PersistentDict("memory") + d.addCallback(lambda ignore: self.memory_data.load()) + d.addCallback(lambda ignore: self.disco.load()) + d.chainDeferred(self.initialized) + + ## Configuration ## + + def getConfig(self, section, name, default=None): + """Get the main configuration option + + @param section: section of the config file (None or '' for DEFAULT) + @param name: name of the option + @param default: value to use if not found + @return: str, list or dict + """ + return tools_config.getConfig(self.config, section, name, default) + + def load_xml(self, filename): + """Load parameters template from xml file + + @param filename (str): input file + @return: bool: True in case of success + """ + if not filename: + return False + filename = os.path.expanduser(filename) + if os.path.exists(filename): + try: + self.params.load_xml(filename) + log.debug(_(u"Parameters loaded from file: %s") % filename) + return True + except Exception as e: + log.error(_(u"Can't load parameters from file: %s") % e) + return False + + def save_xml(self, filename): + """Save parameters template to xml file + + @param filename (str): output file + @return: bool: True in case of success + """ + if not filename: + return False + #TODO: need to encrypt files (at least passwords !) and set permissions + filename = os.path.expanduser(filename) + try: + self.params.save_xml(filename) + log.debug(_(u"Parameters saved to file: %s") % filename) + return True + except Exception as e: + log.error(_(u"Can't save parameters to file: %s") % e) + return False + + def load(self): + """Load parameters and all memory things from db""" + #parameters data + return self.params.loadGenParams() + + def loadIndividualParams(self, profile): + """Load individual parameters for a profile + @param profile: %(doc_profile)s""" + return self.params.loadIndParams(profile) + + ## Profiles/Sessions management ## + + def startSession(self, password, profile): + """"Iniatialise session for a profile + + @param password(unicode): profile session password + or empty string is no password is set + @param profile: %(doc_profile)s + @raise exceptions.ProfileUnknownError if profile doesn't exists + @raise exceptions.PasswordError: the password does not match + """ + profile = self.getProfileName(profile) + + def createSession(dummy): + """Called once params are loaded.""" + self._entities_cache[profile] = {} + log.info(u"[{}] Profile session started".format(profile)) + return False + + def backendInitialised(dummy): + def doStartSession(dummy=None): + if self.isSessionStarted(profile): + log.info("Session already started!") + return True + try: + # if there is a value at this point in self._entities_cache, + # it is the loadIndividualParams Deferred, the session is starting + session_d = self._entities_cache[profile] + except KeyError: + # else we do request the params + session_d = self._entities_cache[profile] = self.loadIndividualParams(profile) + session_d.addCallback(createSession) + finally: + return session_d + + auth_d = self.profileAuthenticate(password, profile) + auth_d.addCallback(doStartSession) + return auth_d + + if self.host.initialised.called: + return defer.succeed(None).addCallback(backendInitialised) + else: + return self.host.initialised.addCallback(backendInitialised) + + def stopSession(self, profile): + """Delete a profile session + + @param profile: %(doc_profile)s + """ + if self.host.isConnected(profile): + log.debug(u"Disconnecting profile because of session stop") + self.host.disconnect(profile) + self.auth_sessions.profileDelUnique(profile) + try: + self._entities_cache[profile] + except KeyError: + log.warning(u"Profile was not in cache") + + def _isSessionStarted(self, profile_key): + return self.isSessionStarted(self.getProfileName(profile_key)) + + def isSessionStarted(self, profile): + try: + # XXX: if the value in self._entities_cache is a Deferred, + # the session is starting but not started yet + return not isinstance(self._entities_cache[profile], defer.Deferred) + except KeyError: + return False + + def profileAuthenticate(self, password, profile): + """Authenticate the profile. + + @param password (unicode): the SàT profile password + @param profile: %(doc_profile)s + @return (D): a deferred None in case of success, a failure otherwise. + @raise exceptions.PasswordError: the password does not match + """ + session_data = self.auth_sessions.profileGetUnique(profile) + if not password and session_data: + # XXX: this allows any frontend to connect with the empty password as soon as + # the profile has been authenticated at least once before. It is OK as long as + # submitting a form with empty passwords is restricted to local frontends. + return defer.succeed(None) + + def check_result(result): + if not result: + log.warning(u'Authentication failure of profile {}'.format(profile)) + raise failure.Failure(exceptions.PasswordError(u"The provided profile password doesn't match.")) + if not session_data: # avoid to create two profile sessions when password if specified + return self.newAuthSession(password, profile) + + d = self.asyncGetParamA(C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile) + d.addCallback(lambda sat_cipher: PasswordHasher.verify(password, sat_cipher)) + return d.addCallback(check_result) + + def newAuthSession(self, key, profile): + """Start a new session for the authenticated profile. + + The personal key is loaded encrypted from a PersistentDict before being decrypted. + + @param key: the key to decrypt the personal key + @param profile: %(doc_profile)s + @return: a deferred None value + """ + def gotPersonalKey(personal_key): + """Create the session for this profile and store the personal key""" + self.auth_sessions.newSession({C.MEMORY_CRYPTO_KEY: personal_key}, profile=profile) + log.debug(u'auth session created for profile %s' % profile) + + d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load() + d.addCallback(lambda data: BlockCipher.decrypt(key, data[C.MEMORY_CRYPTO_KEY])) + return d.addCallback(gotPersonalKey) + + def purgeProfileSession(self, profile): + """Delete cache of data of profile + @param profile: %(doc_profile)s""" + log.info(_("[%s] Profile session purge" % profile)) + self.params.purgeProfile(profile) + try: + del self._entities_cache[profile] + except KeyError: + log.error(_(u"Trying to purge roster status cache for a profile not in memory: [%s]") % profile) + + def getProfilesList(self, clients=True, components=False): + """retrieve profiles list + + @param clients(bool): if True return clients profiles + @param components(bool): if True return components profiles + @return (list[unicode]): selected profiles + """ + if not clients and not components: + log.warning(_(u"requesting no profiles at all")) + return [] + profiles = self.storage.getProfilesList() + if clients and components: + return sorted(profiles) + isComponent = self.storage.profileIsComponent + if clients: + p_filter = lambda p: not isComponent(p) + else: + p_filter = lambda p: isComponent(p) + + return sorted(p for p in profiles if p_filter(p)) + + def getProfileName(self, profile_key, return_profile_keys=False): + """Return name of profile from keyword + + @param profile_key: can be the profile name or a keyword (like @DEFAULT@) + @param return_profile_keys: if True, return unmanaged profile keys (like "@ALL@"). This keys must be managed by the caller + @return: requested profile name + @raise exceptions.ProfileUnknownError if profile doesn't exists + """ + return self.params.getProfileName(profile_key, return_profile_keys) + + def profileSetDefault(self, profile): + """Set default profile + + @param profile: %(doc_profile)s + """ + # we want to be sure that the profile exists + profile = self.getProfileName(profile) + + self.memory_data['Profile_default'] = profile + + def createProfile(self, name, password, component=None): + """Create a new profile + + @param name(unicode): profile name + @param password(unicode): profile password + Can be empty to disable password + @param component(None, unicode): set to entry point if this is a component + @return: Deferred + @raise exceptions.NotFound: component is not a known plugin import name + """ + if not name: + raise ValueError(u"Empty profile name") + if name[0] == '@': + raise ValueError(u"A profile name can't start with a '@'") + if '\n' in name: + raise ValueError(u"A profile name can't contain line feed ('\\n')") + + if name in self._entities_cache: + raise exceptions.ConflictError(u"A session for this profile exists") + + if component: + if not component in self.host.plugins: + raise exceptions.NotFound(_(u"Can't find component {component} entry point".format( + component = component))) + # FIXME: PLUGIN_INFO is not currently accessible after import, but type shoul be tested here + # if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT: + #  raise ValueError(_(u"Plugin {component} is not an entry point !".format( + #  component = component))) + + d = self.params.createProfile(name, component) + + def initPersonalKey(dummy): + # be sure to call this after checking that the profile doesn't exist yet + personal_key = BlockCipher.getRandomKey(base64=True) # generated once for all and saved in a PersistentDict + self.auth_sessions.newSession({C.MEMORY_CRYPTO_KEY: personal_key}, profile=name) # will be encrypted by setParam + + def startFakeSession(dummy): + # avoid ProfileNotConnected exception in setParam + self._entities_cache[name] = None + self.params.loadIndParams(name) + + def stopFakeSession(dummy): + del self._entities_cache[name] + self.params.purgeProfile(name) + + d.addCallback(initPersonalKey) + d.addCallback(startFakeSession) + d.addCallback(lambda dummy: self.setParam(C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=name)) + d.addCallback(stopFakeSession) + d.addCallback(lambda dummy: self.auth_sessions.profileDelUnique(name)) + return d + + def asyncDeleteProfile(self, name, force=False): + """Delete an existing profile + + @param name: Name of the profile + @param force: force the deletion even if the profile is connected. + To be used for direct calls only (not through the bridge). + @return: a Deferred instance + """ + def cleanMemory(dummy): + self.auth_sessions.profileDelUnique(name) + try: + del self._entities_cache[name] + except KeyError: + pass + d = self.params.asyncDeleteProfile(name, force) + d.addCallback(cleanMemory) + return d + + def isComponent(self, profile_name): + """Tell if a profile is a component + + @param profile_name(unicode): name of the profile + @return (bool): True if profile is a component + @raise exceptions.NotFound: profile doesn't exist + """ + return self.storage.profileIsComponent(profile_name) + + def getEntryPoint(self, profile_name): + """Get a component entry point + + @param profile_name(unicode): name of the profile + @return (bool): True if profile is a component + @raise exceptions.NotFound: profile doesn't exist + """ + return self.storage.getEntryPoint(profile_name) + + ## History ## + + def addToHistory(self, client, data): + return self.storage.addToHistory(data, client.profile) + + def _historyGet(self, from_jid_s, to_jid_s, limit=C.HISTORY_LIMIT_NONE, between=True, filters=None, profile=C.PROF_KEY_NONE): + return self.historyGet(jid.JID(from_jid_s), jid.JID(to_jid_s), limit, between, filters, profile) + + def historyGet(self, from_jid, to_jid, limit=C.HISTORY_LIMIT_NONE, between=True, filters=None, profile=C.PROF_KEY_NONE): + """Retrieve messages in history + + @param from_jid (JID): source JID (full, or bare for catchall) + @param to_jid (JID): dest JID (full, or bare for catchall) + @param limit (int): maximum number of messages to get: + - 0 for no message (returns the empty list) + - C.HISTORY_LIMIT_NONE or None for unlimited + - C.HISTORY_LIMIT_DEFAULT to use the HISTORY_LIMIT parameter value + @param between (bool): confound source and dest (ignore the direction) + @param filters (str): pattern to filter the history results (see bridge API for details) + @param profile (str): %(doc_profile)s + @return (D(list)): list of message data as in [messageNew] + """ + assert profile != C.PROF_KEY_NONE + if limit == C.HISTORY_LIMIT_DEFAULT: + limit = int(self.getParamA(C.HISTORY_LIMIT, 'General', profile_key=profile)) + elif limit == C.HISTORY_LIMIT_NONE: + limit = None + if limit == 0: + return defer.succeed([]) + return self.storage.historyGet(from_jid, to_jid, limit, between, filters, profile) + + ## Statuses ## + + def _getPresenceStatuses(self, profile_key): + ret = self.getPresenceStatuses(profile_key) + return {entity.full():data for entity, data in ret.iteritems()} + + def getPresenceStatuses(self, profile_key): + """Get all the presence statuses of a profile + + @param profile_key: %(doc_profile_key)s + @return: presence data: key=entity JID, value=presence data for this entity + """ + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + entities_presence = {} + + for entity_jid, entity_data in profile_cache.iteritems(): + for resource, resource_data in entity_data.iteritems(): + full_jid = copy.copy(entity_jid) + full_jid.resource = resource + try: + presence_data = self.getEntityDatum(full_jid, "presence", profile_key) + except KeyError: + continue + entities_presence.setdefault(entity_jid, {})[resource or ''] = presence_data + + return entities_presence + + def setPresenceStatus(self, entity_jid, show, priority, statuses, profile_key): + """Change the presence status of an entity + + @param entity_jid: jid.JID of the entity + @param show: show status + @param priority: priority + @param statuses: dictionary of statuses + @param profile_key: %(doc_profile_key)s + """ + presence_data = PresenceTuple(show, priority, statuses) + self.updateEntityData(entity_jid, "presence", presence_data, profile_key=profile_key) + if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE: + # If a resource is available, bare jid should not have presence information + try: + self.delEntityDatum(entity_jid.userhostJID(), "presence", profile_key) + except (KeyError, exceptions.UnknownEntityError): + pass + + ## Resources ## + + def _getAllResource(self, jid_s, profile_key): + client = self.host.getClient(profile_key) + jid_ = jid.JID(jid_s) + return self.getAllResources(client, jid_) + + def getAllResources(self, client, entity_jid): + """Return all resource from jid for which we have had data in this session + + @param entity_jid: bare jid of the entity + return (list[unicode]): list of resources + + @raise exceptions.UnknownEntityError: if entity is not in cache + @raise ValueError: entity_jid has a resource + """ + if entity_jid.resource: + raise ValueError("getAllResources must be used with a bare jid (got {})".format(entity_jid)) + profile_cache = self._getProfileCache(client) + try: + entity_data = profile_cache[entity_jid.userhostJID()] + except KeyError: + raise exceptions.UnknownEntityError(u"Entity {} not in cache".format(entity_jid)) + resources= set(entity_data.keys()) + resources.discard(None) + return resources + + def getAvailableResources(self, client, entity_jid): + """Return available resource for entity_jid + + This method differs from getAllResources by returning only available resources + @param entity_jid: bare jid of the entit + return (list[unicode]): list of available resources + + @raise exceptions.UnknownEntityError: if entity is not in cache + """ + available = [] + for resource in self.getAllResources(client, entity_jid): + full_jid = copy.copy(entity_jid) + full_jid.resource = resource + try: + presence_data = self.getEntityDatum(full_jid, "presence", client.profile) + except KeyError: + log.debug(u"Can't get presence data for {}".format(full_jid)) + else: + if presence_data.show != C.PRESENCE_UNAVAILABLE: + available.append(resource) + return available + + def _getMainResource(self, jid_s, profile_key): + client = self.host.getClient(profile_key) + jid_ = jid.JID(jid_s) + return self.getMainResource(client, jid_) or "" + + def getMainResource(self, client, entity_jid): + """Return the main resource used by an entity + + @param entity_jid: bare entity jid + @return (unicode): main resource or None + """ + if entity_jid.resource: + raise ValueError("getMainResource must be used with a bare jid (got {})".format(entity_jid)) + try: + if self.host.plugins["XEP-0045"].isJoinedRoom(client, entity_jid): + return None # MUC rooms have no main resource + except KeyError: # plugin not found + pass + try: + resources = self.getAllResources(client, entity_jid) + except exceptions.UnknownEntityError: + log.warning(u"Entity is not in cache, we can't find any resource") + return None + priority_resources = [] + for resource in resources: + full_jid = copy.copy(entity_jid) + full_jid.resource = resource + try: + presence_data = self.getEntityDatum(full_jid, "presence", client.profile) + except KeyError: + log.debug(u"No presence information for {}".format(full_jid)) + continue + priority_resources.append((resource, presence_data.priority)) + try: + return max(priority_resources, key=lambda res_tuple: res_tuple[1])[0] + except ValueError: + log.warning(u"No resource found at all for {}".format(entity_jid)) + return None + + ## Entities data ## + + def _getProfileCache(self, client): + """Check profile validity and return its cache + + @param client: SatXMPPClient + @return (dict): profile cache + """ + return self._entities_cache[client.profile] + + def setSignalOnUpdate(self, key, signal=True): + """Set a signal flag on the key + + When the key will be updated, a signal will be sent to frontends + @param key: key to signal + @param signal(boolean): if True, do the signal + """ + if signal: + self._key_signals.add(key) + else: + self._key_signals.discard(key) + + def getAllEntitiesIter(self, client, with_bare=False): + """Return an iterator of full jids of all entities in cache + + @param with_bare: if True, include bare jids + @return (list[unicode]): list of jids + """ + profile_cache = self._getProfileCache(client) + # we construct a list of all known full jids (bare jid of entities x resources) + for bare_jid, entity_data in profile_cache.iteritems(): + for resource in entity_data.iterkeys(): + if resource is None: + continue + full_jid = copy.copy(bare_jid) + full_jid.resource = resource + yield full_jid + + def updateEntityData(self, entity_jid, key, value, silent=False, profile_key=C.PROF_KEY_NONE): + """Set a misc data for an entity + + If key was registered with setSignalOnUpdate, a signal will be sent to frontends + @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities, + C.ENTITY_ALL for all entities (all resources + bare jids) + @param key: key to set (eg: "type") + @param value: value for this key (eg: "chatroom") + @param silent(bool): if True, doesn't send signal to frontend, even if there is a signal flag (see setSignalOnUpdate) + @param profile_key: %(doc_profile_key)s + """ + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL): + entities = self.getAllEntitiesIter(client, entity_jid==C.ENTITY_ALL) + else: + entities = (entity_jid,) + + for jid_ in entities: + entity_data = profile_cache.setdefault(jid_.userhostJID(),{}).setdefault(jid_.resource, {}) + + entity_data[key] = value + if key in self._key_signals and not silent: + if not isinstance(value, basestring): + log.error(u"Setting a non string value ({}) for a key ({}) which has a signal flag".format(value, key)) + else: + self.host.bridge.entityDataUpdated(jid_.full(), key, value, self.getProfileName(profile_key)) + + def delEntityDatum(self, entity_jid, key, profile_key): + """Delete a data for an entity + + @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities, + C.ENTITY_ALL for all entities (all resources + bare jids) + @param key: key to delete (eg: "type") + @param profile_key: %(doc_profile_key)s + + @raise exceptions.UnknownEntityError: if entity is not in cache + @raise KeyError: key is not in cache + """ + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL): + entities = self.getAllEntitiesIter(client, entity_jid==C.ENTITY_ALL) + else: + entities = (entity_jid,) + + for jid_ in entities: + try: + entity_data = profile_cache[jid_.userhostJID()][jid_.resource] + except KeyError: + raise exceptions.UnknownEntityError(u"Entity {} not in cache".format(jid_)) + try: + del entity_data[key] + except KeyError as e: + if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL): + continue # we ignore KeyError when deleting keys from several entities + else: + raise e + + def _getEntitiesData(self, entities_jids, keys_list, profile_key): + ret = self.getEntitiesData([jid.JID(jid_) for jid_ in entities_jids], keys_list, profile_key) + return {jid_.full(): data for jid_, data in ret.iteritems()} + + def getEntitiesData(self, entities_jids, keys_list=None, profile_key=C.PROF_KEY_NONE): + """Get a list of cached values for several entities at once + + @param entities_jids: jids of the entities, or empty list for all entities in cache + @param keys_list (iterable,None): list of keys to get, None for everything + @param profile_key: %(doc_profile_key)s + @return: dict withs values for each key in keys_list. + if there is no value of a given key, resulting dict will + have nothing with that key nether + if an entity doesn't exist in cache, it will not appear + in resulting dict + + @raise exceptions.UnknownEntityError: if entity is not in cache + """ + def fillEntityData(entity_cache_data): + entity_data = {} + if keys_list is None: + entity_data = entity_cache_data + else: + for key in keys_list: + try: + entity_data[key] = entity_cache_data[key] + except KeyError: + continue + return entity_data + + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + ret_data = {} + if entities_jids: + for entity in entities_jids: + try: + entity_cache_data = profile_cache[entity.userhostJID()][entity.resource] + except KeyError: + continue + ret_data[entity.full()] = fillEntityData(entity_cache_data, keys_list) + else: + for bare_jid, data in profile_cache.iteritems(): + for resource, entity_cache_data in data.iteritems(): + full_jid = copy.copy(bare_jid) + full_jid.resource = resource + ret_data[full_jid] = fillEntityData(entity_cache_data) + + return ret_data + + def getEntityData(self, entity_jid, keys_list=None, profile_key=C.PROF_KEY_NONE): + """Get a list of cached values for entity + + @param entity_jid: JID of the entity + @param keys_list (iterable,None): list of keys to get, None for everything + @param profile_key: %(doc_profile_key)s + @return: dict withs values for each key in keys_list. + if there is no value of a given key, resulting dict will + have nothing with that key nether + + @raise exceptions.UnknownEntityError: if entity is not in cache + """ + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + try: + entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource] + except KeyError: + raise exceptions.UnknownEntityError(u"Entity {} not in cache (was requesting {})".format(entity_jid, keys_list)) + if keys_list is None: + return entity_data + + return {key: entity_data[key] for key in keys_list if key in entity_data} + + def getEntityDatum(self, entity_jid, key, profile_key): + """Get a datum from entity + + @param entity_jid: JID of the entity + @param keys: key to get + @param profile_key: %(doc_profile_key)s + @return: requested value + + @raise exceptions.UnknownEntityError: if entity is not in cache + @raise KeyError: if there is no value for this key and this entity + """ + return self.getEntityData(entity_jid, (key,), profile_key)[key] + + def delEntityCache(self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE): + """Remove all cached data for entity + + @param entity_jid: JID of the entity to delete + @param delete_all_resources: if True also delete all known resources from cache (a bare jid must be given in this case) + @param profile_key: %(doc_profile_key)s + + @raise exceptions.UnknownEntityError: if entity is not in cache + """ + client = self.host.getClient(profile_key) + profile_cache = self._getProfileCache(client) + + if delete_all_resources: + if entity_jid.resource: + raise ValueError(_("Need a bare jid to delete all resources")) + try: + del profile_cache[entity_jid] + except KeyError: + raise exceptions.UnknownEntityError(u"Entity {} not in cache".format(entity_jid)) + else: + try: + del profile_cache[entity_jid.userhostJID()][entity_jid.resource] + except KeyError: + raise exceptions.UnknownEntityError(u"Entity {} not in cache".format(entity_jid)) + + ## Encryption ## + + def encryptValue(self, value, profile): + """Encrypt a value for the given profile. The personal key must be loaded + already in the profile session, that should be the case if the profile is + already authenticated. + + @param value (str): the value to encrypt + @param profile (str): %(doc_profile)s + @return: the deferred encrypted value + """ + try: + personal_key = self.auth_sessions.profileGetUnique(profile)[C.MEMORY_CRYPTO_KEY] + except TypeError: + raise exceptions.InternalError(_('Trying to encrypt a value for %s while the personal key is undefined!') % profile) + return BlockCipher.encrypt(personal_key, value) + + def decryptValue(self, value, profile): + """Decrypt a value for the given profile. The personal key must be loaded + already in the profile session, that should be the case if the profile is + already authenticated. + + @param value (str): the value to decrypt + @param profile (str): %(doc_profile)s + @return: the deferred decrypted value + """ + try: + personal_key = self.auth_sessions.profileGetUnique(profile)[C.MEMORY_CRYPTO_KEY] + except TypeError: + raise exceptions.InternalError(_('Trying to decrypt a value for %s while the personal key is undefined!') % profile) + return BlockCipher.decrypt(personal_key, value) + + def encryptPersonalData(self, data_key, data_value, crypto_key, profile): + """Re-encrypt a personal data (saved to a PersistentDict). + + @param data_key: key for the individual PersistentDict instance + @param data_value: the value to be encrypted + @param crypto_key: the key to encrypt the value + @param profile: %(profile_doc)s + @return: a deferred None value + """ + + def gotIndMemory(data): + d = BlockCipher.encrypt(crypto_key, data_value) + + def cb(cipher): + data[data_key] = cipher + return data.force(data_key) + + return d.addCallback(cb) + + def done(dummy): + log.debug(_(u'Personal data (%(ns)s, %(key)s) has been successfuly encrypted') % + {'ns': C.MEMORY_CRYPTO_NAMESPACE, 'key': data_key}) + + d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load() + return d.addCallback(gotIndMemory).addCallback(done) + + ## Subscription requests ## + + def addWaitingSub(self, type_, entity_jid, profile_key): + """Called when a subcription request is received""" + profile = self.getProfileName(profile_key) + assert profile + if profile not in self.subscriptions: + self.subscriptions[profile] = {} + self.subscriptions[profile][entity_jid] = type_ + + def delWaitingSub(self, entity_jid, profile_key): + """Called when a subcription request is finished""" + profile = self.getProfileName(profile_key) + assert profile + if profile in self.subscriptions and entity_jid in self.subscriptions[profile]: + del self.subscriptions[profile][entity_jid] + + def getWaitingSub(self, profile_key): + """Called to get a list of currently waiting subscription requests""" + profile = self.getProfileName(profile_key) + if not profile: + log.error(_('Asking waiting subscriptions for a non-existant profile')) + return {} + if profile not in self.subscriptions: + return {} + + return self.subscriptions[profile] + + ## Parameters ## + + def getStringParamA(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE): + return self.params.getStringParamA(name, category, attr, profile_key) + + def getParamA(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE): + return self.params.getParamA(name, category, attr, profile_key=profile_key) + + def asyncGetParamA(self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + return self.params.asyncGetParamA(name, category, attr, security_limit, profile_key) + + def asyncGetParamsValuesFromCategory(self, category, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + return self.params.asyncGetParamsValuesFromCategory(category, security_limit, profile_key) + + def asyncGetStringParamA(self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + return self.params.asyncGetStringParamA(name, category, attr, security_limit, profile_key) + + def getParamsUI(self, security_limit=C.NO_SECURITY_LIMIT, app='', profile_key=C.PROF_KEY_NONE): + return self.params.getParamsUI(security_limit, app, profile_key) + + def getParamsCategories(self): + return self.params.getParamsCategories() + + def setParam(self, name, value, category, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + return self.params.setParam(name, value, category, security_limit, profile_key) + + def updateParams(self, xml): + return self.params.updateParams(xml) + + def paramsRegisterApp(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=''): + return self.params.paramsRegisterApp(xml, security_limit, app) + + def setDefault(self, name, category, callback, errback=None): + return self.params.setDefault(name, category, callback, errback) + + ## Files ## + + def checkFilePermission(self, file_data, peer_jid, perms_to_check): + """check that an entity has the right permission on a file + + @param file_data(dict): data of one file, as returned by getFiles + @param peer_jid(jid.JID): entity trying to access the file + @param perms_to_check(tuple[unicode]): permissions to check + tuple of C.ACCESS_PERM_* + @param check_parents(bool): if True, also check all parents until root node + @raise exceptions.PermissionError: peer_jid doesn't have all permission + in perms_to_check for file_data + @raise exceptions.InternalError: perms_to_check is invalid + """ + if peer_jid is None and perms_to_check is None: + return + peer_jid = peer_jid.userhostJID() + if peer_jid == file_data['owner']: + # the owner has all rights + return + if not C.ACCESS_PERMS.issuperset(perms_to_check): + raise exceptions.InternalError(_(u'invalid permission')) + + for perm in perms_to_check: + # we check each perm and raise PermissionError as soon as one condition is not valid + # we must never return here, we only return after the loop if nothing was blocking the access + try: + perm_data = file_data[u'access'][perm] + perm_type = perm_data[u'type'] + except KeyError: + raise failure.Failure(exceptions.PermissionError()) + if perm_type == C.ACCESS_TYPE_PUBLIC: + continue + elif perm_type == C.ACCESS_TYPE_WHITELIST: + try: + jids = perm_data[u'jids'] + except KeyError: + raise failure.Failure(exceptions.PermissionError()) + if peer_jid.full() in jids: + continue + else: + raise failure.Failure(exceptions.PermissionError()) + else: + raise exceptions.InternalError(_(u'unknown access type: {type}').format(type=perm_type)) + + @defer.inlineCallbacks + def checkPermissionToRoot(self, client, file_data, peer_jid, perms_to_check): + """do checkFilePermission on file_data and all its parents until root""" + current = file_data + while True: + self.checkFilePermission(current, peer_jid, perms_to_check) + parent = current[u'parent'] + if not parent: + break + files_data = yield self.getFile(self, client, peer_jid=None, file_id=parent, perms_to_check=None) + try: + current = files_data[0] + except IndexError: + raise exceptions.DataError(u'Missing parent') + + @defer.inlineCallbacks + def _getParentDir(self, client, path, parent, namespace, owner, peer_jid, perms_to_check): + """Retrieve parent node from a path, or last existing directory + + each directory of the path will be retrieved, until the last existing one + @return (tuple[unicode, list[unicode])): parent, remaining path elements: + - parent is the id of the last retrieved directory (or u'' for root) + - remaining path elements are the directories which have not been retrieved + (i.e. which don't exist) + """ + # if path is set, we have to retrieve parent directory of the file(s) from it + if parent is not None: + raise exceptions.ConflictError(_(u"You can't use path and parent at the same time")) + path_elts = filter(None, path.split(u'/')) + if {u'..', u'.'}.intersection(path_elts): + raise ValueError(_(u'".." or "." can\'t be used in path')) + + # we retrieve all directories from path until we get the parent container + # non existing directories will be created + parent = u'' + for idx, path_elt in enumerate(path_elts): + directories = yield self.storage.getFiles(client, parent=parent, type_=C.FILE_TYPE_DIRECTORY, + name=path_elt, namespace=namespace, owner=owner) + if not directories: + defer.returnValue((parent, path_elts[idx:])) + # from this point, directories don't exist anymore, we have to create them + elif len(directories) > 1: + raise exceptions.InternalError(_(u"Several directories found, this should not happen")) + else: + directory = directories[0] + self.checkFilePermission(directory, peer_jid, perms_to_check) + parent = directory[u'id'] + defer.returnValue((parent, [])) + + @defer.inlineCallbacks + def getFiles(self, client, peer_jid, file_id=None, version=None, parent=None, path=None, type_=None, + file_hash=None, hash_algo=None, name=None, namespace=None, mime_type=None, + owner=None, access=None, projection=None, unique=False, perms_to_check=(C.ACCESS_PERM_READ,)): + """retrieve files with with given filters + + @param peer_jid(jid.JID, None): jid trying to access the file + needed to check permission. + Use None to ignore permission (perms_to_check must be None too) + @param file_id(unicode, None): id of the file + None to ignore + @param version(unicode, None): version of the file + None to ignore + empty string to look for current version + @param parent(unicode, None): id of the directory containing the files + None to ignore + empty string to look for root files/directories + @param projection(list[unicode], None): name of columns to retrieve + None to retrieve all + @param unique(bool): if True will remove duplicates + @param perms_to_check(tuple[unicode],None): permission to check + must be a tuple of C.ACCESS_PERM_* or None + if None, permission will no be checked (peer_jid must be None too in this case) + other params are the same as for [setFile] + @return (list[dict]): files corresponding to filters + @raise exceptions.NotFound: parent directory not found (when path is specified) + @raise exceptions.PermissionError: peer_jid can't use perms_to_check for one of the file + on the path + """ + if peer_jid is None and perms_to_check or perms_to_check is None and peer_jid: + raise exceptions.InternalError('if you want to disable permission check, both peer_jid and perms_to_check must be None') + if owner is not None: + owner = owner.userhostJID() + if path is not None: + # permission are checked by _getParentDir + parent, remaining_path_elts = yield self._getParentDir(client, path, parent, namespace, owner, peer_jid, perms_to_check) + if remaining_path_elts: + # if we have remaining path elements, + # the parent directory is not found + raise failure.Failure(exceptions.NotFound()) + if parent and peer_jid: + # if parent is given directly and permission check is need, + # we need to check all the parents + parent_data = yield self.storage.getFiles(client, file_id=parent) + try: + parent_data = parent_data[0] + except IndexError: + raise exceptions.DataError(u'mising parent') + yield self.checkPermissionToRoot(client, parent_data, peer_jid, perms_to_check) + + files = yield self.storage.getFiles(client, file_id=file_id, version=version, parent=parent, type_=type_, + file_hash=file_hash, hash_algo=hash_algo, name=name, namespace=namespace, + mime_type=mime_type, owner=owner, access=access, + projection=projection, unique=unique) + + if peer_jid: + # if permission are checked, we must remove all file tha use can't access + to_remove = [] + for file_data in files: + try: + self.checkFilePermission(file_data, peer_jid, perms_to_check) + except exceptions.PermissionError: + to_remove.append(file_data) + for file_data in to_remove: + files.remove(file_data) + defer.returnValue(files) + + @defer.inlineCallbacks + def setFile(self, client, name, file_id=None, version=u'', parent=None, path=None, + type_=C.FILE_TYPE_FILE, file_hash=None, hash_algo=None, size=None, namespace=None, + mime_type=None, created=None, modified=None, owner=None, access=None, extra=None, + peer_jid = None, perms_to_check=(C.ACCESS_PERM_WRITE,)): + """set a file metadata + + @param name(unicode): basename of the file + @param file_id(unicode): unique id of the file + @param version(unicode): version of this file + empty string for current version or when there is no versioning + @param parent(unicode, None): id of the directory containing the files + @param path(unicode, None): virtual path of the file in the namespace + if set, parent must be None. All intermediate directories will be created if needed, + using current access. + @param file_hash(unicode): unique hash of the payload + @param hash_algo(unicode): algorithm used for hashing the file (usually sha-256) + @param size(int): size in bytes + @param namespace(unicode, None): identifier (human readable is better) to group files + for instance, namespace could be used to group files in a specific photo album + @param mime_type(unicode): MIME type of the file, or None if not known/guessed + @param created(int): UNIX time of creation + @param modified(int,None): UNIX time of last modification, or None to use created date + @param owner(jid.JID, None): jid of the owner of the file (mainly useful for component) + will be used to check permission (only bare jid is used, don't use with MUC). + Use None to ignore permission (perms_to_check must be None too) + @param access(dict, None): serialisable dictionary with access rules. + None (or empty dict) to use private access, i.e. allow only profile's jid to access the file + key can be on on C.ACCESS_PERM_*, + then a sub dictionary with a type key is used (one of C.ACCESS_TYPE_*). + According to type, extra keys can be used: + - C.ACCESS_TYPE_PUBLIC: the permission is granted for everybody + - C.ACCESS_TYPE_WHITELIST: the permission is granted for jids (as unicode) in the 'jids' key + will be encoded to json in database + @param extra(dict, None): serialisable dictionary of any extra data + will be encoded to json in database + @param perms_to_check(tuple[unicode],None): permission to check + must be a tuple of C.ACCESS_PERM_* or None + if None, permission will no be checked (peer_jid must be None too in this case) + @param profile(unicode): profile owning the file + """ + if '/' in name: + raise ValueError('name must not contain a slash ("/")') + if file_id is None: + file_id = shortuuid.uuid() + if file_hash is not None and hash_algo is None or hash_algo is not None and file_hash is None: + raise ValueError('file_hash and hash_algo must be set at the same time') + if mime_type is None: + mime_type, file_encoding = mimetypes.guess_type(name) + if created is None: + created = time.time() + if namespace is not None: + namespace = namespace.strip() or None + if type_ == C.FILE_TYPE_DIRECTORY: + if any(version, file_hash, size, mime_type): + raise ValueError(u"version, file_hash, size and mime_type can't be set for a directory") + if owner is not None: + owner = owner.userhostJID() + + if path is not None: + # _getParentDir will check permissions if peer_jid is set, so we use owner + parent, remaining_path_elts = yield self._getParentDir(client, path, parent, namespace, owner, owner, perms_to_check) + # if remaining directories don't exist, we have to create them + for new_dir in remaining_path_elts: + new_dir_id = shortuuid.uuid() + yield self.storage.setFile(client, name=new_dir, file_id=new_dir_id, version=u'', parent=parent, + type_=C.FILE_TYPE_DIRECTORY, namespace=namespace, + created=time.time(), + owner=owner, + access=access, extra={}) + parent = new_dir_id + elif parent is None: + parent = u'' + + yield self.storage.setFile(client, file_id=file_id, version=version, parent=parent, type_=type_, + file_hash=file_hash, hash_algo=hash_algo, name=name, size=size, + namespace=namespace, mime_type=mime_type, created=created, modified=modified, + owner=owner, + access=access, extra=extra) + + def fileUpdate(self, file_id, column, update_cb): + """update a file column taking care of race condition + + access is NOT checked in this method, it must be checked beforehand + @param file_id(unicode): id of the file to update + @param column(unicode): one of "access" or "extra" + @param update_cb(callable): method to update the value of the colum + the method will take older value as argument, and must update it in place + Note that the callable must be thread-safe + """ + return self.storage.fileUpdate(file_id, column, update_cb) + + ## Misc ## + + def isEntityAvailable(self, client, entity_jid): + """Tell from the presence information if the given entity is available. + + @param entity_jid (JID): the entity to check (if bare jid is used, all resources are tested) + @return (bool): True if entity is available + """ + if not entity_jid.resource: + return bool(self.getAvailableResources(client, entity_jid)) # is any resource is available, entity is available + try: + presence_data = self.getEntityDatum(entity_jid, "presence", client.profile) + except KeyError: + log.debug(u"No presence information for {}".format(entity_jid)) + return False + return presence_data.show != C.PRESENCE_UNAVAILABLE diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/params.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/params.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,945 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _, D_ + +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.memory.crypto import BlockCipher, PasswordHasher +from xml.dom import minidom, NotFoundErr +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from twisted.python.failure import Failure +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from sat.tools.xml_tools import paramsXML2XMLUI, getText + +# TODO: params should be rewritten using Twisted directly instead of minidom +# general params should be linked to sat.conf and kept synchronised +# this need an overall simplification to make maintenance easier + + +def createJidElts(jids): + """Generator which return elements from jids + + @param jids(iterable[id.jID]): jids to use + @return (generator[domish.Element]): elements + """ + for jid_ in jids: + jid_elt = domish.Element((None, 'jid')) + jid_elt.addContent(jid_.full()) + yield jid_elt + + +class Params(object): + """This class manage parameters with xml""" + ### TODO: add desciption in params + + #TODO: when priority is changed, a new presence stanza must be emitted + #TODO: int type (Priority should be int instead of string) + default_xml = u""" + + + + + + + + + + + + + + + + + + + + + + """ % { + 'category_general': D_("General"), + 'category_connection': D_("Connection"), + 'history_param': C.HISTORY_LIMIT, + 'history_label': D_('Chat history limit'), + 'show_offline_contacts': C.SHOW_OFFLINE_CONTACTS, + 'show_offline_contacts_label': D_('Show offline contacts'), + 'show_empty_groups': C.SHOW_EMPTY_GROUPS, + 'show_empty_groups_label': D_('Show empty groups'), + 'force_server_param': C.FORCE_SERVER_PARAM, + 'force_port_param': C.FORCE_PORT_PARAM, + 'new_account_label': D_("Register new account"), + 'autoconnect_label': D_('Connect on frontend startup'), + 'autodisconnect_label': D_('Disconnect on frontend closure'), + } + + def load_default_params(self): + self.dom = minidom.parseString(Params.default_xml.encode('utf-8')) + + def _mergeParams(self, source_node, dest_node): + """Look for every node in source_node and recursively copy them to dest if they don't exists""" + + def getNodesMap(children): + ret = {} + for child in children: + if child.nodeType == child.ELEMENT_NODE: + ret[(child.tagName, child.getAttribute('name'))] = child + return ret + source_map = getNodesMap(source_node.childNodes) + dest_map = getNodesMap(dest_node.childNodes) + source_set = set(source_map.keys()) + dest_set = set(dest_map.keys()) + to_add = source_set.difference(dest_set) + + for node_key in to_add: + dest_node.appendChild(source_map[node_key].cloneNode(True)) + + to_recurse = source_set - to_add + for node_key in to_recurse: + self._mergeParams(source_map[node_key], dest_map[node_key]) + + def load_xml(self, xml_file): + """Load parameters template from xml file""" + self.dom = minidom.parse(xml_file) + default_dom = minidom.parseString(Params.default_xml.encode('utf-8')) + self._mergeParams(default_dom.documentElement, self.dom.documentElement) + + def loadGenParams(self): + """Load general parameters data from storage + + @return: deferred triggered once params are loaded + """ + return self.storage.loadGenParams(self.params_gen) + + def loadIndParams(self, profile, cache=None): + """Load individual parameters + + set self.params cache or a temporary cache + @param profile: profile to load (*must exist*) + @param cache: if not None, will be used to store the value, as a short time cache + @return: deferred triggered once params are loaded + """ + if cache is None: + self.params[profile] = {} + return self.storage.loadIndParams(self.params[profile] if cache is None else cache, profile) + + def purgeProfile(self, profile): + """Remove cache data of a profile + + @param profile: %(doc_profile)s + """ + try: + del self.params[profile] + except KeyError: + log.error(_(u"Trying to purge cache of a profile not in memory: [%s]") % profile) + + def save_xml(self, filename): + """Save parameters template to xml file""" + with open(filename, 'wb') as xml_file: + xml_file.write(self.dom.toxml('utf-8')) + + def __init__(self, host, storage): + log.debug("Parameters init") + self.host = host + self.storage = storage + self.default_profile = None + self.params = {} + self.params_gen = {} + + def createProfile(self, profile, component): + """Create a new profile + + @param profile(unicode): name of the profile + @param component(unicode): entry point if profile is a component + @param callback: called when the profile actually exists in database and memory + @return: a Deferred instance + """ + if self.storage.hasProfile(profile): + log.info(_('The profile name already exists')) + return defer.fail(Failure(exceptions.ConflictError)) + if not self.host.trigger.point("ProfileCreation", profile): + return defer.fail(Failure(exceptions.CancelError)) + return self.storage.createProfile(profile, component or None) + + def asyncDeleteProfile(self, profile, force=False): + """Delete an existing profile + + @param profile: name of the profile + @param force: force the deletion even if the profile is connected. + To be used for direct calls only (not through the bridge). + @return: a Deferred instance + """ + if not self.storage.hasProfile(profile): + log.info(_('Trying to delete an unknown profile')) + return defer.fail(Failure(exceptions.ProfileUnknownError(profile))) + if self.host.isConnected(profile): + if force: + self.host.disconnect(profile) + else: + log.info(_("Trying to delete a connected profile")) + return defer.fail(Failure(exceptions.ProfileConnected)) + return self.storage.deleteProfile(profile) + + def getProfileName(self, profile_key, return_profile_keys=False): + """return profile according to profile_key + + @param profile_key: profile name or key which can be + C.PROF_KEY_ALL for all profiles + C.PROF_KEY_DEFAULT for default profile + @param return_profile_keys: if True, return unmanaged profile keys (like C.PROF_KEY_ALL). This keys must be managed by the caller + @return: requested profile name + @raise exceptions.ProfileUnknownError: profile doesn't exists + @raise exceptions.ProfileNotSetError: if C.PROF_KEY_NONE is used + """ + if profile_key == '@DEFAULT@': + default = self.host.memory.memory_data.get('Profile_default') + if not default: + log.info(_('No default profile, returning first one')) + try: + default = self.host.memory.memory_data['Profile_default'] = self.storage.getProfilesList()[0] + except IndexError: + log.info(_('No profile exist yet')) + raise exceptions.ProfileUnknownError(profile_key) + return default # FIXME: temporary, must use real default value, and fallback to first one if it doesn't exists + elif profile_key == C.PROF_KEY_NONE: + raise exceptions.ProfileNotSetError + elif return_profile_keys and profile_key in [C.PROF_KEY_ALL]: + return profile_key # this value must be managed by the caller + if not self.storage.hasProfile(profile_key): + log.error(_(u'Trying to access an unknown profile (%s)') % profile_key) + raise exceptions.ProfileUnknownError(profile_key) + return profile_key + + def __get_unique_node(self, parent, tag, name): + """return node with given tag + + @param parent: parent of nodes to check (e.g. documentElement) + @param tag: tag to check (e.g. "category") + @param name: name to check (e.g. "JID") + @return: node if it exist or None + """ + for node in parent.childNodes: + if node.nodeName == tag and node.getAttribute("name") == name: + #the node already exists + return node + #the node is new + return None + + def updateParams(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=''): + """import xml in parameters, update if the param already exists + + If security_limit is specified and greater than -1, the parameters + that have a security level greater than security_limit are skipped. + @param xml: parameters in xml form + @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure + @param app: name of the frontend registering the parameters or empty value + """ + # TODO: should word with domish.Element + src_parent = minidom.parseString(xml.encode('utf-8')).documentElement + + def pre_process_app_node(src_parent, security_limit, app): + """Parameters that are registered from a frontend must be checked""" + to_remove = [] + for type_node in src_parent.childNodes: + if type_node.nodeName != C.INDIVIDUAL: + to_remove.append(type_node) # accept individual parameters only + continue + for cat_node in type_node.childNodes: + if cat_node.nodeName != 'category': + to_remove.append(cat_node) + continue + to_remove_count = 0 # count the params to be removed from current category + for node in cat_node.childNodes: + if node.nodeName != "param" or not self.checkSecurityLimit(node, security_limit): + to_remove.append(node) + to_remove_count += 1 + continue + node.setAttribute('app', app) + if len(cat_node.childNodes) == to_remove_count: # remove empty category + for dummy in xrange(0, to_remove_count): + to_remove.pop() + to_remove.append(cat_node) + for node in to_remove: + node.parentNode.removeChild(node) + + def import_node(tgt_parent, src_parent): + for child in src_parent.childNodes: + if child.nodeName == '#text': + continue + node = self.__get_unique_node(tgt_parent, child.nodeName, child.getAttribute("name")) + if not node: # The node is new + tgt_parent.appendChild(child.cloneNode(True)) + else: + if child.nodeName == "param": + # The child updates an existing parameter, we replace the node + tgt_parent.replaceChild(child, node) + else: + # the node already exists, we recurse 1 more level + import_node(node, child) + + if app: + pre_process_app_node(src_parent, security_limit, app) + import_node(self.dom.documentElement, src_parent) + + def paramsRegisterApp(self, xml, security_limit, app): + """Register frontend's specific parameters + + If security_limit is specified and greater than -1, the parameters + that have a security level greater than security_limit are skipped. + @param xml: XML definition of the parameters to be added + @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure + @param app: name of the frontend registering the parameters + """ + if not app: + log.warning(_(u"Trying to register frontends parameters with no specified app: aborted")) + return + if not hasattr(self, "frontends_cache"): + self.frontends_cache = [] + if app in self.frontends_cache: + log.debug(_(u"Trying to register twice frontends parameters for %(app)s: aborted" % {"app": app})) + return + self.frontends_cache.append(app) + self.updateParams(xml, security_limit, app) + log.debug(u"Frontends parameters registered for %(app)s" % {'app': app}) + + def __default_ok(self, value, name, category): + #FIXME: will not work with individual parameters + self.setParam(name, value, category) + + def __default_ko(self, failure, name, category): + log.error(_(u"Can't determine default value for [%(category)s/%(name)s]: %(reason)s") % {'category': category, 'name': name, 'reason': str(failure.value)}) + + def setDefault(self, name, category, callback, errback=None): + """Set default value of parameter + + 'default_cb' attibute of parameter must be set to 'yes' + @param name: name of the parameter + @param category: category of the parameter + @param callback: must return a string with the value (use deferred if needed) + @param errback: must manage the error with args failure, name, category + """ + #TODO: send signal param update if value changed + #TODO: manage individual paramaters + log.debug ("setDefault called for %(category)s/%(name)s" % {"category": category, "name": name}) + node = self._getParamNode(name, category, '@ALL@') + if not node: + log.error(_(u"Requested param [%(name)s] in category [%(category)s] doesn't exist !") % {'name': name, 'category': category}) + return + if node[1].getAttribute('default_cb') == 'yes': + # del node[1].attributes['default_cb'] # default_cb is not used anymore as a flag to know if we have to set the default value, + # and we can still use it later e.g. to call a generic setDefault method + value = self._getParam(category, name, C.GENERAL) + if value is None: # no value set by the user: we have the default value + log.debug ("Default value to set, using callback") + d = defer.maybeDeferred(callback) + d.addCallback(self.__default_ok, name, category) + d.addErrback(errback or self.__default_ko, name, category) + + def _getAttr_internal(self, node, attr, value): + """Get attribute value. + + /!\ This method would return encrypted password values. + + @param node: XML param node + @param attr: name of the attribute to get (e.g.: 'value' or 'type') + @param value: user defined value + @return: value (can be str, bool, int, list, None) + """ + if attr == 'value': + value_to_use = value if value is not None else node.getAttribute(attr) # we use value (user defined) if it exist, else we use node's default value + if node.getAttribute('type') == 'bool': + return C.bool(value_to_use) + if node.getAttribute('type') == 'int': + return int(value_to_use) + elif node.getAttribute('type') == 'list': + if not value_to_use: # no user defined value, take default value from the XML + options = [option for option in node.childNodes if option.nodeName == 'option'] + selected = [option for option in options if option.getAttribute('selected') == 'true'] + cat, param = node.parentNode.getAttribute('name'), node.getAttribute('name') + if len(selected) == 1: + value_to_use = selected[0].getAttribute('value') + log.info(_("Unset parameter (%(cat)s, %(param)s) of type list will use the default option '%(value)s'") % + {'cat': cat, 'param': param, 'value': value_to_use}) + return value_to_use + if len(selected) == 0: + log.error(_(u'Parameter (%(cat)s, %(param)s) of type list has no default option!') % {'cat': cat, 'param': param}) + else: + log.error(_(u'Parameter (%(cat)s, %(param)s) of type list has more than one default option!') % {'cat': cat, 'param': param}) + raise exceptions.DataError + elif node.getAttribute('type') == 'jids_list': + if value_to_use: + jids = value_to_use.split('\t') # FIXME: it's not good to use tabs as separator ! + else: # no user defined value, take default value from the XML + jids = [getText(jid_) for jid_ in node.getElementsByTagName("jid")] + to_delete = [] + for idx, value in enumerate(jids): + try: + jids[idx] = jid.JID(value) + except (RuntimeError, jid.InvalidFormat, AttributeError): + log.warning(u"Incorrect jid value found in jids list: [{}]".format(value)) + to_delete.append(value) + for value in to_delete: + jids.remove(value) + return jids + return value_to_use + return node.getAttribute(attr) + + def _getAttr(self, node, attr, value): + """Get attribute value (synchronous). + + /!\ This method can not be used to retrieve password values. + @param node: XML param node + @param attr: name of the attribute to get (e.g.: 'value' or 'type') + @param value: user defined value + @return (unicode, bool, int, list): value to retrieve + """ + if attr == 'value' and node.getAttribute('type') == 'password': + raise exceptions.InternalError('To retrieve password values, use _asyncGetAttr instead of _getAttr') + return self._getAttr_internal(node, attr, value) + + def _asyncGetAttr(self, node, attr, value, profile=None): + """Get attribute value. + + Profile passwords are returned hashed (if not empty), + other passwords are returned decrypted (if not empty). + @param node: XML param node + @param attr: name of the attribute to get (e.g.: 'value' or 'type') + @param value: user defined value + @param profile: %(doc_profile)s + @return (unicode, bool, int, list): Deferred value to retrieve + """ + value = self._getAttr_internal(node, attr, value) + if attr != 'value' or node.getAttribute('type') != 'password': + return defer.succeed(value) + param_cat = node.parentNode.getAttribute('name') + param_name = node.getAttribute('name') + if ((param_cat, param_name) == C.PROFILE_PASS_PATH) or not value: + return defer.succeed(value) # profile password and empty passwords are returned "as is" + if not profile: + raise exceptions.ProfileNotSetError('The profile is needed to decrypt a password') + d = self.host.memory.decryptValue(value, profile) + + def gotPlainPassword(password): + if password is None: # empty value means empty password, None means decryption failure + raise exceptions.InternalError(_('The stored password could not be decrypted!')) + return password + + return d.addCallback(gotPlainPassword) + + def __type_to_string(self, result): + """ convert result to string, according to its type """ + if isinstance(result, bool): + return "true" if result else "false" + elif isinstance(result, int): + return str(result) + return result + + def getStringParamA(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE): + """ Same as getParamA but for bridge: convert non string value to string """ + return self.__type_to_string(self.getParamA(name, category, attr, profile_key=profile_key)) + + def getParamA(self, name, category, attr="value", use_default=True, profile_key=C.PROF_KEY_NONE): + """Helper method to get a specific attribute. + + /!\ This method would return encrypted password values, + to get the plain values you have to use _asyncGetParamA. + @param name: name of the parameter + @param category: category of the parameter + @param attr: name of the attribute (default: "value") + @parm use_default(bool): if True and attr=='value', return default value if not set + else return None if not set + @param profile: owner of the param (@ALL@ for everyone) + @return: attribute + """ + # FIXME: looks really dirty and buggy, need to be reviewed/refactored + # FIXME: security_limit is not managed here ! + node = self._getParamNode(name, category) + if not node: + log.error(_(u"Requested param [%(name)s] in category [%(category)s] doesn't exist !") % {'name': name, 'category': category}) + raise exceptions.NotFound + + if attr == 'value' and node[1].getAttribute('type') == 'password': + raise exceptions.InternalError('To retrieve password values, use asyncGetParamA instead of getParamA') + + if node[0] == C.GENERAL: + value = self._getParam(category, name, C.GENERAL) + if value is None and attr=='value' and not use_default: + return value + return self._getAttr(node[1], attr, value) + + assert node[0] == C.INDIVIDUAL + + profile = self.getProfileName(profile_key) + if not profile: + log.error(_('Requesting a param for an non-existant profile')) + raise exceptions.ProfileUnknownError(profile_key) + + if profile not in self.params: + log.error(_('Requesting synchronous param for not connected profile')) + raise exceptions.ProfileNotConnected(profile) + + if attr == "value": + value = self._getParam(category, name, profile=profile) + if value is None and attr=='value' and not use_default: + return value + return self._getAttr(node[1], attr, value) + + def asyncGetStringParamA(self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + d = self.asyncGetParamA(name, category, attr, security_limit, profile_key) + d.addCallback(self.__type_to_string) + return d + + def asyncGetParamA(self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + """Helper method to get a specific attribute. + + @param name: name of the parameter + @param category: category of the parameter + @param attr: name of the attribute (default: "value") + @param profile: owner of the param (@ALL@ for everyone) + @return (defer.Deferred): parameter value, with corresponding type (bool, int, list, etc) + """ + node = self._getParamNode(name, category) + if not node: + log.error(_(u"Requested param [%(name)s] in category [%(category)s] doesn't exist !") % {'name': name, 'category': category}) + raise ValueError("Requested param doesn't exist") + + if not self.checkSecurityLimit(node[1], security_limit): + log.warning(_(u"Trying to get parameter '%(param)s' in category '%(cat)s' without authorization!!!" + % {'param': name, 'cat': category})) + raise exceptions.PermissionError + + if node[0] == C.GENERAL: + value = self._getParam(category, name, C.GENERAL) + return self._asyncGetAttr(node[1], attr, value) + + assert node[0] == C.INDIVIDUAL + + profile = self.getProfileName(profile_key) + if not profile: + raise exceptions.InternalError(_('Requesting a param for a non-existant profile')) + + if attr != "value": + return defer.succeed(node[1].getAttribute(attr)) + try: + value = self._getParam(category, name, profile=profile) + return self._asyncGetAttr(node[1], attr, value, profile) + except exceptions.ProfileNotInCacheError: + #We have to ask data to the storage manager + d = self.storage.getIndParam(category, name, profile) + return d.addCallback(lambda value: self._asyncGetAttr(node[1], attr, value, profile)) + + def asyncGetParamsValuesFromCategory(self, category, security_limit, profile_key): + """Get all parameters "attribute" for a category + + @param category(unicode): the desired category + @param security_limit(int): NO_SECURITY_LIMIT (-1) to return all the params. + Otherwise sole the params which have a security level defined *and* + lower or equal to the specified value are returned. + @param profile_key: %(doc_profile_key)s + @return (dict): key: param name, value: param value (converted to string if needed) + """ + #TODO: manage category of general type (without existant profile) + profile = self.getProfileName(profile_key) + if not profile: + log.error(_("Asking params for inexistant profile")) + return "" + + def setValue(value, ret, name): + ret[name] = value + + def returnCategoryXml(prof_xml): + ret = {} + names_d_list = [] + for category_node in prof_xml.getElementsByTagName("category"): + if category_node.getAttribute("name") == category: + for param_node in category_node.getElementsByTagName("param"): + name = param_node.getAttribute('name') + if not name: + log.warning(u"ignoring attribute without name: {}".format(param_node.toxml())) + continue + d = self.asyncGetStringParamA(name, category, security_limit=security_limit, profile_key=profile) + d.addCallback(setValue, ret, name) + names_d_list.append(d) + break + + prof_xml.unlink() + dlist = defer.gatherResults(names_d_list) + dlist.addCallback(lambda dummy: ret) + return ret + + d = self._constructProfileXml(security_limit, '', profile) + return d.addCallback(returnCategoryXml) + + def _getParam(self, category, name, type_=C.INDIVIDUAL, cache=None, profile=C.PROF_KEY_NONE): + """Return the param, or None if it doesn't exist + + @param category: param category + @param name: param name + @param type_: GENERAL or INDIVIDUAL + @param cache: temporary cache, to use when profile is not logged + @param profile: the profile name (not profile key, i.e. name and not something like @DEFAULT@) + @return: param value or None if it doesn't exist + """ + if type_ == C.GENERAL: + if (category, name) in self.params_gen: + return self.params_gen[(category, name)] + return None # This general param has the default value + assert type_ == C.INDIVIDUAL + if profile == C.PROF_KEY_NONE: + raise exceptions.ProfileNotSetError + if profile in self.params: + cache = self.params[profile] # if profile is in main cache, we use it, + # ignoring the temporary cache + elif cache is None: # else we use the temporary cache if it exists, or raise an exception + raise exceptions.ProfileNotInCacheError + if (category, name) not in cache: + return None + return cache[(category, name)] + + def _constructProfileXml(self, security_limit, app, profile): + """Construct xml for asked profile, filling values when needed + + /!\ as noticed in doc, don't forget to unlink the minidom.Document + @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params. + Otherwise sole the params which have a security level defined *and* + lower or equal to the specified value are returned. + @param app: name of the frontend requesting the parameters, or '' to get all parameters + @param profile: profile name (not key !) + @return: a deferred that fire a minidom.Document of the profile xml (cf warning above) + """ + + def checkNode(node): + """Check the node against security_limit and app""" + return self.checkSecurityLimit(node, security_limit) and self.checkApp(node, app) + + def constructProfile(ignore, profile_cache): + # init the result document + prof_xml = minidom.parseString('') + cache = {} + + for type_node in self.dom.documentElement.childNodes: + if type_node.nodeName != C.GENERAL and type_node.nodeName != C.INDIVIDUAL: + continue + # we use all params, general and individual + for cat_node in type_node.childNodes: + if cat_node.nodeName != 'category': + continue + category = cat_node.getAttribute('name') + dest_params = {} # result (merged) params for category + if category not in cache: + # we make a copy for the new xml + cache[category] = dest_cat = cat_node.cloneNode(True) + to_remove = [] + for node in dest_cat.childNodes: + if node.nodeName != "param": + continue + if not checkNode(node): + to_remove.append(node) + continue + dest_params[node.getAttribute('name')] = node + for node in to_remove: + dest_cat.removeChild(node) + new_node = True + else: + # It's not a new node, we use the previously cloned one + dest_cat = cache[category] + new_node = False + params = cat_node.getElementsByTagName("param") + + for param_node in params: + # we have to merge new params (we are parsing individual parameters, we have to add them + # to the previously parsed general ones) + name = param_node.getAttribute('name') + if not checkNode(param_node): + continue + if name not in dest_params: + # this is reached when a previous category exists + dest_params[name] = param_node.cloneNode(True) + dest_cat.appendChild(dest_params[name]) + + profile_value = self._getParam(category, + name, type_node.nodeName, + cache=profile_cache, profile=profile) + if profile_value is not None: + # there is a value for this profile, we must change the default + if dest_params[name].getAttribute('type') == 'list': + for option in dest_params[name].getElementsByTagName("option"): + if option.getAttribute('value') == profile_value: + option.setAttribute('selected', 'true') + else: + try: + option.removeAttribute('selected') + except NotFoundErr: + pass + elif dest_params[name].getAttribute('type') == 'jids_list': + jids = profile_value.split('\t') + for jid_elt in dest_params[name].getElementsByTagName("jid"): + dest_params[name].removeChild(jid_elt) # remove all default + for jid_ in jids: # rebuilt the children with use values + try: + jid.JID(jid_) + except (RuntimeError, jid.InvalidFormat, AttributeError): + log.warning(u"Incorrect jid value found in jids list: [{}]".format(jid_)) + else: + jid_elt = prof_xml.createElement('jid') + jid_elt.appendChild(prof_xml.createTextNode(jid_)) + dest_params[name].appendChild(jid_elt) + else: + dest_params[name].setAttribute('value', profile_value) + if new_node: + prof_xml.documentElement.appendChild(dest_cat) + + to_remove = [] + for cat_node in prof_xml.documentElement.childNodes: + # we remove empty categories + if cat_node.getElementsByTagName("param").length == 0: + to_remove.append(cat_node) + for node in to_remove: + prof_xml.documentElement.removeChild(node) + return prof_xml + + if profile in self.params: + d = defer.succeed(None) + profile_cache = self.params[profile] + else: + #profile is not in cache, we load values in a short time cache + profile_cache = {} + d = self.loadIndParams(profile, profile_cache) + + return d.addCallback(constructProfile, profile_cache) + + def getParamsUI(self, security_limit, app, profile_key): + """ + @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params. + Otherwise sole the params which have a security level defined *and* + lower or equal to the specified value are returned. + @param app: name of the frontend requesting the parameters, or '' to get all parameters + @param profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile. + @return: a SàT XMLUI for parameters + """ + profile = self.getProfileName(profile_key) + if not profile: + log.error(_("Asking params for inexistant profile")) + return "" + d = self.getParams(security_limit, app, profile) + return d.addCallback(lambda param_xml: paramsXML2XMLUI(param_xml)) + + def getParams(self, security_limit, app, profile_key): + """Construct xml for asked profile, take params xml as skeleton + + @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params. + Otherwise sole the params which have a security level defined *and* + lower or equal to the specified value are returned. + @param app: name of the frontend requesting the parameters, or '' to get all parameters + @param profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile. + @return: XML of parameters + """ + profile = self.getProfileName(profile_key) + if not profile: + log.error(_("Asking params for inexistant profile")) + return defer.succeed("") + + def returnXML(prof_xml): + return_xml = prof_xml.toxml() + prof_xml.unlink() + return '\n'.join((line for line in return_xml.split('\n') if line)) + + return self._constructProfileXml(security_limit, app, profile).addCallback(returnXML) + + def _getParamNode(self, name, category, type_="@ALL@"): # FIXME: is type_ useful ? + """Return a node from the param_xml + @param name: name of the node + @param category: category of the node + @param type_: keyword for search: + @ALL@ search everywhere + @GENERAL@ only search in general type + @INDIVIDUAL@ only search in individual type + @return: a tuple (node type, node) or None if not found""" + + for type_node in self.dom.documentElement.childNodes: + if (((type_ == "@ALL@" or type_ == "@GENERAL@") and type_node.nodeName == C.GENERAL) + or ((type_ == "@ALL@" or type_ == "@INDIVIDUAL@") and type_node.nodeName == C.INDIVIDUAL)): + for node in type_node.getElementsByTagName('category'): + if node.getAttribute("name") == category: + params = node.getElementsByTagName("param") + for param in params: + if param.getAttribute("name") == name: + return (type_node.nodeName, param) + return None + + def getParamsCategories(self): + """return the categories availables""" + categories = [] + for cat in self.dom.getElementsByTagName("category"): + name = cat.getAttribute("name") + if name not in categories: + categories.append(cat.getAttribute("name")) + return categories + + def setParam(self, name, value, category, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): + """Set a parameter, return None if the parameter is not in param xml. + + Parameter of type 'password' that are not the SàT profile password are + stored encrypted (if not empty). The profile password is stored hashed + (if not empty). + + @param name (str): the parameter name + @param value (str): the new value + @param category (str): the parameter category + @param security_limit (int) + @param profile_key (str): %(doc_profile_key)s + @return: a deferred None value when everything is done + """ + # FIXME: setParam should accept the right type for value, not only str ! + if profile_key != C.PROF_KEY_NONE: + profile = self.getProfileName(profile_key) + if not profile: + log.error(_(u'Trying to set parameter for an unknown profile')) + raise exceptions.ProfileUnknownError(profile_key) + + node = self._getParamNode(name, category, '@ALL@') + if not node: + log.error(_(u'Requesting an unknown parameter (%(category)s/%(name)s)') + % {'category': category, 'name': name}) + return defer.succeed(None) + + if not self.checkSecurityLimit(node[1], security_limit): + log.warning(_(u"Trying to set parameter '%(param)s' in category '%(cat)s' without authorization!!!" + % {'param': name, 'cat': category})) + return defer.succeed(None) + + type_ = node[1].getAttribute("type") + if type_ == 'int': + if not value: # replace with the default value (which might also be '') + value = node[1].getAttribute("value") + else: + try: + int(value) + except ValueError: + log.debug(_(u"Trying to set parameter '%(param)s' in category '%(cat)s' with an non-integer value" + % {'param': name, 'cat': category})) + return defer.succeed(None) + if node[1].hasAttribute("constraint"): + constraint = node[1].getAttribute("constraint") + try: + min_, max_ = [int(limit) for limit in constraint.split(";")] + except ValueError: + raise exceptions.InternalError("Invalid integer parameter constraint: %s" % constraint) + value = str(min(max(int(value), min_), max_)) + + + log.info(_("Setting parameter (%(category)s, %(name)s) = %(value)s") % + {'category': category, 'name': name, 'value': value if type_ != 'password' else '********'}) + + if node[0] == C.GENERAL: + self.params_gen[(category, name)] = value + self.storage.setGenParam(category, name, value) + for profile in self.storage.getProfilesList(): + if self.host.memory.isSessionStarted(profile): + self.host.bridge.paramUpdate(name, value, category, profile) + self.host.trigger.point("paramUpdateTrigger", name, value, category, node[0], profile) + return defer.succeed(None) + + assert node[0] == C.INDIVIDUAL + assert profile_key != C.PROF_KEY_NONE + + if type_ == "button": + log.debug(u"Clicked param button %s" % node.toxml()) + return defer.succeed(None) + elif type_ == "password": + try: + personal_key = self.host.memory.auth_sessions.profileGetUnique(profile)[C.MEMORY_CRYPTO_KEY] + except TypeError: + raise exceptions.InternalError(_('Trying to encrypt a password while the personal key is undefined!')) + if (category, name) == C.PROFILE_PASS_PATH: + # using 'value' as the encryption key to encrypt another encryption key... could be confusing! + d = self.host.memory.encryptPersonalData(data_key=C.MEMORY_CRYPTO_KEY, + data_value=personal_key, + crypto_key=value, + profile=profile) + d.addCallback(lambda dummy: PasswordHasher.hash(value)) # profile password is hashed (empty value stays empty) + elif value: # other non empty passwords are encrypted with the personal key + d = BlockCipher.encrypt(personal_key, value) + else: + d = defer.succeed(value) + else: + d = defer.succeed(value) + + def gotFinalValue(value): + if self.host.memory.isSessionStarted(profile): + self.params[profile][(category, name)] = value + self.host.bridge.paramUpdate(name, value, category, profile) + self.host.trigger.point("paramUpdateTrigger", name, value, category, node[0], profile) + return self.storage.setIndParam(category, name, value, profile) + else: + raise exceptions.ProfileNotConnected + + d.addCallback(gotFinalValue) + return d + + def _getNodesOfTypes(self, attr_type, node_type="@ALL@"): + """Return all the nodes matching the given types. + + TODO: using during the dev but not anymore... remove if not needed + + @param attr_type (str): the attribute type (string, text, password, bool, int, button, list) + @param node_type (str): keyword for filtering: + @ALL@ search everywhere + @GENERAL@ only search in general type + @INDIVIDUAL@ only search in individual type + @return: dict{tuple: node}: a dict {key, value} where: + - key is a couple (attribute category, attribute name) + - value is a node + """ + ret = {} + for type_node in self.dom.documentElement.childNodes: + if (((node_type == "@ALL@" or node_type == "@GENERAL@") and type_node.nodeName == C.GENERAL) or + ((node_type == "@ALL@" or node_type == "@INDIVIDUAL@") and type_node.nodeName == C.INDIVIDUAL)): + for cat_node in type_node.getElementsByTagName('category'): + cat = cat_node.getAttribute('name') + params = cat_node.getElementsByTagName("param") + for param in params: + if param.getAttribute("type") == attr_type: + ret[(cat, param.getAttribute("name"))] = param + return ret + + def checkSecurityLimit(self, node, security_limit): + """Check the given node against the given security limit. + The value NO_SECURITY_LIMIT (-1) means that everything is allowed. + @return: True if this node can be accessed with the given security limit. + """ + if security_limit < 0: + return True + if node.hasAttribute("security"): + if int(node.getAttribute("security")) <= security_limit: + return True + return False + + def checkApp(self, node, app): + """Check the given node against the given app. + @param node: parameter node + @param app: name of the frontend requesting the parameters, or '' to get all parameters + @return: True if this node concerns the given app. + """ + if not app or not node.hasAttribute("app"): + return True + return node.getAttribute("app") == app diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/persistent.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/persistent.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,233 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) + + +class MemoryNotInitializedError(Exception): + pass + + +class PersistentDict(object): + r"""A dictionary which save persistently each value assigned + + /!\ be careful, each assignment means a database write + /!\ Memory must be initialised before loading/setting value with instances of this class""" + storage = None + binary = False + + def __init__(self, namespace, profile=None): + """ + + @param namespace: unique namespace for this dictionary + @param profile(unicode, None): profile which *MUST* exists, or None for general values + """ + if not self.storage: + log.error(_("PersistentDict can't be used before memory initialisation")) + raise MemoryNotInitializedError + self._cache = None + self.namespace = namespace + self.profile = profile + + def _setCache(self, data): + self._cache = data + + def load(self): + """Load persistent data from storage. + + need to be called before any other operation + @return: defers the PersistentDict instance itself + """ + d = self.storage.getPrivates(self.namespace, binary=self.binary, profile=self.profile) + d.addCallback(self._setCache) + d.addCallback(lambda dummy: self) + return d + + def iteritems(self): + return self._cache.iteritems() + + def items(self): + return self._cache.items() + + def __repr__(self): + return self._cache.__repr__() + + def __str__(self): + return self._cache.__str__() + + def __lt__(self, other): + return self._cache.__lt__(other) + + def __le__(self, other): + return self._cache.__le__(other) + + def __eq__(self, other): + return self._cache.__eq__(other) + + def __ne__(self, other): + return self._cache.__ne__(other) + + def __gt__(self, other): + return self._cache.__gt__(other) + + def __ge__(self, other): + return self._cache.__ge__(other) + + def __cmp__(self, other): + return self._cache.__cmp__(other) + + def __hash__(self): + return self._cache.__hash__() + + def __nonzero__(self): + return self._cache.__len__() + + def __contains__(self, key): + return self._cache.__contains__(key) + + def __iter__(self): + return self._cache.__iter__() + + def __getitem__(self, key): + return self._cache.__getitem__(key) + + def __setitem__(self, key, value): + self.storage.setPrivateValue(self.namespace, key, value, self.binary, self.profile) + return self._cache.__setitem__(key, value) + + def __delitem__(self, key): + self.storage.delPrivateValue(self.namespace, key, self.binary, self.profile) + return self._cache.__delitem__(key) + + def get(self, key, default=None): + return self._cache.get(key, default) + + def setdefault(self, key, default): + try: + return self._cache[key] + except: + self.__setitem__(key, default) + return default + + def force(self, name): + """Force saving of an attribute to storage + + @return: deferred fired when data is actually saved + """ + return self.storage.setPrivateValue(self.namespace, name, self._cache[name], self.binary, self.profile) + + +class PersistentBinaryDict(PersistentDict): + """Persistent dict where value can be any python data (instead of string only)""" + binary = True + + +class LazyPersistentBinaryDict(PersistentBinaryDict): + ur"""PersistentBinaryDict which get key/value when needed + + This Persistent need more database access, it is suitable for largest data, + to save memory. + /!\ most of methods return a Deferred + """ + # TODO: missing methods should be implemented using database access + # TODO: a cache would be useful (which is deleted after a timeout) + + def load(self): + # we show a warning as calling load on LazyPersistentBinaryDict sounds like a code mistake + log.warning(_(u"Calling load on LazyPersistentBinaryDict while it's not needed")) + + def iteritems(self): + raise NotImplementedError + + def items(self): + return self.storage.getPrivates(self.namespace, binary=self.binary, profile=self.profile) + + def __repr__(self): + raise NotImplementedError + + def __str__(self): + return "LazyPersistentBinaryDict (namespace: {})".format(self.namespace) + + def __lt__(self, other): + raise NotImplementedError + + def __le__(self, other): + raise NotImplementedError + + def __eq__(self, other): + raise NotImplementedError + + def __ne__(self, other): + raise NotImplementedError + + def __gt__(self, other): + raise NotImplementedError + + def __ge__(self, other): + raise NotImplementedError + + def __cmp__(self, other): + raise NotImplementedError + + def __hash__(self): + return hash(unicode(self.__class__) + self.namespace + (self.profile or u'')) + + def __nonzero__(self): + raise NotImplementedError + + def __contains__(self, key): + raise NotImplementedError + + def __iter__(self): + raise NotImplementedError + + def __getitem__(self, key): + """get the value as a Deferred""" + d = self.storage.getPrivates(self.namespace, keys=[key], binary=self.binary, profile=self.profile) + d.addCallback(lambda data: data[key]) + return d + + def __setitem__(self, key, value): + self.storage.setPrivateValue(self.namespace, key, value, self.binary, self.profile) + + def __delitem__(self, key): + self.storage.delPrivateValue(self.namespace, key, self.binary, self.profile) + + def _valueOrDefault(self, value, default): + if value is None: + return default + return value + + def get(self, key, default=None): + d = self.__getitem__(key) + d.addCallback(self._valueOrDefault) + return d + + def setdefault(self, key, default): + raise NotImplementedError + + def force(self, name, value): + """Force saving of an attribute to storage + + @param value(object): value is needed for LazyPersistentBinaryDict + @return: deferred fired when data is actually saved + """ + return self.storage.setPrivateValue(self.namespace, name, value, self.binary, self.profile) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/memory/sqlite.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/memory/sqlite.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1295 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.memory.crypto import BlockCipher, PasswordHasher +from sat.tools.config import fixConfigOption +from twisted.enterprise import adbapi +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.python import failure +from collections import OrderedDict +import re +import os.path +import cPickle as pickle +import hashlib +import sqlite3 +import json + +CURRENT_DB_VERSION = 5 + +# XXX: DATABASE schemas are used in the following way: +# - 'current' key is for the actual database schema, for a new base +# - x(int) is for update needed between x-1 and x. All number are needed between y and z to do an update +# e.g.: if CURRENT_DB_VERSION is 6, 'current' is the actuel DB, and to update from version 3, numbers 4, 5 and 6 are needed +# a 'current' data dict can contains the keys: +# - 'CREATE': it contains an Ordered dict with table to create as keys, and a len 2 tuple as value, where value[0] are the columns definitions and value[1] are the table constraints +# - 'INSERT': it contains an Ordered dict with table where values have to be inserted, and many tuples containing values to insert in the order of the rows (#TODO: manage named columns) +# an update data dict (the ones with a number) can contains the keys 'create', 'delete', 'cols create', 'cols delete', 'cols modify', 'insert' or 'specific'. See Updater.generateUpdateData for more infos. This method can be used to autogenerate update_data, to ease the work of the developers. +# TODO: indexes need to be improved + +DATABASE_SCHEMAS = { + "current": {'CREATE': OrderedDict(( + ('profiles', (("id INTEGER PRIMARY KEY ASC", "name TEXT"), + ("UNIQUE (name)",))), + ('components', (("profile_id INTEGER PRIMARY KEY", "entry_point TEXT NOT NULL"), + ("FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE",))), + ('message_types', (("type TEXT PRIMARY KEY",), + ())), + ('history', (("uid TEXT PRIMARY KEY", "update_uid TEXT", "profile_id INTEGER", "source TEXT", "dest TEXT", "source_res TEXT", "dest_res TEXT", + "timestamp DATETIME NOT NULL", "received_timestamp DATETIME", # XXX: timestamp is the time when the message was emitted. If received time stamp is not NULL, the message was delayed and timestamp is the declared value (and received_timestamp the time of reception) + "type TEXT", "extra BLOB"), + ("FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE", "FOREIGN KEY(type) REFERENCES message_types(type)", + "UNIQUE (profile_id, timestamp, source, dest, source_res, dest_res)" # avoid storing 2 time the same message (specially for delayed ones) + ))), + ('message', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "message TEXT", "language TEXT"), + ("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), + ('subject', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "subject TEXT", "language TEXT"), + ("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), + ('thread', (("id INTEGER PRIMARY KEY ASC", "history_uid INTEGER", "thread_id TEXT", "parent_id TEXT"),("FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE",))), + ('param_gen', (("category TEXT", "name TEXT", "value TEXT"), + ("PRIMARY KEY (category,name)",))), + ('param_ind', (("category TEXT", "name TEXT", "profile_id INTEGER", "value TEXT"), + ("PRIMARY KEY (category,name,profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), + ('private_gen', (("namespace TEXT", "key TEXT", "value TEXT"), + ("PRIMARY KEY (namespace, key)",))), + ('private_ind', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value TEXT"), + ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), + ('private_gen_bin', (("namespace TEXT", "key TEXT", "value BLOB"), + ("PRIMARY KEY (namespace, key)",))), + ('private_ind_bin', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value BLOB"), + ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), + ('files', (("id TEXT NOT NULL", "version TEXT NOT NULL", "parent TEXT NOT NULL", + "type TEXT CHECK(type in ('{file}', '{directory}')) NOT NULL DEFAULT '{file}'".format( + file=C.FILE_TYPE_FILE, directory=C.FILE_TYPE_DIRECTORY), + "file_hash TEXT", "hash_algo TEXT", "name TEXT NOT NULL", "size INTEGER", + "namespace TEXT", "mime_type TEXT", + "created DATETIME NOT NULL", "modified DATETIME", + "owner TEXT", "access TEXT", "extra TEXT", "profile_id INTEGER"), + ("PRIMARY KEY (id, version)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), + )), + 'INSERT': OrderedDict(( + ('message_types', (("'chat'",), + ("'error'",), + ("'groupchat'",), + ("'headline'",), + ("'normal'",), + ("'info'",) # info is not standard, but used to keep track of info like join/leave in a MUC + )), + )), + }, + 5: {'create': {'files': (("id TEXT NOT NULL", "version TEXT NOT NULL", "parent TEXT NOT NULL", + "type TEXT CHECK(type in ('{file}', '{directory}')) NOT NULL DEFAULT '{file}'".format( + file=C.FILE_TYPE_FILE, directory=C.FILE_TYPE_DIRECTORY), + "file_hash TEXT", "hash_algo TEXT", "name TEXT NOT NULL", "size INTEGER", + "namespace TEXT", "mime_type TEXT", + "created DATETIME NOT NULL", "modified DATETIME", + "owner TEXT", "access TEXT", "extra TEXT", "profile_id INTEGER"), + ("PRIMARY KEY (id, version)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))}, + }, + 4: {'create': {'components': (('profile_id INTEGER PRIMARY KEY', 'entry_point TEXT NOT NULL'), ('FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE',))} + }, + 3: {'specific': 'update_v3' + }, + 2: {'specific': 'update2raw_v2' + }, + 1: {'cols create': {'history': ('extra BLOB',)}, + }, + } + +NOT_IN_EXTRA = ('received_timestamp', 'update_uid') # keys which are in message data extra but not stored in sqlite's extra field + # this is specific to this sqlite storage and for now only used for received_timestamp + # because this value is stored in a separate field + + +class ConnectionPool(adbapi.ConnectionPool): + # Workaround to avoid IntegrityError causing (i)pdb to be launched in debug mode + def _runQuery(self, trans, *args, **kw): + retry = kw.pop('query_retry', 6) + try: + trans.execute(*args, **kw) + except sqlite3.IntegrityError as e: + raise failure.Failure(e) + except Exception as e: + # FIXME: in case of error, we retry a couple of times + # this is a workaround, we need to move to better + # Sqlite integration, probably with high level library + retry -= 1 + if retry == 0: + log.error(_(u'too many db tries, we abandon! Error message: {msg}').format( + msg = e)) + raise e + log.warning(_(u'exception while running query, retrying ({try_}): {msg}').format( + try_ = 6 - retry, + msg = e)) + kw['query_retry'] = retry + return self._runQuery(trans, *args, **kw) + return trans.fetchall() + + +class SqliteStorage(object): + """This class manage storage with Sqlite database""" + + def __init__(self, db_filename, sat_version): + """Connect to the given database + + @param db_filename: full path to the Sqlite database + """ + self.initialized = defer.Deferred() # triggered when memory is fully initialised and ready + self.profiles = {} # we keep cache for the profiles (key: profile name, value: profile id) + + log.info(_("Connecting database")) + new_base = not os.path.exists(db_filename) # do we have to create the database ? + if new_base: # the dir may not exist if it's not the XDG recommended one + dir_ = os.path.dirname(db_filename) + if not os.path.exists(dir_): + os.makedirs(dir_, 0700) + + def foreignKeysOn(sqlite): + sqlite.execute('PRAGMA foreign_keys = ON') + + self.dbpool = ConnectionPool("sqlite3", db_filename, cp_openfun=foreignKeysOn, check_same_thread=False, timeout=15) + + def getNewBaseSql(): + log.info(_("The database is new, creating the tables")) + database_creation = ["PRAGMA user_version=%d" % CURRENT_DB_VERSION] + database_creation.extend(Updater.createData2Raw(DATABASE_SCHEMAS['current']['CREATE'])) + database_creation.extend(Updater.insertData2Raw(DATABASE_SCHEMAS['current']['INSERT'])) + return database_creation + + def getUpdateSql(): + updater = Updater(self.dbpool, sat_version) + return updater.checkUpdates() + + def commitStatements(statements): + + if statements is None: + return defer.succeed(None) + log.debug(u"===== COMMITTING STATEMENTS =====\n%s\n============\n\n" % '\n'.join(statements)) + d = self.dbpool.runInteraction(self._updateDb, tuple(statements)) + return d + + # init_defer is the initialisation deferred, initialisation is ok when all its callbacks have been done + + init_defer = defer.succeed(None) + + init_defer.addCallback(lambda ignore: getNewBaseSql() if new_base else getUpdateSql()) + init_defer.addCallback(commitStatements) + + def fillProfileCache(ignore): + d = self.dbpool.runQuery("SELECT profile_id, entry_point FROM components").addCallback(self._cacheComponentsAndProfiles) + d.chainDeferred(self.initialized) + + init_defer.addCallback(fillProfileCache) + + def _updateDb(self, interaction, statements): + for statement in statements: + interaction.execute(statement) + + ## Profiles + + def _cacheComponentsAndProfiles(self, components_result): + """Get components results and send requests profiles + + they will be both put in cache in _profilesCache + """ + return self.dbpool.runQuery("SELECT name,id FROM profiles").addCallback( + self._cacheComponentsAndProfiles2, components_result) + + def _cacheComponentsAndProfiles2(self, profiles_result, components): + """Fill the profiles cache + + @param profiles_result: result of the sql profiles query + """ + self.components = dict(components) + for profile in profiles_result: + name, id_ = profile + self.profiles[name] = id_ + + def getProfilesList(self): + """"Return list of all registered profiles""" + return self.profiles.keys() + + def hasProfile(self, profile_name): + """return True if profile_name exists + + @param profile_name: name of the profile to check + """ + return profile_name in self.profiles + + def profileIsComponent(self, profile_name): + try: + return self.profiles[profile_name] in self.components + except KeyError: + raise exceptions.NotFound(u"the requested profile doesn't exists") + + def getEntryPoint(self, profile_name): + try: + return self.components[self.profiles[profile_name]] + except KeyError: + raise exceptions.NotFound(u"the requested profile doesn't exists or is not a component") + + def createProfile(self, name, component=None): + """Create a new profile + + @param name(unicode): name of the profile + @param component(None, unicode): if not None, must point to a component entry point + @return: deferred triggered once profile is actually created + """ + + def getProfileId(ignore): + return self.dbpool.runQuery("SELECT (id) FROM profiles WHERE name = ?", (name, )) + + def setComponent(profile_id): + id_ = profile_id[0][0] + d_comp = self.dbpool.runQuery("INSERT INTO components(profile_id, entry_point) VALUES (?, ?)", (id_, component)) + d_comp.addCallback(lambda dummy: profile_id) + return d_comp + + def profile_created(profile_id): + id_= profile_id[0][0] + self.profiles[name] = id_ # we synchronise the cache + + d = self.dbpool.runQuery("INSERT INTO profiles(name) VALUES (?)", (name, )) + d.addCallback(getProfileId) + if component is not None: + d.addCallback(setComponent) + d.addCallback(profile_created) + return d + + def deleteProfile(self, name): + """Delete profile + + @param name: name of the profile + @return: deferred triggered once profile is actually deleted + """ + def deletionError(failure_): + log.error(_(u"Can't delete profile [%s]") % name) + return failure_ + + def delete(txn): + profile_id = self.profiles.pop(name) + txn.execute("DELETE FROM profiles WHERE name = ?", (name,)) + # FIXME: the following queries should be done by the ON DELETE CASCADE + # but it seems they are not, so we explicitly do them by security + # this need more investigation + txn.execute("DELETE FROM history WHERE profile_id = ?", (profile_id,)) + txn.execute("DELETE FROM param_ind WHERE profile_id = ?", (profile_id,)) + txn.execute("DELETE FROM private_ind WHERE profile_id = ?", (profile_id,)) + txn.execute("DELETE FROM private_ind_bin WHERE profile_id = ?", (profile_id,)) + txn.execute("DELETE FROM components WHERE profile_id = ?", (profile_id,)) + return None + + d = self.dbpool.runInteraction(delete) + d.addCallback(lambda ignore: log.info(_("Profile [%s] deleted") % name)) + d.addErrback(deletionError) + return d + + ## Params + def loadGenParams(self, params_gen): + """Load general parameters + + @param params_gen: dictionary to fill + @return: deferred + """ + + def fillParams(result): + for param in result: + category, name, value = param + params_gen[(category, name)] = value + log.debug(_(u"loading general parameters from database")) + return self.dbpool.runQuery("SELECT category,name,value FROM param_gen").addCallback(fillParams) + + def loadIndParams(self, params_ind, profile): + """Load individual parameters + + @param params_ind: dictionary to fill + @param profile: a profile which *must* exist + @return: deferred + """ + + def fillParams(result): + for param in result: + category, name, value = param + params_ind[(category, name)] = value + log.debug(_(u"loading individual parameters from database")) + d = self.dbpool.runQuery("SELECT category,name,value FROM param_ind WHERE profile_id=?", (self.profiles[profile], )) + d.addCallback(fillParams) + return d + + def getIndParam(self, category, name, profile): + """Ask database for the value of one specific individual parameter + + @param category: category of the parameter + @param name: name of the parameter + @param profile: %(doc_profile)s + @return: deferred + """ + d = self.dbpool.runQuery("SELECT value FROM param_ind WHERE category=? AND name=? AND profile_id=?", (category, name, self.profiles[profile])) + d.addCallback(self.__getFirstResult) + return d + + def setGenParam(self, category, name, value): + """Save the general parameters in database + + @param category: category of the parameter + @param name: name of the parameter + @param value: value to set + @return: deferred""" + d = self.dbpool.runQuery("REPLACE INTO param_gen(category,name,value) VALUES (?,?,?)", (category, name, value)) + d.addErrback(lambda ignore: log.error(_(u"Can't set general parameter (%(category)s/%(name)s) in database" % {"category": category, "name": name}))) + return d + + def setIndParam(self, category, name, value, profile): + """Save the individual parameters in database + + @param category: category of the parameter + @param name: name of the parameter + @param value: value to set + @param profile: a profile which *must* exist + @return: deferred + """ + d = self.dbpool.runQuery("REPLACE INTO param_ind(category,name,profile_id,value) VALUES (?,?,?,?)", (category, name, self.profiles[profile], value)) + d.addErrback(lambda ignore: log.error(_(u"Can't set individual parameter (%(category)s/%(name)s) for [%(profile)s] in database" % {"category": category, "name": name, "profile": profile}))) + return d + + ## History + + def _addToHistoryCb(self, dummy, data): + # Message metadata were successfuly added to history + # now we can add message and subject + uid = data['uid'] + for key in ('message', 'subject'): + for lang, value in data[key].iteritems(): + d = self.dbpool.runQuery("INSERT INTO {key}(history_uid, {key}, language) VALUES (?,?,?)".format(key=key), + (uid, value, lang or None)) + d.addErrback(lambda dummy: log.error(_(u"Can't save following {key} in history (uid: {uid}, lang:{lang}): {value}".format( + key=key, uid=uid, lang=lang, value=value)))) + try: + thread = data['extra']['thread'] + except KeyError: + pass + else: + thread_parent = data['extra'].get('thread_parent') + d = self.dbpool.runQuery("INSERT INTO thread(history_uid, thread_id, parent_id) VALUES (?,?,?)", + (uid, thread, thread_parent)) + d.addErrback(lambda dummy: log.error(_(u"Can't save following thread in history (uid: {uid}): thread:{thread}), parent:{parent}".format( + uid=uid, thread=thread, parent=thread_parent)))) + + def _addToHistoryEb(self, failure_, data): + failure_.trap(sqlite3.IntegrityError) + sqlite_msg = failure_.value.args[0] + if "UNIQUE constraint failed" in sqlite_msg: + log.debug(u"message {} is already in history, not storing it again".format(data['uid'])) + if 'received_timestamp' not in data: + log.warning(u"duplicate message is not delayed, this is maybe a bug: data={}".format(data)) + # we cancel message to avoid sending duplicate message to frontends + raise failure.Failure(exceptions.CancelError("Cancelled duplicated message")) + else: + log.error(u"Can't store message in history: {}".format(failure_)) + + def _logHistoryError(self, failure_, from_jid, to_jid, data): + if failure_.check(exceptions.CancelError): + # we propagate CancelError to avoid sending message to frontends + raise failure_ + log.error(_(u"Can't save following message in history: from [{from_jid}] to [{to_jid}] (uid: {uid})" + .format(from_jid=from_jid.full(), to_jid=to_jid.full(), uid=data['uid']))) + + def addToHistory(self, data, profile): + """Store a new message in history + + @param data(dict): message data as build by SatMessageProtocol.onMessage + """ + extra = pickle.dumps({k: v for k, v in data['extra'].iteritems() if k not in NOT_IN_EXTRA}, 0) + from_jid = data['from'] + to_jid = data['to'] + d = self.dbpool.runQuery("INSERT INTO history(uid, update_uid, profile_id, source, dest, source_res, dest_res, timestamp, received_timestamp, type, extra) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (data['uid'], data['extra'].get('update_uid'), self.profiles[profile], data['from'].userhost(), to_jid.userhost(), from_jid.resource, to_jid.resource, data['timestamp'], data.get('received_timestamp'), data['type'], sqlite3.Binary(extra))) + d.addCallbacks(self._addToHistoryCb, self._addToHistoryEb, callbackArgs=[data], errbackArgs=[data]) + d.addErrback(self._logHistoryError, from_jid, to_jid, data) + return d + + def sqliteHistoryToList(self, query_result): + """Get SQL query result and return a list of message data dicts""" + result = [] + current = {'uid': None} + for row in reversed(query_result): + uid, update_uid, source, dest, source_res, dest_res, timestamp, received_timestamp,\ + type_, extra, message, message_lang, subject, subject_lang, thread, thread_parent = row + if uid != current['uid']: + # new message + try: + extra = pickle.loads(str(extra or "")) + except EOFError: + extra = {} + current = { + 'from': "%s/%s" % (source, source_res) if source_res else source, + 'to': "%s/%s" % (dest, dest_res) if dest_res else dest, + 'uid': uid, + 'message': {}, + 'subject': {}, + 'type': type_, + 'extra': extra, + 'timestamp': timestamp, + } + if update_uid is not None: + current['extra']['update_uid'] = update_uid + if received_timestamp is not None: + current['extra']['received_timestamp'] = str(received_timestamp) + result.append(current) + + if message is not None: + current['message'][message_lang or ''] = message + + if subject is not None: + current['subject'][subject_lang or ''] = subject + + if thread is not None: + current_extra = current['extra'] + current_extra['thread'] = thread + if thread_parent is not None: + current_extra['thread_parent'] = thread_parent + else: + if thread_parent is not None: + log.error(u"Database inconsistency: thread parent without thread (uid: {uid}, thread_parent: {parent})" + .format(uid=uid, parent=thread_parent)) + + return result + + def listDict2listTuple(self, messages_data): + """Return a list of tuple as used in bridge from a list of messages data""" + ret = [] + for m in messages_data: + ret.append((m['uid'], m['timestamp'], m['from'], m['to'], m['message'], m['subject'], m['type'], m['extra'])) + return ret + + def historyGet(self, from_jid, to_jid, limit=None, between=True, filters=None, profile=None): + """Retrieve messages in history + + @param from_jid (JID): source JID (full, or bare for catchall) + @param to_jid (JID): dest JID (full, or bare for catchall) + @param limit (int): maximum number of messages to get: + - 0 for no message (returns the empty list) + - None for unlimited + @param between (bool): confound source and dest (ignore the direction) + @param search (unicode): pattern to filter the history results + @param profile (unicode): %(doc_profile)s + @return: list of tuple as in [messageNew] + """ + assert profile + if filters is None: + filters = {} + if limit == 0: + return defer.succeed([]) + + query_parts = [u"SELECT uid, update_uid, source, dest, source_res, dest_res, timestamp, received_timestamp,\ + type, extra, message, message.language, subject, subject.language, thread_id, thread.parent_id\ + FROM history LEFT JOIN message ON history.uid = message.history_uid\ + LEFT JOIN subject ON history.uid=subject.history_uid\ + LEFT JOIN thread ON history.uid=thread.history_uid\ + WHERE profile_id=? AND"] # FIXME: not sure if it's the best request, messages and subjects can appear several times here + values = [self.profiles[profile]] + + def test_jid(type_, _jid): + values.append(_jid.userhost()) + if _jid.resource: + values.append(_jid.resource) + return u'(%s=? AND %s_res=?)' % (type_, type_) + return u'%s=?' % (type_, ) + + if between: + query_parts.append(u"((%s AND %s) OR (%s AND %s))" % (test_jid('source', from_jid), + test_jid('dest', to_jid), + test_jid('source', to_jid), + test_jid('dest', from_jid))) + else: + query_parts.append(u"%s AND %s" % (test_jid('source', from_jid), + test_jid('dest', to_jid))) + + if filters: + if 'body' in filters: + # TODO: use REGEXP (function to be defined) instead of GLOB: https://www.sqlite.org/lang_expr.html + query_parts.append(u"AND message LIKE ?") + values.append(u"%{}%".format(filters['body'])) + if 'search' in filters: + query_parts.append(u"AND (message LIKE ? OR source_res LIKE ?)") + values.extend([u"%{}%".format(filters['search'])] * 2) + if 'types' in filters: + types = filters['types'].split() + query_parts.append(u"AND type IN ({})".format(u','.join("?"*len(types)))) + values.extend(types) + if 'not_types' in filters: + types = filters['not_types'].split() + query_parts.append(u"AND type NOT IN ({})".format(u','.join("?"*len(types)))) + values.extend(types) + + + query_parts.append(u"ORDER BY timestamp DESC") # we reverse the order in sqliteHistoryToList + # we use DESC here so LIMIT keep the last messages + if limit is not None: + query_parts.append(u"LIMIT ?") + values.append(limit) + + d = self.dbpool.runQuery(u" ".join(query_parts), values) + d.addCallback(self.sqliteHistoryToList) + d.addCallback(self.listDict2listTuple) + return d + + ## Private values + + def _privateDataEb(self, failure_, operation, namespace, key=None, profile=None): + """generic errback for data queries""" + log.error(_(u"Can't {operation} data in database for namespace {namespace}{and_key}{for_profile}: {msg}").format( + operation = operation, + namespace = namespace, + and_key = (u" and key " + key) if key is not None else u"", + for_profile = (u' [' + profile + u']') if profile is not None else u'', + msg = failure_)) + + def _generateDataDict(self, query_result, binary): + if binary: + return {k: pickle.loads(str(v)) for k,v in query_result} + else: + return dict(query_result) + + def _getPrivateTable(self, binary, profile): + """Get table to use for private values""" + table = [u'private'] + + if profile is None: + table.append(u'gen') + else: + table.append(u'ind') + + if binary: + table.append(u'bin') + + return u'_'.join(table) + + def getPrivates(self, namespace, keys=None, binary=False, profile=None): + """Get private value(s) from databases + + @param namespace(unicode): namespace of the values + @param keys(iterable, None): keys of the values to get + None to get all keys/values + @param binary(bool): True to deserialise binary values + @param profile(unicode, None): profile to use for individual values + None to use general values + @return (dict[unicode, object]): gotten keys/values + """ + log.debug(_(u"getting {type}{binary} private values from database for namespace {namespace}{keys}".format( + type = u"general" if profile is None else "individual", + binary = u" binary" if binary else u"", + namespace = namespace, + keys = u" with keys {}".format(u", ".join(keys)) if keys is not None else u""))) + table = self._getPrivateTable(binary, profile) + query_parts = [u"SELECT key,value FROM", table, "WHERE namespace=?"] + args = [namespace] + + if keys is not None: + placeholders = u','.join(len(keys) * u'?') + query_parts.append(u'AND key IN (' + placeholders + u')') + args.extend(keys) + + if profile is not None: + query_parts.append(u'AND profile_id=?') + args.append(self.profiles[profile]) + + d = self.dbpool.runQuery(u" ".join(query_parts), args) + d.addCallback(self._generateDataDict, binary) + d.addErrback(self._privateDataEb, u"get", namespace, profile=profile) + return d + + def setPrivateValue(self, namespace, key, value, binary=False, profile=None): + """Set a private value in database + + @param namespace(unicode): namespace of the values + @param key(unicode): key of the value to set + @param value(object): value to set + @param binary(bool): True if it's a binary values + binary values need to be serialised, used for everything but strings + @param profile(unicode, None): profile to use for individual value + if None, it's a general value + """ + table = self._getPrivateTable(binary, profile) + query_values_names = [u'namespace', u'key', u'value'] + query_values = [namespace, key] + + if binary: + value = sqlite3.Binary(pickle.dumps(value, 0)) + + query_values.append(value) + + if profile is not None: + query_values_names.append(u'profile_id') + query_values.append(self.profiles[profile]) + + query_parts = [u"REPLACE INTO", table, u'(', u','.join(query_values_names), u')', + u"VALUES (", u",".join(u'?'*len(query_values_names)), u')'] + + d = self.dbpool.runQuery(u" ".join(query_parts), query_values) + d.addErrback(self._privateDataEb, u"set", namespace, key, profile=profile) + return d + + def delPrivateValue(self, namespace, key, binary=False, profile=None): + """Delete private value from database + + @param category: category of the privateeter + @param key: key of the private value + @param binary(bool): True if it's a binary values + @param profile(unicode, None): profile to use for individual value + if None, it's a general value + """ + table = self._getPrivateTable(binary, profile) + query_parts = [u"DELETE FROM", table, u"WHERE namespace=? AND key=?"] + args = [namespace, key] + if profile is not None: + query_parts.append(u"AND profile_id=?") + args.append(self.profiles[profile]) + d = self.dbpool.runQuery(u" ".join(query_parts), args) + d.addErrback(self._privateDataEb, u"delete", namespace, key, profile=profile) + return d + + ## Files + + @defer.inlineCallbacks + def getFiles(self, client, file_id=None, version=u'', parent=None, type_=None, + file_hash=None, hash_algo=None, name=None, namespace=None, mime_type=None, + owner=None, access=None, projection=None, unique=False): + """retrieve files with with given filters + + @param file_id(unicode, None): id of the file + None to ignore + @param version(unicode, None): version of the file + None to ignore + empty string to look for current version + @param parent(unicode, None): id of the directory containing the files + None to ignore + empty string to look for root files/directories + @param projection(list[unicode], None): name of columns to retrieve + None to retrieve all + @param unique(bool): if True will remove duplicates + other params are the same as for [setFile] + @return (list[dict]): files corresponding to filters + """ + query_parts = ["SELECT"] + if unique: + query_parts.append('DISTINCT') + if projection is None: + projection = ['id', 'version', 'parent', 'type', 'file_hash', 'hash_algo', 'name', + 'size', 'namespace', 'mime_type', 'created', 'modified', 'owner', + 'access', 'extra'] + query_parts.append(','.join(projection)) + query_parts.append("FROM files WHERE") + filters = ['profile_id=?'] + args = [self.profiles[client.profile]] + + if file_id is not None: + filters.append(u'id=?') + args.append(file_id) + if version is not None: + filters.append(u'version=?') + args.append(version) + if parent is not None: + filters.append(u'parent=?') + args.append(parent) + if type_ is not None: + filters.append(u'type=?') + args.append(type_) + if file_hash is not None: + filters.append(u'file_hash=?') + args.append(file_hash) + if hash_algo is not None: + filters.append(u'hash_algo=?') + args.append(hash_algo) + if name is not None: + filters.append(u'name=?') + args.append(name) + if namespace is not None: + filters.append(u'namespace=?') + args.append(namespace) + if mime_type is not None: + filters.append(u'mime_type=?') + args.append(mime_type) + if owner is not None: + filters.append(u'owner=?') + args.append(owner.full()) + if access is not None: + raise NotImplementedError('Access check is not implemented yet') + # a JSON comparison is needed here + + filters = u' AND '.join(filters) + query_parts.append(filters) + query = u' '.join(query_parts) + + result = yield self.dbpool.runQuery(query, args) + files_data = [dict(zip(projection, row)) for row in result] + to_parse = {'access', 'extra'}.intersection(projection) + to_filter = {'owner'}.intersection(projection) + if to_parse or to_filter: + for file_data in files_data: + for key in to_parse: + value = file_data[key] + file_data[key] = {} if value is None else json.loads(value) + owner = file_data.get('owner') + if owner is not None: + file_data['owner'] = jid.JID(owner) + defer.returnValue(files_data) + + def setFile(self, client, name, file_id, version=u'', parent=None, type_=C.FILE_TYPE_FILE, + file_hash=None, hash_algo=None, size=None, namespace=None, mime_type=None, + created=None, modified=None, owner=None, access=None, extra=None): + """set a file metadata + + @param client(SatXMPPClient): client owning the file + @param name(unicode): name of the file (must not contain "/") + @param file_id(unicode): unique id of the file + @param version(unicode): version of this file + @param parent(unicode): id of the directory containing this file + None if it is a root file/directory + @param type_(unicode): one of: + - file + - directory + @param file_hash(unicode): unique hash of the payload + @param hash_algo(unicode): algorithm used for hashing the file (usually sha-256) + @param size(int): size in bytes + @param namespace(unicode, None): identifier (human readable is better) to group files + for instance, namespace could be used to group files in a specific photo album + @param mime_type(unicode): MIME type of the file, or None if not known/guessed + @param created(int): UNIX time of creation + @param modified(int,None): UNIX time of last modification, or None to use created date + @param owner(jid.JID, None): jid of the owner of the file (mainly useful for component) + @param access(dict, None): serialisable dictionary with access rules. See [memory.memory] for details + @param extra(dict, None): serialisable dictionary of any extra data + will be encoded to json in database + """ + if extra is not None: + assert isinstance(extra, dict) + query = ('INSERT INTO files(id, version, parent, type, file_hash, hash_algo, name, size, namespace, ' + 'mime_type, created, modified, owner, access, extra, profile_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + d = self.dbpool.runQuery(query, (file_id, version.strip(), parent, type_, + file_hash, hash_algo, + name, size, namespace, + mime_type, created, modified, + owner.full() if owner is not None else None, + json.dumps(access) if access else None, + json.dumps(extra) if extra else None, + self.profiles[client.profile])) + d.addErrback(lambda failure: log.error(_(u"Can't save file metadata for [{profile}]: {reason}".format(profile=client.profile, reason=failure)))) + return d + + def _fileUpdate(self, cursor, file_id, column, update_cb): + query = 'SELECT {column} FROM files where id=?'.format(column=column) + for i in xrange(5): + cursor.execute(query, [file_id]) + try: + older_value_raw = cursor.fetchone()[0] + except TypeError: + raise exceptions.NotFound + value = json.loads(older_value_raw) + update_cb(value) + value_raw = json.dumps(value) + update_query = 'UPDATE files SET {column}=? WHERE id=? AND {column}=?'.format(column=column) + update_args = (value_raw, file_id, older_value_raw) + try: + cursor.execute(update_query, update_args) + except sqlite3.Error: + pass + else: + if cursor.rowcount == 1: + break; + log.warning(_(u"table not updated, probably due to race condition, trying again ({tries})").format(tries=i+1)) + else: + log.error(_(u"Can't update file table")) + + def fileUpdate(self, file_id, column, update_cb): + """update a column value using a method to avoid race conditions + + the older value will be retrieved from database, then update_cb will be applied + to update it, and file will be updated checking that older value has not been changed meanwhile + by an other user. If it has changed, it tries again a couple of times before failing + @param column(str): column name (only "access" or "extra" are allowed) + @param update_cb(callable): method to update the value of the colum + the method will take older value as argument, and must update it in place + update_cb must not care about serialization, + it get the deserialized data (i.e. a Python object) directly + Note that the callable must be thread-safe + @raise exceptions.NotFound: there is not file with this id + """ + if column not in ('access', 'extra'): + raise exceptions.InternalError('bad column name') + return self.dbpool.runInteraction(self._fileUpdate, file_id, column, update_cb) + + ##Helper methods## + + def __getFirstResult(self, result): + """Return the first result of a database query + Useful when we are looking for one specific value""" + return None if not result else result[0][0] + + +class Updater(object): + stmnt_regex = re.compile(r"[\w/' ]+(?:\(.*?\))?[^,]*") + clean_regex = re.compile(r"^ +|(?<= ) +|(?<=,) +| +$") + CREATE_SQL = "CREATE TABLE %s (%s)" + INSERT_SQL = "INSERT INTO %s VALUES (%s)" + DROP_SQL = "DROP TABLE %s" + ALTER_SQL = "ALTER TABLE %s ADD COLUMN %s" + RENAME_TABLE_SQL = "ALTER TABLE %s RENAME TO %s" + + CONSTRAINTS = ('PRIMARY', 'UNIQUE', 'CHECK', 'FOREIGN') + TMP_TABLE = "tmp_sat_update" + + def __init__(self, dbpool, sat_version): + self._sat_version = sat_version + self.dbpool = dbpool + + def getLocalVersion(self): + """ Get local database version + + @return: version (int) + """ + return self.dbpool.runQuery("PRAGMA user_version").addCallback(lambda ret: int(ret[0][0])) + + def _setLocalVersion(self, version): + """ Set local database version + + @param version: version (int) + @return: deferred + """ + return self.dbpool.runOperation("PRAGMA user_version=%d" % version) + + def getLocalSchema(self): + """ return raw local schema + + @return: list of strings with CREATE sql statements for local database + """ + d = self.dbpool.runQuery("select sql from sqlite_master where type = 'table'") + d.addCallback(lambda result: [row[0] for row in result]) + return d + + @defer.inlineCallbacks + def checkUpdates(self): + """ Check if a database schema/content update is needed, according to DATABASE_SCHEMAS + + @return: deferred which fire a list of SQL update statements, or None if no update is needed + """ + local_version = yield self.getLocalVersion() + raw_local_sch = yield self.getLocalSchema() + + local_sch = self.rawStatements2data(raw_local_sch) + current_sch = DATABASE_SCHEMAS['current']['CREATE'] + local_hash = self.statementHash(local_sch) + current_hash = self.statementHash(current_sch) + + # Force the update if the schemas are unchanged but a specific update is needed + force_update = local_hash == current_hash and local_version < CURRENT_DB_VERSION \ + and 'specific' in DATABASE_SCHEMAS[CURRENT_DB_VERSION] + + if local_hash == current_hash and not force_update: + if local_version != CURRENT_DB_VERSION: + log.warning(_("Your local schema is up-to-date, but database versions mismatch, fixing it...")) + yield self._setLocalVersion(CURRENT_DB_VERSION) + else: + # an update is needed + + if local_version == CURRENT_DB_VERSION: + # Database mismatch and we have the latest version + if self._sat_version.endswith('D'): + # we are in a development version + update_data = self.generateUpdateData(local_sch, current_sch, False) + log.warning(_("There is a schema mismatch, but as we are on a dev version, database will be updated")) + update_raw = yield self.update2raw(update_data, True) + defer.returnValue(update_raw) + else: + log.error(_(u"schema version is up-to-date, but local schema differ from expected current schema")) + update_data = self.generateUpdateData(local_sch, current_sch, True) + update_raw = yield self.update2raw(update_data) + log.warning(_(u"Here are the commands that should fix the situation, use at your own risk (do a backup before modifying database), you can go to SàT's MUC room at sat@chat.jabberfr.org for help\n### SQL###\n%s\n### END SQL ###\n") % u'\n'.join("%s;" % statement for statement in update_raw)) + raise exceptions.DatabaseError("Database mismatch") + else: + # Database is not up-to-date, we'll do the update + if force_update: + log.info(_("Database content needs a specific processing, local database will be updated")) + else: + log.info(_("Database schema has changed, local database will be updated")) + update_raw = [] + for version in xrange(local_version + 1, CURRENT_DB_VERSION + 1): + try: + update_data = DATABASE_SCHEMAS[version] + except KeyError: + raise exceptions.InternalError("Missing update definition (version %d)" % version) + update_raw_step = yield self.update2raw(update_data) + update_raw.extend(update_raw_step) + update_raw.append("PRAGMA user_version=%d" % CURRENT_DB_VERSION) + defer.returnValue(update_raw) + + @staticmethod + def createData2Raw(data): + """ Generate SQL statements from statements data + + @param data: dictionary with table as key, and statements data in tuples as value + @return: list of strings with raw statements + """ + ret = [] + for table in data: + defs, constraints = data[table] + assert isinstance(defs, tuple) + assert isinstance(constraints, tuple) + ret.append(Updater.CREATE_SQL % (table, ', '.join(defs + constraints))) + return ret + + @staticmethod + def insertData2Raw(data): + """ Generate SQL statements from statements data + + @param data: dictionary with table as key, and statements data in tuples as value + @return: list of strings with raw statements + """ + ret = [] + for table in data: + values_tuple = data[table] + assert isinstance(values_tuple, tuple) + for values in values_tuple: + assert isinstance(values, tuple) + ret.append(Updater.INSERT_SQL % (table, ', '.join(values))) + return ret + + def statementHash(self, data): + """ Generate hash of template data + + useful to compare schemas + @param data: dictionary of "CREATE" statement, with tables names as key, + and tuples of (col_defs, constraints) as values + @return: hash as string + """ + hash_ = hashlib.sha1() + tables = data.keys() + tables.sort() + + def stmnts2str(stmts): + return ','.join([self.clean_regex.sub('',stmt) for stmt in sorted(stmts)]) + + for table in tables: + col_defs, col_constr = data[table] + hash_.update("%s:%s:%s" % (table, stmnts2str(col_defs), stmnts2str(col_constr))) + return hash_.digest() + + def rawStatements2data(self, raw_statements): + """ separate "CREATE" statements into dictionary/tuples data + + @param raw_statements: list of CREATE statements as strings + @return: dictionary with table names as key, and a (col_defs, constraints) tuple + """ + schema_dict = {} + for create_statement in raw_statements: + if not create_statement.startswith("CREATE TABLE "): + log.warning("Unexpected statement, ignoring it") + continue + _create_statement = create_statement[13:] + table, raw_col_stats = _create_statement.split(' ',1) + if raw_col_stats[0] != '(' or raw_col_stats[-1] != ')': + log.warning("Unexpected statement structure, ignoring it") + continue + col_stats = [stmt.strip() for stmt in self.stmnt_regex.findall(raw_col_stats[1:-1])] + col_defs = [] + constraints = [] + for col_stat in col_stats: + name = col_stat.split(' ',1)[0] + if name in self.CONSTRAINTS: + constraints.append(col_stat) + else: + col_defs.append(col_stat) + schema_dict[table] = (tuple(col_defs), tuple(constraints)) + return schema_dict + + def generateUpdateData(self, old_data, new_data, modify=False): + """ Generate data for automatic update between two schema data + + @param old_data: data of the former schema (which must be updated) + @param new_data: data of the current schema + @param modify: if True, always use "cols modify" table, else try to ALTER tables + @return: update data, a dictionary with: + - 'create': dictionary of tables to create + - 'delete': tuple of tables to delete + - 'cols create': dictionary of columns to create (table as key, tuple of columns to create as value) + - 'cols delete': dictionary of columns to delete (table as key, tuple of columns to delete as value) + - 'cols modify': dictionary of columns to modify (table as key, tuple of old columns to transfert as value). With this table, a new table will be created, and content from the old table will be transfered to it, only cols specified in the tuple will be transfered. + """ + + create_tables_data = {} + create_cols_data = {} + modify_cols_data = {} + delete_cols_data = {} + old_tables = set(old_data.keys()) + new_tables = set(new_data.keys()) + + def getChanges(set_olds, set_news): + to_create = set_news.difference(set_olds) + to_delete = set_olds.difference(set_news) + to_check = set_news.intersection(set_olds) + return tuple(to_create), tuple(to_delete), tuple(to_check) + + tables_to_create, tables_to_delete, tables_to_check = getChanges(old_tables, new_tables) + + for table in tables_to_create: + create_tables_data[table] = new_data[table] + + for table in tables_to_check: + old_col_defs, old_constraints = old_data[table] + new_col_defs, new_constraints = new_data[table] + for obj in old_col_defs, old_constraints, new_col_defs, new_constraints: + if not isinstance(obj, tuple): + raise exceptions.InternalError("Columns definitions must be tuples") + defs_create, defs_delete, ignore = getChanges(set(old_col_defs), set(new_col_defs)) + constraints_create, constraints_delete, ignore = getChanges(set(old_constraints), set(new_constraints)) + created_col_names = set([name.split(' ',1)[0] for name in defs_create]) + deleted_col_names = set([name.split(' ',1)[0] for name in defs_delete]) + if (created_col_names.intersection(deleted_col_names or constraints_create or constraints_delete) or + (modify and (defs_create or constraints_create or defs_delete or constraints_delete))): + # we have modified columns, we need to transfer table + # we determinate which columns are in both schema so we can transfer them + old_names = set([name.split(' ',1)[0] for name in old_col_defs]) + new_names = set([name.split(' ',1)[0] for name in new_col_defs]) + modify_cols_data[table] = tuple(old_names.intersection(new_names)); + else: + if defs_create: + create_cols_data[table] = (defs_create) + if defs_delete or constraints_delete: + delete_cols_data[table] = (defs_delete) + + return {'create': create_tables_data, + 'delete': tables_to_delete, + 'cols create': create_cols_data, + 'cols delete': delete_cols_data, + 'cols modify': modify_cols_data + } + + @defer.inlineCallbacks + def update2raw(self, update, dev_version=False): + """ Transform update data to raw SQLite statements + + @param update: update data as returned by generateUpdateData + @param dev_version: if True, update will be done in dev mode: no deletion will be done, instead a message will be shown. This prevent accidental lost of data while working on the code/database. + @return: list of string with SQL statements needed to update the base + """ + ret = self.createData2Raw(update.get('create', {})) + drop = [] + for table in update.get('delete', tuple()): + drop.append(self.DROP_SQL % table) + if dev_version: + if drop: + log.info("Dev version, SQL NOT EXECUTED:\n--\n%s\n--\n" % "\n".join(drop)) + else: + ret.extend(drop) + + cols_create = update.get('cols create', {}) + for table in cols_create: + for col_def in cols_create[table]: + ret.append(self.ALTER_SQL % (table, col_def)) + + cols_delete = update.get('cols delete', {}) + for table in cols_delete: + log.info("Following columns in table [%s] are not needed anymore, but are kept for dev version: %s" % (table, ", ".join(cols_delete[table]))) + + cols_modify = update.get('cols modify', {}) + for table in cols_modify: + ret.append(self.RENAME_TABLE_SQL % (table, self.TMP_TABLE)) + main, extra = DATABASE_SCHEMAS['current']['CREATE'][table] + ret.append(self.CREATE_SQL % (table, ', '.join(main + extra))) + common_cols = ', '.join(cols_modify[table]) + ret.append("INSERT INTO %s (%s) SELECT %s FROM %s" % (table, common_cols, common_cols, self.TMP_TABLE)) + ret.append(self.DROP_SQL % self.TMP_TABLE) + + insert = update.get('insert', {}) + ret.extend(self.insertData2Raw(insert)) + + specific = update.get('specific', None) + if specific: + cmds = yield getattr(self, specific)() + ret.extend(cmds or []) + defer.returnValue(ret) + + @defer.inlineCallbacks + def update_v3(self): + """Update database from v2 to v3 (message refactoring)""" + # XXX: this update do all the messages in one huge transaction + # this is really memory consuming, but was OK on a reasonably + # big database for tests. If issues are happening, we can cut it + # in smaller transactions using LIMIT and by deleting already updated + # messages + log.info(u"Database update to v3, this may take a while") + + # we need to fix duplicate timestamp, as it can result in conflicts with the new schema + rows = yield self.dbpool.runQuery("SELECT timestamp, COUNT(*) as c FROM history GROUP BY timestamp HAVING c>1") + if rows: + log.info("fixing duplicate timestamp") + fixed = [] + for timestamp, dummy in rows: + ids_rows = yield self.dbpool.runQuery("SELECT id from history where timestamp=?", (timestamp,)) + for idx, (id_,) in enumerate(ids_rows): + fixed.append(id_) + yield self.dbpool.runQuery("UPDATE history SET timestamp=? WHERE id=?", (float(timestamp) + idx * 0.001, id_)) + log.info(u"fixed messages with ids {}".format(u', '.join([unicode(id_) for id_ in fixed]))) + + def historySchema(txn): + log.info(u"History schema update") + txn.execute("ALTER TABLE history RENAME TO tmp_sat_update") + txn.execute("CREATE TABLE history (uid TEXT PRIMARY KEY, update_uid TEXT, profile_id INTEGER, source TEXT, dest TEXT, source_res TEXT, dest_res TEXT, timestamp DATETIME NOT NULL, received_timestamp DATETIME, type TEXT, extra BLOB, FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE, FOREIGN KEY(type) REFERENCES message_types(type), UNIQUE (profile_id, timestamp, source, dest, source_res, dest_res))") + txn.execute("INSERT INTO history (uid, profile_id, source, dest, source_res, dest_res, timestamp, type, extra) SELECT id, profile_id, source, dest, source_res, dest_res, timestamp, type, extra FROM tmp_sat_update") + + yield self.dbpool.runInteraction(historySchema) + + def newTables(txn): + log.info(u"Creating new tables") + txn.execute("CREATE TABLE message (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, message TEXT, language TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + txn.execute("CREATE TABLE thread (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, thread_id TEXT, parent_id TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + txn.execute("CREATE TABLE subject (id INTEGER PRIMARY KEY ASC, history_uid INTEGER, subject TEXT, language TEXT, FOREIGN KEY(history_uid) REFERENCES history(uid) ON DELETE CASCADE)") + + yield self.dbpool.runInteraction(newTables) + + log.info(u"inserting new message type") + yield self.dbpool.runQuery("INSERT INTO message_types VALUES (?)", ('info',)) + + log.info(u"messages update") + rows = yield self.dbpool.runQuery("SELECT id, timestamp, message, extra FROM tmp_sat_update") + total = len(rows) + + def updateHistory(txn, queries): + for query, args in iter(queries): + txn.execute(query, args) + + queries = [] + for idx, row in enumerate(rows, 1): + if idx % 1000 == 0 or total - idx == 0: + log.info("preparing message {}/{}".format(idx, total)) + id_, timestamp, message, extra = row + try: + extra = pickle.loads(str(extra or "")) + except EOFError: + extra = {} + except Exception: + log.warning(u"Can't handle extra data for message id {}, ignoring it".format(id_)) + extra = {} + + queries.append(("INSERT INTO message(history_uid, message) VALUES (?,?)", (id_, message))) + + try: + subject = extra.pop('subject') + except KeyError: + pass + else: + try: + subject = subject.decode('utf-8') + except UnicodeEncodeError: + log.warning(u"Error while decoding subject, ignoring it") + del extra['subject'] + else: + queries.append(("INSERT INTO subject(history_uid, subject) VALUES (?,?)", (id_, subject))) + + received_timestamp = extra.pop('timestamp', None) + try: + del extra['archive'] + except KeyError: + # archive was not used + pass + + queries.append(("UPDATE history SET received_timestamp=?,extra=? WHERE uid=?",(id_, received_timestamp, sqlite3.Binary(pickle.dumps(extra, 0))))) + + yield self.dbpool.runInteraction(updateHistory, queries) + + log.info("Dropping temporary table") + yield self.dbpool.runQuery("DROP TABLE tmp_sat_update") + log.info("Database update finished :)") + + def update2raw_v2(self): + """Update the database from v1 to v2 (add passwords encryptions): + + - the XMPP password value is re-used for the profile password (new parameter) + - the profile password is stored hashed + - the XMPP password is stored encrypted, with the profile password as key + - as there are no other stored passwords yet, it is enough, otherwise we + would need to encrypt the other passwords as it's done for XMPP password + """ + xmpp_pass_path = ('Connection', 'Password') + + def encrypt_values(values): + ret = [] + list_ = [] + + def prepare_queries(result, xmpp_password): + try: + id_ = result[0][0] + except IndexError: + log.error(u"Profile of id %d is referenced in 'param_ind' but it doesn't exist!" % profile_id) + return defer.succeed(None) + + sat_password = xmpp_password + d1 = PasswordHasher.hash(sat_password) + personal_key = BlockCipher.getRandomKey(base64=True) + d2 = BlockCipher.encrypt(sat_password, personal_key) + d3 = BlockCipher.encrypt(personal_key, xmpp_password) + + def gotValues(res): + sat_cipher, personal_cipher, xmpp_cipher = res[0][1], res[1][1], res[2][1] + ret.append("INSERT INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (C.PROFILE_PASS_PATH[0], C.PROFILE_PASS_PATH[1], id_, sat_cipher)) + + ret.append("INSERT INTO private_ind(namespace,key,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (C.MEMORY_CRYPTO_NAMESPACE, C.MEMORY_CRYPTO_KEY, id_, personal_cipher)) + + ret.append("REPLACE INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (xmpp_pass_path[0], xmpp_pass_path[1], id_, xmpp_cipher)) + + return defer.DeferredList([d1, d2, d3]).addCallback(gotValues) + + for profile_id, xmpp_password in values: + d = self.dbpool.runQuery("SELECT id FROM profiles WHERE id=?", (profile_id,)) + d.addCallback(prepare_queries, xmpp_password) + list_.append(d) + + d_list = defer.DeferredList(list_) + d_list.addCallback(lambda dummy: ret) + return d_list + + def updateLiberviaConf(values): + try: + profile_id = values[0][0] + except IndexError: + return # no profile called "libervia" + + def cb(selected): + try: + password = selected[0][0] + except IndexError: + log.error("Libervia profile exists but no password is set! Update Libervia configuration will be skipped.") + return + fixConfigOption('libervia', 'passphrase', password, False) + d = self.dbpool.runQuery("SELECT value FROM param_ind WHERE category=? AND name=? AND profile_id=?", xmpp_pass_path + (profile_id,)) + return d.addCallback(cb) + + d = self.dbpool.runQuery("SELECT id FROM profiles WHERE name='libervia'") + d.addCallback(updateLiberviaConf) + d.addCallback(lambda dummy: self.dbpool.runQuery("SELECT profile_id,value FROM param_ind WHERE category=? AND name=?", xmpp_pass_path)) + d.addCallback(encrypt_values) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/__init__.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,7 @@ +# FIXME: remove this when RSM and MAM are in wokkel +# XXX: the Monkey Patch is here and not in src/__init__ to avoir issues with pyjamas compilation +import wokkel +from sat_tmp.wokkel import pubsub as tmp_pubsub, rsm as tmp_rsm, mam as tmp_mam +wokkel.pubsub = tmp_pubsub +wokkel.rsm = tmp_rsm +wokkel.mam = tmp_mam diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_adhoc_dbus.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_adhoc_dbus.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,222 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for adding D-Bus to Ad-Hoc Commands +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.internet import defer +from wokkel import data_form +try: + from lxml import etree +except ImportError: + raise exceptions.MissingModule(u"Missing module lxml, please download/install it from http://lxml.de/") +import os.path +import uuid +import dbus +from dbus.mainloop.glib import DBusGMainLoop +DBusGMainLoop(set_as_default=True) + +FD_NAME = "org.freedesktop.DBus" +FD_PATH = "/org/freedekstop/DBus" +INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable" + +INTROSPECT_METHOD = "Introspect" +IGNORED_IFACES_START = ('org.freedesktop', 'org.qtproject', 'org.kde.KMainWindow') # commands in interface starting with these values will be ignored +FLAG_LOOP = 'LOOP' + +PLUGIN_INFO = { + C.PI_NAME: "Ad-Hoc Commands - D-Bus", + C.PI_IMPORT_NAME: "AD_HOC_DBUS", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0050"], + C.PI_MAIN: "AdHocDBus", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands""") +} + + +class AdHocDBus(object): + + def __init__(self, host): + log.info(_("plugin Ad-Hoc D-Bus initialization")) + self.host = host + host.bridge.addMethod("adHocDBusAddAuto", ".plugin", in_sign='sasasasasasass', out_sign='(sa(sss))', + method=self._adHocDBusAddAuto, + async=True) + self.session_bus = dbus.SessionBus() + self.fd_object = self.session_bus.get_object(FD_NAME, FD_PATH, introspect=False) + self.XEP_0050 = host.plugins['XEP-0050'] + + def _DBusAsyncCall(self, proxy, method, *args, **kwargs): + """ Call a DBus method asynchronously and return a deferred + @param proxy: DBus object proxy, as returner by get_object + @param method: name of the method to call + @param args: will be transmitted to the method + @param kwargs: will be transmetted to the method, except for the following poped values: + - interface: name of the interface to use + @return: a deferred + + """ + d = defer.Deferred() + interface = kwargs.pop('interface', None) + kwargs['reply_handler'] = lambda ret=None: d.callback(ret) + kwargs['error_handler'] = d.errback + proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs) + return d + + def _DBusListNames(self): + return self._DBusAsyncCall(self.fd_object, "ListNames") + + def _DBusIntrospect(self, proxy): + return self._DBusAsyncCall(proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE) + + def _acceptMethod(self, method): + """ Return True if we accept the method for a command + @param method: etree.Element + @return: True if the method is acceptable + + """ + if method.xpath("arg[@direction='in']"): # we don't accept method with argument for the moment + return False + return True + + @defer.inlineCallbacks + def _introspect(self, methods, bus_name, proxy): + log.debug("introspecting path [%s]" % proxy.object_path) + introspect_xml = yield self._DBusIntrospect(proxy) + el = etree.fromstring(introspect_xml) + for node in el.iterchildren('node', 'interface'): + if node.tag == 'node': + new_path = os.path.join(proxy.object_path, node.get('name')) + new_proxy = self.session_bus.get_object(bus_name, new_path, introspect=False) + yield self._introspect(methods, bus_name, new_proxy) + elif node.tag == 'interface': + name = node.get('name') + if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START): + log.debug('interface [%s] is ignored' % name) + continue + log.debug("introspecting interface [%s]" % name) + for method in node.iterchildren('method'): + if self._acceptMethod(method): + method_name = method.get('name') + log.debug("method accepted: [%s]" % method_name) + methods.add((proxy.object_path, name, method_name)) + + def _adHocDBusAddAuto(self, prog_name, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, flags, profile_key): + return self.adHocDBusAddAuto(prog_name, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, flags, profile_key) + + @defer.inlineCallbacks + def adHocDBusAddAuto(self, prog_name, allowed_jids=None, allowed_groups=None, allowed_magics=None, forbidden_jids=None, forbidden_groups=None, flags=None, profile_key=C.PROF_KEY_NONE): + bus_names = yield self._DBusListNames() + bus_names = [bus_name for bus_name in bus_names if '.' + prog_name in bus_name] + if not bus_names: + log.info("Can't find any bus for [%s]" % prog_name) + defer.returnValue(("", [])) + bus_names.sort() + for bus_name in bus_names: + if bus_name.endswith(prog_name): + break + log.info("bus name found: [%s]" % bus_name) + proxy = self.session_bus.get_object(bus_name, '/', introspect=False) + methods = set() + + yield self._introspect(methods, bus_name, proxy) + + if methods: + self._addCommand(prog_name, bus_name, methods, + allowed_jids = allowed_jids, + allowed_groups = allowed_groups, + allowed_magics = allowed_magics, + forbidden_jids = forbidden_jids, + forbidden_groups = forbidden_groups, + flags = flags, + profile_key = profile_key) + + defer.returnValue((bus_name, methods)) + + + def _addCommand(self, adhoc_name, bus_name, methods, allowed_jids=None, allowed_groups=None, allowed_magics=None, forbidden_jids=None, forbidden_groups=None, flags=None, profile_key=C.PROF_KEY_NONE): + if flags is None: + flags = set() + + def DBusCallback(command_elt, session_data, action, node, profile): + actions = session_data.setdefault('actions',[]) + names_map = session_data.setdefault('names_map', {}) + actions.append(action) + + if len(actions) == 1: + # it's our first request, we ask the desired new status + status = self.XEP_0050.STATUS.EXECUTING + form = data_form.Form('form', title=_('Command selection')) + options = [] + for path, iface, command in methods: + label = command.rsplit('.',1)[-1] + name = str(uuid.uuid4()) + names_map[name] = (path, iface, command) + options.append(data_form.Option(name, label)) + + field = data_form.Field('list-single', 'command', options=options, required=True) + form.addField(field) + + payload = form.toElement() + note = None + + elif len(actions) == 2: + # we should have the answer here + try: + x_elt = command_elt.elements(data_form.NS_X_DATA,'x').next() + answer_form = data_form.Form.fromElement(x_elt) + command = answer_form['command'] + except (KeyError, StopIteration): + raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD) + + if command not in names_map: + raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.BAD_PAYLOAD) + + path, iface, command = names_map[command] + proxy = self.session_bus.get_object(bus_name, path) + + self._DBusAsyncCall(proxy, command, interface=iface) + + # job done, we can end the session, except if we have FLAG_LOOP + if FLAG_LOOP in flags: + # We have a loop, so we clear everything and we execute again the command as we had a first call (command_elt is not used, so None is OK) + del actions[:] + names_map.clear() + return DBusCallback(None, session_data, self.XEP_0050.ACTION.EXECUTE, node, profile) + form = data_form.Form('form', title=_(u'Updated')) + form.addField(data_form.Field('fixed', u'Command sent')) + status = self.XEP_0050.STATUS.COMPLETED + payload = None + note = (self.XEP_0050.NOTE.INFO, _(u"Command sent")) + else: + raise self.XEP_0050.AdHocError(self.XEP_0050.ERROR.INTERNAL) + + return (payload, status, None, note) + + self.XEP_0050.addAdHocCommand(DBusCallback, adhoc_name, + allowed_jids = allowed_jids, + allowed_groups = allowed_groups, + allowed_magics = allowed_magics, + forbidden_jids = forbidden_jids, + forbidden_groups = forbidden_groups, + profile_key = profile_key) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_blog_import.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_blog_import.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,274 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for import external blogs +# 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 . + + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from twisted.web import client as web_client +from twisted.words.xish import domish +from sat.core import exceptions +from sat.tools import xml_tools +import os +import os.path +import tempfile +import urlparse +import shortuuid + + +PLUGIN_INFO = { + C.PI_NAME: "blog import", + C.PI_IMPORT_NAME: "BLOG_IMPORT", + C.PI_TYPE: (C.PLUG_TYPE_BLOG, C.PLUG_TYPE_IMPORT), + C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "TEXT-SYNTAXES", "UPLOAD"], + C.PI_MAIN: "BlogImportPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Blog import management: +This plugin manage the different blog importers which can register to it, and handle generic importing tasks.""") +} + +OPT_HOST = 'host' +OPT_UPLOAD_IMAGES = 'upload_images' +OPT_UPLOAD_IGNORE_HOST = 'upload_ignore_host' +OPT_IGNORE_TLS = 'ignore_tls_errors' +URL_REDIRECT_PREFIX = 'url_redirect_' + + +class BlogImportPlugin(object): + BOOL_OPTIONS = (OPT_UPLOAD_IMAGES, OPT_IGNORE_TLS) + JSON_OPTIONS = () + OPT_DEFAULTS = {OPT_UPLOAD_IMAGES: True, + OPT_IGNORE_TLS: False} + + def __init__(self, host): + log.info(_("plugin Blog Import initialization")) + self.host = host + self._u = host.plugins['UPLOAD'] + self._p = host.plugins['XEP-0060'] + self._m = host.plugins['XEP-0277'] + self._s = self.host.plugins['TEXT-SYNTAXES'] + host.plugins['IMPORT'].initialize(self, u'blog') + + def importItem(self, client, item_import_data, session, options, return_data, service, node): + """importItem specialized for blog import + + @param item_import_data(dict): + * mandatory keys: + 'blog' (dict): microblog data of the blog post (cf. http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en) + the importer MUST NOT create node or call XEP-0277 plugin itself + 'comments*' key MUST NOT be used in this microblog_data, see bellow for comments + It is recommanded to use a unique id in the "id" key which is constant per blog item, + so if the import fail, a new import will overwrite the failed items and avoid duplicates. + + 'comments' (list[list[dict]],None): Dictionaries must have the same keys as main item (i.e. 'blog' and 'comments') + a list of list is used because XEP-0277 can handler several comments nodes, + but in most cases, there will we only one item it the first list (something like [[{comment1_data},{comment2_data}, ...]]) + blog['allow_comments'] must be True if there is any comment, and False (or not present) if comments are not allowed. + If allow_comments is False and some comments are present, an exceptions.DataError will be raised + * optional keys: + 'url' (unicode): former url of the post (only the path, without host part) + if present the association to the new path will be displayed to user, so it can make redirections if necessary + @param options(dict, None): Below are the generic options, + blog importer can have specific ones. All options have unicode values + generic options: + - OPT_HOST (unicode): original host + - OPT_UPLOAD_IMAGES (bool): upload images to XMPP server if True + see OPT_UPLOAD_IGNORE_HOST. + Default: True + - OPT_UPLOAD_IGNORE_HOST (unicode): don't upload images from this host + - OPT_IGNORE_TLS (bool): ignore TLS error for image upload. + Default: False + @param return_data(dict): will contain link between former posts and new items + + """ + mb_data = item_import_data['blog'] + try: + item_id = mb_data['id'] + except KeyError: + item_id = mb_data['id'] = unicode(shortuuid.uuid()) + + try: + # we keep the link between old url and new blog item + # so the user can redirect its former blog urls + old_uri = item_import_data['url'] + except KeyError: + pass + else: + new_uri = return_data[URL_REDIRECT_PREFIX + old_uri] = self._p.getNodeURI( + service if service is not None else client.jid.userhostJID(), + node or self._m.namespace, + item_id) + log.info(u"url link from {old} to {new}".format( + old=old_uri, new=new_uri)) + + return mb_data + + @defer.inlineCallbacks + def importSubItems(self, client, item_import_data, mb_data, session, options): + # comments data + if len(item_import_data['comments']) != 1: + raise NotImplementedError(u"can't manage multiple comment links") + allow_comments = C.bool(mb_data.get('allow_comments', C.BOOL_FALSE)) + if allow_comments: + comments_service = yield self._m.getCommentsService(client) + comments_node = self._m.getCommentsNode(mb_data['id']) + mb_data['comments_service'] = comments_service.full() + mb_data['comments_node'] = comments_node + recurse_kwargs = { + 'items_import_data':item_import_data['comments'][0], + 'service':comments_service, + 'node':comments_node} + defer.returnValue(recurse_kwargs) + else: + if item_import_data['comments'][0]: + raise exceptions.DataError(u"allow_comments set to False, but comments are there") + defer.returnValue(None) + + def publishItem(self, client, mb_data, service, node, session): + log.debug(u"uploading item [{id}]: {title}".format(id=mb_data['id'], title=mb_data.get('title',''))) + return self._m.send(client, mb_data, service, node) + + @defer.inlineCallbacks + def itemFilters(self, client, mb_data, session, options): + """Apply filters according to options + + modify mb_data in place + @param posts_data(list[dict]): data as returned by importer callback + @param options(dict): dict as given in [blogImport] + """ + # FIXME: blog filters don't work on text content + # TODO: text => XHTML conversion should handler links with
+ # filters can then be used by converting text to XHTML + if not options: + return + + # we want only XHTML content + for prefix in ('content',): # a tuple is use, if title need to be added in the future + try: + rich = mb_data['{}_rich'.format(prefix)] + except KeyError: + pass + else: + if '{}_xhtml'.format(prefix) in mb_data: + raise exceptions.DataError(u"importer gave {prefix}_rich and {prefix}_xhtml at the same time, this is not allowed".format(prefix=prefix)) + # we convert rich syntax to XHTML here, so we can handle filters easily + converted = yield self._s.convert(rich, self._s.getCurrentSyntax(client.profile), safe=False) + mb_data['{}_xhtml'.format(prefix)] = converted + del mb_data['{}_rich'.format(prefix)] + + try: + mb_data['txt'] + except KeyError: + pass + else: + if '{}_xhtml'.format(prefix) in mb_data: + log.warning(u"{prefix}_text will be replaced by converted {prefix}_xhtml, so filters can be handled".format(prefix=prefix)) + del mb_data['{}_text'.format(prefix)] + else: + log.warning(u"importer gave a text {prefix}, blog filters don't work on text {prefix}".format(prefix=prefix)) + return + + # at this point, we have only XHTML version of content + try: + top_elt = xml_tools.ElementParser()(mb_data['content_xhtml'], namespace=C.NS_XHTML) + except domish.ParserError: + # we clean the xml and try again our luck + cleaned = yield self._s.cleanXHTML(mb_data['content_xhtml']) + top_elt = xml_tools.ElementParser()(cleaned, namespace=C.NS_XHTML) + opt_host = options.get(OPT_HOST) + if opt_host: + # we normalise the domain + parsed_host = urlparse.urlsplit(opt_host) + opt_host = urlparse.urlunsplit((parsed_host.scheme or 'http', parsed_host.netloc or parsed_host.path, '', '', '')) + + tmp_dir = tempfile.mkdtemp() + try: + # TODO: would be nice to also update the hyperlinks to these images, e.g. when you have + for img_elt in xml_tools.findAll(top_elt, names=[u'img']): + yield self.imgFilters(client, img_elt, options, opt_host, tmp_dir) + finally: + os.rmdir(tmp_dir) # XXX: tmp_dir should be empty, or something went wrong + + # we now replace the content with filtered one + mb_data['content_xhtml'] = top_elt.toXml() + + @defer.inlineCallbacks + def imgFilters(self, client, img_elt, options, opt_host, tmp_dir): + """Filters handling images + + url without host are fixed (if possible) + according to options, images are uploaded to XMPP server + @param img_elt(domish.Element): element to handle + @param options(dict): filters options + @param opt_host(unicode): normalised host given in options + @param tmp_dir(str): path to temp directory + """ + try: + url = img_elt['src'] + if url[0] == u'/': + if not opt_host: + log.warning(u"host was not specified, we can't deal with src without host ({url}) and have to ignore the following :\n{xml}" + .format(url=url, xml=img_elt.toXml())) + return + else: + url = urlparse.urljoin(opt_host, url) + filename = url.rsplit('/',1)[-1].strip() + if not filename: + raise KeyError + except (KeyError, IndexError): + log.warning(u"ignoring invalid img element: {}".format(img_elt.toXml())) + return + + # we change the url for the normalized one + img_elt['src'] = url + + if options.get(OPT_UPLOAD_IMAGES, False): + # upload is requested + try: + ignore_host = options[OPT_UPLOAD_IGNORE_HOST] + except KeyError: + pass + else: + # host is the ignored one, we skip + parsed_url = urlparse.urlsplit(url) + if ignore_host in parsed_url.hostname: + log.info(u"Don't upload image at {url} because of {opt} option".format( + url=url, opt=OPT_UPLOAD_IGNORE_HOST)) + return + + # we download images and re-upload them via XMPP + tmp_file = os.path.join(tmp_dir, filename).encode('utf-8') + upload_options = {'ignore_tls_errors': options.get(OPT_IGNORE_TLS, False)} + + try: + yield web_client.downloadPage(url.encode('utf-8'), tmp_file) + filename = filename.replace(u'%', u'_') # FIXME: tmp workaround for a bug in prosody http upload + dummy, download_d = yield self._u.upload(client, tmp_file, filename, options=upload_options) + download_url = yield download_d + except Exception as e: + log.warning(u"can't download image at {url}: {reason}".format(url=url, reason=e)) + else: + img_elt['src'] = download_url + + try: + os.unlink(tmp_file) + except OSError: + pass diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_blog_import_dokuwiki.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_blog_import_dokuwiki.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,392 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin to import dokuwiki blogs +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools import xml_tools +from twisted.internet import threads +from collections import OrderedDict +import calendar +import urllib +import urlparse +import tempfile +import re +import time +import os.path +try: + from dokuwiki import DokuWiki, DokuWikiError # this is a new dependency +except ImportError: + raise exceptions.MissingModule(u'Missing module dokuwiki, please install it with "pip install dokuwiki"') +try: + from PIL import Image # this is already needed by plugin XEP-0054 +except: + raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io") + +PLUGIN_INFO = { + C.PI_NAME: "Dokuwiki import", + C.PI_IMPORT_NAME: "IMPORT_DOKUWIKI", + C.PI_TYPE: C.PLUG_TYPE_BLOG, + C.PI_DEPENDENCIES: ["BLOG_IMPORT"], + C.PI_MAIN: "DokuwikiImport", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Blog importer for Dokuwiki blog engine.""") +} + +SHORT_DESC = D_(u"import posts from Dokuwiki blog engine") + +LONG_DESC = D_(u"""This importer handle Dokuwiki blog engine. + +To use it, you need an admin access to a running Dokuwiki website +(local or on the Internet). The importer retrieves the data using +the XMLRPC Dokuwiki API. + +You can specify a namespace (that could be a namespace directory +or a single post) or leave it empty to use the root namespace "/" +and import all the posts. + +You can specify a new media repository to modify the internal +media links and make them point to the URL of your choice, but +note that the upload is not done automatically: a temporary +directory will be created on your local drive and you will +need to upload it yourself to your repository via SSH or FTP. + +Following options are recognized: + +location: DokuWiki site URL +user: DokuWiki admin user +passwd: DokuWiki admin password +namespace: DokuWiki namespace to import (default: root namespace "/") +media_repo: URL to the new remote media repository (default: none) +limit: maximal number of posts to import (default: 100) + +Example of usage (with jp frontend): + +jp import dokuwiki -p dave --pwd xxxxxx --connect + http://127.0.1.1 -o user souliane -o passwd qwertz + -o namespace public:2015:10 + -o media_repo http://media.diekulturvermittlung.at + +This retrieves the 100 last blog posts from http://127.0.1.1 that +are inside the namespace "public:2015:10" using the Dokuwiki user +"souliane", and it imports them to sat profile dave's microblog node. +Internal Dokuwiki media that were hosted on http://127.0.1.1 are now +pointing to http://media.diekulturvermittlung.at. +""") +DEFAULT_MEDIA_REPO = "" +DEFAULT_NAMESPACE = "/" +DEFAULT_LIMIT = 100 # you might get a DBUS timeout (no reply) if it lasts too long + + +class Importer(DokuWiki): + + def __init__(self, url, user, passwd, media_repo=DEFAULT_MEDIA_REPO, limit=DEFAULT_LIMIT): + """ + + @param url (unicode): DokuWiki site URL + @param user (unicode): DokuWiki admin user + @param passwd (unicode): DokuWiki admin password + @param media_repo (unicode): New remote media repository + """ + DokuWiki.__init__(self, url, user, passwd) + self.url = url + self.media_repo = media_repo + self.temp_dir = tempfile.mkdtemp() if self.media_repo else None + self.limit = limit + self.posts_data = OrderedDict() + + def getPostId(self, post): + """Return a unique and constant post id + + @param post(dict): parsed post data + @return (unicode): post unique item id + """ + return unicode(post['id']) + + def getPostUpdated(self, post): + """Return the update date. + + @param post(dict): parsed post data + @return (unicode): update date + """ + return unicode(post['mtime']) + + def getPostPublished(self, post): + """Try to parse the date from the message ID, else use "mtime". + + The date can be extracted if the message ID looks like one of: + - namespace:YYMMDD_short_title + - namespace:YYYYMMDD_short_title + @param post (dict): parsed post data + @return (unicode): publication date + """ + id_, default = unicode(post["id"]), unicode(post["mtime"]) + try: + date = id_.split(":")[-1].split("_")[0] + except KeyError: + return default + try: + time_struct = time.strptime(date, "%y%m%d") + except ValueError: + try: + time_struct = time.strptime(date, "%Y%m%d") + except ValueError: + return default + return unicode(calendar.timegm(time_struct)) + + def processPost(self, post, profile_jid): + """Process a single page. + + @param post (dict): parsed post data + @param profile_jid + """ + # get main information + id_ = self.getPostId(post) + updated = self.getPostUpdated(post) + published = self.getPostPublished(post) + + # manage links + backlinks = self.pages.backlinks(id_) + for link in self.pages.links(id_): + if link["type"] != "extern": + assert link["type"] == "local" + page = link["page"] + backlinks.append(page[1:] if page.startswith(":") else page) + + self.pages.get(id_) + content_xhtml = self.processContent(self.pages.html(id_), backlinks, profile_jid) + + # XXX: title is already in content_xhtml and difficult to remove, so leave it + # title = content.split("\n")[0].strip(u"\ufeff= ") + + # build the extra data dictionary + mb_data = {"id": id_, + "published": published, + "updated": updated, + "author": profile_jid.user, + # "content": content, # when passed, it is displayed in Libervia instead of content_xhtml + "content_xhtml": content_xhtml, + # "title": title, + "allow_comments": "true", + } + + # find out if the message access is public or restricted + namespace = id_.split(":")[0] + if namespace and namespace.lower() not in ("public", "/"): + mb_data["group"] = namespace # roster group must exist + + self.posts_data[id_] = {'blog': mb_data, 'comments':[[]]} + + def process(self, client, namespace=DEFAULT_NAMESPACE): + """Process a namespace or a single page. + + @param namespace (unicode): DokuWiki namespace (or page) to import + """ + profile_jid = client.jid + log.info("Importing data from DokuWiki %s" % self.version) + try: + pages_list = self.pages.list(namespace) + except DokuWikiError: + log.warning('Could not list Dokuwiki pages: please turn the "display_errors" setting to "Off" in the php.ini of the webserver hosting DokuWiki.') + return + + if not pages_list: # namespace is actually a page? + names = namespace.split(":") + real_namespace = ":".join(names[0:-1]) + pages_list = self.pages.list(real_namespace) + pages_list = [page for page in pages_list if page["id"] == namespace] + namespace = real_namespace + + count = 0 + for page in pages_list: + self.processPost(page, profile_jid) + count += 1 + if count >= self.limit : + break + + return (self.posts_data.itervalues(), len(self.posts_data)) + + def processContent(self, text, backlinks, profile_jid): + """Do text substitutions and file copy. + + @param text (unicode): message content + @param backlinks (list[unicode]): list of backlinks + """ + text = text.strip(u"\ufeff") # this is at the beginning of the file (BOM) + + for backlink in backlinks: + src = '/doku.php?id=%s"' % backlink + tgt = '/blog/%s/%s" target="#"' % (profile_jid.user, backlink) + text = text.replace(src, tgt) + + subs = {} + + link_pattern = r"""<(img|a)[^>]* (src|href)="([^"]+)"[^>]*>""" + for tag in re.finditer(link_pattern, text): + type_, attr, link = tag.group(1), tag.group(2), tag.group(3) + assert (type_ == "img" and attr == "src") or (type_ == "a" and attr == "href") + if re.match(r"^\w*://", link): # absolute URL to link directly + continue + if self.media_repo: + self.moveMedia(link, subs) + elif link not in subs: + subs[link] = urlparse.urljoin(self.url, link) + + for url, new_url in subs.iteritems(): + text = text.replace(url, new_url) + return text + + def moveMedia(self, link, subs): + """Move a media from the DokuWiki host to the new repository. + + This also updates the hyperlinks to internal media files. + @param link (unicode): media link + @param subs (dict): substitutions data + """ + url = urlparse.urljoin(self.url, link) + user_media = re.match(r"(/lib/exe/\w+.php\?)(.*)", link) + thumb_width = None + + if user_media: # media that has been added by the user + params = urlparse.parse_qs(urlparse.urlparse(url).query) + try: + media = params["media"][0] + except KeyError: + log.warning("No media found in fetch URL: %s" % user_media.group(2)) + return + if re.match(r"^\w*://", media): # external URL to link directly + subs[link] = media + return + try: # create thumbnail + thumb_width = params["w"][0] + except KeyError: + pass + + filename = media.replace(":", "/") + # XXX: avoid "precondition failed" error (only keep the media parameter) + url = urlparse.urljoin(self.url, "/lib/exe/fetch.php?media=%s" % media) + + elif link.startswith("/lib/plugins/"): + # other link added by a plugin or something else + filename = link[13:] + else: # fake alert... there's no media (or we don't handle it yet) + return + + filepath = os.path.join(self.temp_dir, filename) + self.downloadMedia(url, filepath) + + if thumb_width: + filename = os.path.join("thumbs", thumb_width, filename) + thumbnail = os.path.join(self.temp_dir, filename) + self.createThumbnail(filepath, thumbnail, thumb_width) + + new_url = os.path.join(self.media_repo, filename) + subs[link] = new_url + + def downloadMedia(self, source, dest): + """Copy media to localhost. + + @param source (unicode): source url + @param dest (unicode): target path + """ + dirname = os.path.dirname(dest) + if not os.path.exists(dest): + if not os.path.exists(dirname): + os.makedirs(dirname) + urllib.urlretrieve(source, dest) + log.debug("DokuWiki media file copied to %s" % dest) + + def createThumbnail(self, source, dest, width): + """Create a thumbnail. + + @param source (unicode): source file path + @param dest (unicode): destination file path + @param width (unicode): thumbnail's width + """ + thumb_dir = os.path.dirname(dest) + if not os.path.exists(thumb_dir): + os.makedirs(thumb_dir) + try: + im = Image.open(source) + im.thumbnail((width, int(width) * im.size[0] / im.size[1])) + im.save(dest) + log.debug("DokuWiki media thumbnail created: %s" % dest) + except IOError: + log.error("Cannot create DokuWiki media thumbnail %s" % dest) + + + +class DokuwikiImport(object): + + def __init__(self, host): + log.info(_("plugin Dokuwiki Import initialization")) + self.host = host + self._blog_import = host.plugins['BLOG_IMPORT'] + self._blog_import.register('dokuwiki', self.DkImport, SHORT_DESC, LONG_DESC) + + def DkImport(self, client, location, options=None): + """Import from DokuWiki to PubSub + + @param location (unicode): DokuWiki site URL + @param options (dict, None): DokuWiki import parameters + - user (unicode): DokuWiki admin user + - passwd (unicode): DokuWiki admin password + - namespace (unicode): DokuWiki namespace to import + - media_repo (unicode): New remote media repository + """ + options[self._blog_import.OPT_HOST] = location + try: + user = options["user"] + except KeyError: + raise exceptions.DataError('parameter "user" is required') + try: + passwd = options["passwd"] + except KeyError: + raise exceptions.DataError('parameter "passwd" is required') + + opt_upload_images = options.get(self._blog_import.OPT_UPLOAD_IMAGES, None) + try: + media_repo = options["media_repo"] + if opt_upload_images: + options[self._blog_import.OPT_UPLOAD_IMAGES] = False # force using --no-images-upload + info_msg = _("DokuWiki media files will be *downloaded* to {temp_dir} - to finish the import you have to upload them *manually* to {media_repo}") + except KeyError: + media_repo = DEFAULT_MEDIA_REPO + if opt_upload_images: + info_msg = _("DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to these media may not been updated though.") + else: + info_msg = _("DokuWiki media files will *stay* on {location} - some of them may be protected by DokuWiki ACL and will not be accessible.") + + try: + namespace = options["namespace"] + except KeyError: + namespace = DEFAULT_NAMESPACE + try: + limit = options["limit"] + except KeyError: + limit = DEFAULT_LIMIT + + dk_importer = Importer(location, user, passwd, media_repo, limit) + info_msg = info_msg.format(temp_dir=dk_importer.temp_dir, media_repo=media_repo, location=location) + self.host.actionNew({'xmlui': xml_tools.note(info_msg).toXml()}, profile=client.profile) + d = threads.deferToThread(dk_importer.process, client, namespace) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_blog_import_dotclear.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_blog_import_dotclear.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,251 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for import external blogs +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools.common import data_format +from twisted.internet import threads +from collections import OrderedDict +import itertools +import time +import cgi +import os.path + + +PLUGIN_INFO = { + C.PI_NAME: "Dotclear import", + C.PI_IMPORT_NAME: "IMPORT_DOTCLEAR", + C.PI_TYPE: C.PLUG_TYPE_BLOG, + C.PI_DEPENDENCIES: ["BLOG_IMPORT"], + C.PI_MAIN: "DotclearImport", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Blog importer for Dotclear blog engine.""") +} + +SHORT_DESC = D_(u"import posts from Dotclear blog engine") + +LONG_DESC = D_(u"""This importer handle Dotclear blog engine. + +To use it, you'll need to export your blog to a flat file. +You must go in your admin interface and select Plugins/Maintenance then Backup. +Export only one blog if you have many, i.e. select "Download database of current blog" +Depending on your configuration, your may need to use Import/Export plugin and export as a flat file. + +location: you must use the absolute path to your backup for the location parameter +""") +POST_ID_PREFIX = u"sat_dc_" +KNOWN_DATA_TYPES = ('link', 'setting', 'post', 'meta', 'media', 'post_media', 'comment', 'captcha') +ESCAPE_MAP = { + 'r': u'\r', + 'n': u'\n', + '"': u'"', + '\\': u'\\', + } + + +class DotclearParser(object): + # XXX: we have to parse all file to build data + # this can be ressource intensive on huge blogs + + def __init__(self): + self.posts_data = OrderedDict() + self.tags = {} + + def getPostId(self, post): + """Return a unique and constant post id + + @param post(dict): parsed post data + @return (unicode): post unique item id + """ + return u"{}_{}_{}_{}:{}".format(POST_ID_PREFIX, post['blog_id'], post['user_id'], post['post_id'], post['post_url']) + + def getCommentId(self, comment): + """Return a unique and constant comment id + + @param comment(dict): parsed comment + @return (unicode): comment unique comment id + """ + post_id = comment['post_id'] + parent_item_id = self.posts_data[post_id]['blog']['id'] + return u"{}_comment_{}".format(parent_item_id, comment['comment_id']) + + def getTime(self, data, key): + """Parse time as given by dotclear, with timezone handling + + @param data(dict): dotclear data (post or comment) + @param key(unicode): key to get (e.g. "post_creadt") + @return (float): Unix time + """ + return time.mktime(time.strptime(data[key], "%Y-%m-%d %H:%M:%S")) + + def readFields(self, fields_data): + buf = [] + idx = 0 + while True: + if fields_data[idx] != '"': + raise exceptions.ParsingError + while True: + idx += 1 + try: + char = fields_data[idx] + except IndexError: + raise exceptions.ParsingError("Data was expected") + if char == '"': + # we have reached the end of this field, + # we try to parse a new one + yield u''.join(buf) + buf = [] + idx += 1 + try: + separator = fields_data[idx] + except IndexError: + return + if separator != u',': + raise exceptions.ParsingError("Field separator was expeceted") + idx += 1 + break # we have a new field + elif char == u'\\': + idx += 1 + try: + char = ESCAPE_MAP[fields_data[idx]] + except IndexError: + raise exceptions.ParsingError("Escaped char was expected") + except KeyError: + char = fields_data[idx] + log.warning(u"Unknown key to escape: {}".format(char)) + buf.append(char) + + def parseFields(self, headers, data): + return dict(itertools.izip(headers, self.readFields(data))) + + def postHandler(self, headers, data, index): + post = self.parseFields(headers, data) + log.debug(u'({}) post found: {}'.format(index, post['post_title'])) + mb_data = {'id': self.getPostId(post), + 'published': self.getTime(post, 'post_creadt'), + 'updated': self.getTime(post, 'post_upddt'), + 'author': post['user_id'], # there use info are not in the archive + # TODO: option to specify user info + 'content_xhtml': u"{}{}".format(post['post_content_xhtml'], post['post_excerpt_xhtml']), + 'title': post['post_title'], + 'allow_comments': C.boolConst(bool(int(post['post_open_comment']))), + } + self.posts_data[post['post_id']] = {'blog': mb_data, 'comments':[[]], 'url': u'/post/{}'.format(post['post_url'])} + + def metaHandler(self, headers, data, index): + meta = self.parseFields(headers, data) + if meta['meta_type'] == 'tag': + tags = self.tags.setdefault(meta['post_id'], set()) + tags.add(meta['meta_id']) + + def metaFinishedHandler(self): + for post_id, tags in self.tags.iteritems(): + data_format.iter2dict('tag', tags, self.posts_data[post_id]['blog']) + del self.tags + + def commentHandler(self, headers, data, index): + comment = self.parseFields(headers, data) + if comment['comment_site']: + # we don't use atom:uri because it's used for jid in XMPP + content = u'{}\n
\nauthor website'.format( + comment['comment_content'], + cgi.escape(comment['comment_site']).replace('"', u'%22')) + else: + content = comment['comment_content'] + mb_data = {'id': self.getCommentId(comment), + 'published': self.getTime(comment, 'comment_dt'), + 'updated': self.getTime(comment, 'comment_upddt'), + 'author': comment['comment_author'], + # we don't keep email addresses to avoid the author to be spammed + # (they would be available publicly else) + # 'author_email': comment['comment_email'], + 'content_xhtml': content, + } + self.posts_data[comment['post_id']]['comments'][0].append( + {'blog': mb_data, 'comments': [[]]}) + + def parse(self, db_path): + with open(db_path) as f: + signature = f.readline().decode('utf-8') + try: + version = signature.split('|')[1] + except IndexError: + version = None + log.debug(u"Dotclear version: {}".format(version)) + data_type = None + data_headers = None + index = None + while True: + buf = f.readline().decode('utf-8') + if not buf: + break + if buf.startswith('['): + header = buf.split(' ', 1) + data_type = header[0][1:] + if data_type not in KNOWN_DATA_TYPES: + log.warning(u"unkown data type: {}".format(data_type)) + index = 0 + try: + data_headers = header[1].split(',') + # we need to remove the ']' from the last header + last_header = data_headers[-1] + data_headers[-1] = last_header[:last_header.rfind(']')] + except IndexError: + log.warning(u"Can't read data)") + else: + if data_type is None: + continue + buf = buf.strip() + if not buf and data_type in KNOWN_DATA_TYPES: + try: + finished_handler = getattr(self, '{}FinishedHandler'.format(data_type)) + except AttributeError: + pass + else: + finished_handler() + log.debug(u"{} data finished".format(data_type)) + data_type = None + continue + assert data_type + try: + fields_handler = getattr(self, '{}Handler'.format(data_type)) + except AttributeError: + pass + else: + fields_handler(data_headers, buf, index) + index += 1 + return (self.posts_data.itervalues(), len(self.posts_data)) + + +class DotclearImport(object): + + def __init__(self, host): + log.info(_("plugin Dotclear Import initialization")) + self.host = host + host.plugins['BLOG_IMPORT'].register('dotclear', self.DcImport, SHORT_DESC, LONG_DESC) + + def DcImport(self, client, location, options=None): + if not os.path.isabs(location): + raise exceptions.DataError(u"An absolute path to backup data need to be given as location") + dc_parser = DotclearParser() + d = threads.deferToThread(dc_parser.parse, location) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_comp_file_sharing.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_comp_file_sharing.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,382 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for parrot mode (experimental) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools.common import regex +from sat.tools.common import uri +from sat.tools import stream +from twisted.internet import defer +from twisted.words.protocols.jabber import error +from wokkel import pubsub +from wokkel import generic +from functools import partial +import os +import os.path +import mimetypes + + +PLUGIN_INFO = { + C.PI_NAME: "File sharing component", + C.PI_IMPORT_NAME: "file_sharing", + C.PI_MODES: [C.PLUG_MODE_COMPONENT], + C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT, + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["FILE", "XEP-0231", "XEP-0234", "XEP-0260", "XEP-0261", "XEP-0264", "XEP-0329"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "FileSharing", + C.PI_HANDLER: C.BOOL_TRUE, + C.PI_DESCRIPTION: _(u"""Component hosting and sharing files""") +} + +HASH_ALGO = u'sha-256' +NS_COMMENTS = 'org.salut-a-toi.comments' +COMMENT_NODE_PREFIX = 'org.salut-a-toi.file_comments/' + + +class FileSharing(object): + + def __init__(self, host): + log.info(_(u"File Sharing initialization")) + self.host = host + self._f = host.plugins['FILE'] + self._jf = host.plugins['XEP-0234'] + self._h = host.plugins['XEP-0300'] + self._t = host.plugins['XEP-0264'] + host.trigger.add("FILE_getDestDir", self._getDestDirTrigger) + host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger, priority=1000) + host.trigger.add("XEP-0234_buildFileElement", self._addFileComments) + host.trigger.add("XEP-0234_parseFileElement", self._getFileComments) + host.trigger.add("XEP-0329_compGetFilesFromNode", self._addCommentsData) + self.files_path = host.getLocalPath(None, C.FILES_DIR, profile=False) + + def getHandler(self, client): + return Comments_handler(self) + + def profileConnected(self, client): + path = client.file_tmp_dir = os.path.join( + self.host.memory.getConfig('', 'local_dir'), + C.FILES_TMP_DIR, + regex.pathEscape(client.profile)) + if not os.path.exists(path): + os.makedirs(path) + + @defer.inlineCallbacks + def _fileTransferedCb(self, dummy, client, peer_jid, file_data, file_path): + """post file reception tasks + + on file is received, this method create hash/thumbnails if necessary + move the file to the right location, and create metadata entry in database + """ + name = file_data[u'name'] + extra = {} + + if file_data[u'hash_algo'] == HASH_ALGO: + log.debug(_(u"Reusing already generated hash")) + file_hash = file_data[u'hash_hasher'].hexdigest() + else: + hasher = self._h.getHasher(HASH_ALGO) + with open('file_path') as f: + file_hash = yield self._h.calculateHash(f, hasher) + final_path = os.path.join(self.files_path, file_hash) + + if os.path.isfile(final_path): + log.debug(u"file [{file_hash}] already exists, we can remove temporary one".format(file_hash = file_hash)) + os.unlink(file_path) + else: + os.rename(file_path, final_path) + log.debug(u"file [{file_hash}] moved to {files_path}".format(file_hash=file_hash, files_path=self.files_path)) + + mime_type = file_data.get(u'mime_type') + if not mime_type or mime_type == u'application/octet-stream': + mime_type = mimetypes.guess_type(name)[0] + + if mime_type is not None and mime_type.startswith(u'image'): + thumbnails = extra.setdefault(C.KEY_THUMBNAILS, []) + for max_thumb_size in (self._t.SIZE_SMALL, self._t.SIZE_MEDIUM): + try: + thumb_size, thumb_id = yield self._t.generateThumbnail(final_path, + max_thumb_size, + # we keep thumbnails for 6 months + 60*60*24*31*6) + except Exception as e: + log.warning(_(u"Can't create thumbnail: {reason}").format(reason=e)) + break + thumbnails.append({u'id': thumb_id, u'size': thumb_size}) + + self.host.memory.setFile(client, + name=name, + version=u'', + file_hash=file_hash, + hash_algo=HASH_ALGO, + size=file_data[u'size'], + path=file_data.get(u'path'), + namespace=file_data.get(u'namespace'), + mime_type=mime_type, + owner=peer_jid, + extra=extra) + + def _getDestDirTrigger(self, client, peer_jid, transfer_data, file_data, stream_object): + """This trigger accept file sending request, and store file locally""" + if not client.is_component: + return True, None + assert stream_object + assert 'stream_object' not in transfer_data + assert C.KEY_PROGRESS_ID in file_data + filename = file_data['name'] + assert filename and not '/' in filename + file_tmp_dir = self.host.getLocalPath(client, C.FILES_TMP_DIR, peer_jid.userhost(), component=True, profile=False) + file_tmp_path = file_data['file_path'] = os.path.join(file_tmp_dir, file_data['name']) + + transfer_data['finished_d'].addCallback(self._fileTransferedCb, client, peer_jid, file_data, file_tmp_path) + + self._f.openFileWrite(client, file_tmp_path, transfer_data, file_data, stream_object) + return False, defer.succeed(True) + + @defer.inlineCallbacks + def _retrieveFiles(self, client, session, content_data, content_name, file_data, file_elt): + """This method retrieve a file on request, and send if after checking permissions""" + peer_jid = session[u'peer_jid'] + try: + found_files = yield self.host.memory.getFiles(client, + peer_jid=peer_jid, + name=file_data.get(u'name'), + file_hash=file_data.get(u'file_hash'), + hash_algo=file_data.get(u'hash_algo'), + path=file_data.get(u'path'), + namespace=file_data.get(u'namespace')) + except exceptions.NotFound: + found_files = None + except exceptions.PermissionError: + log.warning(_(u"{peer_jid} is trying to access an unauthorized file: {name}").format( + peer_jid=peer_jid, name=file_data.get(u'name'))) + defer.returnValue(False) + + if not found_files: + log.warning(_(u"no matching file found ({file_data})").format(file_data=file_data)) + defer.returnValue(False) + + # we only use the first found file + found_file = found_files[0] + file_hash = found_file[u'file_hash'] + file_path = os.path.join(self.files_path, file_hash) + file_data[u'hash_hasher'] = hasher = self._h.getHasher(found_file[u'hash_algo']) + size = file_data[u'size'] = found_file[u'size'] + file_data[u'file_hash'] = file_hash + file_data[u'hash_algo'] = found_file[u'hash_algo'] + + # we complete file_elt so peer can have some details on the file + if u'name' not in file_data: + file_elt.addElement(u'name', content=found_file[u'name']) + file_elt.addElement(u'size', content=unicode(size)) + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + uid=self._jf.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) + defer.returnValue(True) + + def _fileSendingRequestTrigger(self, client, session, content_data, content_name, file_data, file_elt): + if not client.is_component: + return True, None + else: + return False, self._retrieveFiles(client, session, content_data, content_name, file_data, file_elt) + + ## comments triggers ## + + def _addFileComments(self, file_elt, extra_args): + try: + comments_url = extra_args.pop('comments_url') + except KeyError: + return + + comment_elt = file_elt.addElement((NS_COMMENTS, 'comments'), content=comments_url) + + try: + count = len(extra_args[u'extra'][u'comments']) + except KeyError: + count = 0 + + comment_elt['count'] = unicode(count) + return True + + def _getFileComments(self, file_elt, file_data): + try: + comments_elt = next(file_elt.elements(NS_COMMENTS, 'comments')) + except StopIteration: + return + file_data['comments_url'] = unicode(comments_elt) + file_data['comments_count'] = comments_elt['count'] + return True + + def _addCommentsData(self, client, iq_elt, owner, node_path, files_data): + for file_data in files_data: + file_data['comments_url'] = uri.buildXMPPUri('pubsub', + path=client.jid.full(), + node=COMMENT_NODE_PREFIX + file_data['id']) + return True + + +class Comments_handler(pubsub.PubSubService): + """This class is a minimal Pubsub service handling virtual nodes for comments""" + + def __init__(self, plugin_parent): + super(Comments_handler, self).__init__() # PubsubVirtualResource()) + self.host = plugin_parent.host + self.plugin_parent = plugin_parent + self.discoIdentity = {'category': 'pubsub', + 'type': 'virtual', # FIXME: non standard, here to avoid this service being considered as main pubsub one + 'name': 'files commenting service'} + + def _getFileId(self, nodeIdentifier): + if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX): + raise error.StanzaError('item-not-found') + file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX):] + if not file_id: + raise error.StanzaError('item-not-found') + return file_id + + @defer.inlineCallbacks + def getFileData(self, requestor, nodeIdentifier): + file_id = self._getFileId(nodeIdentifier) + try: + files = yield self.host.memory.getFiles(self.parent, requestor, file_id) + except (exceptions.NotFound, exceptions.PermissionError): + # we don't differenciate between NotFound and PermissionError + # to avoid leaking information on existing files + raise error.StanzaError('item-not-found') + if not files: + raise error.StanzaError('item-not-found') + if len(files) > 1: + raise error.InternalError('there should be only one file') + defer.returnValue(files[0]) + + def commentsUpdate(self, extra, new_comments, peer_jid): + """update comments (replace or insert new_comments) + + @param extra(dict): extra data to update + @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert + @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner + """ + current_comments = extra.setdefault('comments', []) + new_comments_by_id = {c[0]:c for c in new_comments} + updated = [] + # we now check every current comment, to see if one id in new ones + # exist, in which case we must update + for idx, comment in enumerate(current_comments): + comment_id = comment[0] + if comment_id in new_comments_by_id: + # a new comment has an existing id, update is requested + if peer_jid and comment[1] != peer_jid: + # requestor has not the right to modify the comment + raise exceptions.PermissionError + # we replace old_comment with updated one + new_comment = new_comments_by_id[comment_id] + current_comments[idx] = new_comment + updated.append(new_comment) + + # we now remove every updated comments, to only keep + # the ones to insert + for comment in updated: + new_comments.remove(comment) + + current_comments.extend(new_comments) + + def commentsDelete(self, extra, comments): + try: + comments_dict = extra['comments'] + except KeyError: + return + for comment in comments: + try: + comments_dict.remove(comment) + except ValueError: + continue + + def _getFrom(self, item_elt): + """retrieve published of an item + + @param item_elt(domish.element): element + @return (unicode): full jid as string + """ + iq_elt = item_elt + while iq_elt.parent != None: + iq_elt = iq_elt.parent + return iq_elt['from'] + + @defer.inlineCallbacks + def publish(self, requestor, service, nodeIdentifier, items): + # we retrieve file a first time to check authorisations + file_data = yield self.getFileData(requestor, nodeIdentifier) + file_id = file_data['id'] + comments = [(item['id'], self._getFrom(item), item.toXml()) for item in items] + if requestor.userhostJID() == file_data['owner']: + peer_jid = None + else: + peer_jid = requestor.userhost() + update_cb = partial(self.commentsUpdate, new_comments=comments, peer_jid=peer_jid) + try: + yield self.host.memory.fileUpdate(file_id, 'extra', update_cb) + except exceptions.PermissionError: + raise error.StanzaError('not-authorized') + + @defer.inlineCallbacks + def items(self, requestor, service, nodeIdentifier, maxItems, + itemIdentifiers): + file_data = yield self.getFileData(requestor, nodeIdentifier) + comments = file_data['extra'].get('comments', []) + if itemIdentifiers: + defer.returnValue([generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers]) + else: + defer.returnValue([generic.parseXml(c[2]) for c in comments]) + + @defer.inlineCallbacks + def retract(self, requestor, service, nodeIdentifier, itemIdentifiers): + file_data = yield self.getFileData(requestor, nodeIdentifier) + file_id = file_data['id'] + try: + comments = file_data['extra']['comments'] + except KeyError: + raise error.StanzaError('item-not-found') + + to_remove = [] + for comment in comments: + comment_id = comment[0] + if comment_id in itemIdentifiers: + to_remove.append(comment) + itemIdentifiers.remove(comment_id) + if not itemIdentifiers: + break + + if itemIdentifiers: + # not all items have been to_remove, we can't continue + raise error.StanzaError('item-not-found') + + if requestor.userhostJID() != file_data['owner']: + if not all([c[1] == requestor.userhost() for c in to_remove]): + raise error.StanzaError('not-authorized') + + remove_cb = partial(self.commentsDelete, comments=to_remove) + yield self.host.memory.fileUpdate(file_id, 'extra', remove_cb) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_command_export.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_command_export.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,156 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin to export commands (experimental) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid +from twisted.internet import reactor, protocol + +from sat.tools import trigger +from sat.tools.utils import clean_ustr + +PLUGIN_INFO = { + C.PI_NAME: "Command export plugin", + C.PI_IMPORT_NAME: "EXP-COMMANS-EXPORT", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "CommandExport", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of command export""") +} + +class ExportCommandProtocol(protocol.ProcessProtocol): + """ Try to register an account with prosody """ + + def __init__(self, parent, client, target, options): + self.parent = parent + self.target = target + self.options = options + self.client = client + + def _clean(self, data): + if not data: + log.error ("data should not be empty !") + return u"" + decoded = data.decode('utf-8', 'ignore')[:-1 if data[-1] == '\n' else None] + return clean_ustr(decoded) + + def connectionMade(self): + log.info("connectionMade :)") + + def outReceived(self, data): + self.client.sendMessage(self.target, {'': self._clean(data)}, no_trigger=True) + + def errReceived(self, data): + self.client.sendMessage(self.target, {'': self._clean(data)}, no_trigger=True) + + def processEnded(self, reason): + log.info (u"process finished: %d" % (reason.value.exitCode,)) + self.parent.removeProcess(self.target, self) + + def write(self, message): + self.transport.write(message.encode('utf-8')) + + def boolOption(self, key): + """ Get boolean value from options + @param key: name of the option + @return: True if key exists and set to "true" (case insensitive), + False in all other cases """ + value = self.options.get(key, "") + return value.lower() == "true" + + +class CommandExport(object): + """Command export plugin: export a command to an entity""" + # XXX: This plugin can be potentially dangerous if we don't trust entities linked + # this is specially true if we have other triggers. + # FIXME: spawned should be a client attribute, not a class one + + def __init__(self, host): + log.info(_("Plugin command export initialization")) + self.host = host + self.spawned = {} # key = entity + host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=10000) + host.bridge.addMethod("exportCommand", ".plugin", in_sign='sasasa{ss}s', out_sign='', method=self._exportCommand) + + def removeProcess(self, entity, process): + """ Called when the process is finished + @param entity: jid.JID attached to the process + @param process: process to remove""" + try: + processes_set = self.spawned[(entity, process.client.profile)] + processes_set.discard(process) + if not processes_set: + del(self.spawned[(entity, process.client.profile)]) + except ValueError: + pass + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + """ Check if source is linked and repeat message, else do nothing """ + from_jid = jid.JID(message_elt["from"]) + spawned_key = (from_jid.userhostJID(), client.profile) + + if spawned_key in self.spawned: + try: + body = message_elt.elements(C.NS_CLIENT, 'body').next() + except StopIteration: + # do not block message without body (chat state notification...) + return True + + mess_data = unicode(body) + '\n' + processes_set = self.spawned[spawned_key] + _continue = False + exclusive = False + for process in processes_set: + process.write(mess_data) + _continue &= process.boolOption("continue") + exclusive |= process.boolOption("exclusive") + if exclusive: + raise trigger.SkipOtherTriggers + return _continue + + return True + + def _exportCommand(self, command, args, targets, options, profile_key): + """ Export a commands to authorised targets + @param command: full path of the command to execute + @param args: list of arguments, with command name as first one + @param targets: list of allowed entities + @param options: export options, a dict which can have the following keys ("true" to set booleans): + - exclusive: if set, skip all other triggers + - loop: if set, restart the command once terminated #TODO + - pty: if set, launch in a pseudo terminal + - continue: continue normal MessageReceived handling + """ + client = self.host.getClient(profile_key) + for target in targets: + try: + _jid = jid.JID(target) + if not _jid.user or not _jid.host: + raise jid.InvalidFormat + _jid = _jid.userhostJID() + except (RuntimeError, jid.InvalidFormat, AttributeError): + log.info(u"invalid target ignored: %s" % (target,)) + continue + process_prot = ExportCommandProtocol(self, client, _jid, options) + self.spawned.setdefault((_jid, client.profile),set()).add(process_prot) + reactor.spawnProcess(process_prot, command, args, usePTY = process_prot.boolOption('pty')) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_events.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_events.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,419 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin to detect language (experimental) +# 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 . + +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import utils +from sat.tools.common import uri as uri_parse +from twisted.internet import defer +from twisted.words.protocols.jabber import jid, error +from twisted.words.xish import domish +from wokkel import pubsub + + +PLUGIN_INFO = { + C.PI_NAME: "Event plugin", + C.PI_IMPORT_NAME: "EVENTS", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060"], + C.PI_RECOMMENDATIONS: ["INVITATIONS", "XEP-0277"], + C.PI_MAIN: "Events", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Experimental implementation of XMPP events management""") +} + +NS_EVENT = 'org.salut-a-toi.event:0' + + +class Events(object): + """Q&D module to handle event attendance answer, experimentation only""" + + def __init__(self, host): + log.info(_(u"Event plugin initialization")) + self.host = host + self._p = self.host.plugins["XEP-0060"] + self._i = self.host.plugins.get("INVITATIONS") + self._b = self.host.plugins.get("XEP-0277") + host.bridge.addMethod("eventGet", ".plugin", + in_sign='ssss', out_sign='(ia{ss})', + method=self._eventGet, + async=True) + host.bridge.addMethod("eventCreate", ".plugin", + in_sign='ia{ss}ssss', out_sign='s', + method=self._eventCreate, + async=True) + host.bridge.addMethod("eventModify", ".plugin", + in_sign='sssia{ss}s', out_sign='', + method=self._eventModify, + async=True) + host.bridge.addMethod("eventInviteeGet", ".plugin", + in_sign='sss', out_sign='a{ss}', + method=self._eventInviteeGet, + async=True) + host.bridge.addMethod("eventInviteeSet", ".plugin", + in_sign='ssa{ss}s', out_sign='', + method=self._eventInviteeSet, + async=True) + host.bridge.addMethod("eventInviteesList", ".plugin", + in_sign='sss', out_sign='a{sa{ss}}', + method=self._eventInviteesList, + async=True), + host.bridge.addMethod("eventInvite", ".plugin", in_sign='ssssassssssss', out_sign='', + method=self._invite, + async=True) + + def _eventGet(self, service, node, id_=u'', profile_key=C.PROF_KEY_NONE): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventGet(client, service, node, id_) + + @defer.inlineCallbacks + def eventGet(self, client, service, node, id_=NS_EVENT): + """Retrieve event data + + @param service(unicode, None): PubSub service + @param node(unicode): PubSub node of the event + @param id_(unicode): id_ with even data + @return (tuple[int, dict[unicode, unicode]): event data: + - timestamp of the event + - event metadata where key can be: + location: location of the event + image: URL of a picture to use to represent event + background-image: URL of a picture to use in background + """ + if not id_: + id_ = NS_EVENT + items, metadata = yield self._p.getItems(client, service, node, item_ids=[id_]) + try: + event_elt = next(items[0].elements(NS_EVENT, u'event')) + except IndexError: + raise exceptions.NotFound(_(u"No event with this id has been found")) + + try: + timestamp = utils.date_parse(next(event_elt.elements(NS_EVENT, "date"))) + except StopIteration: + timestamp = -1 + + data = {} + + for key in (u'name',): + try: + data[key] = event_elt[key] + except KeyError: + continue + + for elt_name in (u'description',): + try: + elt = next(event_elt.elements(NS_EVENT, elt_name)) + except StopIteration: + continue + else: + data[elt_name] = unicode(elt) + + for elt_name in (u'image', 'background-image'): + try: + image_elt = next(event_elt.elements(NS_EVENT, elt_name)) + data[elt_name] = image_elt['src'] + except StopIteration: + continue + except KeyError: + log.warning(_(u'no src found for image')) + + for uri_type in (u'invitees', u'blog'): + try: + elt = next(event_elt.elements(NS_EVENT, uri_type)) + uri = data[uri_type + u'_uri'] = elt['uri'] + uri_data = uri_parse.parseXMPPUri(uri) + if uri_data[u'type'] != u'pubsub': + raise ValueError + except StopIteration: + log.warning(_(u"no {uri_type} element found!").format(uri_type=uri_type)) + except KeyError: + log.warning(_(u"incomplete {uri_type} element").format(uri_type=uri_type)) + except ValueError: + log.warning(_(u"bad {uri_type} element").format(uri_type=uri_type)) + else: + data[uri_type + u'_service'] = uri_data[u'path'] + data[uri_type + u'_node'] = uri_data[u'node'] + + for meta_elt in event_elt.elements(NS_EVENT, 'meta'): + key = meta_elt[u'name'] + if key in data: + log.warning(u'Ignoring conflicting meta element: {xml}'.format(xml=meta_elt.toXml())) + continue + data[key] = unicode(meta_elt) + + defer.returnValue((timestamp, data)) + + def _eventCreate(self, timestamp, data, service, node, id_=u'', profile_key=C.PROF_KEY_NONE): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT) + + @defer.inlineCallbacks + def eventCreate(self, client, timestamp, data, service, node=None, item_id=NS_EVENT): + """Create or replace an event + + @param service(jid.JID, None): PubSub service + @param node(unicode, None): PubSub node of the event + None will create instant node. + @param item_id(unicode): ID of the item to create. + @param timestamp(timestamp, None) + @param data(dict[unicode, unicode]): data to update + dict will be cleared, do a copy if data are still needed + key can be: + - name: name of the event + - description: details + - image: main picture of the event + - background-image: image to use as background + @return (unicode): created node + """ + if not item_id: + raise ValueError(_(u"item_id must be set")) + if not service: + service = client.jid.userhostJID() + event_elt = domish.Element((NS_EVENT, 'event')) + if timestamp is not None and timestamp != -1: + formatted_date = utils.xmpp_date(timestamp) + event_elt.addElement((NS_EVENT, 'date'), content=formatted_date) + for key in (u'name',): + if key in data: + event_elt[key] = data.pop(key) + for key in (u'description',): + if key in data: + event_elt.addElement((NS_EVENT, key), content=data.pop(key)) + for key in (u'image', u'background-image'): + if key in data: + elt = event_elt.addElement((NS_EVENT, key)) + elt['src'] = data.pop(key) + + # we first create the invitees and blog nodes (if not specified in data) + for uri_type in (u'invitees', u'blog'): + key = uri_type + u'_uri' + for to_delete in (u'service', u'node'): + k = uri_type + u'_' + to_delete + if k in data: + del data[k] + if key not in data: + # FIXME: affiliate invitees + uri_node = yield self._p.createNode(client, service) + yield self._p.setConfiguration(client, service, uri_node, {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}) + uri_service = service + else: + uri = data.pop(key) + uri_data = uri_parse.parseXMPPUri(uri) + if uri_data[u'type'] != u'pubsub': + raise ValueError(_(u'The given URI is not valid: {uri}').format(uri=uri)) + uri_service = jid.JID(uri_data[u'path']) + uri_node = uri_data[u'node'] + + elt = event_elt.addElement((NS_EVENT, uri_type)) + elt['uri'] = uri_parse.buildXMPPUri('pubsub', path=uri_service.full(), node=uri_node) + + # remaining data are put in elements + for key in data.keys(): + elt = event_elt.addElement((NS_EVENT, 'meta'), content = data.pop(key)) + elt['name'] = key + + item_elt = pubsub.Item(id=item_id, payload=event_elt) + try: + # TODO: check auto-create, no need to create node first if available + node = yield self._p.createNode(client, service, nodeIdentifier=node) + except error.StanzaError as e: + if e.condition == u'conflict': + log.debug(_(u"requested node already exists")) + + yield self._p.publish(client, service, node, items=[item_elt]) + + defer.returnValue(node) + + def _eventModify(self, service, node, id_, timestamp_update, data_update, profile_key=C.PROF_KEY_NONE): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventModify(client, service, node, id_ or NS_EVENT, timestamp_update or None, data_update) + + @defer.inlineCallbacks + def eventModify(self, client, service, node, id_=NS_EVENT, timestamp_update=None, data_update=None): + """Update an event + + Similar as create instead that it update existing item instead of + creating or replacing it. Params are the same as for [eventCreate]. + """ + event_timestamp, event_metadata = yield self.eventGet(client, service, node, id_) + new_timestamp = event_timestamp if timestamp_update is None else timestamp_update + new_data = event_metadata + if data_update: + for k, v in data_update.iteritems(): + new_data[k] = v + yield self.eventCreate(client, new_timestamp, new_data, service, node, id_) + + def _eventInviteeGet(self, service, node, profile_key): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventInviteeGet(client, service, node) + + @defer.inlineCallbacks + def eventInviteeGet(self, client, service, node): + """Retrieve attendance from event node + + @param service(unicode, None): PubSub service + @param node(unicode): PubSub node of the event + @return (dict): a dict with current attendance status, + an empty dict is returned if nothing has been answered yed + """ + items, metadata = yield self._p.getItems(client, service, node, item_ids=[client.jid.userhost()]) + try: + event_elt = next(items[0].elements(NS_EVENT, u'invitee')) + except IndexError: + # no item found, event data are not set yet + defer.returnValue({}) + data = {} + for key in (u'attend', u'guests'): + try: + data[key] = event_elt[key] + except KeyError: + continue + defer.returnValue(data) + + def _eventInviteeSet(self, service, node, event_data, profile_key): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventInviteeSet(client, service, node, event_data) + + def eventInviteeSet(self, client, service, node, data): + """Set or update attendance data in event node + + @param service(unicode, None): PubSub service + @param node(unicode): PubSub node of the event + @param data(dict[unicode, unicode]): data to update + key can be: + attend: one of "yes", "no", "maybe" + guests: an int + """ + event_elt = domish.Element((NS_EVENT, 'invitee')) + for key in (u'attend', u'guests'): + try: + event_elt[key] = data.pop(key) + except KeyError: + pass + item_elt = pubsub.Item(id=client.jid.userhost(), payload=event_elt) + return self._p.publish(client, service, node, items=[item_elt]) + + def _eventInviteesList(self, service, node, profile_key): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return self.eventInviteesList(client, service, node) + + @defer.inlineCallbacks + def eventInviteesList(self, client, service, node): + """Retrieve attendance from event node + + @param service(unicode, None): PubSub service + @param node(unicode): PubSub node of the event + @return (dict): a dict with current attendance status, + an empty dict is returned if nothing has been answered yed + """ + items, metadata = yield self._p.getItems(client, service, node) + invitees = {} + for item in items: + try: + event_elt = next(item.elements(NS_EVENT, u'invitee')) + except IndexError: + # no item found, event data are not set yet + log.warning(_(u"no data found for {item_id} (service: {service}, node: {node})".format( + item_id=item['id'], + service=service, + node=node + ))) + data = {} + for key in (u'attend', u'guests'): + try: + data[key] = event_elt[key] + except KeyError: + continue + invitees[item['id']] = data + defer.returnValue(invitees) + + def _invite(self, service, node, id_=NS_EVENT, email=u'', emails_extra=None, name=u'', host_name=u'', language=u'', url_template=u'', + message_subject=u'', message_body=u'', profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + kwargs = {u'profile': client.profile, + u'emails_extra': [unicode(e) for e in emails_extra] + } + for key in ("email", "name", "host_name", "language", "url_template", "message_subject", "message_body"): + value = locals()[key] + kwargs[key] = unicode(value) + return self.invite(client, + jid.JID(service) if service else None, + node, + id_ or NS_EVENT, + **kwargs) + + @defer.inlineCallbacks + def invite(self, client, service, node, id_=NS_EVENT, **kwargs): + """High level method to create an email invitation to an event + + @param service(unicode, None): PubSub service + @param node(unicode): PubSub node of the event + @param id_(unicode): id_ with even data + """ + if self._i is None: + raise exceptions.FeatureNotFound(_(u'"Invitations" plugin is needed for this feature')) + if self._b is None: + raise exceptions.FeatureNotFound(_(u'"XEP-0277" (blog) plugin is needed for this feature')) + event_service = (service or client.jid.userhostJID()) + event_uri = uri_parse.buildXMPPUri('pubsub', + path=event_service.full(), + node=node, + item=id_) + kwargs['extra'] = {u'event_uri': event_uri} + invitation_data = yield self._i.create(**kwargs) + invitee_jid = invitation_data[u'jid'] + log.debug(_(u'invitation created')) + yield self._p.setNodeAffiliations(client, event_service, node, {invitee_jid: u'member'}) + log.debug(_(u'affiliation set on event node')) + dummy, event_data = yield self.eventGet(client, service, node, id_) + log.debug(_(u'got event data')) + invitees_service = jid.JID(event_data['invitees_service']) + invitees_node = event_data['invitees_node'] + blog_service = jid.JID(event_data['blog_service']) + blog_node = event_data['blog_node'] + yield self._p.setNodeAffiliations(client, invitees_service, invitees_node, {invitee_jid: u'publisher'}) + log.debug(_(u'affiliation set on invitee node')) + yield self._p.setNodeAffiliations(client, blog_service, blog_node, {invitee_jid: u'member'}) + # FIXME: what follow is crazy, we have no good way to handle comments affiliations for blog + blog_items, dummy = yield self._b.mbGet(client, blog_service, blog_node, None) + + for item in blog_items: + comments_service = jid.JID(item['comments_service']) + comments_node = item['comments_node'] + yield self._p.setNodeAffiliations(client, comments_service, comments_node, {invitee_jid: u'publisher'}) + log.debug(_(u'affiliation set on blog and comments nodes')) + + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_jingle_stream.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_jingle_stream.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,281 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing pipes (experimental) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import xml_tools +from sat.tools import stream +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from twisted.internet import protocol +from twisted.internet import endpoints +from twisted.internet import reactor +from twisted.internet import error +from twisted.internet import interfaces +from zope import interface +import errno + +NS_STREAM = 'http://salut-a-toi.org/protocol/stream' +SECURITY_LIMIT=30 +START_PORT = 8888 + +PLUGIN_INFO = { + C.PI_NAME: "Jingle Stream Plugin", + C.PI_IMPORT_NAME: "STREAM", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0166"], + C.PI_MAIN: "JingleStream", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Jingle Stream plugin""") +} + +CONFIRM = D_(u"{peer} wants to send you a stream, do you accept ?") +CONFIRM_TITLE = D_(u"Stream Request") + + +class StreamProtocol(protocol.Protocol): + + def __init__(self): + self.pause = False + + def setPause(self, paused): + # in Python 2.x, Twisted classes are old style + # so we can use property and setter + if paused: + if not self.pause: + self.transport.pauseProducing() + self.pause = True + else: + if self.pause: + self.transport.resumeProducing() + self.pause = False + + def disconnect(self): + self.transport.loseConnection() + + def connectionMade(self): + if self.factory.client_conn is not None: + self.transport.loseConnection() + self.factory.setClientConn(self) + + def dataReceived(self, data): + self.factory.writeToConsumer(data) + + def sendData(self, data): + self.transport.write(data) + + def connectionLost(self, reason): + if self.factory.client_conn != self: + # only the first connected client_conn is relevant + return + + if reason.type == error.ConnectionDone: + self.factory.streamFinished() + else: + self.factory.streamFailed(reason) + + +@interface.implementer(stream.IStreamProducer) +@interface.implementer(interfaces.IPushProducer) +@interface.implementer(interfaces.IConsumer) +class StreamFactory(protocol.Factory): + protocol = StreamProtocol + consumer = None + producer = None + deferred = None + + def __init__(self): + self.client_conn = None + + def setClientConn(self, stream_protocol): + # in Python 2.x, Twisted classes are old style + # so we can use property and setter + assert self.client_conn is None + self.client_conn = stream_protocol + if self.consumer is None: + self.client_conn.setPause(True) + + def startStream(self, consumer): + if self.consumer is not None: + raise exceptions.InternalError(_(u"stream can't be used with multiple consumers")) + assert self.deferred is None + self.consumer = consumer + consumer.registerProducer(self, True) + self.deferred = defer.Deferred() + if self.client_conn is not None: + self.client_conn.setPause(False) + return self.deferred + + def streamFinished(self): + self.client_conn = None + if self.consumer: + self.consumer.unregisterProducer() + self.port_listening.stopListening() + self.deferred.callback(None) + + def streamFailed(self, failure_): + self.client_conn = None + if self.consumer: + self.consumer.unregisterProducer() + self.port_listening.stopListening() + self.deferred.errback(failure_) + elif self.producer: + self.producer.stopProducing() + + def stopStream(self): + if self.client_conn is not None: + self.client_conn.disconnect() + + def registerProducer(self, producer, streaming): + self.producer = producer + + def pauseProducing(self): + self.client_conn.setPause(True) + + def resumeProducing(self): + self.client_conn.setPause(False) + + def stopProducing(self): + if self.client_conn: + self.client_conn.disconnect() + + def write(self, data): + try: + self.client_conn.sendData(data) + except AttributeError: + log.warning(_(u"No client connected, can't send data")) + + def writeToConsumer(self, data): + self.consumer.write(data) + + +class JingleStream(object): + """This non standard jingle application send byte stream""" + + def __init__(self, host): + log.info(_("Plugin Stream initialization")) + self.host = host + self._j = host.plugins["XEP-0166"] # shortcut to access jingle + self._j.registerApplication(NS_STREAM, self) + host.bridge.addMethod("streamOut", ".plugin", in_sign='ss', out_sign='s', method=self._streamOut, async=True) + # jingle callbacks + + def _streamOut(self, to_jid_s, profile_key): + client = self.host.getClient(profile_key) + return self.streamOut(client, jid.JID(to_jid_s)) + + @defer.inlineCallbacks + def streamOut(self, client, to_jid): + """send a stream + + @param peer_jid(jid.JID): recipient + @return: an unique id to identify the transfer + """ + port = START_PORT + factory = StreamFactory() + while True: + endpoint = endpoints.TCP4ServerEndpoint(reactor, port) + try: + port_listening = yield endpoint.listen(factory) + except error.CannotListenError as e: + if e.socketError.errno == errno.EADDRINUSE: + port += 1 + else: + raise e + else: + factory.port_listening = port_listening + break + self._j.initiate(client, + to_jid, + [{'app_ns': NS_STREAM, + 'senders': self._j.ROLE_INITIATOR, + 'app_kwargs': {'stream_object': factory}, + }]) + defer.returnValue(unicode(port)) + + def jingleSessionInit(self, client, session, content_name, stream_object): + content_data = session['contents'][content_name] + application_data = content_data['application_data'] + assert 'stream_object' not in application_data + application_data['stream_object'] = stream_object + desc_elt = domish.Element((NS_STREAM, 'description')) + return desc_elt + + @defer.inlineCallbacks + def jingleRequestConfirmation(self, client, action, session, content_name, desc_elt): + """This method request confirmation for a jingle session""" + content_data = session['contents'][content_name] + if content_data['senders'] not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER): + log.warning(u"Bad sender, assuming initiator") + content_data['senders'] = self._j.ROLE_INITIATOR + + confirm_data = yield xml_tools.deferDialog(self.host, + _(CONFIRM).format(peer=session['peer_jid'].full()), + _(CONFIRM_TITLE), + type_=C.XMLUI_DIALOG_CONFIRM, + action_extra={'meta_from_jid': session['peer_jid'].full(), + 'meta_type': "STREAM", + }, + security_limit=SECURITY_LIMIT, + profile=client.profile) + + if not C.bool(confirm_data['answer']): + defer.returnValue(False) + try: + port = int(confirm_data['port']) + except (ValueError, KeyError): + raise exceptions.DataError(_(u'given port is invalid')) + endpoint = endpoints.TCP4ClientEndpoint(reactor, 'localhost', port) + factory = StreamFactory() + yield endpoint.connect(factory) + content_data['stream_object'] = factory + finished_d = content_data['finished_d'] = defer.Deferred() + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + defer.returnValue(True) + + def jingleHandler(self, client, action, session, content_name, desc_elt): + content_data = session['contents'][content_name] + application_data = content_data['application_data'] + if action in (self._j.A_ACCEPTED_ACK, self._j.A_SESSION_INITIATE): + pass + elif action == self._j.A_SESSION_ACCEPT: + assert not 'stream_object' in content_data + content_data['stream_object'] = application_data['stream_object'] + finished_d = content_data['finished_d'] = defer.Deferred() + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + else: + log.warning(u"FIXME: unmanaged action {}".format(action)) + return desc_elt + + def _finishedCb(self, dummy, client, session, content_name, content_data): + log.info(u"Pipe transfer completed") + self._j.contentTerminate(client, session, content_name) + content_data['stream_object'].stopStream() + + def _finishedEb(self, failure, client, session, content_name, content_data): + log.warning(u"Error while streaming pipe: {}".format(failure)) + self._j.contentTerminate(client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT) + content_data['stream_object'].stopStream() diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_lang_detect.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_lang_detect.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,91 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin to detect language (experimental) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions + +try: + from langid.langid import LanguageIdentifier, model +except ImportError: + raise exceptions.MissingModule(u'Missing module langid, please download/install it with "pip install langid")') + +identifier = LanguageIdentifier.from_modelstring(model, norm_probs=False) + + +PLUGIN_INFO = { + C.PI_NAME: "Language detection plugin", + C.PI_IMPORT_NAME: "EXP-LANG-DETECT", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "LangDetect", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Detect and set message language when unknown""") +} + +CATEGORY = D_(u"Misc") +NAME = u"lang_detect" +LABEL = D_(u"language detection") +PARAMS = """ + + + + + + + + """.format(category_name=CATEGORY, + name=NAME, + label=_(LABEL), + ) + + +class LangDetect(object): + + def __init__(self, host): + log.info(_(u"Language detection plugin initialization")) + self.host = host + host.memory.updateParams(PARAMS) + host.trigger.add("MessageReceived", self.MessageReceivedTrigger) + host.trigger.add("sendMessage", self.MessageSendTrigger) + + def addLanguage(self, mess_data): + message = mess_data['message'] + if len(message) == 1 and message.keys()[0] == '': + msg = message.values()[0] + lang = identifier.classify(msg)[0] + mess_data["message"] = {lang: msg} + return mess_data + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + """ Check if source is linked and repeat message, else do nothing """ + + lang_detect = self.host.memory.getParamA(NAME, CATEGORY, profile_key=client.profile) + if lang_detect: + post_treat.addCallback(self.addLanguage) + return True + + def MessageSendTrigger(self, client, data, pre_xml_treatments, post_xml_treatments): + lang_detect = self.host.memory.getParamA(NAME, CATEGORY, profile_key=client.profile) + if lang_detect: + self.addLanguage(data) + return True diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_parrot.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_parrot.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,176 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for parrot mode (experimental) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid + +from sat.core.exceptions import UnknownEntityError +#from sat.tools import trigger + +PLUGIN_INFO = { + C.PI_NAME: "Parrot Plugin", + C.PI_IMPORT_NAME: "EXP-PARROT", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0045"], + C.PI_RECOMMENDATIONS: [C.TEXT_CMDS], + C.PI_MAIN: "Exp_Parrot", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Implementation of parrot mode (repeat messages between 2 entities)""") +} + + +class Exp_Parrot(object): + """Parrot mode plugin: repeat messages from one entity or MUC room to another one""" + # XXX: This plugin can be potentially dangerous if we don't trust entities linked + # this is specially true if we have other triggers. + # sendMessageTrigger avoid other triggers execution, it's deactivated to allow + # /unparrot command in text commands plugin. + # FIXME: potentially unsecure, specially with e2e encryption + + def __init__(self, host): + log.info(_("Plugin Parrot initialization")) + self.host = host + host.trigger.add("MessageReceived", self.MessageReceivedTrigger, priority=100) + #host.trigger.add("sendMessage", self.sendMessageTrigger, priority=100) + try: + self.host.plugins[C.TEXT_CMDS].registerTextCommands(self) + except KeyError: + log.info(_(u"Text commands not available")) + + #def sendMessageTrigger(self, client, mess_data, treatments): + # """ Deactivate other triggers if recipient is in parrot links """ + # try: + # _links = client.parrot_links + # except AttributeError: + # return True + # + # if mess_data['to'].userhostJID() in _links.values(): + # log.debug("Parrot link detected, skipping other triggers") + # raise trigger.SkipOtherTriggers + + def MessageReceivedTrigger(self, client, message_elt, post_treat): + """ Check if source is linked and repeat message, else do nothing """ + # TODO: many things are not repeated (subject, thread, etc) + profile = client.profile + client = self.host.getClient(profile) + from_jid = message_elt["from"] + + try: + _links = client.parrot_links + except AttributeError: + return True + + if not from_jid.userhostJID() in _links: + return True + + message = {} + for e in message_elt.elements(C.NS_CLIENT, 'body'): + body = unicode(e) + lang = e.getAttribute('lang') or '' + + try: + entity_type = self.host.memory.getEntityData(from_jid, ['type'], profile)["type"] + except (UnknownEntityError, KeyError): + entity_type = "contact" + if entity_type == 'chatroom': + src_txt = from_jid.resource + if src_txt == self.host.plugins["XEP-0045"].getRoomNick(client, from_jid.userhostJID()): + #we won't repeat our own messages + return True + else: + src_txt = from_jid.user + message[lang] = u"[{}] {}".format(src_txt, body) + + linked = _links[from_jid.userhostJID()] + + client.sendMessage(jid.JID(unicode(linked)), message, None, "auto", no_trigger=True) + + return True + + def addParrot(self, client, source_jid, dest_jid): + """Add a parrot link from one entity to another one + + @param source_jid: entity from who messages will be repeated + @param dest_jid: entity where the messages will be repeated + """ + try: + _links = client.parrot_links + except AttributeError: + _links = client.parrot_links = {} + + _links[source_jid.userhostJID()] = dest_jid + log.info(u"Parrot mode: %s will be repeated to %s" % (source_jid.userhost(), unicode(dest_jid))) + + def removeParrot(self, client, source_jid): + """Remove parrot link + + @param source_jid: this entity will no more be repeated + """ + try: + del client.parrot_links[source_jid.userhostJID()] + except (AttributeError, KeyError): + pass + + def cmd_parrot(self, client, mess_data): + """activate Parrot mode between 2 entities, in both directions.""" + log.debug("Catched parrot command") + txt_cmd = self.host.plugins[C.TEXT_CMDS] + + try: + link_left_jid = jid.JID(mess_data["unparsed"].strip()) + if not link_left_jid.user or not link_left_jid.host: + raise jid.InvalidFormat + except (RuntimeError, jid.InvalidFormat, AttributeError): + txt_cmd.feedBack(client, "Can't activate Parrot mode for invalid jid", mess_data) + return False + + link_right_jid = mess_data['to'] + + self.addParrot(client, link_left_jid, link_right_jid) + self.addParrot(client, link_right_jid, link_left_jid) + + txt_cmd.feedBack(client, "Parrot mode activated for {}".format(unicode(link_left_jid)), mess_data) + + return False + + def cmd_unparrot(self, client, mess_data): + """remove Parrot mode between 2 entities, in both directions.""" + log.debug("Catched unparrot command") + txt_cmd = self.host.plugins[C.TEXT_CMDS] + + try: + link_left_jid = jid.JID(mess_data["unparsed"].strip()) + if not link_left_jid.user or not link_left_jid.host: + raise jid.InvalidFormat + except jid.InvalidFormat: + txt_cmd.feedBack(client, u"Can't deactivate Parrot mode for invalid jid", mess_data) + return False + + link_right_jid = mess_data['to'] + + self.removeParrot(client, link_left_jid) + self.removeParrot(client, link_right_jid) + + txt_cmd.feedBack(client, u"Parrot mode deactivated for {} and {}".format(unicode(link_left_jid), unicode(link_right_jid)), mess_data) + + return False diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_pubsub_hook.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_pubsub_hook.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,249 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Pubsub Hooks +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +from sat.memory import persistent +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +log = getLogger(__name__) + +NS_PUBSUB_HOOK = 'PUBSUB_HOOK' + +PLUGIN_INFO = { + C.PI_NAME: "PubSub Hook", + C.PI_IMPORT_NAME: NS_PUBSUB_HOOK, + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060"], + C.PI_MAIN: "PubsubHook", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Experimental plugin to launch on action on Pubsub notifications""") +} + +# python module +HOOK_TYPE_PYTHON = u'python' +# python file path +HOOK_TYPE_PYTHON_FILE = u'python_file' +# python code directly +HOOK_TYPE_PYTHON_CODE = u'python_code' +HOOK_TYPES = (HOOK_TYPE_PYTHON, HOOK_TYPE_PYTHON_FILE, HOOK_TYPE_PYTHON_CODE) + + +class PubsubHook(object): + + def __init__(self, host): + log.info(_(u"PubSub Hook initialization")) + self.host = host + self.node_hooks = {} # keep track of the number of hooks per node (for all profiles) + host.bridge.addMethod("psHookAdd", ".plugin", + in_sign='ssssbs', out_sign='', + method=self._addHook + ) + host.bridge.addMethod("psHookRemove", ".plugin", + in_sign='sssss', out_sign='i', + method=self._removeHook + ) + host.bridge.addMethod("psHookList", ".plugin", + in_sign='s', out_sign='aa{ss}', + method=self._listHooks + ) + + @defer.inlineCallbacks + def profileConnected(self, client): + hooks = client._hooks = persistent.PersistentBinaryDict(NS_PUBSUB_HOOK, client.profile) + client._hooks_temporary = {} + yield hooks.load() + for node in hooks: + self._installNodeManager(client, node) + + def profileDisconnected(self, client): + for node in client._hooks: + self._removeNodeManager(client, node) + + def _installNodeManager(self, client, node): + if node in self.node_hooks: + log.debug(_(u"node manager already set for {node}").format(node=node)) + self.node_hooks[node] += 1 + else: + # first hook on this node + self.host.plugins['XEP-0060'].addManagedNode(node, items_cb=self._itemsReceived) + self.node_hooks[node] = 0 + log.info(_(u"node manager installed on {node}").format( + node = node)) + + def _removeNodeManager(self, client, node): + try: + self.node_hooks[node] -= 1 + except KeyError: + log.error(_(u"trying to remove a {node} without hook").format(node=node)) + else: + if self.node_hooks[node] == 0: + del self.node_hooks[node] + self.host.plugins['XEP-0060'].removeManagedNode(node, self._itemsReceived) + log.debug(_(u"hook removed")) + else: + log.debug(_(u"node still needed for an other hook")) + + def installHook(self, client, service, node, hook_type, hook_arg, persistent): + if hook_type not in HOOK_TYPES: + raise exceptions.DataError(_(u'{hook_type} is not handled').format(hook_type=hook_type)) + if hook_type != HOOK_TYPE_PYTHON_FILE: + raise NotImplementedError(_(u'{hook_type} hook type not implemented yet').format(hook_type=hook_type)) + self._installNodeManager(client, node) + hook_data = {'service': service, + 'type': hook_type, + 'arg': hook_arg + } + + if persistent: + hooks_list = client._hooks.setdefault(node,[]) + hooks_list.append(hook_data) + client._hooks.force(node) + else: + hooks_list = client._hooks_temporary.setdefault(node,[]) + hooks_list.append(hook_data) + + log.info(_(u"{persistent} hook installed on {node} for {profile}").format( + persistent = _(u'persistent') if persistent else _(u'temporary'), + node = node, + profile = client.profile)) + + def _itemsReceived(self, client, itemsEvent): + node = itemsEvent.nodeIdentifier + for hooks in (client._hooks, client._hooks_temporary): + if node not in hooks: + continue + hooks_list = hooks[node] + for hook_data in hooks_list[:]: + if hook_data['service'] != itemsEvent.sender.userhostJID(): + continue + try: + callback = hook_data['callback'] + except KeyError: + # first time we get this hook, we create the callback + hook_type = hook_data['type'] + try: + if hook_type == HOOK_TYPE_PYTHON_FILE: + hook_globals = {} + execfile(hook_data['arg'], hook_globals) + callback = hook_globals['hook'] + else: + raise NotImplementedError(_(u'{hook_type} hook type not implemented yet').format( + hook_type=hook_type)) + except Exception as e: + log.warning(_(u"Can't load Pubsub hook at node {node}, it will be removed: {reason}").format( + node=node, reason=e)) + hooks_list.remove(hook_data) + continue + + for item in itemsEvent.items: + try: + callback(self.host, client, item) + except Exception as e: + log.warning(_(u"Error while running Pubsub hook for node {node}: {msg}").format( + node = node, + msg = e)) + + def _addHook(self, service, node, hook_type, hook_arg, persistent, profile): + client = self.host.getClient(profile) + service = jid.JID(service) if service else client.jid.userhostJID() + return self.addHook(client, service, unicode(node), unicode(hook_type), unicode(hook_arg), persistent) + + def addHook(self, client, service, node, hook_type, hook_arg, persistent): + r"""Add a hook which will be triggered on a pubsub notification + + @param service(jid.JID): service of the node + @param node(unicode): Pubsub node + @param hook_type(unicode): type of the hook, one of: + - HOOK_TYPE_PYTHON: a python module (must be in path) + module must have a "hook" method which will be called + - HOOK_TYPE_PYTHON_FILE: a python file + file must have a "hook" method which will be called + - HOOK_TYPE_PYTHON_CODE: direct python code + /!\ Python hooks will be executed in SàT context, + with host, client and item as arguments, it means that: + - they can do whatever they wants, so don't run untrusted hooks + - they MUST NOT BLOCK, they are run in Twisted async environment and blocking would block whole SàT process + - item are domish.Element + @param hook_arg(unicode): argument of the hook, depending on the hook_type + can be a module path, file path, python code + """ + assert service is not None + return self.installHook(client, service, node, hook_type, hook_arg, persistent) + + def _removeHook(self, service, node, hook_type, hook_arg, profile): + client = self.host.getClient(profile) + service = jid.JID(service) if service else client.jid.userhostJID() + return self.removeHook(client, service, node, hook_type or None, hook_arg or None) + + def removeHook(self, client, service, node, hook_type=None, hook_arg=None): + """Remove a persistent or temporaty root + + @param service(jid.JID): service of the node + @param node(unicode): Pubsub node + @param hook_type(unicode, None): same as for [addHook] + match all if None + @param hook_arg(unicode, None): same as for [addHook] + match all if None + @return(int): number of hooks removed + """ + removed = 0 + for hooks in (client._hooks, client._hooks_temporary): + if node in hooks: + for hook_data in hooks[node]: + if (service != hook_data[u'service'] + or hook_type is not None and hook_type != hook_data[u'type'] + or hook_arg is not None and hook_arg != hook_data[u'arg']): + continue + hooks[node].remove(hook_data) + removed += 1 + if not hooks[node]: + # no more hooks, we can remove the node + del hooks[node] + self._removeNodeManager(client, node) + else: + if hooks == client._hooks: + hooks.force(node) + return removed + + def _listHooks(self, profile): + hooks_list = self.listHooks(self.host.getClient(profile)) + for hook in hooks_list: + hook[u'service'] = hook[u'service'].full() + hook[u'persistent'] = C.boolConst(hook[u'persistent']) + return hooks_list + + def listHooks(self, client): + """return list of registered hooks""" + hooks_list = [] + for hooks in (client._hooks, client._hooks_temporary): + persistent = hooks is client._hooks + for node, hooks_data in hooks.iteritems(): + for hook_data in hooks_data: + hooks_list.append({u'service': hook_data[u'service'], + u'node': node, + u'type': hook_data[u'type'], + u'arg': hook_data[u'arg'], + u'persistent': persistent + }) + return hooks_list + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_exp_pubsub_schema.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_exp_pubsub_schema.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,493 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Pubsub Schemas +# 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 . + +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.tools import xml_tools +from sat.tools import utils +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.internet import defer +from sat.core.log import getLogger +log = getLogger(__name__) +from wokkel import disco, iwokkel +from wokkel import data_form +from wokkel import generic +from zope.interface import implements +from collections import Iterable +import copy +import itertools + +NS_SCHEMA = 'https://salut-a-toi/protocol/schema:0' + +PLUGIN_INFO = { + C.PI_NAME: "PubSub Schema", + C.PI_IMPORT_NAME: "PUBSUB_SCHEMA", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060", "IDENTITY"], + C.PI_MAIN: "PubsubSchema", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Handle Pubsub data schemas""") +} + + +class PubsubSchema(object): + + def __init__(self, host): + log.info(_(u"PubSub Schema initialization")) + self.host = host + self._p = self.host.plugins["XEP-0060"] + self._i = self.host.plugins["IDENTITY"] + host.bridge.addMethod("psSchemaGet", ".plugin", + in_sign='sss', out_sign='s', + method=self._getSchema, + async=True + ) + host.bridge.addMethod("psSchemaSet", ".plugin", + in_sign='ssss', out_sign='', + method=self._setSchema, + async=True + ) + host.bridge.addMethod("psSchemaUIGet", ".plugin", + in_sign='sss', out_sign='s', + method=utils.partial(self._getUISchema, default_node=None), + async=True + ) + host.bridge.addMethod("psItemsFormGet", ".plugin", + in_sign='ssssiassa{ss}s', out_sign='(asa{ss})', + method=self._getDataFormItems, + async=True) + host.bridge.addMethod("psItemFormSend", ".plugin", + in_sign='ssa{sas}ssa{ss}s', out_sign='s', + method=self._sendDataFormItem, + async=True) + + def getHandler(self, client): + return SchemaHandler() + + def _getSchemaBridgeCb(self, schema_elt): + if schema_elt is None: + return u'' + return schema_elt.toXml() + + def _getSchema(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + d = self.getSchema(client, service, nodeIdentifier) + d.addCallback(self._getSchemaBridgeCb) + return d + + def _getSchemaCb(self, iq_elt): + try: + schema_elt = next(iq_elt.elements(NS_SCHEMA, 'schema')) + except StopIteration: + raise exceptions.DataError('missing element') + try: + x_elt = next(schema_elt.elements((data_form.NS_X_DATA, 'x'))) + except StopIteration: + # there is not schema on this node + return None + return x_elt + + def getSchema(self, client, service, nodeIdentifier): + """retrieve PubSub node schema + + @param service(jid.JID, None): jid of PubSub service + None to use our PEP + @param nodeIdentifier(unicode): node to get schema from + @return (domish.Element, None): schema ( element) + None if not schema has been set on this node + """ + iq_elt = client.IQ(u'get') + if service is not None: + iq_elt['to'] = service.full() + pubsub_elt = iq_elt.addElement((NS_SCHEMA, 'pubsub')) + schema_elt = pubsub_elt.addElement((NS_SCHEMA, 'schema')) + schema_elt['node'] = nodeIdentifier + d = iq_elt.send() + d.addCallback(self._getSchemaCb) + return d + + @defer.inlineCallbacks + def getSchemaForm(self, client, service, nodeIdentifier, schema=None, form_type='form', copy_form=True): + """get data form from node's schema + + @param service(None, jid.JID): PubSub service + @param nodeIdentifier(unicode): node + @param schema(domish.Element, data_form.Form, None): node schema + if domish.Element, will be converted to data form + if data_form.Form it will be returned without modification + if None, it will be retrieved from node (imply one additional XMPP request) + @param form_type(unicode): type of the form + @param copy_form(bool): if True and if schema is already a data_form.Form, will deep copy it before returning + needed when the form is reused and it will be modified (e.g. in sendDataFormItem) + @return(data_form.Form): data form + the form should not be modified if copy_form is not set + """ + if schema is None: + log.debug(_(u"unspecified schema, we need to request it")) + schema = yield self.getSchema(client, service, nodeIdentifier) + if schema is None: + raise exceptions.DataError(_(u"no schema specified, and this node has no schema either, we can't construct the data form")) + elif isinstance(schema, data_form.Form): + if copy_form: + schema = copy.deepcopy(schema) + defer.returnValue(schema) + + try: + form = data_form.Form.fromElement(schema) + except data_form.Error as e: + raise exceptions.DataError(_(u"Invalid Schema: {msg}").format( + msg = e)) + form.formType = form_type + defer.returnValue(form) + + def schema2XMLUI(self, schema_elt): + form = data_form.Form.fromElement(schema_elt) + xmlui = xml_tools.dataForm2XMLUI(form, '') + return xmlui + + def _getUISchema(self, service, nodeIdentifier, default_node=None, profile_key=C.PROF_KEY_NONE): + if not nodeIdentifier: + if not default_node: + raise ValueError(_(u"nodeIndentifier needs to be set")) + nodeIdentifier = default_node + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + d = self.getUISchema(client, service, nodeIdentifier) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def getUISchema(self, client, service, nodeIdentifier): + d = self.getSchema(client, service, nodeIdentifier) + d.addCallback(self.schema2XMLUI) + return d + + def _setSchema(self, service, nodeIdentifier, schema, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + schema = generic.parseXml(schema.encode('utf-8')) + return self.setSchema(client, service, nodeIdentifier, schema) + + def setSchema(self, client, service, nodeIdentifier, schema): + """set or replace PubSub node schema + + @param schema(domish.Element, None): schema to set + None if schema need to be removed + """ + iq_elt = client.IQ() + if service is not None: + iq_elt['to'] = service.full() + pubsub_elt = iq_elt.addElement((NS_SCHEMA, 'pubsub')) + schema_elt = pubsub_elt.addElement((NS_SCHEMA, 'schema')) + schema_elt['node'] = nodeIdentifier + if schema is not None: + schema_elt.addChild(schema) + return iq_elt.send() + + def _getDataFormItems(self, form_ns='', service='', node='', schema='', max_items=10, item_ids=None, sub_id=None, extra_dict=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = jid.JID(service) if service else None + if not node: + raise exceptions.DataError(_(u'empty node is not allowed')) + if schema: + schema = generic.parseXml(schema.encode('utf-8')) + else: + schema = None + max_items = None if max_items == C.NO_LIMIT else max_items + extra = self._p.parseExtra(extra_dict) + d = self.getDataFormItems(client, service, node, schema, max_items or None, item_ids, sub_id or None, extra.rsm_request, extra.extra, form_ns=form_ns or None) + d.addCallback(self._p.serItemsData) + return d + + @defer.inlineCallbacks + def getDataFormItems(self, client, service, nodeIdentifier, schema=None, max_items=None, item_ids=None, sub_id=None, rsm_request=None, extra=None, default_node=None, form_ns=None, filters=None): + """Get items known as being data forms, and convert them to XMLUI + + @param schema(domish.Element, data_form.Form, None): schema of the node if known + if None, it will be retrieved from node + @param default_node(unicode): node to use if nodeIdentifier is None or empty + @param form_ns (unicode, None): namespace of the form + None to accept everything, even if form has no namespace + @param filters(dict, None): same as for xml_tools.dataFormResult2XMLUI + other parameters as the same as for [getItems] + @return (list[unicode]): XMLUI of the forms + if an item is invalid (not corresponding to form_ns or not a data_form) + it will be skipped + @raise ValueError: one argument is invalid + """ + if not nodeIdentifier: + if not default_node: + raise ValueError(_(u"default_node must be set if nodeIdentifier is not set")) + nodeIdentifier = default_node + # we need the initial form to get options of fields when suitable + schema_form = yield self.getSchemaForm(client, service, nodeIdentifier, schema, form_type='result', copy_form=False) + items_data = yield self._p.getItems(client, service, nodeIdentifier, max_items, item_ids, sub_id, rsm_request, extra) + items, metadata = items_data + items_xmlui = [] + for item_elt in items: + for x_elt in item_elt.elements((data_form.NS_X_DATA, u'x')): + form = data_form.Form.fromElement(x_elt) + if form_ns and form.formNamespace != form_ns: + continue + xmlui = xml_tools.dataFormResult2XMLUI( + form, + schema_form, + # FIXME: conflicts with schema (i.e. if "id" or "publisher" already exists) + # are not checked + prepend = (('label', 'id'),('text', item_elt['id'], u'id'), + ('label', 'publisher'),('text', item_elt.getAttribute('publisher',''), u'publisher')), + filters = filters, + ) + items_xmlui.append(xmlui) + break + defer.returnValue((items_xmlui, metadata)) + + + def _sendDataFormItem(self, service, nodeIdentifier, values, schema=None, item_id=None, extra=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + if schema: + schema = generic.parseXml(schema.encode('utf-8')) + else: + schema = None + d = self.sendDataFormItem(client, service, nodeIdentifier, values, schema, item_id or None, extra, deserialise=True) + d.addCallback(lambda ret: ret or u'') + return d + + @defer.inlineCallbacks + def sendDataFormItem(self, client, service, nodeIdentifier, values, schema=None, item_id=None, extra=None, deserialise=False): + """Publish an item as a dataform when we know that there is a schema + + @param values(dict[key(unicode), [iterable[object], object]]): values set for the form + if not iterable, will be put in a list + @param schema(domish.Element, data_form.Form, None): data schema + None to retrieve data schema from node (need to do a additional XMPP call) + Schema is needed to construct data form to publish + @param deserialise(bool): if True, data are list of unicode and must be deserialized according to expected type + This is done in this method and not directly in _sendDataFormItem because we need to know the data type + which is in the form, not availablable in _sendDataFormItem + other parameters as the same as for [self._p.sendItem] + @return (unicode): id of the created item + """ + form = yield self.getSchemaForm(client, service, nodeIdentifier, schema, form_type='submit') + + for name, values_list in values.iteritems(): + try: + field = form.fields[name] + except KeyError: + log.warning(_(u"field {name} doesn't exist, ignoring it").format(name=name)) + continue + if isinstance(values_list, basestring) or not isinstance(values_list, Iterable): + values_list = [values_list] + if deserialise: + if field.fieldType == 'boolean': + values_list = [C.bool(v) for v in values_list] + elif field.fieldType == 'text-multi': + # for text-multi, lines must be put on separate values + values_list = list(itertools.chain(*[v.splitlines() for v in values_list])) + + elif 'jid' in field.fieldType: + values_list = [jid.JID(v) for v in values_list] + if 'list' in field.fieldType: + # for lists, we check that given values are allowed in form + allowed_values = [o.value for o in field.options] + values_list = [v for v in values_list if v in allowed_values] + if not values_list: + # if values don't map to allowed values, we use default ones + values_list = field.values + field.values = values_list + + yield self._p.sendItem(client, service, nodeIdentifier, form.toElement(), item_id, extra) + + ## filters ## + # filters useful for data form to XMLUI conversion # + + def valueOrPublisherFilter(self, form_xmlui, widget_type, args, kwargs): + """Replace missing value by publisher's user part""" + if not args[0]: + # value is not filled: we use user part of publisher (if we have it) + try: + publisher = jid.JID(form_xmlui.named_widgets['publisher'].value) + except (KeyError, RuntimeError): + pass + else: + args[0] = publisher.user.capitalize() + return widget_type, args, kwargs + + def textbox2ListFilter(self, form_xmlui, widget_type, args, kwargs): + """Split lines of a textbox in a list + + main use case is using a textbox for labels + """ + if widget_type != u'textbox': + return widget_type, args, kwargs + widget_type = u'list' + options = [o for o in args.pop(0).split(u'\n') if o] + kwargs = {'options': options, + 'name': kwargs.get('name'), + 'styles': (u'noselect', u'extensible', u'reducible')} + return widget_type, args, kwargs + + def dateFilter(self, form_xmlui, widget_type, args, kwargs): + """Convert a string with a date to a unix timestamp""" + if widget_type != u'string' or not args[0]: + return widget_type, args, kwargs + # we convert XMPP date to timestamp + try: + args[0] = unicode(utils.date_parse(args[0])) + except Exception as e: + log.warning(_(u"Can't parse date field: {msg}").format(msg=e)) + return widget_type, args, kwargs + + ## Helper methods ## + + def prepareBridgeGet(self, service, node, max_items, sub_id, extra_dict, profile_key): + """Parse arguments received from bridge *Get methods and return higher level data + + @return (tuple): (client, service, node, max_items, extra, sub_id) usable for internal methods + """ + client = self.host.getClient(profile_key) + service = jid.JID(service) if service else None + if not node: + node = None + max_items = None if max_items == C.NO_LIMIT else max_items + if not sub_id: + sub_id = None + extra = self._p.parseExtra(extra_dict) + + return client, service, node, max_items, extra, sub_id + + def _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None, extra_dict=None, default_node=None, form_ns=None, filters=None, profile_key=C.PROF_KEY_NONE): + """Bridge method to retrieve data from node with schema + + this method is a helper so dependant plugins can use it directly + when adding *Get methods + """ + client, service, node, max_items, extra, sub_id = self.prepareBridgeGet(service, node, max_items, sub_id, extra_dict, profile_key) + d = self.getDataFormItems(client, service, node or None, + max_items=max_items, + item_ids=item_ids, + sub_id=sub_id, + rsm_request=extra.rsm_request, + extra=extra.extra, + default_node=default_node, + form_ns=form_ns, + filters=filters) + d.addCallback(self._p.serItemsData) + return d + + def prepareBridgeSet(self, service, node, schema, item_id, extra, profile_key): + """Parse arguments received from bridge *Set methods and return higher level data + + @return (tuple): (client, service, node, schema, item_id, extra) usable for internal methods + """ + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + if schema: + schema = generic.parseXml(schema.encode('utf-8')) + else: + schema = None + if extra and u'update' in extra: + extra[u'update'] = C.bool(extra[u'update']) + return client, service, node or None, schema, item_id or None, extra + + def _set(self, service, node, values, schema=None, item_id=None, extra=None, default_node=None, form_ns=None, fill_author=True, profile_key=C.PROF_KEY_NONE): + """Bridge method to set item in node with schema + + this method is a helper so dependant plugins can use it directly + when adding *Set methods + """ + client, service, node, schema, item_id, extra = self.prepareBridgeSet(service, node, schema, item_id, extra) + d = self.set(client, service, node, values, schema, item_id, extra, + deserialise=True, + form_ns=form_ns, + default_node=default_node, + fill_author=fill_author) + d.addCallback(lambda ret: ret or u'') + return d + + @defer.inlineCallbacks + def set(self, client, service, node, values, schema, item_id, extra, deserialise, form_ns, default_node=None, fill_author=True): + """Set an item in a node with a schema + + This method can be used directly by *Set methods added by dependant plugin + @param values(dict[key(unicode), [iterable[object]|object]]): values of the items + if value is not iterable, it will be put in a list + 'created' and 'updated' will be forced to current time: + - 'created' is set if item_id is None, i.e. if it's a new ticket + - 'updated' is set everytime + @param extra(dict, None): same as for [XEP-0060.sendItem] with additional keys: + - update(bool): if True, get previous item data to merge with current one + if True, item_id must be None + @param form_ns (unicode, None): namespace of the form + needed when an update is done + @param default_node(unicode, None): value to use if node is not set + other arguments are same as for [self._s.sendDataFormItem] + @return (unicode): id of the created item + """ + if not node: + if default_node is None: + raise ValueError(_(u"default_node must be set if node is not set")) + node = default_node + now = utils.xmpp_date() + if not item_id: + values['created'] = now + elif extra.get(u'update', False): + if item_id is None: + raise exceptions.DataError(_(u'if extra["update"] is set, item_id must be set too')) + try: + # we get previous item + items_data = yield self._p.getItems(client, service, node, item_ids=[item_id]) + item_elt = items_data[0][0] + except Exception as e: + log.warning(_(u"Can't get previous item, update ignored: {reason}").format( + reason = e)) + else: + # and parse it + form = data_form.findForm(item_elt, form_ns) + if form is None: + log.warning(_(u"Can't parse previous item, update ignored: data form not found").format( + reason = e)) + else: + for name, field in form.fields.iteritems(): + if name not in values: + values[name] = u'\n'.join(unicode(v) for v in field.values) + + values['updated'] = now + if fill_author: + if not values.get('author'): + identity = yield self._i.getIdentity(client, client.jid) + values['author'] = identity['nick'] + if not values.get('author_jid'): + values['author_jid'] = client.jid.full() + item_id = yield self.sendDataFormItem(client, service, node, values, schema, item_id, extra, deserialise) + defer.returnValue(item_id) + + +class SchemaHandler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, service, nodeIdentifier=''): + return [disco.DiscoFeature(NS_SCHEMA)] + + def getDiscoItems(self, requestor, service, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_import.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_import.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,248 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for generic data import handling +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from sat.core import exceptions +from twisted.words.protocols.jabber import jid +from functools import partial +import collections +import uuid +import json + + +PLUGIN_INFO = { + C.PI_NAME: "import", + C.PI_IMPORT_NAME: "IMPORT", + C.PI_TYPE: C.PLUG_TYPE_IMPORT, + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "ImportPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Generic import plugin, base for specialized importers""") +} + +Importer = collections.namedtuple('Importer', ('callback', 'short_desc', 'long_desc')) + + +class ImportPlugin(object): + + def __init__(self, host): + log.info(_("plugin Import initialization")) + self.host = host + + def initialize(self, import_handler, name): + """Initialize a specialized import handler + + @param import_handler(object): specialized import handler instance + must have the following methods: + - importItem: import a single main item (i.e. prepare data for publishing) + - importSubitems: import sub items (i.e. items linked to main item, e.g. comments). + Must return a dict with kwargs for recursiveImport if items are to be imported recursively. + At least "items_import_data", "service" and "node" keys must be provided. + if None is returned, no recursion will be done to import subitems, but import can still be done directly by the method. + - publishItem: actualy publish an item + - itemFilters: modify item according to options + @param name(unicode): import handler name + """ + assert name == name.lower().strip() + log.info(_(u'initializing {name} import handler').format(name=name)) + import_handler.name = name + import_handler.register = partial(self.register, import_handler) + import_handler.unregister = partial(self.unregister, import_handler) + import_handler.importers = {} + def _import(name, location, options, pubsub_service, pubsub_node, profile): + return self._doImport(import_handler, name, location, options, pubsub_service, pubsub_node, profile) + def _importList(): + return self.listImporters(import_handler) + def _importDesc(name): + return self.getDescription(import_handler, name) + + self.host.bridge.addMethod(name + "Import", ".plugin", in_sign='ssa{ss}sss', out_sign='s', method=_import, async=True) + self.host.bridge.addMethod(name + "ImportList", ".plugin", in_sign='', out_sign='a(ss)', method=_importList) + self.host.bridge.addMethod(name + "ImportDesc", ".plugin", in_sign='s', out_sign='(ss)', method=_importDesc) + + def getProgress(self, import_handler, progress_id, profile): + client = self.host.getClient(profile) + return client._import[import_handler.name][progress_id] + + def listImporters(self, import_handler): + importers = import_handler.importers.keys() + importers.sort() + return [(name, import_handler.importers[name].short_desc) for name in import_handler.importers] + + def getDescription(self, import_handler, name): + """Return import short and long descriptions + + @param name(unicode): importer name + @return (tuple[unicode,unicode]): short and long description + """ + try: + importer = import_handler.importers[name] + except KeyError: + raise exceptions.NotFound(u"{handler_name} importer not found [{name}]".format( + handler_name = import_handler.name, + name = name)) + else: + return importer.short_desc, importer.long_desc + + def _doImport(self, import_handler, name, location, options, pubsub_service='', pubsub_node='', profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + options = {key: unicode(value) for key, value in options.iteritems()} + for option in import_handler.BOOL_OPTIONS: + try: + options[option] = C.bool(options[option]) + except KeyError: + pass + for option in import_handler.JSON_OPTIONS: + try: + options[option] = json.loads(options[option]) + except ValueError: + raise exceptions.DataError(_(u'invalid json option: {name}').format(name=option)) + pubsub_service = jid.JID(pubsub_service) if pubsub_service else None + return self.doImport(client, import_handler, unicode(name), unicode(location), options, pubsub_service, pubsub_node or None) + + @defer.inlineCallbacks + def doImport(self, client, import_handler, name, location, options=None, pubsub_service=None, pubsub_node=None): + """Import data + + @param import_handler(object): instance of the import handler + @param name(unicode): name of the importer + @param location(unicode): location of the data to import + can be an url, a file path, or anything which make sense + check importer description for more details + @param options(dict, None): extra options. + @param pubsub_service(jid.JID, None): jid of the PubSub service where data must be imported + None to use profile's server + @param pubsub_node(unicode, None): PubSub node to use + None to use importer's default node + @return (unicode): progress id + """ + if options is None: + options = {} + else: + for opt_name, opt_default in import_handler.OPT_DEFAULTS.iteritems(): + # we want a filled options dict, with all empty or False values removed + try: + value =options[opt_name] + except KeyError: + if opt_default: + options[opt_name] = opt_default + else: + if not value: + del options[opt_name] + + try: + importer = import_handler.importers[name] + except KeyError: + raise exceptions.NotFound(u"Importer [{}] not found".format(name)) + items_import_data, items_count = yield importer.callback(client, location, options) + progress_id = unicode(uuid.uuid4()) + try: + _import = client._import + except AttributeError: + _import = client._import = {} + progress_data = _import.setdefault(import_handler.name, {}) + progress_data[progress_id] = {u'position': '0'} + if items_count is not None: + progress_data[progress_id]['size'] = unicode(items_count) + metadata = {'name': u'{}: {}'.format(name, location), + 'direction': 'out', + 'type': import_handler.name.upper() + '_IMPORT' + } + self.host.registerProgressCb(progress_id, partial(self.getProgress, import_handler), metadata, profile=client.profile) + self.host.bridge.progressStarted(progress_id, metadata, client.profile) + session = { # session data, can be used by importers + u'root_service': pubsub_service, + u'root_node': pubsub_node + } + self.recursiveImport(client, import_handler, items_import_data, progress_id, session, options, None, pubsub_service, pubsub_node) + defer.returnValue(progress_id) + + @defer.inlineCallbacks + def recursiveImport(self, client, import_handler, items_import_data, progress_id, session, options, return_data=None, service=None, node=None, depth=0): + """Do the import recursively + + @param import_handler(object): instance of the import handler + @param items_import_data(iterable): iterable of data as specified in [register] + @param progress_id(unicode): id of progression + @param session(dict): data for this import session + can be used by importer so store any useful data + "root_service" and "root_node" are set to the main pubsub service and node of the import + @param options(dict): import options + @param return_data(dict): data to return on progressFinished + @param service(jid.JID, None): PubSub service to use + @param node(unicode, None): PubSub node to use + @param depth(int): level of recursion + """ + if return_data is None: + return_data = {} + for idx, item_import_data in enumerate(items_import_data): + item_data = yield import_handler.importItem(client, item_import_data, session, options, return_data, service, node) + yield import_handler.itemFilters(client, item_data, session, options) + recurse_kwargs = yield import_handler.importSubItems(client, item_import_data, item_data, session, options) + yield import_handler.publishItem(client, item_data, service, node, session) + + if recurse_kwargs is not None: + recurse_kwargs['client'] = client + recurse_kwargs['import_handler'] = import_handler + recurse_kwargs['progress_id'] = progress_id + recurse_kwargs['session'] = session + recurse_kwargs.setdefault('options', options) + recurse_kwargs['return_data'] = return_data + recurse_kwargs['depth'] = depth + 1 + log.debug(_(u"uploading subitems")) + yield self.recursiveImport(**recurse_kwargs) + + if depth == 0: + client._import[import_handler.name][progress_id]['position'] = unicode(idx+1) + + if depth == 0: + self.host.bridge.progressFinished(progress_id, + return_data, + client.profile) + self.host.removeProgressCb(progress_id, client.profile) + del client._import[import_handler.name][progress_id] + + def register(self, import_handler, name, callback, short_desc='', long_desc=''): + """Register an Importer method + + @param name(unicode): unique importer name, should indicate the software it can import and always lowercase + @param callback(callable): method to call: + the signature must be (client, location, options) (cf. [doImport]) + the importer must return a tuple with (items_import_data, items_count) + items_import_data(iterable[dict]) data specific to specialized importer + cf. importItem docstring of specialized importer for details + items_count (int, None) indicate the total number of items (without subitems) + useful to display a progress indicator when the iterator is a generator + use None if you can't guess the total number of items + @param short_desc(unicode): one line description of the importer + @param long_desc(unicode): long description of the importer, its options, etc. + """ + name = name.lower() + if name in import_handler.importers: + raise exceptions.ConflictError(_(u"An {handler_name} importer with the name {name} already exist").format( + handler_name = import_handler.name, + name = name)) + import_handler.importers[name] = Importer(callback, short_desc, long_desc) + + def unregister(self, import_handler, name): + del import_handler.importers[name] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_merge_req_mercurial.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_merge_req_mercurial.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,197 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for import external blogs +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from twisted.internet import reactor, defer, protocol +from twisted.python.failure import Failure +from twisted.python.procutils import which +import re +from sat.core.log import getLogger +log = getLogger(__name__) + + +PLUGIN_INFO = { + C.PI_NAME: "Mercurial Merge Request handler", + C.PI_IMPORT_NAME: "MERGE_REQUEST_MERCURIAL", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_DEPENDENCIES: ["MERGE_REQUESTS"], + C.PI_MAIN: "MercurialHandler", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Merge request handler for Mercurial""") +} + +SHORT_DESC = D_(u"handle Mercurial repository") + + +class MercurialProtocol(protocol.ProcessProtocol): + """handle hg commands""" + hg = None + + def __init__(self, deferred, stdin=None): + """ + @param deferred(defer.Deferred): will be called when command is completed + @param stdin(str, None): if not None, will be push to standard input + """ + self._stdin = stdin + self._deferred = deferred + self.data = [] + + def connectionMade(self): + if self._stdin is not None: + self.transport.write(self._stdin) + self.transport.closeStdin() + + def outReceived(self, data): + self.data.append(data) + + def errReceived(self, data): + self.data.append(data) + + def processEnded(self, reason): + data = u''.join([d.decode('utf-8') for d in self.data]) + if (reason.value.exitCode == 0): + log.debug(_('Mercurial command succeed')) + self._deferred.callback(data) + else: + msg = _(u"Can't complete Mercurial command (error code: {code}): {message}").format( + code = reason.value.exitCode, + message = data) + log.warning(msg) + self._deferred.errback(Failure(RuntimeError(msg))) + + @classmethod + def run(cls, path, command, *args, **kwargs): + """Create a new MercurialRegisterProtocol and execute the given mercurialctl command. + + @param path(unicode): path to the repository + @param command(unicode): command to run + @param *args(unicode): command arguments + @param **kwargs: used because Python2 doesn't handle normal kw args after *args + can only be: + - stdin(unicode, None): data to push to standard input + @return ((D)): + """ + stdin = kwargs.pop('stdin', None) + if kwargs: + raise exceptions.InternalError(u'only stdin is allowed as keyword argument') + if stdin is not None: + stdin = stdin.encode('utf-8') + d = defer.Deferred() + mercurial_prot = MercurialProtocol(d, stdin=stdin) + cmd_args = [cls.hg, command.encode('utf-8')] + cmd_args.extend([a.encode('utf-8') for a in args]) + reactor.spawnProcess(mercurial_prot, + cls.hg, + cmd_args, + path=path.encode('utf-8')) + return d + + +class MercurialHandler(object): + data_types = (u'mercurial_changeset',) + + def __init__(self, host): + log.info(_(u"Mercurial merge request handler initialization")) + try: + MercurialProtocol.hg = which('hg')[0] + except IndexError: + raise exceptions.NotFound(_(u"Mercurial executable (hg) not found, can't use Mercurial handler")) + self.host = host + self._m = host.plugins['MERGE_REQUESTS'] + self._m.register('mercurial', self, self.data_types, SHORT_DESC) + + def check(self, repository): + d = MercurialProtocol.run(repository, 'identify') + d.addCallback(lambda dummy: True) + d.addErrback(lambda dummy: False) + return d + + def export(self, repository): + return MercurialProtocol.run(repository, 'export', '-g', '-r', 'outgoing()', '--encoding=utf-8') + + def import_(self, repository, data, data_type, item_id, service, node, extra): + parsed_data = self.parse(data) + try: + parsed_name = parsed_data[0][u'commit_msg'].split(u'\n')[0] + parsed_name = re.sub(ur'[^\w -.]', u'', parsed_name, flags=re.UNICODE)[:40] + except Exception: + parsed_name = u'' + name = u'mr_{item_id}_{parsed_name}'.format(item_id=item_id, parsed_name=parsed_name) + return MercurialProtocol.run(repository, 'qimport', '-g', '--name', name, '--encoding=utf-8', '-', stdin=data) + + def parse(self, data, data_type=None): + lines = data.splitlines() + total_lines = len(lines) + patches = [] + while lines: + patch = {} + commit_msg = [] + diff = [] + state = 'init' + if lines[0] != '# HG changeset patch': + raise exceptions.DataError(_(u'invalid changeset signature')) + # line index of this patch in the whole data + patch_idx = total_lines - len(lines) + del lines[0] + + for idx, line in enumerate(lines): + if state == 'init': + if line.startswith(u'# '): + if line.startswith(u'# User '): + elems = line[7:].split() + if not elems: + continue + last = elems[-1] + if last.startswith(u'<') and last.endswith(u'>') and u'@' in last: + patch[self._m.META_EMAIL] = elems.pop()[1:-1] + patch[self._m.META_AUTHOR] = u' '.join(elems) + elif line.startswith(u'# Date '): + time_data = line[7:].split() + if len(time_data) != 2: + log.warning(_(u'unexpected time data: {data}').format(data=line[7:])) + continue + patch[self._m.META_TIMESTAMP] = int(time_data[0]) + int(time_data[1]) + elif line.startswith(u'# Node ID '): + patch[self._m.META_HASH] = line[10:] + elif line.startswith(u'# Parent '): + patch[self._m.META_PARENT_HASH] = line[10:] + else: + state = 'commit_msg' + if state == 'commit_msg': + if line.startswith(u'diff --git a/'): + state = 'diff' + patch[self._m.META_DIFF_IDX] = patch_idx + idx + 1 + else: + commit_msg.append(line) + if state == 'diff': + if line.startswith(u'# ') or idx == len(lines)-1: + # a new patch is starting or we have reached end of patches + patch[self._m.META_COMMIT_MSG] = u'\n'.join(commit_msg) + patch[self._m.META_DIFF] = u'\n'.join(diff) + patches.append(patch) + if idx == len(lines)-1: + del lines[:] + else: + del lines[:idx] + break + else: + diff.append(line) + return patches diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_account.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_account.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,561 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for account creation (experimental) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools import xml_tools +from sat.memory.memory import Sessions +from sat.memory.crypto import PasswordHasher +from sat.core.constants import Const as C +import ConfigParser +from twisted.internet import defer +from twisted.python.failure import Failure +from twisted.words.protocols.jabber import jid +from sat.tools import email as sat_email + +# FIXME: this plugin code is old and need a cleaning +# TODO: account deletion/password change need testing + + +PLUGIN_INFO = { + C.PI_NAME: "Account Plugin", + C.PI_IMPORT_NAME: "MISC-ACCOUNT", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0077"], + C.PI_RECOMMENDATIONS: ['GROUPBLOG'], + C.PI_MAIN: "MiscAccount", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""SàT account creation""") +} + +CONFIG_SECTION = "plugin account" + +# You need do adapt the following consts to your server +# all theses values (key=option name, value=default) can (and should) be overriden in sat.conf +# in section CONFIG_SECTION + +default_conf = {"email_from": "NOREPLY@example.net", + "email_server": "localhost", + "email_sender_domain": "", + "email_port": 25, + "email_username": "", + "email_password": "", + "email_starttls": "false", + "email_auth": "false", + "email_admins_list": [], + "admin_email": "", + "new_account_server": "localhost", + "new_account_domain": "", # use xmpp_domain if not found + "reserved_list": ['libervia'] # profiles which can't be used + } + +WELCOME_MSG = D_(u"""Welcome to Libervia, the web interface of Salut à Toi. + +Your account on {domain} has been successfully created. +This is a demonstration version to show you the current status of the project. +It is still under development, please keep it in mind! + +Here is your connection information: + +Login on {domain}: {profile} +Jabber ID (JID): {jid} +Your password has been chosen by yourself during registration. + +In the beginning, you have nobody to talk to. To find some contacts, you may use the users' directory: + - make yourself visible in "Service / Directory subscription". + - search for people with "Contacts" / Search directory". + +Any feedback welcome. Thank you! + +Salut à Toi association +https://www.salut-a-toi.org +""") + +DEFAULT_DOMAIN = u"example.net" + + +class MiscAccount(object): + """Account plugin: create a SàT + XMPP account, used by Libervia""" + # XXX: This plugin was initialy a Q&D one used for the demo. + # TODO: cleaning, separate email handling, more configuration/tests, fixes + + + def __init__(self, host): + log.info(_(u"Plugin Account initialization")) + self.host = host + host.bridge.addMethod("registerSatAccount", ".plugin", in_sign='sss', out_sign='', method=self._registerAccount, async=True) + host.bridge.addMethod("getNewAccountDomain", ".plugin", in_sign='', out_sign='s', method=self.getNewAccountDomain, async=False) + host.bridge.addMethod("getAccountDialogUI", ".plugin", in_sign='s', out_sign='s', method=self._getAccountDialogUI, async=False) + host.bridge.addMethod("asyncConnectWithXMPPCredentials", ".plugin", in_sign='ss', out_sign='b', method=self.asyncConnectWithXMPPCredentials, async=True) + + self.fixEmailAdmins() + self._sessions = Sessions() + + self.__account_cb_id = host.registerCallback(self._accountDialogCb, with_data=True) + self.__change_password_id = host.registerCallback(self.__changePasswordCb, with_data=True) + + def deleteBlogCallback(posts, comments): + return lambda data, profile: self.__deleteBlogPostsCb(posts, comments, data, profile) + + self.__delete_posts_id = host.registerCallback(deleteBlogCallback(True, False), with_data=True) + self.__delete_comments_id = host.registerCallback(deleteBlogCallback(False, True), with_data=True) + self.__delete_posts_comments_id = host.registerCallback(deleteBlogCallback(True, True), with_data=True) + + self.__delete_account_id = host.registerCallback(self.__deleteAccountCb, with_data=True) + + + # FIXME: remove this after some time, when the deprecated parameter is really abandoned + def fixEmailAdmins(self): + """Handle deprecated config option "admin_email" to fix the admin emails list""" + admin_email = self.getConfig('admin_email') + if not admin_email: + return + log.warning(u"admin_email parameter is deprecated, please use email_admins_list instead") + param_name = "email_admins_list" + try: + section = "" + value = self.host.memory.getConfig(section, param_name, Exception) + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + section = CONFIG_SECTION + value = self.host.memory.getConfig(section, param_name, default_conf[param_name]) + + value = set(value) + value.add(admin_email) + self.host.memory.config.set(section, param_name, ",".join(value)) + + def getConfig(self, name, section=CONFIG_SECTION): + if name.startswith("email_"): + # XXX: email_ parameters were first in [plugin account] section + # but as it make more sense to have them in common with other plugins, + # they can now be in [DEFAULT] section + try: + value = self.host.memory.getConfig(None, name, Exception) + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + pass + else: + return value + + if section == CONFIG_SECTION: + default = default_conf[name] + else: + default = None + return self.host.memory.getConfig(section, name, default) + + def _registerAccount(self, email, password, profile): + return self.registerAccount(email, password, None, profile) + + def registerAccount(self, email, password, jid_s, profile): + """Register a new profile, its associated XMPP account, send the confirmation emails. + + @param email (unicode): where to send to confirmation email to + @param password (unicode): password chosen by the user + while be used for profile *and* XMPP account + @param jid_s (unicode): JID to re-use or to register: + - non empty value: bind this JID to the new sat profile + - None or "": register a new JID on the local XMPP server + @param profile + @return Deferred + """ + d = self.createProfile(password, jid_s, profile) + d.addCallback(lambda dummy: self.sendEmails(email, profile)) + return d + + def createProfile(self, password, jid_s, profile): + """Register a new profile and its associated XMPP account. + + @param password (unicode): password chosen by the user + while be used for profile *and* XMPP account + @param jid_s (unicode): JID to re-use or to register: + - non empty value: bind this JID to the new sat profile + - None or "": register a new JID on the local XMPP server + @param profile + @return Deferred + """ + if not password or not profile: + raise exceptions.DataError + + if profile.lower() in self.getConfig('reserved_list'): + return defer.fail(Failure(exceptions.ConflictError)) + + d = self.host.memory.createProfile(profile, password) + d.addCallback(lambda dummy: self.profileCreated(password, jid_s, profile)) + return d + + def profileCreated(self, password, jid_s, profile): + """Create the XMPP account and set the profile connection parameters. + + @param password (unicode): password chosen by the user + @param jid_s (unicode): JID to re-use or to register: + - non empty value: bind this JID to the new sat profile + - None or empty: register a new JID on the local XMPP server + @param profile + @return: Deferred + """ + if jid_s: + d = defer.succeed(None) + jid_ = jid.JID(jid_s) + else: + jid_s = profile + u"@" + self.getNewAccountDomain() + jid_ = jid.JID(jid_s) + d = self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) + + def setParams(dummy): + self.host.memory.setParam("JabberID", jid_s, "Connection", profile_key=profile) + d = self.host.memory.setParam("Password", password, "Connection", profile_key=profile) + return d + + def removeProfile(failure): + self.host.memory.asyncDeleteProfile(profile) + return failure + + d.addCallback(lambda dummy: self.host.memory.startSession(password, profile)) + d.addCallback(setParams) + d.addCallback(lambda dummy: self.host.memory.stopSession(profile)) + d.addErrback(removeProfile) + return d + + def _sendEmailEb(self, failure_, email): + # TODO: return error code to user + log.error(_(u"Failed to send account creation confirmation to {email}: {msg}").format( + email = email, + msg = failure_)) + + def sendEmails(self, email, profile): + # time to send the email + + domain = self.getNewAccountDomain() + + # email to the administrators + admins_emails = self.getConfig('email_admins_list') + if not admins_emails: + log.warning(u"No known admin email, we can't send email to administrator(s).\nPlease fill email_admins_list parameter") + d_admin = defer.fail(exceptions.DataError("no admin email")) + else: + subject = _(u'New Libervia account created') + body = (u"""New account created: {profile} [{email}]""".format( + profile = profile, + # there is no email when an existing XMPP account is used + email = email or u"")) + d_admin = sat_email.sendEmail(self.host, admins_emails, subject, body) + + admins_emails_txt = u', '.join([u'<' + addr + u'>' for addr in admins_emails]) + d_admin.addCallbacks(lambda dummy: log.debug(u"Account creation notification sent to admin(s) {}".format(admins_emails_txt)), + lambda dummy: log.error(u"Failed to send account creation notification to admin {}".format(admins_emails_txt))) + if not email: + # TODO: if use register with an existing account, an XMPP message should be sent + return d_admin + + jid_s = self.host.memory.getParamA(u"JabberID", u"Connection", profile_key=profile) + subject = _(u'Your Libervia account has been created') + body = (_(WELCOME_MSG).format(profile=profile, jid=jid_s, domain=domain)) + + # XXX: this will not fail when the email address doesn't exist + # FIXME: check email reception to validate email given by the user + # FIXME: delete the profile if the email could not been sent? + d_user = sat_email.sendEmail(self.host, [email], subject, body) + d_user.addCallbacks(lambda dummy: log.debug(u"Account creation confirmation sent to <{}>".format(email)), + self._sendEmailEb) + return defer.DeferredList([d_user, d_admin]) + + def getNewAccountDomain(self): + """get the domain that will be set to new account""" + + domain = self.getConfig('new_account_domain') or self.getConfig('xmpp_domain', None) + if not domain: + log.warning(_(u'xmpp_domain needs to be set in sat.conf. Using "{default}" meanwhile').format(default=DEFAULT_DOMAIN)) + return DEFAULT_DOMAIN + return domain + + def _getAccountDialogUI(self, profile): + """Get the main dialog to manage your account + @param menu_data + @param profile: %(doc_profile)s + @return: XML of the dialog + """ + form_ui = xml_tools.XMLUI("form", "tabs", title=D_("Manage your account"), submit_id=self.__account_cb_id) + tab_container = form_ui.current_container + + tab_container.addTab("update", D_("Change your password"), container=xml_tools.PairsContainer) + form_ui.addLabel(D_("Current profile password")) + form_ui.addPassword("current_passwd", value="") + form_ui.addLabel(D_("New password")) + form_ui.addPassword("new_passwd1", value="") + form_ui.addLabel(D_("New password (again)")) + form_ui.addPassword("new_passwd2", value="") + + # FIXME: uncomment and fix these features + """ + if 'GROUPBLOG' in self.host.plugins: + tab_container.addTab("delete_posts", D_("Delete your posts"), container=xml_tools.PairsContainer) + form_ui.addLabel(D_("Current profile password")) + form_ui.addPassword("delete_posts_passwd", value="") + form_ui.addLabel(D_("Delete all your posts and their comments")) + form_ui.addBool("delete_posts_checkbox", "false") + form_ui.addLabel(D_("Delete all your comments on other's posts")) + form_ui.addBool("delete_comments_checkbox", "false") + + tab_container.addTab("delete", D_("Delete your account"), container=xml_tools.PairsContainer) + form_ui.addLabel(D_("Current profile password")) + form_ui.addPassword("delete_passwd", value="") + form_ui.addLabel(D_("Delete your account")) + form_ui.addBool("delete_checkbox", "false") + """ + + return form_ui.toXml() + + @defer.inlineCallbacks + def _accountDialogCb(self, data, profile): + """Called when the user submits the main account dialog + @param data + @param profile + """ + sat_cipher = yield self.host.memory.asyncGetParamA(C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile) + + @defer.inlineCallbacks + def verify(attempt): + auth = yield PasswordHasher.verify(attempt, sat_cipher) + defer.returnValue(auth) + + def error_ui(message=None): + if not message: + message = D_("The provided profile password doesn't match.") + error_ui = xml_tools.XMLUI("popup", title=D_("Attempt failure")) + error_ui.addText(message) + return {'xmlui': error_ui.toXml()} + + # check for account deletion + # FIXME: uncomment and fix these features + """ + delete_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_passwd'] + delete_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_checkbox'] + if delete_checkbox == 'true': + verified = yield verify(delete_passwd) + assert isinstance(verified, bool) + if verified: + defer.returnValue(self.__deleteAccount(profile)) + defer.returnValue(error_ui()) + + # check for blog posts deletion + if 'GROUPBLOG' in self.host.plugins: + delete_posts_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_passwd'] + delete_posts_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_checkbox'] + delete_comments_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_comments_checkbox'] + posts = delete_posts_checkbox == 'true' + comments = delete_comments_checkbox == 'true' + if posts or comments: + verified = yield verify(delete_posts_passwd) + assert isinstance(verified, bool) + if verified: + defer.returnValue(self.__deleteBlogPosts(posts, comments, profile)) + defer.returnValue(error_ui()) + """ + + # check for password modification + current_passwd = data[xml_tools.SAT_FORM_PREFIX + 'current_passwd'] + new_passwd1 = data[xml_tools.SAT_FORM_PREFIX + 'new_passwd1'] + new_passwd2 = data[xml_tools.SAT_FORM_PREFIX + 'new_passwd2'] + if new_passwd1 or new_passwd2: + verified = yield verify(current_passwd) + assert isinstance(verified, bool) + if verified: + if new_passwd1 == new_passwd2: + data = yield self.__changePassword(new_passwd1, profile=profile) + defer.returnValue(data) + else: + defer.returnValue(error_ui(D_("The values entered for the new password are not equal."))) + defer.returnValue(error_ui()) + + defer.returnValue({}) + + def __changePassword(self, password, profile): + """Ask for a confirmation before changing the XMPP account and SàT profile passwords. + + @param password (str): the new password + @param profile (str): %(doc_profile)s + """ + session_id, dummy = self._sessions.newSession({'new_password': password}, profile=profile) + form_ui = xml_tools.XMLUI("form", title=D_("Change your password?"), submit_id=self.__change_password_id, session_id=session_id) + form_ui.addText(D_("Note for advanced users: this will actually change both your SàT profile password AND your XMPP account password.")) + form_ui.addText(D_("Continue with changing the password?")) + return {'xmlui': form_ui.toXml()} + + def __changePasswordCb(self, data, profile): + """Actually change the user XMPP account and SàT profile password + @param data (dict) + @profile (str): %(doc_profile)s + """ + client = self.host.getClient(profile) + password = self._sessions.profileGet(data['session_id'], profile)['new_password'] + del self._sessions[data['session_id']] + + def passwordChanged(dummy): + d = self.host.memory.setParam(C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=profile) + d.addCallback(lambda dummy: self.host.memory.setParam("Password", password, "Connection", profile_key=profile)) + confirm_ui = xml_tools.XMLUI("popup", title=D_("Confirmation")) + confirm_ui.addText(D_("Your password has been changed.")) + return defer.succeed({'xmlui': confirm_ui.toXml()}) + + def errback(failure): + error_ui = xml_tools.XMLUI("popup", title=D_("Error")) + error_ui.addText(D_("Your password could not be changed: %s") % failure.getErrorMessage()) + return defer.succeed({'xmlui': error_ui.toXml()}) + + d = self.host.plugins['XEP-0077'].changePassword(client, password) + d.addCallbacks(passwordChanged, errback) + return d + + def __deleteAccount(self, profile): + """Ask for a confirmation before deleting the XMPP account and SàT profile + @param profile + """ + form_ui = xml_tools.XMLUI("form", title=D_("Delete your account?"), submit_id=self.__delete_account_id) + form_ui.addText(D_("If you confirm this dialog, you will be disconnected and then your XMPP account AND your SàT profile will both be DELETED.")) + target = D_('contact list, messages history, blog posts and comments' if 'GROUPBLOG' in self.host.plugins else D_('contact list and messages history')) + form_ui.addText(D_("All your data stored on %(server)s, including your %(target)s will be erased.") % {'server': self.getNewAccountDomain(), 'target': target}) + form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) + return {'xmlui': form_ui.toXml()} + + def __deleteAccountCb(self, data, profile): + """Actually delete the XMPP account and SàT profile + + @param data + @param profile + """ + client = self.host.getClient(profile) + def userDeleted(dummy): + + # FIXME: client should be disconnected at this point, so 2 next loop should be removed (to be confirmed) + for jid_ in client.roster._jids: # empty roster + client.presence.unsubscribe(jid_) + + for jid_ in self.host.memory.getWaitingSub(profile): # delete waiting subscriptions + self.host.memory.delWaitingSub(jid_) + + delete_profile = lambda: self.host.memory.asyncDeleteProfile(profile, force=True) + if 'GROUPBLOG' in self.host.plugins: + d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsAndComments(profile_key=profile) + d.addCallback(lambda dummy: delete_profile()) + else: + delete_profile() + + return defer.succeed({}) + + def errback(failure): + error_ui = xml_tools.XMLUI("popup", title=D_("Error")) + error_ui.addText(D_("Your XMPP account could not be deleted: %s") % failure.getErrorMessage()) + return defer.succeed({'xmlui': error_ui.toXml()}) + + d = self.host.plugins['XEP-0077'].unregister(client, jid.JID(client.jid.host)) + d.addCallbacks(userDeleted, errback) + return d + + def __deleteBlogPosts(self, posts, comments, profile): + """Ask for a confirmation before deleting the blog posts + @param posts: delete all posts of the user (and their comments) + @param comments: delete all the comments of the user on other's posts + @param data + @param profile + """ + if posts: + if comments: # delete everything + form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog posts and comments?"), submit_id=self.__delete_posts_comments_id) + form_ui.addText(D_("If you confirm this dialog, all the (micro-)blog data you submitted will be erased.")) + form_ui.addText(D_("These are the public and private posts and comments you sent to any group.")) + form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) + else: # delete only the posts + form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog posts?"), submit_id=self.__delete_posts_id) + form_ui.addText(D_("If you confirm this dialog, all the public and private posts you sent to any group will be erased.")) + form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) + elif comments: # delete only the comments + form_ui = xml_tools.XMLUI("form", title=D_("Delete all your (micro-)blog comments?"), submit_id=self.__delete_comments_id) + form_ui.addText(D_("If you confirm this dialog, all the public and private comments you made on other people's posts will be erased.")) + form_ui.addText(D_("There is no other confirmation dialog, this is the very last one! Are you sure?")) + + return {'xmlui': form_ui.toXml()} + + def __deleteBlogPostsCb(self, posts, comments, data, profile): + """Actually delete the XMPP account and SàT profile + @param posts: delete all posts of the user (and their comments) + @param comments: delete all the comments of the user on other's posts + @param profile + """ + if posts: + if comments: + target = D_('blog posts and comments') + d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsAndComments(profile_key=profile) + else: + target = D_('blog posts') + d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogs(profile_key=profile) + elif comments: + target = D_('comments') + d = self.host.plugins['GROUPBLOG'].deleteAllGroupBlogsComments(profile_key=profile) + + def deleted(result): + ui = xml_tools.XMLUI("popup", title=D_("Deletion confirmation")) + # TODO: change the message when delete/retract notifications are done with XEP-0060 + ui.addText(D_("Your %(target)s have been deleted.") % {'target': target}) + ui.addText(D_("Known issue of the demo version: you need to refresh the page to make the deleted posts actually disappear.")) + return defer.succeed({'xmlui': ui.toXml()}) + + def errback(failure): + error_ui = xml_tools.XMLUI("popup", title=D_("Error")) + error_ui.addText(D_("Your %(target)s could not be deleted: %(message)s") % {'target': target, 'message': failure.getErrorMessage()}) + return defer.succeed({'xmlui': error_ui.toXml()}) + + d.addCallbacks(deleted, errback) + return d + + def asyncConnectWithXMPPCredentials(self, jid_s, password): + """Create and connect a new SàT profile using the given XMPP credentials. + + Re-use given JID and XMPP password for the profile name and profile password. + @param jid_s (unicode): JID + @param password (unicode): XMPP password + @return Deferred(bool) + @raise exceptions.PasswordError, exceptions.ConflictError + """ + try: # be sure that the profile doesn't exist yet + self.host.memory.getProfileName(jid_s) + except exceptions.ProfileUnknownError: + pass + else: + raise exceptions.ConflictError + + d = self.createProfile(password, jid_s, jid_s) + d.addCallback(lambda dummy: self.host.memory.getProfileName(jid_s)) # checks if the profile has been successfuly created + d.addCallback(self.host.connect, password, {}, 0) + + + def connected(result): + self.sendEmails(None, profile=jid_s) + return result + + def removeProfile(failure): # profile has been successfully created but the XMPP credentials are wrong! + log.debug("Removing previously auto-created profile: %s" % failure.getErrorMessage()) + self.host.memory.asyncDeleteProfile(jid_s) + raise failure + + # FIXME: we don't catch the case where the JID host is not an XMPP server, and the user + # has to wait until the DBUS timeout ; as a consequence, emails are sent to the admins + # and the profile is not deleted. When the host exists, removeProfile is well called. + d.addCallbacks(connected, removeProfile) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_android.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_android.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,100 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for file tansfer +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +import sys +import mmap + + +PLUGIN_INFO = { + C.PI_NAME: "Android ", + C.PI_IMPORT_NAME: "android", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MAIN: "AndroidPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: D_("""Manage Android platform specificities, like pause or notifications""") +} + +if sys.platform != "android": + raise exceptions.CancelError(u"this module is not needed on this platform") + +from plyer import notification, vibrator + +PARAM_VIBRATE_CATEGORY = "Notifications" +PARAM_VIBRATE_NAME = "vibrate" +PARAM_VIBRATE_LABEL = D_(u"Vibrate on notifications") + +class AndroidPlugin(object): + + params = """ + + + + + + + + """.format( + category_name = PARAM_VIBRATE_CATEGORY, + category_label = D_(PARAM_VIBRATE_CATEGORY), + param_name = PARAM_VIBRATE_NAME, + param_label = PARAM_VIBRATE_LABEL, + ) + + def __init__(self, host): + log.info(_("plugin Android initialization")) + self.host = host + host.memory.updateParams(self.params) + self.cagou_status_fd = open('.cagou_status', 'rb') + self.cagou_status = mmap.mmap(self.cagou_status_fd.fileno(), 1, prot=mmap.PROT_READ) + # we set a low priority because we want the notification to be sent after all plugins have done their job + host.trigger.add("MessageReceived", self.messageReceivedTrigger, priority=-1000) + + @property + def cagou_active(self): + # 'R' status means Cagou is running in front + return self.cagou_status[0] == 'R' + + def _notifyMessage(self, mess_data, client): + # send notification if there is a message and it is not a groupchat + if mess_data['message'] and mess_data['type'] != C.MESS_TYPE_GROUPCHAT: + message = mess_data['message'].itervalues().next() + try: + subject = mess_data['subject'].itervalues().next() + except StopIteration: + subject = u'Cagou new message' + + notification.notify( + title = subject, + message = message + ) + if self.host.memory.getParamA(PARAM_VIBRATE_NAME, PARAM_VIBRATE_CATEGORY, profile_key=client.profile): + vibrator.vibrate() + return mess_data + + def messageReceivedTrigger(self, client, message_elt, post_treat): + if not self.cagou_active: + # we only send notification is the frontend is not displayed + post_treat.addCallback(self._notifyMessage, client) + + return True diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_debug.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_debug.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,61 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for managing raw XML log +# Copyright (C) 2009-2016 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 . + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C +import json + +PLUGIN_INFO = { + C.PI_NAME: "Debug Plugin", + C.PI_IMPORT_NAME: "DEBUG", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "Debug", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Set of method to make development and debugging easier""") +} + + +class Debug(object): + + def __init__(self, host): + log.info(_("Plugin Debug initialization")) + self.host = host + host.bridge.addMethod("debugFakeSignal", ".plugin", in_sign='sss', out_sign='', method=self._fakeSignal) + + + def _fakeSignal(self, signal, arguments, profile_key): + """send a signal from backend + + @param signal(str): name of the signal + @param arguments(unicode): json encoded list of arguments + @parm profile_key(unicode): profile_key to use or C.PROF_KEY_NONE if profile is not needed + """ + args = json.loads(arguments) + method = getattr(self.host.bridge, signal) + if profile_key != C.PROF_KEY_NONE: + profile = self.host.memory.getProfileName(profile_key) + args.append(profile) + method(*args) + + + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_extra_pep.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_extra_pep.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,73 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for displaying messages from extra PEP services +# Copyright (C) 2015 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.memory import params +from twisted.words.protocols.jabber import jid + + +PLUGIN_INFO = { + C.PI_NAME: "Extra PEP", + C.PI_IMPORT_NAME: "EXTRA-PEP", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "ExtraPEP", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Display messages from extra PEP services""") +} + + +PARAM_KEY = u"Misc" +PARAM_NAME = u"blogs" +PARAM_LABEL = u"Blog authors following list" +PARAM_DEFAULT = (jid.JID("salut-a-toi@libervia.org"),) + + +class ExtraPEP(object): + + params = """ + + + + + %(jids)s + + + + + """ % { + 'category_name': PARAM_KEY, + 'category_label': D_(PARAM_KEY), + 'param_name': PARAM_NAME, + 'param_label': D_(PARAM_LABEL), + 'jids': u"\n".join({elt.toXml() for elt in params.createJidElts(PARAM_DEFAULT)}) + } + + def __init__(self, host): + log.info(_(u"Plugin Extra PEP initialization")) + self.host = host + host.memory.updateParams(self.params) + + def getFollowedEntities(self, profile_key): + return self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile_key) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_file.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_file.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,262 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for file tansfer +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools import xml_tools +from sat.tools import stream +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +import os +import os.path + + +PLUGIN_INFO = { + C.PI_NAME: "File Tansfer", + C.PI_IMPORT_NAME: "FILE", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_MAIN: "FilePlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""File Tansfer Management: +This plugin manage the various ways of sending a file, and choose the best one.""") +} + + +SENDING = D_(u'Please select a file to send to {peer}') +SENDING_TITLE = D_(u'File sending') +CONFIRM = D_(u'{peer} wants to send the file "{name}" to you:\n{desc}\n\nThe file has a size of {size_human}\n\nDo you accept ?') +CONFIRM_TITLE = D_(u'Confirm file transfer') +CONFIRM_OVERWRITE = D_(u'File {} already exists, are you sure you want to overwrite ?') +CONFIRM_OVERWRITE_TITLE = D_(u'File exists') +SECURITY_LIMIT = 30 + +PROGRESS_ID_KEY = 'progress_id' + + +class FilePlugin(object): + File=stream.SatFile + + def __init__(self, host): + log.info(_("plugin File initialization")) + self.host = host + host.bridge.addMethod("fileSend", ".plugin", in_sign='ssssa{ss}s', out_sign='a{ss}', method=self._fileSend, async=True) + self._file_callbacks = [] + host.importMenu((D_("Action"), D_("send file")), self._fileSendMenu, security_limit=10, help_string=D_("Send a file"), type_=C.MENU_SINGLE) + + def _fileSend(self, peer_jid_s, filepath, name="", file_desc="", extra=None, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + return self.fileSend(client, jid.JID(peer_jid_s), filepath, name or None, file_desc or None, extra) + + @defer.inlineCallbacks + def fileSend(self, client, peer_jid, filepath, filename=None, file_desc=None, extra=None): + """Send a file using best available method + + @param peer_jid(jid.JID): jid of the destinee + @param filepath(str): absolute path to the file + @param filename(unicode, None): name to use, or None to find it from filepath + @param file_desc(unicode, None): description of the file + @param profile: %(doc_profile)s + @return (dict): action dictionary, with progress id in case of success, else xmlui message + """ + if not os.path.isfile(filepath): + raise exceptions.DataError(u"The given path doesn't link to a file") + if not filename: + filename = os.path.basename(filepath) or '_' + for namespace, callback, priority, method_name in self._file_callbacks: + has_feature = yield self.host.hasFeature(client, namespace, peer_jid) + if has_feature: + log.info(u"{name} method will be used to send the file".format(name=method_name)) + progress_id = yield callback(client, peer_jid, filepath, filename, file_desc, extra) + defer.returnValue({'progress': progress_id}) + msg = u"Can't find any method to send file to {jid}".format(jid=peer_jid.full()) + log.warning(msg) + defer.returnValue({'xmlui': xml_tools.note(u"Can't transfer file", msg, C.XMLUI_DATA_LVL_WARNING).toXml()}) + + def _onFileChoosed(self, client, peer_jid, data): + cancelled = C.bool(data.get("cancelled", C.BOOL_FALSE)) + if cancelled: + return + path=data['path'] + return self.fileSend(client, peer_jid, path) + + def _fileSendMenu(self, data, profile): + """ XMLUI activated by menu: return file sending UI + + @param profile: %(doc_profile)s + """ + try: + jid_ = jid.JID(data['jid']) + except RuntimeError: + raise exceptions.DataError(_("Invalid JID")) + + file_choosed_id = self.host.registerCallback(lambda data, profile: self._onFileChoosed(self.host.getClient(profile), jid_, data), with_data=True, one_shot=True) + xml_ui = xml_tools.XMLUI( + C.XMLUI_DIALOG, + dialog_opt = { + C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_FILE, + C.XMLUI_DATA_MESS: _(SENDING).format(peer=jid_.full())}, + title = _(SENDING_TITLE), + submit_id = file_choosed_id) + + return {'xmlui': xml_ui.toXml()} + + def register(self, namespace, callback, priority=0, method_name=None): + """Register a fileSending method + + @param namespace(unicode): XEP namespace + @param callback(callable): method to call (must have the same signature as [fileSend]) + @param priority(int): pririoty of this method, the higher available will be used + @param method_name(unicode): short name for the method, namespace will be used if None + """ + for data in self._file_callbacks: + if namespace == data[0]: + raise exceptions.ConflictError(u'A method with this namespace is already registered') + self._file_callbacks.append((namespace, callback, priority, method_name or namespace)) + self._file_callbacks.sort(key=lambda data: data[2], reverse=True) + + def unregister(self, namespace): + for idx, data in enumerate(self._file_callbacks): + if data[0] == namespace: + del [idx] + return + raise exceptions.NotFound(u"The namespace to unregister doesn't exist") + + # Dialogs with user + # the overwrite check is done here + + def openFileWrite(self, client, file_path, transfer_data, file_data, stream_object): + """create SatFile or FileStremaObject for the requested file and fill suitable data + """ + if stream_object: + assert 'stream_object' not in transfer_data + transfer_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + mode='wb', + uid=file_data[PROGRESS_ID_KEY], + size=file_data['size'], + data_cb = file_data.get('data_cb'), + ) + else: + assert 'file_obj' not in transfer_data + transfer_data['file_obj'] = stream.SatFile( + self.host, + client, + file_path, + mode='wb', + uid=file_data[PROGRESS_ID_KEY], + size=file_data['size'], + data_cb = file_data.get('data_cb'), + ) + + def _gotConfirmation(self, data, client, peer_jid, transfer_data, file_data, stream_object): + """Called when the permission and dest path have been received + + @param peer_jid(jid.JID): jid of the file sender + @param transfer_data(dict): same as for [self.getDestDir] + @param file_data(dict): same as for [self.getDestDir] + @param stream_object(bool): same as for [self.getDestDir] + return (bool): True if copy is wanted and OK + False if user wants to cancel + if file exists ask confirmation and call again self._getDestDir if needed + """ + if data.get('cancelled', False): + return False + path = data['path'] + file_data['file_path'] = file_path = os.path.join(path, file_data['name']) + log.debug(u'destination file path set to {}'.format(file_path)) + + # we manage case where file already exists + if os.path.exists(file_path): + def check_overwrite(overwrite): + if overwrite: + self.openFileWrite(client, file_path, transfer_data, file_data, stream_object) + return True + else: + return self.getDestDir(client, peer_jid, transfer_data, file_data) + + exists_d = xml_tools.deferConfirm( + self.host, + _(CONFIRM_OVERWRITE).format(file_path), + _(CONFIRM_OVERWRITE_TITLE), + action_extra={'meta_from_jid': peer_jid.full(), + 'meta_type': C.META_TYPE_OVERWRITE, + 'meta_progress_id': file_data[PROGRESS_ID_KEY] + }, + security_limit=SECURITY_LIMIT, + profile=client.profile) + exists_d.addCallback(check_overwrite) + return exists_d + + self.openFileWrite(client, file_path, transfer_data, file_data, stream_object) + return True + + def getDestDir(self, client, peer_jid, transfer_data, file_data, stream_object=False): + """Request confirmation and destination dir to user + + Overwrite confirmation is managed. + if transfer is confirmed, 'file_obj' is added to transfer_data + @param peer_jid(jid.JID): jid of the file sender + @param filename(unicode): name of the file + @param transfer_data(dict): data of the transfer session, + it will be only used to store the file_obj. + "file_obj" (or "stream_object") key *MUST NOT* exist before using getDestDir + @param file_data(dict): information about the file to be transfered + It MUST contain the following keys: + - peer_jid (jid.JID): other peer jid + - name (unicode): name of the file to trasnsfer + the name must not be empty or contain a "/" character + - size (int): size of the file + - desc (unicode): description of the file + - progress_id (unicode): id to use for progression + It *MUST NOT* contain the "peer" key + It may contain: + - data_cb (callable): method called on each data read/write + "file_path" will be added to this dict once destination selected + "size_human" will also be added with human readable file size + @param stream_object(bool): if True, a stream_object will be used instead of file_obj + a stream.FileStreamObject will be used + return (defer.Deferred): True if transfer is accepted + """ + cont,ret_value = self.host.trigger.returnPoint("FILE_getDestDir", client, peer_jid, transfer_data, file_data, stream_object) + if not cont: + return ret_value + filename = file_data['name'] + assert filename and not '/' in filename + assert PROGRESS_ID_KEY in file_data + # human readable size + file_data['size_human'] = u'{:.6n} Mio'.format(float(file_data['size'])/(1024**2)) + d = xml_tools.deferDialog(self.host, + _(CONFIRM).format(peer=peer_jid.full(), **file_data), + _(CONFIRM_TITLE), + type_=C.XMLUI_DIALOG_FILE, + options={C.XMLUI_DATA_FILETYPE: C.XMLUI_DATA_FILETYPE_DIR}, + action_extra={'meta_from_jid': peer_jid.full(), + 'meta_type': C.META_TYPE_FILE, + 'meta_progress_id': file_data[PROGRESS_ID_KEY] + }, + security_limit=SECURITY_LIMIT, + profile=client.profile) + d.addCallback(self._gotConfirmation, client, peer_jid, transfer_data, file_data, stream_object) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_forums.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_forums.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,297 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Pubsub Schemas +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +from sat.tools.common import uri +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from twisted.internet import defer +import shortuuid +import json +log = getLogger(__name__) + +NS_FORUMS = u'org.salut-a-toi.forums:0' +NS_FORUMS_TOPICS = NS_FORUMS + u'#topics' + +PLUGIN_INFO = { + C.PI_NAME: _("forums management"), + C.PI_IMPORT_NAME: "forums", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0277"], + C.PI_MAIN: "forums", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""forums management plugin""") +} +FORUM_ATTR = {u'title', u'name', u'main-language', u'uri'} +FORUM_SUB_ELTS = (u'short-desc', u'desc') +FORUM_TOPICS_NODE_TPL = u'{node}#topics_{uuid}' +FORUM_TOPIC_NODE_TPL = u'{node}_{uuid}' + + +class forums(object): + + def __init__(self, host): + log.info(_(u"forums plugin initialization")) + self.host = host + self._m = self.host.plugins['XEP-0277'] + self._p = self.host.plugins['XEP-0060'] + self._node_options = { + self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, + self._p.OPT_PERSIST_ITEMS: 1, + self._p.OPT_MAX_ITEMS: -1, + self._p.OPT_DELIVER_PAYLOADS: 1, + self._p.OPT_SEND_ITEM_SUBSCRIBE: 1, + self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN, + } + host.registerNamespace('forums', NS_FORUMS) + host.bridge.addMethod("forumsGet", ".plugin", + in_sign='ssss', out_sign='s', + method=self._get, + async=True) + host.bridge.addMethod("forumsSet", ".plugin", + in_sign='sssss', out_sign='', + method=self._set, + async=True) + host.bridge.addMethod("forumTopicsGet", ".plugin", + in_sign='ssa{ss}s', out_sign='(aa{ss}a{ss})', + method=self._getTopics, + async=True) + host.bridge.addMethod("forumTopicCreate", ".plugin", + in_sign='ssa{ss}s', out_sign='', + method=self._createTopic, + async=True) + + @defer.inlineCallbacks + def _createForums(self, client, forums, service, node, forums_elt=None, names=None): + """recursively create element(s) + + @param forums(list): forums which may have subforums + @param service(jid.JID): service where the new nodes will be created + @param node(unicode): node of the forums + will be used as basis for the newly created nodes + @param parent_elt(domish.Element, None): element where the forum must be added + if None, the root element will be created + @return (domish.Element): created forums + """ + if not isinstance(forums, list): + raise ValueError(_(u"forums arguments must be a list of forums")) + if forums_elt is None: + forums_elt = domish.Element((NS_FORUMS, u'forums')) + assert names is None + names = set() + else: + if names is None or forums_elt.name != u'forums': + raise exceptions.InternalError(u'invalid forums or names') + assert names is not None + + for forum in forums: + if not isinstance(forum, dict): + raise ValueError(_(u"A forum item must be a dictionary")) + forum_elt = forums_elt.addElement('forum') + + for key, value in forum.iteritems(): + if key == u'name' and key in names: + raise exceptions.ConflictError(_(u"following forum name is not unique: {name}").format(name=key)) + if key == u'uri' and not value.strip(): + log.info(_(u"creating missing forum node")) + forum_node = FORUM_TOPICS_NODE_TPL.format(node=node, uuid=shortuuid.uuid()) + yield self._p.createNode(client, service, forum_node, self._node_options) + value = uri.buildXMPPUri(u'pubsub', + path=service.full(), + node=forum_node) + if key in FORUM_ATTR: + forum_elt[key] = value.strip() + elif key in FORUM_SUB_ELTS: + forum_elt.addElement(key, content=value) + elif key == u'sub-forums': + sub_forums_elt = forum_elt.addElement(u'forums') + yield self._createForums(client, value, service, node, sub_forums_elt, names=names) + else: + log.warning(_(u"Unknown forum attribute: {key}").format(key=key)) + if not forum_elt.getAttribute(u'title'): + name = forum_elt.getAttribute(u'name') + if name: + forum_elt[u'title'] = name + else: + raise ValueError(_(u"forum need a title or a name")) + if not forum_elt.getAttribute(u'uri') and not forum_elt.children: + raise ValueError(_(u"forum need uri or sub-forums")) + defer.returnValue(forums_elt) + + def _parseForums(self, parent_elt=None, forums=None): + """recursivly parse a elements and return corresponding forums data + + @param item(domish.Element): item with element + @param parent_elt(domish.Element, None): element to parse + @return (list): parsed data + @raise ValueError: item is invalid + """ + if parent_elt.name == u'item': + forums = [] + try: + forums_elt = next(parent_elt.elements(NS_FORUMS, u'forums')) + except StopIteration: + raise ValueError(_(u"missing element")) + else: + forums_elt = parent_elt + if forums is None: + raise exceptions.InternalError(u'expected forums') + if forums_elt.name != 'forums': + raise ValueError(_(u'Unexpected element: {xml}').format(xml=forums_elt.toXml())) + for forum_elt in forums_elt.elements(): + if forum_elt.name == 'forum': + data = {} + for attrib in FORUM_ATTR.intersection(forum_elt.attributes): + data[attrib] = forum_elt[attrib] + unknown = set(forum_elt.attributes).difference(FORUM_ATTR) + if unknown: + log.warning(_(u"Following attributes are unknown: {unknown}").format(unknown=unknown)) + for elt in forum_elt.elements(): + if elt.name in FORUM_SUB_ELTS: + data[elt.name] = unicode(elt) + elif elt.name == u'forums': + sub_forums = data[u'sub-forums'] = [] + self._parseForums(elt, sub_forums) + if not u'title' in data or not {u'uri', u'sub-forums'}.intersection(data): + log.warning(_(u"invalid forum, ignoring: {xml}").format(xml=forum_elt.toXml())) + else: + forums.append(data) + else: + log.warning(_(u"unkown forums sub element: {xml}").format(xml=forum_elt)) + + return forums + + def _get(self, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + if service.strip(): + service = jid.JID(service) + else: + service = None + if not node.strip(): + node = None + d=self.get(client, service, node, forums_key or None) + d.addCallback(lambda data: json.dumps(data)) + return d + + @defer.inlineCallbacks + def get(self, client, service=None, node=None, forums_key=None): + if service is None: + service = client.pubsub_service + if node is None: + node = NS_FORUMS + if forums_key is None: + forums_key = u'default' + items_data = yield self._p.getItems(client, service, node, item_ids=[forums_key]) + item = items_data[0][0] + # we have the item and need to convert it to json + forums = self._parseForums(item) + defer.returnValue(forums) + + def _set(self, forums, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + forums = json.loads(forums) + if service.strip(): + service = jid.JID(service) + else: + service = None + if not node.strip(): + node = None + return self.set(client, forums, service, node, forums_key or None) + + @defer.inlineCallbacks + def set(self, client, forums, service=None, node=None, forums_key=None): + """create or replace forums structure + + @param forums(list): list of dictionary as follow: + a dictionary represent a forum metadata, with the following keys: + - title: title of the forum + - name: short name (unique in those forums) for the forum + - main-language: main language to be use in the forums + - uri: XMPP uri to the microblog node hosting the forum + - short-desc: short description of the forum (in main-language) + - desc: long description of the forum (in main-language) + - sub-forums: a list of sub-forums with the same structure + title or name is needed, and uri or sub-forums + @param forums_key(unicode, None): key (i.e. item id) of the forums + may be used to store different forums structures for different languages + None to use "default" + """ + if service is None: + service = client.pubsub_service + if node is None: + node = NS_FORUMS + if forums_key is None: + forums_key = u'default' + forums_elt = yield self._createForums(client, forums, service, node) + yield self._p.sendItem(client, service, node, forums_elt, item_id=forums_key) + + def _getTopics(self, service, node, extra=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + extra = self._p.parseExtra(extra) + d = self.getTopics(client, jid.JID(service), node, rsm_request=extra.rsm_request, extra=extra.extra) + d.addCallback(lambda(topics, metadata): (topics, {k: unicode(v) for k,v in metadata.iteritems()})) + return d + + @defer.inlineCallbacks + def getTopics(self, client, service, node, rsm_request=None, extra=None): + """retrieve topics data + + Topics are simple microblog URIs with some metadata duplicated from first post + """ + topics_data = yield self._p.getItems(client, service, node, rsm_request=rsm_request, extra=extra) + topics = [] + item_elts, metadata = topics_data + for item_elt in item_elts: + topic_elt = next(item_elt.elements(NS_FORUMS, u'topic')) + title_elt = next(topic_elt.elements(NS_FORUMS, u'title')) + topic = {u'uri': topic_elt[u'uri'], + u'author': topic_elt[u'author'], + u'title': unicode(title_elt)} + topics.append(topic) + defer.returnValue((topics, metadata)) + + def _createTopic(self, service, node, mb_data, profile_key): + client = self.host.getClient(profile_key) + return self.createTopic(client, jid.JID(service), node, mb_data) + + @defer.inlineCallbacks + def createTopic(self, client, service, node, mb_data): + try: + title = mb_data[u'title'] + if not u'content' in mb_data: + raise KeyError(u'content') + except KeyError as e: + raise exceptions.DataError(u"missing mandatory data: {key}".format(key=e.args[0])) + + topic_node = FORUM_TOPIC_NODE_TPL.format(node=node, uuid=shortuuid.uuid()) + yield self._p.createNode(client, service, topic_node, self._node_options) + self._m.send(client, mb_data, service, topic_node) + topic_uri = uri.buildXMPPUri(u'pubsub', + subtype=u'microblog', + path=service.full(), + node=topic_node) + topic_elt = domish.Element((NS_FORUMS, 'topic')) + topic_elt[u'uri'] = topic_uri + topic_elt[u'author'] = client.jid.userhost() + topic_elt.addElement(u'title', content = title) + yield self._p.sendItem(client, service, node, topic_elt) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_groupblog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_groupblog.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,141 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for microbloging with roster access +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from sat.core import exceptions +from wokkel import disco, data_form, iwokkel +from zope.interface import implements +from sat.tools.common import data_format + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' +NS_GROUPBLOG = 'http://salut-a-toi.org/protocol/groupblog' +#NS_PUBSUB_EXP = 'http://goffi.org/protocol/pubsub' #for non official features +NS_PUBSUB_EXP = NS_PUBSUB # XXX: we can't use custom namespace as Wokkel's PubSubService use official NS +NS_PUBSUB_GROUPBLOG = NS_PUBSUB_EXP + "#groupblog" +NS_PUBSUB_ITEM_CONFIG = NS_PUBSUB_EXP + "#item-config" + + +PLUGIN_INFO = { + C.PI_NAME: "Group blogging through collections", + C.PI_IMPORT_NAME: "GROUPBLOG", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0277"], + C.PI_MAIN: "GroupBlog", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of microblogging fine permissions""") +} + + +class GroupBlog(object): + """This class use a SàT PubSub Service to manage access on microblog""" + + def __init__(self, host): + log.info(_("Group blog plugin initialization")) + self.host = host + self._p = self.host.plugins["XEP-0060"] + host.trigger.add("XEP-0277_item2data", self._item2dataTrigger) + host.trigger.add("XEP-0277_data2entry", self._data2entryTrigger) + host.trigger.add("XEP-0277_comments", self._commentsTrigger) + + ## plugin management methods ## + + def getHandler(self, client): + return GroupBlog_handler() + + @defer.inlineCallbacks + def profileConnected(self, client): + try: + yield self.host.checkFeatures(client, (NS_PUBSUB_GROUPBLOG,)) + except exceptions.FeatureNotFound: + client.server_groupblog_available = False + log.warning(_(u"Server is not able to manage item-access pubsub, we can't use group blog")) + else: + client.server_groupblog_available = True + log.info(_(u"Server can manage group blogs")) + + def getFeatures(self, profile): + try: + client = self.host.getClient(profile) + except exceptions.ProfileNotSetError: + return {} + try: + return {'available': C.boolConst(client.server_groupblog_available)} + except AttributeError: + if self.host.isConnected(profile): + log.debug("Profile is not connected, service is not checked yet") + else: + log.error("client.server_groupblog_available should be available !") + return {} + + def _item2dataTrigger(self, item_elt, entry_elt, microblog_data): + """Parse item to find group permission elements""" + config_form = data_form.findForm(item_elt, NS_PUBSUB_ITEM_CONFIG) + if config_form is None: + return + access_model = config_form.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN) + if access_model == self._p.ACCESS_PUBLISHER_ROSTER: + data_format.iter2dict('group', config_form.fields[self._p.OPT_ROSTER_GROUPS_ALLOWED].values, microblog_data) + + def _data2entryTrigger(self, client, mb_data, entry_elt, item_elt): + """Build fine access permission if needed + + This trigger check if "group*" key are present, + and create a fine item config to restrict view to these groups + """ + groups = list(data_format.dict2iter('group', mb_data)) + if not groups: + return + if not client.server_groupblog_available: + raise exceptions.CancelError(u"GroupBlog is not available") + log.debug(u"This entry use group blog") + form = data_form.Form('submit', formNamespace=NS_PUBSUB_ITEM_CONFIG) + access = data_form.Field(None, self._p.OPT_ACCESS_MODEL, value=self._p.ACCESS_PUBLISHER_ROSTER) + allowed = data_form.Field(None, self._p.OPT_ROSTER_GROUPS_ALLOWED, values=groups) + form.addField(access) + form.addField(allowed) + item_elt.addChild(form.toElement()) + + def _commentsTrigger(self, client, mb_data, options): + """This method is called when a comments node is about to be created + + It changes the access mode to roster if needed, and give the authorized groups + """ + if "group" in mb_data: + options[self._p.OPT_ACCESS_MODEL] = self._p.ACCESS_PUBLISHER_ROSTER + options[self._p.OPT_ROSTER_GROUPS_ALLOWED] = list(data_format.dict2iter('group', mb_data)) + + +class GroupBlog_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_GROUPBLOG)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_identity.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_identity.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,108 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0054 +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) + +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +import os.path + + +PLUGIN_INFO = { + C.PI_NAME: "Identity Plugin", + C.PI_IMPORT_NAME: "IDENTITY", + C.PI_TYPE: C.PLUG_TYPE_MISC , + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0054"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "Identity", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Identity manager""") +} + + +class Identity(object): + + def __init__(self, host): + log.info(_(u"Plugin Identity initialization")) + self.host = host + self._v = host.plugins[u'XEP-0054'] + host.bridge.addMethod(u"identityGet", u".plugin", in_sign=u'ss', out_sign=u'a{ss}', method=self._getIdentity, async=True) + host.bridge.addMethod(u"identitySet", u".plugin", in_sign=u'a{ss}s', out_sign=u'', method=self._setIdentity, async=True) + + def _getIdentity(self, jid_str, profile): + jid_ = jid.JID(jid_str) + client = self.host.getClient(profile) + return self.getIdentity(client, jid_) + + @defer.inlineCallbacks + def getIdentity(self, client, jid_): + """Retrieve identity of an entity + + @param jid_(jid.JID): entity to check + @return (dict(unicode, unicode)): identity data where key can be: + - nick: nickname of the entity + nickname is checked from, in this order: + roster, vCard, user part of jid + cache is used when possible + """ + id_data = {} + # we first check roster + roster_item = yield client.roster.getItem(jid_.userhostJID()) + if roster_item is not None and roster_item.name: + id_data[u'nick'] = roster_item.name + elif jid_.resource and self._v.isRoom(client, jid_): + id_data[u'nick'] = jid_.resource + else: + # and finally then vcard + nick = yield self._v.getNick(client, jid_) + id_data[u'nick'] = nick if nick else jid_.user.capitalize() + + try: + avatar_path = id_data[u'avatar'] = yield self._v.getAvatar(client, jid_, cache_only=False) + except exceptions.NotFound: + pass + else: + if avatar_path: + id_data[u'avatar_basename'] = os.path.basename(avatar_path) + else: + del id_data[u'avatar'] + + defer.returnValue(id_data) + + def _setIdentity(self, id_data, profile): + client = self.host.getClient(profile) + return self.setIdentity(client, id_data) + + def setIdentity(self, client, id_data): + """Update profile's identity + + @param id_data(dict[unicode, unicode]): data to update, key can be: + - nick: nickname + the vCard will be updated + """ + if id_data.keys() != [u'nick']: + raise NotImplementedError(u'Only nick can be updated for now') + if u'nick' in id_data: + return self._v.setNick(client, id_data[u'nick']) + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_imap.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_imap.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,447 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for managing imap server +# Copyright (C) 2011 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import protocol, defer +from twisted.cred import portal, checkers, credentials +from twisted.cred import error as cred_error +from twisted.mail import imap4 +from twisted.python import failure +from email.parser import Parser +import os +from cStringIO import StringIO +from twisted.internet import reactor + +from zope.interface import implements + +PLUGIN_INFO = { + C.PI_NAME: "IMAP server Plugin", + C.PI_IMPORT_NAME: "IMAP", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["Maildir"], + C.PI_MAIN: "IMAP_server", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Create an Imap server that you can use to read your "normal" type messages""") +} + + +class IMAP_server(object): + #TODO: connect profile on mailbox request, once password is accepted + + params = """ + + + + + + + + """ + + def __init__(self, host): + log.info(_("Plugin Imap Server initialization")) + self.host = host + + #parameters + host.memory.updateParams(self.params) + + port = int(self.host.memory.getParamA("IMAP Port", "Mail Server")) + log.info(_("Launching IMAP server on port %d") % port) + + self.server_factory = ImapServerFactory(self.host) + reactor.listenTCP(port, self.server_factory) + + +class Message(object): + implements(imap4.IMessage) + + def __init__(self, uid, flags, mess_fp): + log.debug('Message Init') + self.uid = uid + self.flags = flags + self.mess_fp = mess_fp + self.message = Parser().parse(mess_fp) + + def getUID(self): + """Retrieve the unique identifier associated with this message. + """ + log.debug('getUID (message)') + return self.uid + + def getFlags(self): + """Retrieve the flags associated with this message. + @return: The flags, represented as strings. + """ + log.debug('getFlags') + return self.flags + + def getInternalDate(self): + """Retrieve the date internally associated with this message. + @return: An RFC822-formatted date string. + """ + log.debug('getInternalDate') + return self.message['Date'] + + def getHeaders(self, negate, *names): + """Retrieve a group of message headers. + @param names: The names of the headers to retrieve or omit. + @param negate: If True, indicates that the headers listed in names + should be omitted from the return value, rather than included. + @return: A mapping of header field names to header field values + """ + log.debug(u'getHeaders %s - %s' % (negate, names)) + final_dict = {} + to_check = [name.lower() for name in names] + for header in self.message.keys(): + if (negate and not header.lower() in to_check) or \ + (not negate and header.lower() in to_check): + final_dict[header] = self.message[header] + return final_dict + + def getBodyFile(self): + """Retrieve a file object containing only the body of this message. + """ + log.debug('getBodyFile') + return StringIO(self.message.get_payload()) + + def getSize(self): + """Retrieve the total size, in octets, of this message. + """ + log.debug('getSize') + self.mess_fp.seek(0, os.SEEK_END) + return self.mess_fp.tell() + + def isMultipart(self): + """Indicate whether this message has subparts. + """ + log.debug('isMultipart') + return False + + def getSubPart(self, part): + """Retrieve a MIME sub-message + @param part: The number of the part to retrieve, indexed from 0. + @return: The specified sub-part. + """ + log.debug('getSubPart') + return TypeError + + +class SatMailbox(object): + implements(imap4.IMailbox) + + def __init__(self, host, name, profile): + self.host = host + self.listeners = set() + log.debug(u'Mailbox init (%s)' % name) + if name != "INBOX": + raise imap4.MailboxException("Only INBOX is managed for the moment") + self.mailbox = self.host.plugins["Maildir"].accessMessageBox(name, self.messageNew, profile) + + def messageNew(self): + """Called when a new message is in the mailbox""" + log.debug("messageNew signal received") + nb_messages = self.getMessageCount() + for listener in self.listeners: + listener.newMessages(nb_messages, None) + + def getUIDValidity(self): + """Return the unique validity identifier for this mailbox. + """ + log.debug('getUIDValidity') + return 0 + + def getUIDNext(self): + """Return the likely UID for the next message added to this mailbox. + """ + log.debug('getUIDNext') + return self.mailbox.getNextUid() + + def getUID(self, message): + """Return the UID of a message in the mailbox + @param message: The message sequence number + @return: The UID of the message. + """ + log.debug(u'getUID (%i)' % message) + #return self.mailbox.getUid(message-1) #XXX: it seems that this method get uid and not message sequence number + return message + + def getMessageCount(self): + """Return the number of messages in this mailbox. + """ + log.debug('getMessageCount') + ret = self.mailbox.getMessageCount() + log.debug("count = %i" % ret) + return ret + + def getRecentCount(self): + """Return the number of messages with the 'Recent' flag. + """ + log.debug('getRecentCount') + return len(self.mailbox.getMessageIdsWithFlag('\\Recent')) + + def getUnseenCount(self): + """Return the number of messages with the 'Unseen' flag. + """ + log.debug('getUnseenCount') + return self.getMessageCount() - len(self.mailbox.getMessageIdsWithFlag('\\SEEN')) + + def isWriteable(self): + """Get the read/write status of the mailbox. + @return: A true value if write permission is allowed, a false value otherwise. + """ + log.debug('isWriteable') + return True + + def destroy(self): + """Called before this mailbox is deleted, permanently. + """ + log.debug('destroy') + + def requestStatus(self, names): + """Return status information about this mailbox. + @param names: The status names to return information regarding. + The possible values for each name are: MESSAGES, RECENT, UIDNEXT, + UIDVALIDITY, UNSEEN. + @return: A dictionary containing status information about the + requested names is returned. If the process of looking this + information up would be costly, a deferred whose callback will + eventually be passed this dictionary is returned instead. + """ + log.debug('requestStatus') + return imap4.statusRequestHelper(self, names) + + def addListener(self, listener): + """Add a mailbox change listener + + @type listener: Any object which implements C{IMailboxListener} + @param listener: An object to add to the set of those which will + be notified when the contents of this mailbox change. + """ + log.debug(u'addListener %s' % listener) + self.listeners.add(listener) + + def removeListener(self, listener): + """Remove a mailbox change listener + + @type listener: Any object previously added to and not removed from + this mailbox as a listener. + @param listener: The object to remove from the set of listeners. + + @raise ValueError: Raised when the given object is not a listener for + this mailbox. + """ + log.debug('removeListener') + if listener in self.listeners: + self.listeners.remove(listener) + else: + raise imap4.MailboxException('Trying to remove an unknown listener') + + def addMessage(self, message, flags=(), date=None): + """Add the given message to this mailbox. + @param message: The RFC822 formatted message + @param flags: The flags to associate with this message + @param date: If specified, the date to associate with this + @return: A deferred whose callback is invoked with the message + id if the message is added successfully and whose errback is + invoked otherwise. + """ + log.debug('addMessage') + raise imap4.MailboxException("Client message addition not implemented yet") + + def expunge(self): + """Remove all messages flagged \\Deleted. + @return: The list of message sequence numbers which were deleted, + or a Deferred whose callback will be invoked with such a list. + """ + log.debug('expunge') + self.mailbox.removeDeleted() + + def fetch(self, messages, uid): + """Retrieve one or more messages. + @param messages: The identifiers of messages to retrieve information + about + @param uid: If true, the IDs specified in the query are UIDs; + """ + log.debug(u'fetch (%s, %s)' % (messages, uid)) + if uid: + messages.last = self.mailbox.getMaxUid() + messages.getnext = self.mailbox.getNextExistingUid + for mess_uid in messages: + if mess_uid is None: + log.debug('stopping iteration') + raise StopIteration + try: + yield (mess_uid, Message(mess_uid, self.mailbox.getFlagsUid(mess_uid), self.mailbox.getMessageUid(mess_uid))) + except IndexError: + continue + else: + messages.last = self.getMessageCount() + for mess_idx in messages: + if mess_idx > self.getMessageCount(): + raise StopIteration + yield (mess_idx, Message(mess_idx, self.mailbox.getFlags(mess_idx), self.mailbox.getMessage(mess_idx - 1))) + + def store(self, messages, flags, mode, uid): + """Set the flags of one or more messages. + @param messages: The identifiers of the messages to set the flags of. + @param flags: The flags to set, unset, or add. + @param mode: If mode is -1, these flags should be removed from the + specified messages. If mode is 1, these flags should be added to + the specified messages. If mode is 0, all existing flags should be + cleared and these flags should be added. + @param uid: If true, the IDs specified in the query are UIDs; + otherwise they are message sequence IDs. + @return: A dict mapping message sequence numbers to sequences of str + representing the flags set on the message after this operation has + been performed, or a Deferred whose callback will be invoked with + such a dict. + """ + log.debug('store') + + flags = [flag.upper() for flag in flags] + + def updateFlags(getF, setF): + ret = {} + for mess_id in messages: + if (uid and mess_id is None) or (not uid and mess_id > self.getMessageCount()): + break + _flags = set(getF(mess_id) if mode else []) + if mode == -1: + _flags.difference_update(set(flags)) + else: + _flags.update(set(flags)) + new_flags = list(_flags) + setF(mess_id, new_flags) + ret[mess_id] = tuple(new_flags) + return ret + + if uid: + messages.last = self.mailbox.getMaxUid() + messages.getnext = self.mailbox.getNextExistingUid + ret = updateFlags(self.mailbox.getFlagsUid, self.mailbox.setFlagsUid) + for listener in self.listeners: + listener.flagsChanged(ret) + return ret + + else: + messages.last = self.getMessageCount() + ret = updateFlags(self.mailbox.getFlags, self.mailbox.setFlags) + newFlags = {} + for idx in ret: + #we have to convert idx to uid for the listeners + newFlags[self.mailbox.getUid(idx)] = ret[idx] + for listener in self.listeners: + listener.flagsChanged(newFlags) + return ret + + def getFlags(self): + """Return the flags defined in this mailbox + Flags with the \\ prefix are reserved for use as system flags. + @return: A list of the flags that can be set on messages in this mailbox. + """ + log.debug('getFlags') + return ['\\SEEN', '\\ANSWERED', '\\FLAGGED', '\\DELETED', '\\DRAFT'] # TODO: add '\\RECENT' + + def getHierarchicalDelimiter(self): + """Get the character which delimits namespaces for in this mailbox. + """ + log.debug('getHierarchicalDelimiter') + return '.' + + +class ImapSatAccount(imap4.MemoryAccount): + #implements(imap4.IAccount) + + def __init__(self, host, profile): + log.debug("ImapAccount init") + self.host = host + self.profile = profile + imap4.MemoryAccount.__init__(self, profile) + self.addMailbox("Inbox") # We only manage Inbox for the moment + log.debug('INBOX added') + + def _emptyMailbox(self, name, id): + return SatMailbox(self.host, name, self.profile) + + +class ImapRealm(object): + implements(portal.IRealm) + + def __init__(self, host): + self.host = host + + def requestAvatar(self, avatarID, mind, *interfaces): + log.debug('requestAvatar') + profile = avatarID.decode('utf-8') + if imap4.IAccount not in interfaces: + raise NotImplementedError + return imap4.IAccount, ImapSatAccount(self.host, profile), lambda: None + + +class SatProfileCredentialChecker(object): + """ + This credential checker check against SàT's profile and associated jabber's password + Check if the profile exists, and if the password is OK + Return the profile as avatarId + """ + implements(checkers.ICredentialsChecker) + credentialInterfaces = (credentials.IUsernamePassword, + credentials.IUsernameHashedPassword) + + def __init__(self, host): + self.host = host + + def _cbPasswordMatch(self, matched, profile): + if matched: + return profile.encode('utf-8') + else: + return failure.Failure(cred_error.UnauthorizedLogin()) + + def requestAvatarId(self, credentials): + profiles = self.host.memory.getProfilesList() + if not credentials.username in profiles: + return defer.fail(cred_error.UnauthorizedLogin()) + d = self.host.memory.asyncGetParamA("Password", "Connection", profile_key=credentials.username) + d.addCallback(lambda password: credentials.checkPassword(password)) + d.addCallback(self._cbPasswordMatch, credentials.username) + return d + + +class ImapServerFactory(protocol.ServerFactory): + protocol = imap4.IMAP4Server + + def __init__(self, host): + self.host = host + + def startedConnecting(self, connector): + log.debug(_("IMAP server connection started")) + + def clientConnectionLost(self, connector, reason): + log.debug(_(u"IMAP server connection lost (reason: %s)"), reason) + + def buildProtocol(self, addr): + log.debug("Building protocol") + prot = protocol.ServerFactory.buildProtocol(self, addr) + prot.portal = portal.Portal(ImapRealm(self.host)) + prot.portal.registerChecker(SatProfileCredentialChecker(self.host)) + return prot diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_invitations.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_invitations.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,371 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for file tansfer +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +import shortuuid +from sat.tools import utils +from sat.tools.common import data_format +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import error +from sat.memory import persistent +from sat.tools import email as sat_email + + +PLUGIN_INFO = { + C.PI_NAME: "Invitations", + C.PI_IMPORT_NAME: "INVITATIONS", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_DEPENDENCIES: ['XEP-0077'], + C.PI_RECOMMENDATIONS: ["IDENTITY"], + C.PI_MAIN: "InvitationsPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""invitation of people without XMPP account""") +} + + +SUFFIX_MAX = 5 +INVITEE_PROFILE_TPL = u"guest@@{uuid}" +KEY_ID = u'id' +KEY_JID = u'jid' +KEY_CREATED = u'created' +KEY_LAST_CONNECTION = u'last_connection' +KEY_GUEST_PROFILE = u'guest_profile' +KEY_PASSWORD = u'password' +KEY_EMAILS_EXTRA = u'emails_extra' +EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, u'jid_', u'jid', KEY_LAST_CONNECTION, KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA} +DEFAULT_SUBJECT = D_(u"You have been invited by {host_name} to {app_name}") +DEFAULT_BODY = D_(u"""Hello {name}! + +You have received an invitation from {host_name} to participate to "{app_name}". +To join, you just have to click on the following URL: +{url} + +Please note that this URL should not be shared with anybody! +If you want more details on {app_name}, you can check {app_url}. + +Welcome! +""") + + +class InvitationsPlugin(object): + + def __init__(self, host): + log.info(_(u"plugin Invitations initialization")) + self.host = host + self.invitations = persistent.LazyPersistentBinaryDict(u'invitations') + host.bridge.addMethod("invitationCreate", ".plugin", in_sign='sasssssssssa{ss}s', out_sign='a{ss}', + method=self._create, + async=True) + host.bridge.addMethod("invitationGet", ".plugin", in_sign='s', out_sign='a{ss}', + method=self.get, + async=True) + host.bridge.addMethod("invitationModify", ".plugin", in_sign='sa{ss}b', out_sign='', + method=self._modify, + async=True) + host.bridge.addMethod("invitationList", ".plugin", in_sign='s', out_sign='a{sa{ss}}', + method=self._list, + async=True) + + def checkExtra(self, extra): + if EXTRA_RESERVED.intersection(extra): + raise ValueError(_(u"You can't use following key(s) in extra, they are reserved: {}").format( + u', '.join(EXTRA_RESERVED.intersection(extra)))) + + def _create(self, email=u'', emails_extra=None, jid_=u'', password=u'', name=u'', host_name=u'', language=u'', url_template=u'', message_subject=u'', message_body=u'', extra=None, profile=u''): + # XXX: we don't use **kwargs here to keep arguments name for introspection with D-Bus bridge + if emails_extra is None: + emails_extra = [] + + if extra is None: + extra = {} + else: + extra = {unicode(k): unicode(v) for k,v in extra.iteritems()} + + kwargs = {"extra": extra, + KEY_EMAILS_EXTRA: [unicode(e) for e in emails_extra] + } + + # we need to be sure that values are unicode, else they won't be pickled correctly with D-Bus + for key in ("jid_", "password", "name", "host_name", "email", "language", "url_template", "message_subject", "message_body", "profile"): + value = locals()[key] + if value: + kwargs[key] = unicode(value) + d = self.create(**kwargs) + def serialize(data): + data[KEY_JID] = data[KEY_JID].full() + return data + d.addCallback(serialize) + return d + + @defer.inlineCallbacks + def create(self, **kwargs): + ur"""create an invitation + + this will create an XMPP account and a profile, and use a UUID to retrieve them. + the profile is automatically generated in the form guest@@[UUID], this way they can be retrieved easily + **kwargs: keywords arguments which can have the following keys, unset values are equivalent to None: + jid_(jid.JID, None): jid to use for invitation, the jid will be created using XEP-0077 + if the jid has no user part, an anonymous account will be used (no XMPP account created in this case) + if None, automatically generate an account name (in the form "invitation-[random UUID]@domain.tld") (note that this UUID is not the + same as the invitation one, as jid can be used publicly (leaking the UUID), and invitation UUID give access to account. + in case of conflict, a suffix number is added to the account until a free one if found (with a failure if SUFFIX_MAX is reached) + password(unicode, None): password to use (will be used for XMPP account and profile) + None to automatically generate one + name(unicode, None): name of the invitee + will be set as profile identity if present + host_name(unicode, None): name of the host + email(unicode, None): email to send the invitation to + if None, no invitation email is sent, you can still associate email using extra + if email is used, extra can't have "email" key + language(unicode): language of the invitee (used notabily to translate the invitation) + TODO: not used yet + url_template(unicode, None): template to use to construct the invitation URL + use {uuid} as a placeholder for identifier + use None if you don't want to include URL (or if it is already specified in custom message) + /!\ you must put full URL, don't forget https:// + /!\ the URL will give access to the invitee account, you should warn in message to not publish it publicly + message_subject(unicode, None): customised message body for the invitation email + None to use default subject + uses the same substitution as for message_body + message_body(unicode, None): customised message body for the invitation email + None to use default body + use {name} as a place holder for invitee name + use {url} as a placeholder for the invitation url + use {uuid} as a placeholder for the identifier + use {app_name} as a placeholder for this software name + use {app_url} as a placeholder for this software official website + use {profile} as a placeholder for host's profile + use {host_name} as a placeholder for host's name + extra(dict, None): extra data to associate with the invitee + some keys are reserved: + - created (creation date) + if email argument is used, "email" key can't be used + profile(unicode, None): profile of the host (person who is inviting) + @return (dict[unicode, unicode]): dictionary with: + - UUID associated with the invitee (key: id) + - filled extra dictionary, as saved in the databae + """ + ## initial checks + extra = kwargs.pop('extra', {}) + if set(kwargs).intersection(extra): + raise ValueError(_(u"You can't use following key(s) in both args and extra: {}").format( + u', '.join(set(kwargs).intersection(extra)))) + + self.checkExtra(extra) + + email = kwargs.pop(u'email', None) + emails_extra = kwargs.pop(u'emails_extra', []) + if not email and emails_extra: + raise ValueError(_(u'You need to provide a main email address before using emails_extra')) + + if email is not None and not 'url_template' in kwargs and not 'message_body' in kwargs: + raise ValueError(_(u"You need to provide url_template if you use default message body")) + + ## uuid + log.info(_(u"creating an invitation")) + id_ = unicode(shortuuid.uuid()) + + ## XMPP account creation + password = kwargs.pop(u'password', None) + if password is None: + password = utils.generatePassword() + assert password + # XXX: password is here saved in clear in database + # it is needed for invitation as the same password is used for profile + # and SàT need to be able to automatically open the profile with the uuid + # FIXME: we could add an extra encryption key which would be used with the uuid + # when the invitee is connecting (e.g. with URL). This key would not be saved + # and could be used to encrypt profile password. + extra[KEY_PASSWORD] = password + + jid_ = kwargs.pop(u'jid_', None) + if not jid_: + domain = self.host.memory.getConfig(None, 'xmpp_domain') + if not domain: + # TODO: fallback to profile's domain + raise ValueError(_(u"You need to specify xmpp_domain in sat.conf")) + jid_ = u"invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(), domain=domain) + jid_ = jid.JID(jid_) + if jid_.user: + # we don't register account if there is no user as anonymous login is then used + try: + yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) + except error.StanzaError as e: + prefix = jid_.user + idx = 0 + while e.condition == u'conflict': + if idx >= SUFFIX_MAX: + raise exceptions.ConflictError(_(u"Can't create XMPP account")) + jid_.user = prefix + '_' + unicode(idx) + log.info(_(u"requested jid already exists, trying with {}".format(jid_.full()))) + try: + yield self.host.plugins['XEP-0077'].registerNewAccount(jid_, password) + except error.StanzaError as e: + idx += 1 + else: + break + if e.condition != u'conflict': + raise e + + log.info(_(u"account {jid_} created").format(jid_=jid_.full())) + + ## profile creation + + extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(uuid=id_) + # profile creation should not fail as we generate unique name ourselves + yield self.host.memory.createProfile(guest_profile, password) + yield self.host.memory.startSession(password, guest_profile) + yield self.host.memory.setParam("JabberID", jid_.full(), "Connection", profile_key=guest_profile) + yield self.host.memory.setParam("Password", password, "Connection", profile_key=guest_profile) + name = kwargs.pop(u'name', None) + if name is not None: + extra[u'name'] = name + try: + id_plugin = self.host.plugins[u'IDENTITY'] + except KeyError: + pass + else: + yield self.host.connect(guest_profile, password) + guest_client = self.host.getClient(guest_profile) + yield id_plugin.setIdentity(guest_client, {u'nick': name}) + yield self.host.disconnect(guest_profile) + + ## email + language = kwargs.pop(u'language', None) + if language is not None: + extra[u'language'] = language.strip() + + if email is not None: + extra[u'email'] = email + data_format.iter2dict(KEY_EMAILS_EXTRA, extra) + url_template = kwargs.pop(u'url_template', '') + format_args = { + u'uuid': id_, + u'app_name': C.APP_NAME, + u'app_url': C.APP_URL} + + if name is None: + format_args[u'name'] = email + else: + format_args[u'name'] = name + + profile = kwargs.pop(u'profile', None) + if profile is None: + format_args[u'profile'] = u'' + else: + format_args[u'profile'] = extra[u'profile'] = profile + + host_name = kwargs.pop(u'host_name', None) + if host_name is None: + format_args[u'host_name'] = profile or _(u"somebody") + else: + format_args[u'host_name'] = extra[u'host_name'] = host_name + + invite_url = url_template.format(**format_args) + format_args[u'url'] = invite_url + + yield sat_email.sendEmail( + self.host, + [email] + emails_extra, + (kwargs.pop(u'message_subject', None) or DEFAULT_SUBJECT).format(**format_args), + (kwargs.pop(u'message_body', None) or DEFAULT_BODY).format(**format_args), + ) + + ## extra data saving + self.invitations[id_] = extra + + if kwargs: + log.warning(_(u"Not all arguments have been consumed: {}").format(kwargs)) + + extra[KEY_ID] = id_ + extra[KEY_JID] = jid_ + defer.returnValue(extra) + + def get(self, id_): + """Retrieve invitation linked to uuid if it exists + + @param id_(unicode): UUID linked to an invitation + @return (dict[unicode, unicode]): data associated to the invitation + @raise KeyError: there is not invitation with this id_ + """ + return self.invitations[id_] + + def _modify(self, id_, new_extra, replace): + return self.modify(id_, {unicode(k): unicode(v) for k,v in new_extra.iteritems()}, replace) + + def modify(self, id_, new_extra, replace=False): + """Modify invitation data + + @param id_(unicode): UUID linked to an invitation + @param new_extra(dict[unicode, unicode]): data to update + empty values will be deleted if replace is True + @param replace(bool): if True replace the data + else update them + @raise KeyError: there is not invitation with this id_ + """ + self.checkExtra(new_extra) + def gotCurrentData(current_data): + if replace: + new_data = new_extra + for k in EXTRA_RESERVED: + try: + new_data[k] = current_data[k] + except KeyError: + continue + else: + new_data = current_data + for k,v in new_extra.iteritems(): + if k in EXTRA_RESERVED: + log.warning(_(u"Skipping reserved key {key}".format(k))) + continue + if v: + new_data[k] = v + else: + try: + del new_data[k] + except KeyError: + pass + + self.invitations[id_] = new_data + + d = self.invitations[id_] + d.addCallback(gotCurrentData) + return d + + def _list(self, profile=C.PROF_KEY_NONE): + return self.list(profile) + + @defer.inlineCallbacks + def list(self, profile=C.PROF_KEY_NONE): + """List invitations + + @param profile(unicode): return invitation linked to this profile only + C.PROF_KEY_NONE: don't filter invitations + @return list(unicode): invitations uids + """ + invitations = yield self.invitations.items() + if profile != C.PROF_KEY_NONE: + invitations = {id_:data for id_, data in invitations.iteritems() if data.get(u'profile') == profile} + + defer.returnValue(invitations) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_ip.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_ip.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,306 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for IP address discovery +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import xml_tools +from twisted.web import client as webclient +from twisted.web import error as web_error +from twisted.internet import defer +from twisted.internet import reactor +from twisted.internet import protocol +from twisted.internet import endpoints +from twisted.internet import error as internet_error +from zope.interface import implements +from wokkel import disco, iwokkel +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.protocols.jabber.error import StanzaError +import urlparse +try: + import netifaces +except ImportError: + log.warning(u"netifaces is not available, it help discovering IPs, you can install it on https://pypi.python.org/pypi/netifaces") + netifaces = None + + +PLUGIN_INFO = { + C.PI_NAME: "IP discovery", + C.PI_IMPORT_NAME: "IP", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0279"], + C.PI_RECOMMENDATIONS: ["NAT-PORT"], + C.PI_MAIN: "IPPlugin", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""This plugin help to discover our external IP address.""") +} + +# TODO: GET_IP_PAGE should be configurable in sat.conf +GET_IP_PAGE = "http://salut-a-toi.org/whereami/" # This page must only return external IP of the requester +GET_IP_LABEL = D_(u"Allow external get IP") +GET_IP_CATEGORY = "General" +GET_IP_NAME = "allow_get_ip" +GET_IP_CONFIRM_TITLE = D_(u"Confirm external site request") +GET_IP_CONFIRM = D_(u"""To facilitate data transfer, we need to contact a website. +A request will be done on {page} +That means that administrators of {domain} can know that you use "{app_name}" and your IP Address. + +IP address is an identifier to locate you on Internet (similar to a phone number). + +Do you agree to do this request ? +""").format( + page = GET_IP_PAGE, + domain = urlparse.urlparse(GET_IP_PAGE).netloc, + app_name = C.APP_NAME) +NS_IP_CHECK = "urn:xmpp:sic:1" + +PARAMS = """ + + + + + + + + """.format(category=GET_IP_CATEGORY, name=GET_IP_NAME, label=GET_IP_LABEL) + + +class IPPlugin(object): + # TODO: refresh IP if a new connection is detected + # TODO: manage IPv6 when implemented in SàT + + def __init__(self, host): + log.info(_("plugin IP discovery initialization")) + self.host = host + host.memory.updateParams(PARAMS) + + # NAT-Port + try: + self._nat = host.plugins['NAT-PORT'] + except KeyError: + log.debug(u"NAT port plugin not available") + self._nat = None + + # XXX: cache is kept until SàT is restarted + # if IP may have changed, use self.refreshIP + self._external_ip_cache = None + self._local_ip_cache = None + + def getHandler(self, client): + return IPPlugin_handler() + + def refreshIP(self): + # FIXME: use a trigger instead ? + self._external_ip_cache = None + self._local_ip_cache = None + + def _externalAllowed(self, client): + """Return value of parameter with autorisation of user to do external requests + + if parameter is not set, a dialog is shown to use to get its confirmation, and parameted is set according to answer + @return (defer.Deferred[bool]): True if external request is autorised + """ + allow_get_ip = self.host.memory.params.getParamA(GET_IP_NAME, GET_IP_CATEGORY, use_default=False) + + if allow_get_ip is None: + # we don't have autorisation from user yet to use get_ip, we ask him + def setParam(allowed): + # FIXME: we need to use boolConst as setParam only manage str/unicode + # need to be fixed when params will be refactored + self.host.memory.setParam(GET_IP_NAME, C.boolConst(allowed), GET_IP_CATEGORY) + return allowed + d = xml_tools.deferConfirm(self.host, _(GET_IP_CONFIRM), _(GET_IP_CONFIRM_TITLE), profile=client.profile) + d.addCallback(setParam) + return d + + return defer.succeed(allow_get_ip) + + def _filterAddresse(self, ip_addr): + """Filter acceptable addresses + + For now, just remove IPv4 local addresses + @param ip_addr(str): IP addresse + @return (bool): True if addresse is acceptable + """ + return not ip_addr.startswith('127.') + + def _insertFirst(self, addresses, ip_addr): + """Insert ip_addr as first item in addresses + + @param ip_addr(str): IP addresse + @param addresses(list): list of IP addresses + """ + if ip_addr in addresses: + if addresses[0] != ip_addr: + addresses.remove(ip_addr) + addresses.insert(0, ip_addr) + else: + addresses.insert(0, ip_addr) + + def _getIPFromExternal(self, ext_url): + """Get local IP by doing a connection on an external url + + @param ext_utl(str): url to connect to + @return (D(str)): return local IP + """ + url = urlparse.urlparse(ext_url) + port = url.port + if port is None: + if url.scheme=='http': + port = 80 + elif url.scheme=='https': + port = 443 + else: + log.error(u"Unknown url scheme: {}".format(url.scheme)) + defer.returnValue(None) + if url.hostname is None: + log.error(u"Can't find url hostname for {}".format(GET_IP_PAGE)) + + point = endpoints.TCP4ClientEndpoint(reactor, url.hostname, port) + def gotConnection(p): + local_ip = p.transport.getHost().host + p.transport.loseConnection() + return local_ip + + d = endpoints.connectProtocol(point, protocol.Protocol()) + d.addCallback(gotConnection) + return d + + @defer.inlineCallbacks + def getLocalIPs(self, client): + """Try do discover local area network IPs + + @return (deferred): list of lan IP addresses + if there are several addresses, the one used with the server is put first + if no address is found, localhost IP will be in the list + """ + # TODO: manage permission requesting (e.g. for UMTS link) + if self._local_ip_cache is not None: + defer.returnValue(self._local_ip_cache) + addresses = [] + localhost = ['127.0.0.1'] + + # we first try our luck with netifaces + if netifaces is not None: + addresses = [] + for interface in netifaces.interfaces(): + if_addresses = netifaces.ifaddresses(interface) + try: + inet_list = if_addresses[netifaces.AF_INET] + except KeyError: + continue + for data in inet_list: + addresse = data['addr'] + if self._filterAddresse(addresse): + addresses.append(addresse) + + # then we use our connection to server + ip = client.xmlstream.transport.getHost().host + if self._filterAddresse(ip): + self._insertFirst(addresses, ip) + defer.returnValue(addresses) + + # if server is local, we try with NAT-Port + if self._nat is not None: + nat_ip = yield self._nat.getIP(local=True) + if nat_ip is not None: + self._insertFirst(addresses, nat_ip) + defer.returnValue(addresses) + + if addresses: + defer.returnValue(addresses) + + # still not luck, we need to contact external website + allow_get_ip = yield self._externalAllowed(client) + + if not allow_get_ip: + defer.returnValue(addresses or localhost) + + try: + ip_tuple = yield self._getIPFromExternal(GET_IP_PAGE) + except (internet_error.DNSLookupError, internet_error.TimeoutError): + log.warning(u"Can't access Domain Name System") + defer.returnValue(addresses or localhost) + self._insertFirst(addresses, ip_tuple.local) + defer.returnValue(addresses) + + @defer.inlineCallbacks + def getExternalIP(self, client): + """Try to discover external IP + + @return (deferred): external IP address or None if it can't be discovered + """ + if self._external_ip_cache is not None: + defer.returnValue(self._external_ip_cache) + + + # we first try with XEP-0279 + ip_check = yield self.host.hasFeature(client, NS_IP_CHECK) + if ip_check: + log.debug(u"Server IP Check available, we use it to retrieve our IP") + iq_elt = client.IQ("get") + iq_elt.addElement((NS_IP_CHECK, 'address')) + try: + result_elt = yield iq_elt.send() + address_elt = result_elt.elements(NS_IP_CHECK, 'address').next() + ip_elt = address_elt.elements(NS_IP_CHECK,'ip').next() + except StopIteration: + log.warning(u"Server returned invalid result on XEP-0279 request, we ignore it") + except StanzaError as e: + log.warning(u"error while requesting ip to server: {}".format(e)) + else: + # FIXME: server IP may not be the same as external IP (server can be on local machine or network) + # IP should be checked to see if we have a local one, and rejected in this case + external_ip = str(ip_elt) + log.debug(u"External IP found: {}".format(external_ip)) + self._external_ip_cache = external_ip + defer.returnValue(self._external_ip_cache) + + # then with NAT-Port + if self._nat is not None: + nat_ip = yield self._nat.getIP() + if nat_ip is not None: + self._external_ip_cache = nat_ip + defer.returnValue(nat_ip) + + # and finally by requesting external website + allow_get_ip = yield self._externalAllowed(client) + try: + ip = (yield webclient.getPage(GET_IP_PAGE)) if allow_get_ip else None + except (internet_error.DNSLookupError, internet_error.TimeoutError): + log.warning(u"Can't access Domain Name System") + ip = None + except web_error.Error as e: + log.warning(u"Error while retrieving IP on {url}: {message}".format(url=GET_IP_PAGE, message=e)) + ip = None + else: + self._external_ip_cache = ip + defer.returnValue(ip) + + +class IPPlugin_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_IP_CHECK)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_maildir.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_maildir.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,500 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for managing Maildir type mail boxes +# Copyright (C) 2011 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 . + +from sat.core.i18n import D_, _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +import warnings +warnings.filterwarnings('ignore', 'the MimeWriter', DeprecationWarning, 'twisted') # FIXME: to be removed, see http://twistedmatrix.com/trac/ticket/4038 +from twisted.mail import maildir +import email.message +import email.utils +import os +from sat.core.exceptions import ProfileUnknownError +from sat.memory.persistent import PersistentBinaryDict + + +PLUGIN_INFO = { + C.PI_NAME: "Maildir Plugin", + C.PI_IMPORT_NAME: "Maildir", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "MaildirBox", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Intercept "normal" type messages, and put them in a Maildir type box""") +} + +MAILDIR_PATH = "Maildir" +CATEGORY = D_("Mail Server") +NAME = D_('Block "normal" messages propagation') +# FIXME: (very) old and (very) experimental code, need a big cleaning/review or to be deprecated + + +class MaildirError(Exception): + pass + + +class MaildirBox(object): + params = """ + + + + + + + + """.format(category_name=CATEGORY, + category_label=_(CATEGORY), + name=NAME, + label=_(NAME), + ) + + def __init__(self, host): + log.info(_("Plugin Maildir initialization")) + self.host = host + host.memory.updateParams(self.params) + + self.__observed = {} + self.data = {} # list of profile spectific data. key = profile, value = PersistentBinaryDict where key=mailbox name, + # and value is a dictionnary with the following value + # - cur_idx: value of the current unique integer increment (UID) + # - message_id (as returned by MaildirMailbox): a tuple of (UID, [flag1, flag2, ...]) + self.__mailboxes = {} # key: profile, value: {boxname: MailboxUser instance} + + #the triggers + host.trigger.add("MessageReceived", self.messageReceivedTrigger) + + def profileConnected(self, client): + """Called on client connection, create profile data""" + profile = client.profile + self.data[profile] = PersistentBinaryDict("plugin_maildir", profile) + self.__mailboxes[profile] = {} + + def dataLoaded(ignore): + if not self.data[profile]: + #the mailbox is new, we initiate the data + self.data[profile]["INBOX"] = {"cur_idx": 0} + self.data[profile].load().addCallback(dataLoaded) + + def profileDisconnected(self, client): + """Called on profile disconnection, free profile's resources""" + profile = client.profile + del self.__mailboxes[profile] + del self.data[profile] + + def messageReceivedTrigger(self, client, message, post_treat): + """This trigger catch normal message and put the in the Maildir box. + If the message is not of "normal" type, do nothing + @param message: message xmlstrem + @return: False if it's a normal message, True else""" + profile = client.profile + for e in message.elements(C.NS_CLIENT, 'body'): + mess_type = message.getAttribute('type', 'normal') + if mess_type != 'normal': + return True + self.accessMessageBox("INBOX", profile_key=profile).addMessage(message) + return not self.host.memory.getParamA(NAME, CATEGORY, profile_key=profile) + return True + + def accessMessageBox(self, boxname, observer=None, profile_key=C.PROF_KEY_NONE): + """Create and return a MailboxUser instance + @param boxname: name of the box + @param observer: method to call when a NewMessage arrive""" + profile = self.host.memory.getProfileName(profile_key) + if not profile: + raise ProfileUnknownError(profile_key) + if boxname not in self.__mailboxes[profile]: + self.__mailboxes[profile][boxname] = MailboxUser(self, boxname, observer, profile=profile) + else: + if observer: + self.addObserver(observer, profile, boxname) + return self.__mailboxes[profile][boxname] + + def _getProfilePath(self, profile): + """Return a unique path for profile's mailbox + The path must be unique, usable as a dir name, and bijectional""" + return profile.replace('/', '_').replace('..', '_') # FIXME: this is too naive to work well, must be improved + + def _removeBoxAccess(self, boxname, mailboxUser, profile): + """Remove a reference to a box + @param name: name of the box + @param mailboxUser: MailboxUser instance""" + if boxname not in self.__mailboxes: + err_msg = _("Trying to remove an mailboxUser not referenced") + log.error(_(u"INTERNAL ERROR: ") + err_msg) + raise MaildirError(err_msg) + assert self.__mailboxes[profile][boxname] == mailboxUser + del self.__mailboxes[profile][boxname] + + def _checkBoxReference(self, boxname, profile): + """Check if there is a reference on a box, and return it + @param boxname: name of the box to check + @return: MailboxUser instance or None""" + if profile in self.__mailboxes: + if boxname in self.__mailboxes[profile]: + return self.__mailboxes[profile][boxname] + + def __getBoxData(self, boxname, profile): + """Return the date of a box""" + try: + return self.data[profile][boxname] # the boxname MUST exist in the data + except KeyError: + err_msg = _("Boxname doesn't exist in internal data") + log.error(_(u"INTERNAL ERROR: ") + err_msg) + raise MaildirError(err_msg) + + def getUid(self, boxname, message_id, profile): + """Return an unique integer, always ascending, for a message + This is mainly needed for the IMAP protocol + @param boxname: name of the box where the message is + @param message_id: unique id of the message as given by MaildirMailbox + @return: Integer UID""" + box_data = self.__getBoxData(boxname, profile) + if message_id in box_data: + ret = box_data[message_id][0] + else: + box_data['cur_idx'] += 1 + box_data[message_id] = [box_data['cur_idx'], []] + ret = box_data[message_id] + self.data[profile].force(boxname) + return ret + + def getNextUid(self, boxname, profile): + """Return next unique integer that will generated + This is mainly needed for the IMAP protocol + @param boxname: name of the box where the message is + @return: Integer UID""" + box_data = self.__getBoxData(boxname, profile) + return box_data['cur_idx'] + 1 + + def getNextExistingUid(self, boxname, uid, profile): + """Give the next uid of existing message + @param boxname: name of the box where the message is + @param uid: uid to start from + @return: uid or None if the is no more message""" + box_data = self.__getBoxData(boxname, profile) + idx = uid + 1 + while self.getIdFromUid(boxname, idx, profile) is None: # TODO: this is highly inefficient because getIdfromUid is inefficient, fix this + idx += 1 + if idx > box_data['cur_idx']: + return None + return idx + + def getMaxUid(self, boxname, profile): + """Give the max existing uid + @param boxname: name of the box where the message is + @return: uid""" + box_data = self.__getBoxData(boxname, profile) + return box_data['cur_idx'] + + def getIdFromUid(self, boxname, message_uid, profile): + """Return the message unique id from it's integer UID + @param boxname: name of the box where the message is + @param message_uid: unique integer identifier + @return: unique id of the message as given by MaildirMailbox or None if not found""" + box_data = self.__getBoxData(boxname, profile) + for message_id in box_data.keys(): # TODO: this is highly inefficient on big mailbox, must be replaced in the future + if message_id == 'cur_idx': + continue + if box_data[message_id][0] == message_uid: + return message_id + return None + + def getFlags(self, boxname, mess_id, profile): + """Return the messages flags + @param boxname: name of the box where the message is + @param message_idx: message id as given by MaildirMailbox + @return: list of strings""" + box_data = self.__getBoxData(boxname, profile) + if mess_id not in box_data: + raise MaildirError("Trying to get flags from an unexisting message") + return box_data[mess_id][1] + + def setFlags(self, boxname, mess_id, flags, profile): + """Change the flags of the message + @param boxname: name of the box where the message is + @param message_idx: message id as given by MaildirMailbox + @param flags: list of strings + """ + box_data = self.__getBoxData(boxname, profile) + assert(type(flags) == list) + flags = [flag.upper() for flag in flags] # we store every flag UPPERCASE + if mess_id not in box_data: + raise MaildirError("Trying to set flags for an unexisting message") + box_data[mess_id][1] = flags + self.data[profile].force(boxname) + + def getMessageIdsWithFlag(self, boxname, flag, profile): + """Return ids of messages where a flag is set + @param boxname: name of the box where the message is + @param flag: flag to check + @return: list of id (as given by MaildirMailbox)""" + box_data = self.__getBoxData(boxname, profile) + assert(isinstance(flag, basestring)) + flag = flag.upper() + result = [] + for key in box_data: + if key == 'cur_idx': + continue + if flag in box_data[key][1]: + result.append(key) + return result + + def purgeDeleted(self, boxname, profile): + """Remove data for messages with flag "\\Deleted" + @param boxname: name of the box where the message is + """ + box_data = self.__getBoxData(boxname, profile) + for mess_id in self.getMessageIdsWithFlag(boxname, "\\Deleted", profile): + del(box_data[mess_id]) + self.data[profile].force(boxname) + + def cleanTable(self, boxname, existant_id, profile): + """Remove mails which no longuer exist from the table + @param boxname: name of the box to clean + @param existant_id: list of id which actually exist""" + box_data = self.__getBoxData(boxname, profile) + to_remove = [] + for key in box_data: + if key not in existant_id and key != "cur_idx": + to_remove.append(key) + for key in to_remove: + del box_data[key] + + def addObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"): + """Add an observer for maildir box changes + @param callback: method to call when the the box is updated + @param boxname: name of the box to observe + @param signal: which signal is observed by the caller""" + if (profile, boxname) not in self.__observed: + self.__observed[(profile, boxname)] = {} + if signal not in self.__observed[(profile, boxname)]: + self.__observed[(profile, boxname)][signal] = set() + self.__observed[(profile, boxname)][signal].add(callback) + + def removeObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"): + """Remove an observer of maildir box changes + @param callback: method to remove from obervers + @param boxname: name of the box which was observed + @param signal: which signal was observed by the caller""" + if (profile, boxname) not in self.__observed: + err_msg = _(u"Trying to remove an observer for an inexistant mailbox") + log.error(_(u"INTERNAL ERROR: ") + err_msg) + raise MaildirError(err_msg) + if signal not in self.__observed[(profile, boxname)]: + err_msg = _(u"Trying to remove an inexistant observer, no observer for this signal") + log.error(_(u"INTERNAL ERROR: ") + err_msg) + raise MaildirError(err_msg) + if not callback in self.__observed[(profile, boxname)][signal]: + err_msg = _(u"Trying to remove an inexistant observer") + log.error(_(u"INTERNAL ERROR: ") + err_msg) + raise MaildirError(err_msg) + self.__observed[(profile, boxname)][signal].remove(callback) + + def emitSignal(self, profile, boxname, signal_name): + """Emit the signal to observer""" + log.debug(u'emitSignal %s %s %s' % (profile, boxname, signal_name)) + try: + for observer_cb in self.__observed[(profile, boxname)][signal_name]: + observer_cb() + except KeyError: + pass + + +class MailboxUser(object): + """This class is used to access a mailbox""" + + def xmppMessage2mail(self, message): + """Convert the XMPP's XML message to a basic rfc2822 message + @param xml: domish.Element of the message + @return: string email""" + mail = email.message.Message() + mail['MIME-Version'] = "1.0" + mail['Content-Type'] = "text/plain; charset=UTF-8; format=flowed" + mail['Content-Transfer-Encoding'] = "8bit" + mail['From'] = message['from'].encode('utf-8') + mail['To'] = message['to'].encode('utf-8') + mail['Date'] = email.utils.formatdate().encode('utf-8') + #TODO: save thread id + for e in message.elements(): + if e.name == "body": + mail.set_payload(e.children[0].encode('utf-8')) + elif e.name == "subject": + mail['Subject'] = e.children[0].encode('utf-8') + return mail.as_string() + + def __init__(self, _maildir, name, observer=None, profile=C.PROF_KEY_NONE): + """@param _maildir: the main MaildirBox instance + @param name: name of the mailbox + @param profile: real profile (ie not a profile_key) + THIS OBJECT MUST NOT BE USED DIRECTLY: use MaildirBox.accessMessageBox instead""" + if _maildir._checkBoxReference(name, profile): + log.error(u"INTERNAL ERROR: MailboxUser MUST NOT be instancied directly") + raise MaildirError('double MailboxUser instanciation') + if name != "INBOX": + raise NotImplementedError + self.name = name + self.profile = profile + self.maildir = _maildir + profile_path = self.maildir._getProfilePath(profile) + full_profile_path = os.path.join(self.maildir.host.memory.getConfig('', 'local_dir'), 'maildir', profile_path) + if not os.path.exists(full_profile_path): + os.makedirs(full_profile_path, 0700) + mailbox_path = os.path.join(full_profile_path, MAILDIR_PATH) + self.mailbox_path = mailbox_path + self.mailbox = maildir.MaildirMailbox(mailbox_path) + self.observer = observer + self.__uid_table_update() + + if observer: + log.debug(u"adding observer for %s (%s)" % (name, profile)) + self.maildir.addObserver(observer, profile, name, "NEW_MESSAGE") + + def __uid_table_update(self): + existant_id = [] + for mess_idx in range(self.getMessageCount()): + #we update the uid table + existant_id.append(self.getId(mess_idx)) + self.getUid(mess_idx) + self.maildir.cleanTable(self.name, existant_id, profile=self.profile) + + def __del__(self): + if self.observer: + log.debug(u"removing observer for %s" % self.name) + self._maildir.removeObserver(self.observer, self.name, "NEW_MESSAGE") + self.maildir._removeBoxAccess(self.name, self, profile=self.profile) + + def addMessage(self, message): + """Add a message to the box + @param message: XMPP XML message""" + self.mailbox.appendMessage(self.xmppMessage2mail(message)).addCallback(self.emitSignal, "NEW_MESSAGE") + + def emitSignal(self, ignore, signal): + """Emit the signal to the observers""" + if signal == "NEW_MESSAGE": + self.getUid(self.getMessageCount() - 1) # XXX: we make an uid for the last message added + self.maildir.emitSignal(self.profile, self.name, signal) + + def getId(self, mess_idx): + """Return the Unique ID of the message + @mess_idx: message index""" + return self.mailbox.getUidl(mess_idx) + + def getUid(self, mess_idx): + """Return a unique interger id for the message, always ascending""" + mess_id = self.getId(mess_idx) + return self.maildir.getUid(self.name, mess_id, profile=self.profile) + + def getNextUid(self): + return self.maildir.getNextUid(self.name, profile=self.profile) + + def getNextExistingUid(self, uid): + return self.maildir.getNextExistingUid(self.name, uid, profile=self.profile) + + def getMaxUid(self): + return self.maildir.getMaxUid(self.name, profile=self.profile) + + def getMessageCount(self): + """Return number of mails present in this box""" + return len(self.mailbox.list) + + def getMessageIdx(self, mess_idx): + """Return the full message + @mess_idx: message index""" + return self.mailbox.getMessage(mess_idx) + + def getIdxFromUid(self, mess_uid): + """Return the message index from the uid + @param mess_uid: message unique identifier + @return: message index, as managed by MaildirMailbox""" + for mess_idx in range(self.getMessageCount()): + if self.getUid(mess_idx) == mess_uid: + return mess_idx + raise IndexError + + def getIdxFromId(self, mess_id): + """Return the message index from the unique index + @param mess_id: message unique index as given by MaildirMailbox + @return: message sequence index""" + for mess_idx in range(self.getMessageCount()): + if self.mailbox.getUidl(mess_idx) == mess_id: + return mess_idx + raise IndexError + + def getMessage(self, mess_idx): + """Return the full message + @param mess_idx: message index""" + return self.mailbox.getMessage(mess_idx) + + def getMessageUid(self, mess_uid): + """Return the full message + @param mess_idx: message unique identifier""" + return self.mailbox.getMessage(self.getIdxFromUid(mess_uid)) + + def getFlags(self, mess_idx): + """Return the flags of the message + @param mess_idx: message index + @return: list of strings""" + id = self.getId(mess_idx) + return self.maildir.getFlags(self.name, id, profile=self.profile) + + def getFlagsUid(self, mess_uid): + """Return the flags of the message + @param mess_uid: message unique identifier + @return: list of strings""" + id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile) + return self.maildir.getFlags(self.name, id, profile=self.profile) + + def setFlags(self, mess_idx, flags): + """Change the flags of the message + @param mess_idx: message index + @param flags: list of strings + """ + id = self.getId(mess_idx) + self.maildir.setFlags(self.name, id, flags, profile=self.profile) + + def setFlagsUid(self, mess_uid, flags): + """Change the flags of the message + @param mess_uid: message unique identifier + @param flags: list of strings + """ + id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile) + return self.maildir.setFlags(self.name, id, flags, profile=self.profile) + + def getMessageIdsWithFlag(self, flag): + """Return ids of messages where a flag is set + @param flag: flag to check + @return: list of id (as given by MaildirMailbox)""" + return self.maildir.getMessageIdsWithFlag(self.name, flag, profile=self.profile) + + def removeDeleted(self): + """Actually delete message flagged "\\Deleted" + Also purge the internal data of these messages + """ + for mess_id in self.getMessageIdsWithFlag("\\Deleted"): + print ("Deleting %s" % mess_id) + self.mailbox.deleteMessage(self.getIdxFromId(mess_id)) + self.mailbox = maildir.MaildirMailbox(self.mailbox_path) # We need to reparse the dir to have coherent indexing + self.maildir.purgeDeleted(self.name, profile=self.profile) + + def emptyTrash(self): + """Delete everything in the .Trash dir""" + pass #TODO diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_merge_requests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_merge_requests.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,293 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Pubsub Schemas +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from collections import namedtuple +from sat.tools import utils +from sat.core.log import getLogger +log = getLogger(__name__) + +NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0' + +PLUGIN_INFO = { + C.PI_NAME: _("Merge requests management"), + C.PI_IMPORT_NAME: "MERGE_REQUESTS", + C.PI_TYPE: "EXP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0060", "PUBSUB_SCHEMA", "TICKETS"], + C.PI_MAIN: "MergeRequests", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Merge requests management plugin""") +} + +FIELD_DATA_TYPE = u'type' +FIELD_DATA = u'request_data' + + +MergeRequestHandler = namedtuple("MergeRequestHandler", ['name', + 'handler', + 'data_types', + 'short_desc', + 'priority']) + + +class MergeRequests(object): + META_AUTHOR = u'author' + META_EMAIL = u'email' + META_TIMESTAMP = u'timestamp' + META_HASH = u'hash' + META_PARENT_HASH = u'parent_hash' + META_COMMIT_MSG = u'commit_msg' + META_DIFF = u'diff' + # index of the diff in the whole data + # needed to retrieve comments location + META_DIFF_IDX = u'diff_idx' + + def __init__(self, host): + log.info(_(u"Merge requests plugin initialization")) + self.host = host + host.registerNamespace('merge_requests', NS_MERGE_REQUESTS) + self._p = self.host.plugins["XEP-0060"] + self._s = self.host.plugins["PUBSUB_SCHEMA"] + self._t = self.host.plugins["TICKETS"] + self._handlers = {} + self._handlers_list = [] # handlers sorted by priority + self._type_handlers = {} # data type => handler map + host.bridge.addMethod("mergeRequestsGet", ".plugin", + in_sign='ssiassa{ss}s', out_sign='(asa{ss}aaa{ss})', + method=self._get, + async=True + ) + host.bridge.addMethod("mergeRequestSet", ".plugin", + in_sign='ssssa{sas}ssa{ss}s', out_sign='s', + method=self._set, + async=True) + host.bridge.addMethod("mergeRequestsSchemaGet", ".plugin", + in_sign='sss', out_sign='s', + method=utils.partial(self._s._getUISchema, default_node=NS_MERGE_REQUESTS), + async=True) + host.bridge.addMethod("mergeRequestParseData", ".plugin", + in_sign='ss', out_sign='aa{ss}', + method=self._parseData, + async=True) + host.bridge.addMethod("mergeRequestsImport", ".plugin", + in_sign='ssssa{ss}s', out_sign='', + method=self._import, + async=True + ) + + def register(self, name, handler, data_types, short_desc, priority=0): + """register an merge request handler + + @param name(unicode): name of the handler + @param handler(object): instance of the handler. + It must have the following methods, which may all return a Deferred: + - check(repository): True if repository can be handled + - export(repository): return export data, i.e. the patches + - parse(export_data): parse report data and return a list of dict (1 per patch) with: + - title: title of the commit message (first line) + - body: body of the commit message + @aram data_types(list[unicode]): data types that his handler can generate or parse + """ + if name in self._handlers: + raise exceptions.ConflictError(_(u"a handler with name {name} already exists!").format( + name = name)) + self._handlers[name] = MergeRequestHandler(name, + handler, + data_types, + short_desc, + priority) + self._handlers_list.append(name) + self._handlers_list.sort(key=lambda name: self._handlers[name].priority) + if isinstance(data_types, basestring): + data_types = [data_types] + for data_type in data_types: + if data_type in self._type_handlers: + log.warning(_(u'merge requests of type {type} are already handled by {old_handler}, ' + u'ignoring {new_handler}').format( + type = data_type, + old_handler = self._type_handlers[data_type].name, + new_handler = name)) + continue + self._type_handlers[data_type] = self._handlers[name] + + def _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None, extra_dict=None, profile_key=C.PROF_KEY_NONE): + if extra_dict and 'parse' in extra_dict: + extra_dict['parse'] = C.bool(extra_dict['parse']) + client, service, node, max_items, extra, sub_id = self._s.prepareBridgeGet(service, node, max_items, sub_id, extra_dict, profile_key) + d = self.get(client, service, node or None, max_items, item_ids, sub_id or None, extra.rsm_request, extra.extra) + d.addCallback(lambda (tickets, metadata, parsed_patches): ( + self._p.serItemsData((tickets, metadata)) + + ([[{key: unicode(value) for key, value in p.iteritems()} + for p in patches] for patches in parsed_patches],))) + return d + + @defer.inlineCallbacks + def get(self, client, service=None, node=None, max_items=None, item_ids=None, sub_id=None, rsm_request=None, extra=None): + """Retrieve merge requests and convert them to XMLUI + + @param extra(XEP-0060.parse, None): can have following keys: + - update(bool): if True, will return list of parsed request data + other params are the same as for [TICKETS._get] + @return (tuple[list[unicode], list[dict[unicode, unicode]])): tuple with + - XMLUI of the tickets, like [TICKETS._get] + - node metadata + - list of parsed request data (if extra['parse'] is set, else empty list) + """ + if not node: + node = NS_MERGE_REQUESTS + tickets_xmlui, metadata = yield self._s.getDataFormItems( + client, + service, + node, + max_items=max_items, + item_ids=item_ids, + sub_id=sub_id, + rsm_request=rsm_request, + extra=extra, + form_ns=NS_MERGE_REQUESTS, + filters = {u'labels': self._s.textbox2ListFilter}) + parsed_patches = [] + if extra.get('parse', False): + for ticket in tickets_xmlui: + request_type = ticket.named_widgets[FIELD_DATA_TYPE].value + request_data = ticket.named_widgets[FIELD_DATA].value + parsed_data = yield self.parseData(request_type, request_data) + parsed_patches.append(parsed_data) + defer.returnValue((tickets_xmlui, metadata, parsed_patches)) + + def _set(self, service, node, repository, method, values, schema=None, item_id=None, extra=None, profile_key=C.PROF_KEY_NONE): + client, service, node, schema, item_id, extra = self._s.prepareBridgeSet(service, node, schema, item_id, extra, profile_key) + d = self.set(client, service, node, repository, method, values, schema, item_id, extra, deserialise=True) + d.addCallback(lambda ret: ret or u'') + return d + + @defer.inlineCallbacks + def set(self, client, service, node, repository, method=u'auto', values=None, schema=None, item_id=None, extra=None, deserialise=False): + """Publish a tickets + + @param service(None, jid.JID): Pubsub service to use + @param node(unicode, None): Pubsub node to use + None to use default tickets node + @param repository(unicode): path to the repository where the code stands + @param method(unicode): name of one of the registered handler, or "auto" to try autodetection. + other arguments are same as for [TICKETS.set] + @return (unicode): id of the created item + """ + if not node: + node = NS_MERGE_REQUESTS + if values is None: + values = {} + + if FIELD_DATA in values: + raise exceptions.DataError(_(u"{field} is set by backend, you must not set it in frontend").format( + field = FIELD_DATA)) + + if method == u'auto': + for name in self._handlers_list: + handler = self._handlers[name].handler + can_handle = yield handler.check(repository) + if can_handle: + log.info(_(u"{name} handler will be used").format(name=name)) + break + else: + log.warning(_(u"repository {path} can't be handled by any installed handler").format( + path = repository)) + raise exceptions.NotFound(_(u"no handler for this repository has been found")) + else: + try: + handler = self._handlers[name].handler + except KeyError: + raise exceptions.NotFound(_(u"No handler of this name found")) + + data = yield handler.export(repository) + if not data.strip(): + raise exceptions.DataError(_(u'export data is empty, do you have any change to send?')) + + if not values.get(u'title') or not values.get(u'body'): + patches = yield handler.parse(data, values.get(FIELD_DATA_TYPE)) + commits_msg = patches[-1][self.META_COMMIT_MSG] + msg_lines = commits_msg.splitlines() + if not values.get(u'title'): + values[u'title'] = msg_lines[0] + if not values.get(u'body'): + values[u'body'] = u'\n'.join(msg_lines[1:]) + + values[FIELD_DATA] = data + + item_id = yield self._t.set(client, service, node, values, schema, item_id, extra, deserialise, form_ns=NS_MERGE_REQUESTS) + defer.returnValue(item_id) + + def _parseData(self, data_type, data): + d = self.parseData(data_type, data) + d.addCallback(lambda parsed_patches: + {key: unicode(value) for key, value in parsed_patches.iteritems()}) + return d + + def parseData(self, data_type, data): + """Parse a merge request data according to type + + @param data_type(unicode): type of the data to parse + @param data(unicode): data to parse + @return(list[dict[unicode, unicode]]): parsed data + key of dictionary are self.META_* or keys specifics to handler + @raise NotFound: no handler can parse this data_type + """ + try: + handler = self._type_handlers[data_type] + except KeyError: + raise exceptions.NotFound(_(u'No handler can handle data type "{type}"').format(type=data_type)) + return defer.maybeDeferred(handler.handler.parse, data, data_type) + + def _import(self, repository, item_id, service=None, node=None, extra=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = jid.JID(service) if service else None + d = self.import_request(client, repository, item_id, service, node or None, extra=extra or None) + return d + + @defer.inlineCallbacks + def import_request(self, client, repository, item, service=None, node=None, extra=None): + """Import a merge request in specified directory + + @param repository(unicode): path to the repository where the code stands + """ + if not node: + node = NS_MERGE_REQUESTS + tickets_xmlui, metadata = yield self._s.getDataFormItems( + client, + service, + node, + max_items=1, + item_ids=[item], + form_ns=NS_MERGE_REQUESTS) + ticket_xmlui = tickets_xmlui[0] + data = ticket_xmlui.named_widgets[FIELD_DATA].value + data_type = ticket_xmlui.named_widgets[FIELD_DATA_TYPE].value + try: + handler = self._type_handlers[data_type] + except KeyError: + raise exceptions.NotFound(_(u'No handler found to import {data_type}').format(data_type=data_type)) + log.info(_(u"Importing patch [{item_id}] using {name} handler").format( + item_id = item, + name = handler.name)) + yield handler.handler.import_(repository, data, data_type, item, service, node, extra) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_nat-port.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_nat-port.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,197 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for NAT port mapping +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.internet import threads +from twisted.internet import defer +from twisted.python import failure +import threading + +try: + import miniupnpc +except ImportError: + raise exceptions.MissingModule(u"Missing module MiniUPnPc, please download/install it (and its Python binding) at http://miniupnp.free.fr/ (or use pip install miniupnpc)") + + +PLUGIN_INFO = { + C.PI_NAME: "NAT port mapping", + C.PI_IMPORT_NAME: "NAT-PORT", + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MAIN: "NatPort", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Automatic NAT port mapping using UPnP"""), +} + +STARTING_PORT = 6000 # starting point to automatically find a port +DEFAULT_DESC = u'SaT port mapping' # we don't use "à" here as some bugged NAT don't manage charset correctly + + +class MappingError(Exception): + pass + + +class NatPort(object): + # TODO: refresh data if a new connection is detected (see plugin_misc_ip) + + def __init__(self, host): + log.info(_("plugin NAT Port initialization")) + self.host = host + self._external_ip = None + self._initialised = defer.Deferred() + self._upnp = miniupnpc.UPnP() # will be None if no device is available + self._upnp.discoverdelay=200 + self._mutex = threading.Lock() # used to protect access to self._upnp + self._starting_port_cache = None # used to cache the first available port + self._to_unmap = [] # list of tuples (ext_port, protocol) of ports to unmap on unload + discover_d = threads.deferToThread(self._discover) + discover_d.chainDeferred(self._initialised) + self._initialised.addErrback(self._init_failed) + + def unload(self): + if self._to_unmap: + log.info(u"Cleaning mapped ports") + return threads.deferToThread(self._unmapPortsBlocking) + + def _init_failed(self, failure_): + e = failure_.trap(exceptions.NotFound, exceptions.FeatureNotFound) + if e == exceptions.FeatureNotFound: + log.info(u"UPnP-IGD seems to be not activated on the device") + else: + log.info(u"UPnP-IGD not available") + self._upnp = None + + def _discover(self): + devices = self._upnp.discover() + if devices: + log.info(u"{nb} UPnP-IGD device(s) found".format(nb=devices)) + else: + log.info(u"Can't find UPnP-IGD device on the local network") + raise failure.Failure(exceptions.NotFound()) + self._upnp.selectigd() + try: + self._external_ip = self._upnp.externalipaddress() + except Exception: + raise failure.Failure(exceptions.FeatureNotFound()) + + def getIP(self, local=False): + """Return IP address found with UPnP-IGD + + @param local(bool): True to get external IP address, False to get local network one + @return (None, str): found IP address, or None of something got wrong + """ + def getIP(dummy): + if self._upnp is None: + return None + # lanaddr can be the empty string if not found, + # we need to return None in this case + return (self._upnp.lanaddr or None) if local else self._external_ip + return self._initialised.addCallback(getIP) + + def _unmapPortsBlocking(self): + """Unmap ports mapped in this session""" + self._mutex.acquire() + try: + for port, protocol in self._to_unmap: + log.info(u"Unmapping port {}".format(port)) + unmapping = self._upnp.deleteportmapping( + # the last parameter is remoteHost, we don't use it + port, protocol, '') + + if not unmapping: + log.error(u"Can't unmap port {port} ({protocol})".format( + port=port, protocol=protocol)) + del self._to_unmap[:] + finally: + self._mutex.release() + + def _mapPortBlocking(self, int_port, ext_port, protocol, desc): + """Internal blocking method to map port + + @param int_port(int): internal port to use + @param ext_port(int): external port to use, or None to find one automatically + @param protocol(str): 'TCP' or 'UDP' + @param desc(str): description of the mapping + @param return(int, None): external port used in case of success, otherwise None + """ + # we use mutex to avoid race condition if 2 threads + # try to acquire a port at the same time + self._mutex.acquire() + try: + if ext_port is None: + # find a free port + starting_port = self._starting_port_cache + ext_port = STARTING_PORT if starting_port is None else starting_port + ret = self._upnp.getspecificportmapping(ext_port, protocol) + while ret != None and ext_port < 65536: + ext_port += 1 + ret = self._upnp.getspecificportmapping(ext_port, protocol) + if starting_port is None: + # XXX: we cache the first successfuly found external port + # to avoid testing again the first series the next time + self._starting_port_cache = ext_port + + try: + mapping = self._upnp.addportmapping( + # the last parameter is remoteHost, we don't use it + ext_port, protocol, self._upnp.lanaddr, int_port, desc, '') + except Exception as e: + log.error(_(u"addportmapping error: {msg}").format(msg=e)) + raise failure.Failure(MappingError()) + + if not mapping: + raise failure.Failure(MappingError()) + else: + self._to_unmap.append((ext_port, protocol)) + finally: + self._mutex.release() + + return ext_port + + def mapPort(self, int_port, ext_port=None, protocol='TCP', desc=DEFAULT_DESC): + """Add a port mapping + + @param int_port(int): internal port to use + @param ext_port(int,None): external port to use, or None to find one automatically + @param protocol(str): 'TCP' or 'UDP' + @param desc(unicode): description of the mapping + Some UPnP IGD devices have broken encoding. It's probably a good idea to avoid non-ascii chars here + @return (D(int, None)): external port used in case of success, otherwise None + """ + if self._upnp is None: + return defer.succeed(None) + def mappingCb(ext_port): + log.info(u"{protocol} mapping from {int_port} to {ext_port} successful".format( + protocol = protocol, + int_port = int_port, + ext_port = ext_port, + )) + return ext_port + def mappingEb(failure_): + failure_.trap(MappingError) + log.warning(u"Can't map internal {int_port}".format(int_port=int_port)) + def mappingUnknownEb(failure_): + log.error(_(u"error while trying to map ports: {msg}").format(msg=failure_)) + d = threads.deferToThread(self._mapPortBlocking, int_port, ext_port, protocol, desc) + d.addCallbacks(mappingCb, mappingEb) + d.addErrback(mappingUnknownEb) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_quiz.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_quiz.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,330 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing Quiz game +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.xish import domish +from twisted.internet import reactor +from twisted.words.protocols.jabber import client as jabber_client, jid +from time import time + + +NS_QG = 'http://www.goffi.org/protocol/quiz' +QG_TAG = 'quiz' + +PLUGIN_INFO = { + C.PI_NAME: "Quiz game plugin", + C.PI_IMPORT_NAME: "Quiz", + C.PI_TYPE: "Game", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"], + C.PI_MAIN: "Quiz", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Quiz game""") +} + + +class Quiz(object): + + def inheritFromRoomGame(self, host): + global RoomGame + RoomGame = host.plugins["ROOM-GAME"].__class__ + self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {}) + + def __init__(self, host): + log.info(_("Plugin Quiz initialization")) + self.inheritFromRoomGame(host) + RoomGame._init_(self, host, PLUGIN_INFO, (NS_QG, QG_TAG), game_init={'stage': None}, player_init={'score': 0}) + host.bridge.addMethod("quizGameLaunch", ".plugin", in_sign='asss', out_sign='', method=self._prepareRoom) # args: players, room_jid, profile + host.bridge.addMethod("quizGameCreate", ".plugin", in_sign='sass', out_sign='', method=self._createGame) # args: room_jid, players, profile + host.bridge.addMethod("quizGameReady", ".plugin", in_sign='sss', out_sign='', method=self._playerReady) # args: player, referee, profile + host.bridge.addMethod("quizGameAnswer", ".plugin", in_sign='ssss', out_sign='', method=self.playerAnswer) + host.bridge.addSignal("quizGameStarted", ".plugin", signature='ssass') # args: room_jid, referee, players, profile + host.bridge.addSignal("quizGameNew", ".plugin", + signature='sa{ss}s', + doc={'summary': 'Start a new game', + 'param_0': "room_jid: jid of game's room", + 'param_1': "game_data: data of the game", + 'param_2': '%(doc_profile)s'}) + host.bridge.addSignal("quizGameQuestion", ".plugin", + signature='sssis', + doc={'summary': "Send the current question", + 'param_0': "room_jid: jid of game's room", + 'param_1': "question_id: question id", + 'param_2': "question: question to ask", + 'param_3': "timer: timer", + 'param_4': '%(doc_profile)s'}) + host.bridge.addSignal("quizGamePlayerBuzzed", ".plugin", + signature='ssbs', + doc={'summary': "A player just pressed the buzzer", + 'param_0': "room_jid: jid of game's room", + 'param_1': "player: player who pushed the buzzer", + 'param_2': "pause: should the game be paused ?", + 'param_3': '%(doc_profile)s'}) + host.bridge.addSignal("quizGamePlayerSays", ".plugin", + signature='sssis', + doc={'summary': "A player just pressed the buzzer", + 'param_0': "room_jid: jid of game's room", + 'param_1': "player: player who pushed the buzzer", + 'param_2': "text: what the player say", + 'param_3': "delay: how long, in seconds, the text must appear", + 'param_4': '%(doc_profile)s'}) + host.bridge.addSignal("quizGameAnswerResult", ".plugin", + signature='ssba{si}s', + doc={'summary': "Result of the just given answer", + 'param_0': "room_jid: jid of game's room", + 'param_1': "player: player who gave the answer", + 'param_2': "good_answer: True if the answer is right", + 'param_3': "score: dict of score with player as key", + 'param_4': '%(doc_profile)s'}) + host.bridge.addSignal("quizGameTimerExpired", ".plugin", + signature='ss', + doc={'summary': "Nobody answered the question in time", + 'param_0': "room_jid: jid of game's room", + 'param_1': '%(doc_profile)s'}) + host.bridge.addSignal("quizGameTimerRestarted", ".plugin", + signature='sis', + doc={'summary': "Nobody answered the question in time", + 'param_0': "room_jid: jid of game's room", + 'param_1': "time_left: time left before timer expiration", + 'param_2': '%(doc_profile)s'}) + + def __game_data_to_xml(self, game_data): + """Convert a game data dict to domish element""" + game_data_elt = domish.Element((None, 'game_data')) + for data in game_data: + data_elt = domish.Element((None, data)) + data_elt.addContent(game_data[data]) + game_data_elt.addChild(data_elt) + return game_data_elt + + def __xml_to_game_data(self, game_data_elt): + """Convert a domish element with game_data to a dict""" + game_data = {} + for data_elt in game_data_elt.elements(): + game_data[data_elt.name] = unicode(data_elt) + return game_data + + def __answer_result_to_signal_args(self, answer_result_elt): + """Parse answer result element and return a tuple of signal arguments + @param answer_result_elt: answer result element + @return: (player, good_answer, score)""" + score = {} + for score_elt in answer_result_elt.elements(): + score[score_elt['player']] = int(score_elt['score']) + return (answer_result_elt['player'], answer_result_elt['good_answer'] == str(True), score) + + def __answer_result(self, player_answering, good_answer, game_data): + """Convert a domish an answer_result element + @param player_answering: player who gave the answer + @param good_answer: True is the answer is right + @param game_data: data of the game""" + players_data = game_data['players_data'] + score = {} + for player in game_data['players']: + score[player] = players_data[player]['score'] + + answer_result_elt = domish.Element((None, 'answer_result')) + answer_result_elt['player'] = player_answering + answer_result_elt['good_answer'] = str(good_answer) + + for player in score: + score_elt = domish.Element((None, "score")) + score_elt['player'] = player + score_elt['score'] = str(score[player]) + answer_result_elt.addChild(score_elt) + + return answer_result_elt + + def __ask_question(self, question_id, question, timer): + """Create a element for asking a question""" + question_elt = domish.Element((None, 'question')) + question_elt['id'] = question_id + question_elt['timer'] = str(timer) + question_elt.addContent(question) + return question_elt + + def __start_play(self, room_jid, game_data, profile): + """Start the game (tell to the first player after dealer to play""" + client = self.host.getClient(profile) + game_data['stage'] = "play" + next_player_idx = game_data['current_player'] = (game_data['init_player'] + 1) % len(game_data['players']) # the player after the dealer start + game_data['first_player'] = next_player = game_data['players'][next_player_idx] + to_jid = jid.JID(room_jid.userhost() + "/" + next_player) + mess = self.createGameElt(to_jid) + mess.firstChildElement().addElement('your_turn') + client.send(mess) + + def playerAnswer(self, player, referee, answer, profile_key=C.PROF_KEY_NONE): + """Called when a player give an answer""" + client = self.host.getClient(profile_key) + log.debug(u'new player answer (%(profile)s): %(answer)s' % {'profile': client.profile, 'answer': answer}) + mess = self.createGameElt(jid.JID(referee)) + answer_elt = mess.firstChildElement().addElement('player_answer') + answer_elt['player'] = player + answer_elt.addContent(answer) + client.send(mess) + + def timerExpired(self, room_jid, profile): + """Called when nobody answered the question in time""" + client = self.host.getClient(profile) + game_data = self.games[room_jid] + game_data['stage'] = 'expired' + mess = self.createGameElt(room_jid) + mess.firstChildElement().addElement('timer_expired') + client.send(mess) + reactor.callLater(4, self.askQuestion, room_jid, client.profile) + + def pauseTimer(self, room_jid): + """Stop the timer and save the time left""" + game_data = self.games[room_jid] + left = max(0, game_data["timer"].getTime() - time()) + game_data['timer'].cancel() + game_data['time_left'] = int(left) + game_data['previous_stage'] = game_data['stage'] + game_data['stage'] = "paused" + + def restartTimer(self, room_jid, profile): + """Restart a timer with the saved time""" + client = self.host.getClient(profile) + game_data = self.games[room_jid] + assert game_data['time_left'] is not None + mess = self.createGameElt(room_jid) + mess.firstChildElement().addElement('timer_restarted') + jabber_client.restarted_elt["time_left"] = str(game_data['time_left']) + client.send(mess) + game_data["timer"] = reactor.callLater(game_data['time_left'], self.timerExpired, room_jid, profile) + game_data["time_left"] = None + game_data['stage'] = game_data['previous_stage'] + del game_data['previous_stage'] + + def askQuestion(self, room_jid, profile): + """Ask a new question""" + client = self.host.getClient(profile) + game_data = self.games[room_jid] + game_data['stage'] = "question" + game_data['question_id'] = "1" + timer = 30 + mess = self.createGameElt(room_jid) + mess.firstChildElement().addChild(self.__ask_question(game_data['question_id'], u"Quel est l'âge du capitaine ?", timer)) + client.send(mess) + game_data["timer"] = reactor.callLater(timer, self.timerExpired, room_jid, profile) + game_data["time_left"] = None + + def checkAnswer(self, room_jid, player, answer, profile): + """Check if the answer given is right""" + client = self.host.getClient(profile) + game_data = self.games[room_jid] + players_data = game_data['players_data'] + good_answer = game_data['question_id'] == "1" and answer == "42" + players_data[player]['score'] += 1 if good_answer else -1 + players_data[player]['score'] = min(9, max(0, players_data[player]['score'])) + + mess = self.createGameElt(room_jid) + mess.firstChildElement().addChild(self.__answer_result(player, good_answer, game_data)) + client.send(mess) + + if good_answer: + reactor.callLater(4, self.askQuestion, room_jid, profile) + else: + reactor.callLater(4, self.restartTimer, room_jid, profile) + + def newGame(self, room_jid, profile): + """Launch a new round""" + common_data = {'game_score': 0} + new_game_data = {"instructions": _(u"""Bienvenue dans cette partie rapide de quizz, le premier à atteindre le score de 9 remporte le jeu + +Attention, tu es prêt ?""")} + msg_elts = self.__game_data_to_xml(new_game_data) + RoomGame.newRound(self, room_jid, (common_data, msg_elts), profile) + reactor.callLater(10, self.askQuestion, room_jid, profile) + + def room_game_cmd(self, mess_elt, profile): + client = self.host.getClient(profile) + from_jid = jid.JID(mess_elt['from']) + room_jid = jid.JID(from_jid.userhost()) + game_elt = mess_elt.firstChildElement() + game_data = self.games[room_jid] + # if 'players_data' in game_data: + #  players_data = game_data['players_data'] + + for elt in game_elt.elements(): + + if elt.name == 'started': # new game created + players = [] + for player in elt.elements(): + players.append(unicode(player)) + self.host.bridge.quizGameStarted(room_jid.userhost(), from_jid.full(), players, profile) + + elif elt.name == 'player_ready': # ready to play + player = elt['player'] + status = self.games[room_jid]['status'] + nb_players = len(self.games[room_jid]['players']) + status[player] = 'ready' + log.debug(_(u'Player %(player)s is ready to start [status: %(status)s]') % {'player': player, 'status': status}) + if status.values().count('ready') == nb_players: # everybody is ready, we can start the game + self.newGame(room_jid, profile) + + elif elt.name == 'game_data': + self.host.bridge.quizGameNew(room_jid.userhost(), self.__xml_to_game_data(elt), profile) + + elif elt.name == 'question': # A question is asked + self.host.bridge.quizGameQuestion(room_jid.userhost(), elt["id"], unicode(elt), int(elt["timer"]), profile) + + elif elt.name == 'player_answer': + player = elt['player'] + pause = game_data['stage'] == 'question' # we pause the game only if we are have a question at the moment + # we first send a buzzer message + mess = self.createGameElt(room_jid) + buzzer_elt = mess.firstChildElement().addElement('player_buzzed') + buzzer_elt['player'] = player + buzzer_elt['pause'] = str(pause) + client.send(mess) + if pause: + self.pauseTimer(room_jid) + # and we send the player answer + mess = self.createGameElt(room_jid) + _answer = unicode(elt) + say_elt = mess.firstChildElement().addElement('player_says') + say_elt['player'] = player + say_elt.addContent(_answer) + say_elt['delay'] = "3" + reactor.callLater(2, client.send, mess) + reactor.callLater(6, self.checkAnswer, room_jid, player, _answer, profile=profile) + + elif elt.name == 'player_buzzed': + self.host.bridge.quizGamePlayerBuzzed(room_jid.userhost(), elt["player"], elt['pause'] == str(True), profile) + + elif elt.name == 'player_says': + self.host.bridge.quizGamePlayerSays(room_jid.userhost(), elt["player"], unicode(elt), int(elt["delay"]), profile) + + elif elt.name == 'answer_result': + player, good_answer, score = self.__answer_result_to_signal_args(elt) + self.host.bridge.quizGameAnswerResult(room_jid.userhost(), player, good_answer, score, profile) + + elif elt.name == 'timer_expired': + self.host.bridge.quizGameTimerExpired(room_jid.userhost(), profile) + + elif elt.name == 'timer_restarted': + self.host.bridge.quizGameTimerRestarted(room_jid.userhost(), int(elt['time_left']), profile) + + else: + log.error(_(u'Unmanaged game element: %s') % elt.name) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_radiocol.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_radiocol.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,269 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing Radiocol +# 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 . + +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.words.xish import domish +from twisted.internet import reactor +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from sat.core import exceptions +import os.path +import copy +import time +from os import unlink +try: + from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError + from mutagen.mp3 import MP3, HeaderNotFoundError + from mutagen.easyid3 import EasyID3 + from mutagen.id3 import ID3NoHeaderError +except ImportError: + raise exceptions.MissingModule(u"Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen") + + +NC_RADIOCOL = 'http://www.goffi.org/protocol/radiocol' +RADIOC_TAG = 'radiocol' + +PLUGIN_INFO = { + C.PI_NAME: "Radio collective plugin", + C.PI_IMPORT_NAME: "Radiocol", + C.PI_TYPE: "Exp", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"], + C.PI_MAIN: "Radiocol", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of radio collective""") +} + + +# Number of songs needed in the queue before we start playing +QUEUE_TO_START = 2 +# Maximum number of songs in the queue (the song being currently played doesn't count) +QUEUE_LIMIT = 2 + + +class Radiocol(object): + + def inheritFromRoomGame(self, host): + global RoomGame + RoomGame = host.plugins["ROOM-GAME"].__class__ + self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {}) + + def __init__(self, host): + log.info(_("Radio collective initialization")) + self.inheritFromRoomGame(host) + RoomGame._init_(self, host, PLUGIN_INFO, (NC_RADIOCOL, RADIOC_TAG), + game_init={'queue': [], 'upload': True, 'playing': None, 'playing_time': 0, 'to_delete': {}}) + self.host = host + host.bridge.addMethod("radiocolLaunch", ".plugin", in_sign='asss', out_sign='', method=self._prepareRoom, async=True) + host.bridge.addMethod("radiocolCreate", ".plugin", in_sign='sass', out_sign='', method=self._createGame) + host.bridge.addMethod("radiocolSongAdded", ".plugin", in_sign='sss', out_sign='', method=self._radiocolSongAdded, async=True) + host.bridge.addSignal("radiocolPlayers", ".plugin", signature='ssass') # room_jid, referee, players, profile + host.bridge.addSignal("radiocolStarted", ".plugin", signature='ssasais') # room_jid, referee, players, [QUEUE_TO_START, QUEUE_LIMIT], profile + host.bridge.addSignal("radiocolSongRejected", ".plugin", signature='sss') # room_jid, reason, profile + host.bridge.addSignal("radiocolPreload", ".plugin", signature='ssssssss') # room_jid, timestamp, filename, title, artist, album, profile + host.bridge.addSignal("radiocolPlay", ".plugin", signature='sss') # room_jid, filename, profile + host.bridge.addSignal("radiocolNoUpload", ".plugin", signature='ss') # room_jid, profile + host.bridge.addSignal("radiocolUploadOk", ".plugin", signature='ss') # room_jid, profile + + def __create_preload_elt(self, sender, song_added_elt): + preload_elt = copy.deepcopy(song_added_elt) + preload_elt.name = 'preload' + preload_elt['sender'] = sender + preload_elt['timestamp'] = str(time.time()) + # attributes filename, title, artist, album, length have been copied + # XXX: the frontend should know the temporary directory where file is put + return preload_elt + + def _radiocolSongAdded(self, referee_s, song_path, profile): + return self.radiocolSongAdded(jid.JID(referee_s), song_path, profile) + + def radiocolSongAdded(self, referee, song_path, profile): + """This method is called by libervia when a song has been uploaded + @param referee (jid.JID): JID of the referee in the room (room userhost + '/' + nick) + @param song_path (unicode): absolute path of the song added + @param profile_key (unicode): %(doc_profile_key)s + @return: a Deferred instance + """ + # XXX: this is a Q&D way for the proof of concept. In the future, the song should + # be streamed to the backend using XMPP file copy + # Here we cheat because we know we are on the same host, and we don't + # check data. Referee will have to parse the song himself to check it + try: + if song_path.lower().endswith('.mp3'): + actual_song = MP3(song_path) + try: + song = EasyID3(song_path) + + class Info(object): + def __init__(self, length): + self.length = length + song.info = Info(actual_song.info.length) + except ID3NoHeaderError: + song = actual_song + else: + song = OggVorbis(song_path) + except (OggVorbisHeaderError, HeaderNotFoundError): + # this file is not ogg vorbis nor mp3, we reject it + self.deleteFile(song_path) # FIXME: same host trick (see note above) + return defer.fail(exceptions.DataError(D_("The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted."))) + + attrs = {'filename': os.path.basename(song_path), + 'title': song.get("title", ["Unknown"])[0], + 'artist': song.get("artist", ["Unknown"])[0], + 'album': song.get("album", ["Unknown"])[0], + 'length': str(song.info.length) + } + radio_data = self.games[referee.userhostJID()] # FIXME: referee comes from Libervia's client side, it's unsecure + radio_data['to_delete'][attrs['filename']] = song_path # FIXME: works only because of the same host trick, see the note under the docstring + return self.send(referee, ('', 'song_added'), attrs, profile=profile) + + def playNext(self, room_jid, profile): + """"Play next song in queue if exists, and put a timer + which trigger after the song has been played to play next one""" + # TODO: songs need to be erased once played or found invalids + # ==> unlink done the Q&D way with the same host trick (see above) + radio_data = self.games[room_jid] + if len(radio_data['players']) == 0: + log.debug(_(u'No more participants in the radiocol: cleaning data')) + radio_data['queue'] = [] + for filename in radio_data['to_delete']: + self.deleteFile(filename, radio_data) + radio_data['to_delete'] = {} + queue = radio_data['queue'] + if not queue: + # nothing left to play, we need to wait for uploads + radio_data['playing'] = None + return + song = queue.pop(0) + filename, length = song['filename'], float(song['length']) + self.send(room_jid, ('', 'play'), {'filename': filename}, profile=profile) + radio_data['playing'] = song + radio_data['playing_time'] = time.time() + + if not radio_data['upload'] and len(queue) < QUEUE_LIMIT: + # upload is blocked and we now have resources to get more, we reactivate it + self.send(room_jid, ('', 'upload_ok'), profile=profile) + radio_data['upload'] = True + + reactor.callLater(length, self.playNext, room_jid, profile) + # we wait more than the song length to delete the file, to manage poorly reactive networks/clients + reactor.callLater(length + 90, self.deleteFile, filename, radio_data) # FIXME: same host trick (see above) + + def deleteFile(self, filename, radio_data=None): + """ + Delete a previously uploaded file. + @param filename: filename to delete, or full filepath if radio_data is None + @param radio_data: current game data + @return: True if the file has been deleted + """ + if radio_data: + try: + file_to_delete = radio_data['to_delete'][filename] + except KeyError: + log.error(_(u"INTERNAL ERROR: can't find full path of the song to delete")) + return False + else: + file_to_delete = filename + try: + unlink(file_to_delete) + except OSError: + log.error(_(u"INTERNAL ERROR: can't find %s on the file system" % file_to_delete)) + return False + return True + + def room_game_cmd(self, mess_elt, profile): + from_jid = jid.JID(mess_elt['from']) + room_jid = from_jid.userhostJID() + nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid, profile) + + radio_elt = mess_elt.firstChildElement() + radio_data = self.games[room_jid] + if 'queue' in radio_data: + queue = radio_data['queue'] + + from_referee = self.isReferee(room_jid, from_jid.resource) + to_referee = self.isReferee(room_jid, jid.JID(mess_elt['to']).user) + is_player = self.isPlayer(room_jid, nick) + for elt in radio_elt.elements(): + if not from_referee and not (to_referee and elt.name == 'song_added'): + continue # sender must be referee, expect when a song is submitted + if not is_player and (elt.name not in ('started', 'players')): + continue # user is in the room but not playing + + if elt.name in ('started', 'players'): # new game created and/or players list updated + players = [] + for player in elt.elements(): + players.append(unicode(player)) + signal = self.host.bridge.radiocolStarted if elt.name == 'started' else self.host.bridge.radiocolPlayers + signal(room_jid.userhost(), from_jid.full(), players, [QUEUE_TO_START, QUEUE_LIMIT], profile) + elif elt.name == 'preload': # a song is in queue and must be preloaded + self.host.bridge.radiocolPreload(room_jid.userhost(), elt['timestamp'], elt['filename'], elt['title'], elt['artist'], elt['album'], elt['sender'], profile) + elif elt.name == 'play': + self.host.bridge.radiocolPlay(room_jid.userhost(), elt['filename'], profile) + elif elt.name == 'song_rejected': # a song has been refused + self.host.bridge.radiocolSongRejected(room_jid.userhost(), elt['reason'], profile) + elif elt.name == 'no_upload': + self.host.bridge.radiocolNoUpload(room_jid.userhost(), profile) + elif elt.name == 'upload_ok': + self.host.bridge.radiocolUploadOk(room_jid.userhost(), profile) + elif elt.name == 'song_added': # a song has been added + # FIXME: we are KISS for the proof of concept: every song is added, to a limit of 3 in queue. + # Need to manage some sort of rules to allow peoples to send songs + if len(queue) >= QUEUE_LIMIT: + # there are already too many songs in queue, we reject this one + # FIXME: add an error code + self.send(from_jid, ('', 'song_rejected'), {'reason': "Too many songs in queue"}, profile=profile) + return + + # The song is accepted and added in queue + preload_elt = self.__create_preload_elt(from_jid.resource, elt) + queue.append(preload_elt) + + if len(queue) >= QUEUE_LIMIT: + # We are at the limit, we refuse new upload until next play + self.send(room_jid, ('', 'no_upload'), profile=profile) + radio_data['upload'] = False + + self.send(room_jid, preload_elt, profile=profile) + if not radio_data['playing'] and len(queue) == QUEUE_TO_START: + # We have not started playing yet, and we have QUEUE_TO_START + # songs in queue. We can now start the party :) + self.playNext(room_jid, profile) + else: + log.error(_(u'Unmanaged game element: %s') % elt.name) + + def getSyncDataForPlayer(self, room_jid, nick): + game_data = self.games[room_jid] + elements = [] + if game_data['playing']: + preload = copy.deepcopy(game_data['playing']) + current_time = game_data['playing_time'] + 1 if self.testing else time.time() + preload['filename'] += '#t=%.2f' % (current_time - game_data['playing_time']) + elements.append(preload) + play = domish.Element(('', 'play')) + play['filename'] = preload['filename'] + elements.append(play) + if len(game_data['queue']) > 0: + elements.extend(copy.deepcopy(game_data['queue'])) + if len(game_data['queue']) == QUEUE_LIMIT: + elements.append(domish.Element(('', 'no_upload'))) + return elements diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_register_account.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_register_account.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,113 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for registering a new XMPP account +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _, D_ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C +from twisted.words.protocols.jabber import jid +from sat.memory.memory import Sessions +from sat.tools import xml_tools +from sat.tools.xml_tools import SAT_FORM_PREFIX, SAT_PARAM_SEPARATOR + + +PLUGIN_INFO = { + C.PI_NAME: "Register Account Plugin", + C.PI_IMPORT_NAME: "REGISTER-ACCOUNT", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0077"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "RegisterAccount", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Register XMPP account""") +} + + +class RegisterAccount(object): + # FIXME: this plugin is messy and difficult to read, it needs to be cleaned up and documented + + def __init__(self, host): + log.info(_(u"Plugin Register Account initialization")) + self.host = host + self._sessions = Sessions() + host.registerCallback(self.registerNewAccountCB, with_data=True, force_id="registerNewAccount") + self.__register_account_id = host.registerCallback(self._registerConfirmation, with_data=True) + + def registerNewAccountCB(self, data, profile): + """Called when the user click on the "New account" button.""" + session_data = {} + + # FIXME: following loop is overcomplicated, hard to read + # FIXME: while used with parameters, hashed password is used and overwrite clear one + for param in (u'JabberID', u'Password', C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM): + try: + session_data[param] = data[SAT_FORM_PREFIX + u"Connection" + SAT_PARAM_SEPARATOR + param] + except KeyError: + if param in (C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM): + session_data[param] = '' + + for param in (u'JabberID', u'Password'): + if not session_data[param]: + form_ui = xml_tools.XMLUI(u"popup", title=D_(u"Missing values")) + form_ui.addText(D_(u"No user JID or password given: can't register new account.")) + return {u'xmlui': form_ui.toXml()} + + session_data['user'], host, resource = jid.parse(session_data['JabberID']) + session_data['server'] = session_data[C.FORCE_SERVER_PARAM] or host + session_id, dummy = self._sessions.newSession(session_data, profile=profile) + form_ui = xml_tools.XMLUI("form", title=D_("Register new account"), submit_id=self.__register_account_id, session_id=session_id) + form_ui.addText(D_(u"Do you want to register a new XMPP account {jid}?").format( + jid = session_data['JabberID'])) + return {'xmlui': form_ui.toXml()} + + def _registerConfirmation(self, data, profile): + """Save the related parameters and proceed the registration.""" + session_data = self._sessions.profileGet(data['session_id'], profile) + + self.host.memory.setParam("JabberID", session_data["JabberID"], "Connection", profile_key=profile) + self.host.memory.setParam("Password", session_data["Password"], "Connection", profile_key=profile) + self.host.memory.setParam(C.FORCE_SERVER_PARAM, session_data[C.FORCE_SERVER_PARAM], "Connection", profile_key=profile) + self.host.memory.setParam(C.FORCE_PORT_PARAM, session_data[C.FORCE_PORT_PARAM], "Connection", profile_key=profile) + + d = self._registerNewAccount(jid.JID(session_data['JabberID']), session_data["Password"], None, session_data['server']) + del self._sessions[data['session_id']] + return d + + def _registerNewAccount(self, client, jid_, password, email, server): + # FIXME: port is not set here + def registeredCb(dummy): + xmlui = xml_tools.XMLUI(u"popup", title=D_(u"Confirmation")) + xmlui.addText(D_("Registration successful.")) + return ({'xmlui': xmlui.toXml()}) + + def registeredEb(failure): + xmlui = xml_tools.XMLUI("popup", title=D_("Failure")) + xmlui.addText(D_("Registration failed: %s") % failure.getErrorMessage()) + try: + if failure.value.condition == 'conflict': + xmlui.addText(D_("Username already exists, please choose an other one.")) + except AttributeError: + pass + return ({'xmlui': xmlui.toXml()}) + + registered_d = self.host.plugins['XEP-0077'].registerNewAccount(client, jid_, password, email=email, host=server, port=C.XMPP_C2S_PORT) + registered_d.addCallbacks(registeredCb, registeredEb) + return registered_d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_room_game.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_room_game.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,704 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from twisted.internet import defer +from time import time +from wokkel import disco, iwokkel +from zope.interface import implements +import copy +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +# Don't forget to set it to False before you commit +_DEBUG = False + +PLUGIN_INFO = { + C.PI_NAME: "Room game", + C.PI_IMPORT_NAME: "ROOM-GAME", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249"], + C.PI_MAIN: "RoomGame", + C.PI_HANDLER: "no", # handler MUST be "no" (dynamic inheritance) + C.PI_DESCRIPTION: _("""Base class for MUC games""") +} + + +# FIXME: this plugin is broken, need to be fixed + +class RoomGame(object): + """This class is used to help launching a MUC game. + + Bridge methods callbacks: _prepareRoom, _playerReady, _createGame + Triggered methods: userJoinedTrigger, userLeftTrigger + Also called from subclasses: newRound + + For examples of messages sequences, please look in sub-classes. + """ + + # Values for self.invite_mode (who can invite after the game creation) + FROM_ALL, FROM_NONE, FROM_REFEREE, FROM_PLAYERS = xrange(0, 4) + # Values for self.wait_mode (for who we should wait before creating the game) + FOR_ALL, FOR_NONE = xrange(0, 2) + # Values for self.join_mode (who can join the game - NONE means solo game) + ALL, INVITED, NONE = xrange(0, 3) + # Values for ready_mode (how to turn a MUC user into a player) + ASK, FORCE = xrange(0, 2) + + MESSAGE = '/message' + REQUEST = '%s/%s[@xmlns="%s"]' + + def __init__(self, host): + """For other plugin to dynamically inherit this class, it is necessary to not use __init__ but _init_. + The subclass itself must be initialized this way: + + class MyGame(object): + + def inheritFromRoomGame(self, host): + global RoomGame + RoomGame = host.plugins["ROOM-GAME"].__class__ + self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {}) + + def __init__(self, host): + self.inheritFromRoomGame(host) + RoomGame._init_(self, host, ...) + + """ + self.host = host + + def _init_(self, host, plugin_info, ns_tag, game_init=None, player_init=None): + """ + @param host + @param plugin_info: PLUGIN_INFO map of the game plugin + @param ns_tag: couple (nameservice, tag) to construct the messages + @param game_init: dictionary for general game initialization + @param player_init: dictionary for player initialization, applicable to each player + """ + self.host = host + self.name = plugin_info["import_name"] + self.ns_tag = ns_tag + self.request = self.REQUEST % (self.MESSAGE, ns_tag[1], ns_tag[0]) + if game_init is None: + game_init = {} + if player_init is None: + player_init = {} + self.game_init = game_init + self.player_init = player_init + self.games = {} + self.invitations = {} # values are a couple (x, y) with x the time and y a list of users + + # These are the default settings, which can be overwritten by child class after initialization + self.invite_mode = self.FROM_PLAYERS if self.player_init == {} else self.FROM_NONE + self.wait_mode = self.FOR_NONE if self.player_init == {} else self.FOR_ALL + self.join_mode = self.INVITED + self.ready_mode = self.FORCE # TODO: asking for confirmation is not implemented + + # this has been added for testing purpose. It is sometimes needed to remove a dependence + # while building the synchronization data, for example to replace a call to time.time() + # by an arbitrary value. If needed, this attribute would be set to True from the testcase. + self.testing = False + + host.trigger.add("MUC user joined", self.userJoinedTrigger) + host.trigger.add("MUC user left", self.userLeftTrigger) + + def _createOrInvite(self, room_jid, other_players, profile): + """ + This is called only when someone explicitly wants to play. + + The game will not be created if one already exists in the room, + also its creation could be postponed until all the expected players + join the room (in that case it will be created from userJoinedTrigger). + @param room (wokkel.muc.Room): the room + @param other_players (list[jid.JID]): list of the other players JID (bare) + """ + # FIXME: broken ! + raise NotImplementedError("To be fixed") + client = self.host.getClient(profile) + user_jid = self.host.getJidNStream(profile)[0] + nick = self.host.plugins["XEP-0045"].getRoomNick(client, room_jid) + nicks = [nick] + if self._gameExists(room_jid): + if not self._checkJoinAuth(room_jid, user_jid, nick): + return + nicks.extend(self._invitePlayers(room_jid, other_players, nick, profile)) + self._updatePlayers(room_jid, nicks, True, profile) + else: + self._initGame(room_jid, nick) + (auth, waiting, missing) = self._checkWaitAuth(room_jid, other_players) + nicks.extend(waiting) + nicks.extend(self._invitePlayers(room_jid, missing, nick, profile)) + if auth: + self.createGame(room_jid, nicks, profile) + else: + self._updatePlayers(room_jid, nicks, False, profile) + + def _initGame(self, room_jid, referee_nick): + """ + + @param room_jid (jid.JID): JID of the room + @param referee_nick (unicode): nickname of the referee + """ + # Important: do not add the referee to 'players' yet. For a + # message to be emitted whenever a new player is joining, + # it is necessary to not modify 'players' outside of _updatePlayers. + referee_jid = jid.JID(room_jid.userhost() + '/' + referee_nick) + self.games[room_jid] = {'referee': referee_jid, 'players': [], 'started': False, 'status': {}} + self.games[room_jid].update(copy.deepcopy(self.game_init)) + self.invitations.setdefault(room_jid, []) + + def _gameExists(self, room_jid, started=False): + """Return True if a game has been initialized/started. + @param started: if False, the game must be initialized to return True, + otherwise it must be initialized and started with createGame. + @return: True if a game is initialized/started in that room""" + return room_jid in self.games and (not started or self.games[room_jid]['started']) + + def _checkJoinAuth(self, room_jid, user_jid=None, nick="", verbose=False): + """Checks if this profile is allowed to join the game. + + The parameter nick is used to check if the user is already + a player in that game. When this method is called from + userJoinedTrigger, nick is also used to check the user + identity instead of user_jid_s (see TODO comment below). + @param room_jid (jid.JID): the JID of the room hosting the game + @param user_jid (jid.JID): JID of the user + @param nick (unicode): nick of the user + @return: True if this profile can join the game + """ + auth = False + if not self._gameExists(room_jid): + auth = False + elif self.join_mode == self.ALL or self.isPlayer(room_jid, nick): + auth = True + elif self.join_mode == self.INVITED: + # considering all the batches of invitations + for invitations in self.invitations[room_jid]: + if user_jid is not None: + if user_jid.userhostJID() in invitations[1]: + auth = True + break + else: + # TODO: that's not secure enough but what to do if + # wokkel.muc.User's 'entity' attribute is not set?! + if nick in [invited.user for invited in invitations[1]]: + auth = True + break + + if not auth and (verbose or _DEBUG): + log.debug(_(u"%(user)s not allowed to join the game %(game)s in %(room)s") % {'user': user_jid.userhost() or nick, 'game': self.name, 'room': room_jid.userhost()}) + return auth + + def _updatePlayers(self, room_jid, nicks, sync, profile): + """Update the list of players and signal to the room that some players joined the game. + If sync is True, the news players are synchronized with the game data they have missed. + Remark: self.games[room_jid]['players'] should not be modified outside this method. + @param room_jid (jid.JID): JID of the room + @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position) + @param sync (bool): set to True to send synchronization data to the new players + @param profile (unicode): %(doc_profile)s + """ + if nicks == []: + return + # this is better than set(nicks).difference(...) as it keeps the order + new_nicks = [nick for nick in nicks if nick not in self.games[room_jid]['players']] + if len(new_nicks) == 0: + return + + def setStatus(status): + for nick in new_nicks: + self.games[room_jid]['status'][nick] = status + + sync = sync and self._gameExists(room_jid, True) and len(self.games[room_jid]['players']) > 0 + setStatus('desync' if sync else 'init') + self.games[room_jid]['players'].extend(new_nicks) + self._synchronizeRoom(room_jid, [room_jid], profile) + if sync: + setStatus('init') + + def _synchronizeRoom(self, room_jid, recipients, profile): + """Communicate the list of players to the whole room or only to some users, + also send the synchronization data to the players who recently joined the game. + @param room_jid (jid.JID): JID of the room + @recipients (list[jid.JID]): list of JIDs, the recipients of the message could be: + - room JID + - room JID + "/" + user nick + @param profile (unicode): %(doc_profile)s + """ + if self._gameExists(room_jid, started=True): + element = self._createStartElement(self.games[room_jid]['players']) + else: + element = self._createStartElement(self.games[room_jid]['players'], name="players") + elements = [(element, None, None)] + + sync_args = [] + sync_data = self._getSyncData(room_jid) + for nick in sync_data: + user_jid = jid.JID(room_jid.userhost() + '/' + nick) + if user_jid in recipients: + user_elements = copy.deepcopy(elements) + for child in sync_data[nick]: + user_elements.append((child, None, None)) + recipients.remove(user_jid) + else: + user_elements = [(child, None, None) for child in sync_data[nick]] + sync_args.append(([user_jid, user_elements], {'profile': profile})) + + for recipient in recipients: + self._sendElements(recipient, elements, profile=profile) + for args, kwargs in sync_args: + self._sendElements(*args, **kwargs) + + def _getSyncData(self, room_jid, force_nicks=None): + """The synchronization data are returned for each player who + has the state 'desync' or if he's been contained by force_nicks. + @param room_jid (jid.JID): JID of the room + @param force_nicks: force the synchronization for this list of the nicks + @return: a mapping between player nicks and a list of elements to + be sent by self._synchronizeRoom for the game to be synchronized. + """ + if not self._gameExists(room_jid): + return {} + data = {} + status = self.games[room_jid]['status'] + nicks = [nick for nick in status if status[nick] == 'desync'] + if force_nicks is None: + force_nicks = [] + for nick in force_nicks: + if nick not in nicks: + nicks.append(nick) + for nick in nicks: + elements = self.getSyncDataForPlayer(room_jid, nick) + if elements: + data[nick] = elements + return data + + def getSyncDataForPlayer(self, room_jid, nick): + """This method may (and should probably) be overwritten by a child class. + @param room_jid (jid.JID): JID of the room + @param nick: the nick of the player to be synchronized + @return: a list of elements to synchronize this player with the game. + """ + return [] + + def _invitePlayers(self, room_jid, other_players, nick, profile): + """Invite players to a room, associated game may exist or not. + + @param other_players (list[jid.JID]): list of the players to invite + @param nick (unicode): nick of the user who send the invitation + @return: list[unicode] of room nicks for invited players who are already in the room + """ + raise NotImplementedError("Need to be fixed !") + # FIXME: this is broken and unsecure ! + if not self._checkInviteAuth(room_jid, nick): + return [] + # TODO: remove invitation waiting for too long, using the time data + self.invitations[room_jid].append((time(), [player.userhostJID() for player in other_players])) + nicks = [] + for player_jid in [player.userhostJID() for player in other_players]: + # TODO: find a way to make it secure + other_nick = self.host.plugins["XEP-0045"].getRoomEntityNick(room_jid, player_jid, secure=self.testing) + if other_nick is None: + self.host.plugins["XEP-0249"].invite(player_jid, room_jid, {"game": self.name}, profile) + else: + nicks.append(other_nick) + return nicks + + def _checkInviteAuth(self, room_jid, nick, verbose=False): + """Checks if this user is allowed to invite players + + @param room_jid (jid.JID): JID of the room + @param nick: user nick in the room + @param verbose: display debug message + @return: True if the user is allowed to invite other players + """ + auth = False + if self.invite_mode == self.FROM_ALL or not self._gameExists(room_jid): + auth = True + elif self.invite_mode == self.FROM_NONE: + auth = not self._gameExists(room_jid, started=True) and self.isReferee(room_jid, nick) + elif self.invite_mode == self.FROM_REFEREE: + auth = self.isReferee(room_jid, nick) + elif self.invite_mode == self.FROM_PLAYERS: + auth = self.isPlayer(room_jid, nick) + if not auth and (verbose or _DEBUG): + log.debug(_(u"%(user)s not allowed to invite for the game %(game)s in %(room)s") % {'user': nick, 'game': self.name, 'room': room_jid.userhost()}) + return auth + + def isReferee(self, room_jid, nick): + """Checks if the player with this nick is the referee for the game in this room" + @param room_jid (jid.JID): room JID + @param nick: user nick in the room + @return: True if the user is the referee of the game in this room + """ + if not self._gameExists(room_jid): + return False + return jid.JID(room_jid.userhost() + '/' + nick) == self.games[room_jid]['referee'] + + def isPlayer(self, room_jid, nick): + """Checks if the user with this nick is a player for the game in this room. + @param room_jid (jid.JID): JID of the room + @param nick: user nick in the room + @return: True if the user is a player of the game in this room + """ + if not self._gameExists(room_jid): + return False + # Important: the referee is not in the 'players' list right after + # the game initialization, that's why we do also check with isReferee + return nick in self.games[room_jid]['players'] or self.isReferee(room_jid, nick) + + def _checkWaitAuth(self, room, other_players, verbose=False): + """Check if we must wait for other players before starting the game. + + @param room (wokkel.muc.Room): the room + @param other_players (list[jid.JID]): list of the players without the referee + @param verbose (bool): display debug message + @return: (x, y, z) with: + x: False if we must wait, True otherwise + y: the nicks of the players that have been checked and confirmed + z: the JID of the players that have not been checked or that are missing + """ + if self.wait_mode == self.FOR_NONE or other_players == []: + result = (True, [], other_players) + elif len(room.roster) < len(other_players): + # do not check the players until we may actually have them all + result = (False, [], other_players) + else: + # TODO: find a way to make it secure + (nicks, missing) = self.host.plugins["XEP-0045"].getRoomNicksOfUsers(room, other_players, secure=False) + result = (len(nicks) == len(other_players), nicks, missing) + if not result[0] and (verbose or _DEBUG): + log.debug(_(u"Still waiting for %(users)s before starting the game %(game)s in %(room)s") % {'users': result[2], 'game': self.name, 'room': room.occupantJID.userhost()}) + return result + + def getUniqueName(self, muc_service=None, profile_key=C.PROF_KEY_NONE): + """Generate unique room name + + @param muc_service (jid.JID): you can leave empty to autofind the muc service + @param profile_key (unicode): %(doc_profile_key)s + @return: jid.JID (unique name for a new room to be created) + """ + client = self.host.getClient(profile_key) + # FIXME: jid.JID must be used instead of strings + room = self.host.plugins["XEP-0045"].getUniqueName(client, muc_service) + return jid.JID("sat_%s_%s" % (self.name.lower(), room.userhost())) + + def _prepareRoom(self, other_players=None, room_jid_s='', profile_key=C.PROF_KEY_NONE): + room_jid = jid.JID(room_jid_s) if room_jid_s else None + other_players = [jid.JID(player).userhostJID() for player in other_players] + return self.prepareRoom(other_players, room_jid, profile_key) + + def prepareRoom(self, other_players=None, room_jid=None, profile_key=C.PROF_KEY_NONE): + """Prepare the room for a game: create it if it doesn't exist and invite players. + + @param other_players (list[JID]): list of other players JID (bare) + @param room_jid (jid.JID): JID of the room, or None to generate a unique name + @param profile_key (unicode): %(doc_profile_key)s + """ + # FIXME: need to be refactored + client = self.host.getClient(profile_key) + log.debug(_(u'Preparing room for %s game') % self.name) + profile = self.host.memory.getProfileName(profile_key) + if not profile: + log.error(_("Unknown profile")) + return defer.succeed(None) + if other_players is None: + other_players = [] + + # Create/join the given room, or a unique generated one if no room is specified. + if room_jid is None: + room_jid = self.getUniqueName(profile_key=profile_key) + else: + self.host.plugins["XEP-0045"].checkRoomJoined(client, room_jid) + self._createOrInvite(client, room_jid, other_players) + return defer.succeed(None) + + user_jid = self.host.getJidNStream(profile)[0] + d = self.host.plugins["XEP-0045"].join(room_jid, user_jid.user, {}, profile) + return d.addCallback(lambda dummy: self._createOrInvite(client, room_jid, other_players)) + + def userJoinedTrigger(self, room, user, profile): + """This trigger is used to check if the new user can take part of a game, create the game if we were waiting for him or just update the players list. + + @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User} + @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID + @return: True to not interrupt the main process. + """ + room_jid = room.occupantJID.userhostJID() + profile_nick = room.occupantJID.resource + if not self.isReferee(room_jid, profile_nick): + return True # profile is not the referee + if not self._checkJoinAuth(room_jid, user.entity if user.entity else None, user.nick): + # user not allowed but let him know that we are playing :p + self._synchronizeRoom(room_jid, [jid.JID(room_jid.userhost() + '/' + user.nick)], profile) + return True + if self.wait_mode == self.FOR_ALL: + # considering the last batch of invitations + batch = len(self.invitations[room_jid]) - 1 + if batch < 0: + log.error(u"Invitations from %s to play %s in %s have been lost!" % (profile_nick, self.name, room_jid.userhost())) + return True + other_players = self.invitations[room_jid][batch][1] + (auth, nicks, dummy) = self._checkWaitAuth(room, other_players) + if auth: + del self.invitations[room_jid][batch] + nicks.insert(0, profile_nick) # add the referee + self.createGame(room_jid, nicks, profile_key=profile) + return True + # let the room know that a new player joined + self._updatePlayers(room_jid, [user.nick], True, profile) + return True + + def userLeftTrigger(self, room, user, profile): + """This trigger is used to update or stop the game when a user leaves. + + @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User} + @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID + @return: True to not interrupt the main process. + """ + room_jid = room.occupantJID.userhostJID() + profile_nick = room.occupantJID.resource + if not self.isReferee(room_jid, profile_nick): + return True # profile is not the referee + if self.isPlayer(room_jid, user.nick): + try: + self.games[room_jid]['players'].remove(user.nick) + except ValueError: + pass + if len(self.games[room_jid]['players']) == 0: + return True + if self.wait_mode == self.FOR_ALL: + # allow this user to join the game again + user_jid = user.entity.userhostJID() + if len(self.invitations[room_jid]) == 0: + self.invitations[room_jid].append((time(), [user_jid])) + else: + batch = 0 # add to the first batch of invitations + if user_jid not in self.invitations[room_jid][batch][1]: + self.invitations[room_jid][batch][1].append(user_jid) + return True + + def _checkCreateGameAndInit(self, room_jid, profile): + """Check if that profile can create the game. If the game can be created + but is not initialized yet, this method will also do the initialization. + + @param room_jid (jid.JID): JID of the room + @param profile + @return: a couple (create, sync) with: + - create: set to True to allow the game creation + - sync: set to True to advice a game synchronization + """ + user_nick = self.host.plugins["XEP-0045"].getRoomNick(room_jid, profile) + if not user_nick: + log.error(u'Internal error: profile %s has not joined the room %s' % (profile, room_jid.userhost())) + return False, False + if self._gameExists(room_jid): + is_referee = self.isReferee(room_jid, user_nick) + if self._gameExists(room_jid, started=True): + log.info(_(u"%(game)s game already created in room %(room)s") % {'game': self.name, 'room': room_jid.userhost()}) + return False, is_referee + elif not is_referee: + log.info(_(u"%(game)s game in room %(room)s can only be created by %(user)s") % {'game': self.name, 'room': room_jid.userhost(), 'user': user_nick}) + return False, False + else: + self._initGame(room_jid, user_nick) + return True, False + + def _createGame(self, room_jid_s, nicks=None, profile_key=C.PROF_KEY_NONE): + self.createGame(jid.JID(room_jid_s), nicks, profile_key) + + def createGame(self, room_jid, nicks=None, profile_key=C.PROF_KEY_NONE): + """Create a new game. + + This can be called directly from a frontend and skips all the checks and invitation system, + but the game must not exist and all the players must be in the room already. + @param room_jid (jid.JID): JID of the room + @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position) + @param profile_key (unicode): %(doc_profile_key)s + """ + log.debug(_(u"Creating %(game)s game in room %(room)s") % {'game': self.name, 'room': room_jid}) + profile = self.host.memory.getProfileName(profile_key) + if not profile: + log.error(_(u"profile %s is unknown") % profile_key) + return + (create, sync) = self._checkCreateGameAndInit(room_jid, profile) + if nicks is None: + nicks = [] + if not create: + if sync: + self._updatePlayers(room_jid, nicks, True, profile) + return + self.games[room_jid]['started'] = True + self._updatePlayers(room_jid, nicks, False, profile) + if self.player_init: + # specific data to each player (score, private data) + self.games[room_jid].setdefault('players_data', {}) + for nick in nicks: + # The dict must be COPIED otherwise it is shared between all users + self.games[room_jid]['players_data'][nick] = copy.deepcopy(self.player_init) + + def _playerReady(self, player_nick, referee_jid_s, profile_key=C.PROF_KEY_NONE): + self.playerReady(player_nick, jid.JID(referee_jid_s), profile_key) + + def playerReady(self, player_nick, referee_jid, profile_key=C.PROF_KEY_NONE): + """Must be called when player is ready to start a new game + + @param player: the player nick in the room + @param referee_jid (jid.JID): JID of the referee + """ + profile = self.host.memory.getProfileName(profile_key) + if not profile: + log.error(_(u"profile %s is unknown") % profile_key) + return + log.debug(u'new player ready: %s' % profile) + # TODO: we probably need to add the game and room names in the sent message + self.send(referee_jid, 'player_ready', {'player': player_nick}, profile=profile) + + def newRound(self, room_jid, data, profile): + """Launch a new round (reinit the user data) + + @param room_jid: room userhost + @param data: a couple (common_data, msg_elts) with: + - common_data: backend initialization data for the new round + - msg_elts: dict to map each user to his specific initialization message + @param profile + """ + log.debug(_(u'new round for %s game') % self.name) + game_data = self.games[room_jid] + players = game_data['players'] + players_data = game_data['players_data'] + game_data['stage'] = "init" + + common_data, msg_elts = copy.deepcopy(data) if data is not None else (None, None) + + if isinstance(msg_elts, dict): + for player in players: + to_jid = jid.JID(room_jid.userhost() + "/" + player) # FIXME: gof: + elem = msg_elts[player] if isinstance(msg_elts[player], domish.Element) else None + self.send(to_jid, elem, profile=profile) + elif isinstance(msg_elts, domish.Element): + self.send(room_jid, msg_elts, profile=profile) + if common_data is not None: + for player in players: + players_data[player].update(copy.deepcopy(common_data)) + + def _createGameElt(self, to_jid): + """Create a generic domish Element for the game messages + + @param to_jid: JID of the recipient + @return: the created element + """ + type_ = "normal" if to_jid.resource else "groupchat" + elt = domish.Element((None, 'message')) + elt["to"] = to_jid.full() + elt["type"] = type_ + elt.addElement(self.ns_tag) + return elt + + def _createStartElement(self, players=None, name="started"): + """Create a domish Element listing the game users + + @param players: list of the players + @param name: element name: + - "started" to signal the players that the game has been started + - "players" to signal the list of players when the game is not started yet + @return the create element + """ + started_elt = domish.Element((None, name)) + if players is None: + return started_elt + idx = 0 + for player in players: + player_elt = domish.Element((None, 'player')) + player_elt.addContent(player) + player_elt['index'] = str(idx) + idx += 1 + started_elt.addChild(player_elt) + return started_elt + + def _sendElements(self, to_jid, data, profile=None): + """ TODO + + @param to_jid: recipient JID + @param data: list of (elem, attr, content) with: + - elem: domish.Element, unicode or a couple: + - domish.Element to be directly added as a child to the message + - unicode name or couple (uri, name) to create a new domish.Element + and add it as a child to the message (see domish.Element.addElement) + - attrs: dictionary of attributes for the new child + - content: unicode that is appended to the child content + @param profile: the profile from which the message is sent + @return: a Deferred instance + """ + client = self.host.getClient(profile) + msg = self._createGameElt(to_jid) + for elem, attrs, content in data: + if elem is not None: + if isinstance(elem, domish.Element): + msg.firstChildElement().addChild(elem) + else: + elem = msg.firstChildElement().addElement(elem) + if attrs is not None: + elem.attributes.update(attrs) + if content is not None: + elem.addContent(content) + client.send(msg) + return defer.succeed(None) + + def send(self, to_jid, elem=None, attrs=None, content=None, profile=None): + """ TODO + + @param to_jid: recipient JID + @param elem: domish.Element, unicode or a couple: + - domish.Element to be directly added as a child to the message + - unicode name or couple (uri, name) to create a new domish.Element + and add it as a child to the message (see domish.Element.addElement) + @param attrs: dictionary of attributes for the new child + @param content: unicode that is appended to the child content + @param profile: the profile from which the message is sent + @return: a Deferred instance + """ + return self._sendElements(to_jid, [(elem, attrs, content)], profile) + + def getHandler(self, client): + return RoomGameHandler(self) + + +class RoomGameHandler (XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + self.xmlstream.addObserver(self.plugin_parent.request, self.plugin_parent.room_game_cmd, profile=self.parent.profile) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(self.plugin_parent.ns_tag[0])] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_smtp.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_smtp.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,211 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for managing smtp server +# Copyright (C) 2011 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from twisted.cred import portal, checkers, credentials +from twisted.cred import error as cred_error +from twisted.mail import smtp +from twisted.python import failure +from email.parser import Parser +from email.utils import parseaddr +from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials +from twisted.internet import reactor +import sys + +from zope.interface import implements + +PLUGIN_INFO = { + C.PI_NAME: "SMTP server Plugin", + C.PI_IMPORT_NAME: "SMTP", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["Maildir"], + C.PI_MAIN: "SMTP_server", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Create a SMTP server that you can use to send your "normal" type messages""") +} + + +class SMTP_server(object): + + params = """ + + + + + + + + """ + + def __init__(self, host): + log.info(_("Plugin SMTP Server initialization")) + self.host = host + + #parameters + host.memory.updateParams(self.params) + + port = int(self.host.memory.getParamA("SMTP Port", "Mail Server")) + log.info(_("Launching SMTP server on port %d") % port) + + self.server_factory = SmtpServerFactory(self.host) + reactor.listenTCP(port, self.server_factory) + + +class SatSmtpMessage(object): + implements(smtp.IMessage) + + def __init__(self, host, profile): + self.host = host + self.profile = profile + self.message = [] + + def lineReceived(self, line): + """handle another line""" + self.message.append(line) + + def eomReceived(self): + """handle end of message""" + mail = Parser().parsestr("\n".join(self.message)) + try: + self.host._sendMessage(parseaddr(mail['to'].decode('utf-8', 'replace'))[1], mail.get_payload().decode('utf-8', 'replace'), # TODO: manage other charsets + subject=mail['subject'].decode('utf-8', 'replace'), mess_type='normal', profile_key=self.profile) + except: + exc_type, exc_value, exc_traceback = sys.exc_info() + log.error(_(u"Can't send message: %s") % exc_value) # The email is invalid or incorreclty parsed + return defer.fail() + self.message = None + return defer.succeed(None) + + def connectionLost(self): + """handle message truncated""" + raise smtp.SMTPError + + +class SatSmtpDelivery(object): + implements(smtp.IMessageDelivery) + + def __init__(self, host, profile): + self.host = host + self.profile = profile + + def receivedHeader(self, helo, origin, recipients): + """ + Generate the Received header for a message + @param helo: The argument to the HELO command and the client's IP + address. + @param origin: The address the message is from + @param recipients: A list of the addresses for which this message + is bound. + @return: The full \"Received\" header string. + """ + return "Received:" + + def validateTo(self, user): + """ + Validate the address for which the message is destined. + @param user: The address to validate. + @return: A Deferred which becomes, or a callable which + takes no arguments and returns an object implementing IMessage. + This will be called and the returned object used to deliver the + message when it arrives. + """ + return lambda: SatSmtpMessage(self.host, self.profile) + + def validateFrom(self, helo, origin): + """ + Validate the address from which the message originates. + @param helo: The argument to the HELO command and the client's IP + address. + @param origin: The address the message is from + @return: origin or a Deferred whose callback will be + passed origin. + """ + return origin + + +class SmtpRealm(object): + implements(portal.IRealm) + + def __init__(self, host): + self.host = host + + def requestAvatar(self, avatarID, mind, *interfaces): + log.debug('requestAvatar') + profile = avatarID.decode('utf-8') + if smtp.IMessageDelivery not in interfaces: + raise NotImplementedError + return smtp.IMessageDelivery, SatSmtpDelivery(self.host, profile), lambda: None + + +class SatProfileCredentialChecker(object): + """ + This credential checker check against SàT's profile and associated jabber's password + Check if the profile exists, and if the password is OK + Return the profile as avatarId + """ + implements(checkers.ICredentialsChecker) + credentialInterfaces = (credentials.IUsernamePassword, + credentials.IUsernameHashedPassword) + + def __init__(self, host): + self.host = host + + def _cbPasswordMatch(self, matched, profile): + if matched: + return profile.encode('utf-8') + else: + return failure.Failure(cred_error.UnauthorizedLogin()) + + def requestAvatarId(self, credentials): + profiles = self.host.memory.getProfilesList() + if not credentials.username in profiles: + return defer.fail(cred_error.UnauthorizedLogin()) + d = self.host.memory.asyncGetParamA("Password", "Connection", profile_key=credentials.username) + d.addCallback(credentials.checkPassword) + d.addCallback(self._cbPasswordMatch, credentials.username) + return d + + +class SmtpServerFactory(smtp.SMTPFactory): + + def __init__(self, host): + self.protocol = smtp.ESMTP + self.host = host + _portal = portal.Portal(SmtpRealm(self.host)) + _portal.registerChecker(SatProfileCredentialChecker(self.host)) + smtp.SMTPFactory.__init__(self, _portal) + + def startedConnecting(self, connector): + log.debug(_("SMTP server connection started")) + smtp.SMTPFactory.startedConnecting(self, connector) + + def clientConnectionLost(self, connector, reason): + log.debug(_(u"SMTP server connection lost (reason: %s)"), reason) + smtp.SMTPFactory.clientConnectionLost(self, connector, reason) + + def buildProtocol(self, addr): + p = smtp.SMTPFactory.buildProtocol(self, addr) + # add the challengers from imap4, more secure and complicated challengers are available + p.challengers = {"LOGIN": LOGINCredentials, "PLAIN": PLAINCredentials} + return p diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_static_blog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_static_blog.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,103 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for static blogs +# Copyright (C) 2014 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.tools import xml_tools + +from twisted.internet import defer +from twisted.words.protocols.jabber import jid + + +PLUGIN_INFO = { + C.PI_NAME: "Static Blog Plugin", + C.PI_IMPORT_NAME: "STATIC-BLOG", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ['MISC-ACCOUNT'], # TODO: remove when all blogs can be retrieved + C.PI_MAIN: "StaticBlog", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Plugin for static blogs""") +} + + +class StaticBlog(object): + + params = u""" + + + + + + + + + + + + """.format( + category_name = C.STATIC_BLOG_KEY, + category_label = D_(C.STATIC_BLOG_KEY), + title_name = C.STATIC_BLOG_PARAM_TITLE, + title_label = D_('Page title'), + banner_name = C.STATIC_BLOG_PARAM_BANNER, + banner_label = D_('Banner URL'), + background_name = u"Background", + background_label = D_(u"Background image URL"), + keywords_name = C.STATIC_BLOG_PARAM_KEYWORDS, + keywords_label = D_('Keywords'), + description_name = C.STATIC_BLOG_PARAM_DESCRIPTION, + description_label = D_('Description'), + ) + + def __init__(self, host): + try: # TODO: remove this attribute when all blogs can be retrieved + self.domain = host.plugins['MISC-ACCOUNT'].getNewAccountDomain() + except KeyError: + self.domain = None + host.memory.updateParams(self.params) + # host.importMenu((D_("User"), D_("Public blog")), self._displayPublicBlog, security_limit=1, help_string=D_("Display public blog page"), type_=C.MENU_JID_CONTEXT) + + def _displayPublicBlog(self, menu_data, profile): + """Check if the blog can be displayed and answer the frontend. + + @param menu_data: %(menu_data)s + @param profile: %(doc_profile)s + @return: dict + """ + # FIXME: "public_blog" key has been removed + # TODO: replace this with a more generic widget call with URIs + try: + user_jid = jid.JID(menu_data['jid']) + except KeyError: + log.error(_("jid key is not present !")) + return defer.fail(exceptions.DataError) + + # TODO: remove this check when all blogs can be retrieved + if self.domain and user_jid.host != self.domain: + info_ui = xml_tools.XMLUI("popup", title=D_("Not available")) + info_ui.addText(D_("Retrieving a blog from an external domain is not implemented yet.")) + return {'xmlui': info_ui.toXml()} + + return {"public_blog": user_jid.userhost()} diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_tarot.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_tarot.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,672 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing French Tarot game +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from wokkel import data_form + +from sat.memory import memory +from sat.tools import xml_tools +from sat_frontends.tools.games import TarotCard +import random + + +NS_CG = 'http://www.goffi.org/protocol/card_game' +CG_TAG = 'card_game' + +PLUGIN_INFO = { + C.PI_NAME: "Tarot cards plugin", + C.PI_IMPORT_NAME: "Tarot", + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"], + C.PI_MAIN: "Tarot", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Tarot card game""") +} + + +class Tarot(object): + + def inheritFromRoomGame(self, host): + global RoomGame + RoomGame = host.plugins["ROOM-GAME"].__class__ + self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {}) + + def __init__(self, host): + log.info(_("Plugin Tarot initialization")) + self._sessions = memory.Sessions() + self.inheritFromRoomGame(host) + RoomGame._init_(self, host, PLUGIN_INFO, (NS_CG, CG_TAG), + game_init={'hand_size': 18, 'init_player': 0, 'current_player': None, 'contrat': None, 'stage': None}, + player_init={'score': 0}) + self.contrats = [_('Passe'), _('Petite'), _('Garde'), _('Garde Sans'), _('Garde Contre')] + host.bridge.addMethod("tarotGameLaunch", ".plugin", in_sign='asss', out_sign='', method=self._prepareRoom, async=True) # args: players, room_jid, profile + host.bridge.addMethod("tarotGameCreate", ".plugin", in_sign='sass', out_sign='', method=self._createGame) # args: room_jid, players, profile + host.bridge.addMethod("tarotGameReady", ".plugin", in_sign='sss', out_sign='', method=self._playerReady) # args: player, referee, profile + host.bridge.addMethod("tarotGamePlayCards", ".plugin", in_sign='ssa(ss)s', out_sign='', method=self.play_cards) # args: player, referee, cards, profile + host.bridge.addSignal("tarotGamePlayers", ".plugin", signature='ssass') # args: room_jid, referee, players, profile + host.bridge.addSignal("tarotGameStarted", ".plugin", signature='ssass') # args: room_jid, referee, players, profile + host.bridge.addSignal("tarotGameNew", ".plugin", signature='sa(ss)s') # args: room_jid, hand, profile + host.bridge.addSignal("tarotGameChooseContrat", ".plugin", signature='sss') # args: room_jid, xml_data, profile + host.bridge.addSignal("tarotGameShowCards", ".plugin", signature='ssa(ss)a{ss}s') # args: room_jid, type ["chien", "poignée",...], cards, data[dict], profile + host.bridge.addSignal("tarotGameCardsPlayed", ".plugin", signature='ssa(ss)s') # args: room_jid, player, type ["chien", "poignée",...], cards, data[dict], profile + host.bridge.addSignal("tarotGameYourTurn", ".plugin", signature='ss') # args: room_jid, profile + host.bridge.addSignal("tarotGameScore", ".plugin", signature='ssasass') # args: room_jid, xml_data, winners (list of nicks), loosers (list of nicks), profile + host.bridge.addSignal("tarotGameInvalidCards", ".plugin", signature='ssa(ss)a(ss)s') # args: room_jid, game phase, played_cards, invalid_cards, profile + self.deck_ordered = [] + for value in ['excuse'] + map(str, range(1, 22)): + self.deck_ordered.append(TarotCard(("atout", value))) + for suit in ["pique", "coeur", "carreau", "trefle"]: + for value in map(str, range(1, 11)) + ["valet", "cavalier", "dame", "roi"]: + self.deck_ordered.append(TarotCard((suit, value))) + self.__choose_contrat_id = host.registerCallback(self._contratChoosed, with_data=True) + self.__score_id = host.registerCallback(self._scoreShowed, with_data=True) + + def __card_list_to_xml(self, cards_list, elt_name): + """Convert a card list to domish element""" + cards_list_elt = domish.Element((None, elt_name)) + for card in cards_list: + card_elt = domish.Element((None, 'card')) + card_elt['suit'] = card.suit + card_elt['value'] = card.value + cards_list_elt.addChild(card_elt) + return cards_list_elt + + def __xml_to_list(self, cards_list_elt): + """Convert a domish element with cards to a list of tuples""" + cards_list = [] + for card in cards_list_elt.elements(): + cards_list.append((card['suit'], card['value'])) + return cards_list + + def __ask_contrat(self): + """Create a element for asking contrat""" + contrat_elt = domish.Element((None, 'contrat')) + form = data_form.Form('form', title=_('contrat selection')) + field = data_form.Field('list-single', 'contrat', options=map(data_form.Option, self.contrats), required=True) + form.addField(field) + contrat_elt.addChild(form.toElement()) + return contrat_elt + + def __give_scores(self, scores, winners, loosers): + """Create an element to give scores + @param scores: unicode (can contain line feed) + @param winners: list of unicode nicks of winners + @param loosers: list of unicode nicks of loosers""" + + score_elt = domish.Element((None, 'score')) + form = data_form.Form('form', title=_('scores')) + for line in scores.split('\n'): + field = data_form.Field('fixed', value=line) + form.addField(field) + score_elt.addChild(form.toElement()) + for winner in winners: + winner_elt = domish.Element((None, 'winner')) + winner_elt.addContent(winner) + score_elt.addChild(winner_elt) + for looser in loosers: + looser_elt = domish.Element((None, 'looser')) + looser_elt.addContent(looser) + score_elt.addChild(looser_elt) + return score_elt + + def __invalid_cards_elt(self, played_cards, invalid_cards, game_phase): + """Create a element for invalid_cards error + @param list_cards: list of Card + @param game_phase: phase of the game ['ecart', 'play']""" + error_elt = domish.Element((None, 'error')) + played_elt = self.__card_list_to_xml(played_cards, 'played') + invalid_elt = self.__card_list_to_xml(invalid_cards, 'invalid') + error_elt['type'] = 'invalid_cards' + error_elt['phase'] = game_phase + error_elt.addChild(played_elt) + error_elt.addChild(invalid_elt) + return error_elt + + def __next_player(self, game_data, next_pl=None): + """Increment player number & return player name + @param next_pl: if given, then next_player is forced to this one + """ + if next_pl: + game_data['current_player'] = game_data['players'].index(next_pl) + return next_pl + else: + pl_idx = game_data['current_player'] = (game_data['current_player'] + 1) % len(game_data['players']) + return game_data['players'][pl_idx] + + def __winner(self, game_data): + """give the nick of the player who win this trick""" + players_data = game_data['players_data'] + first = game_data['first_player'] + first_idx = game_data['players'].index(first) + suit_asked = None + strongest = None + winner = None + for idx in [(first_idx + i) % 4 for i in range(4)]: + player = game_data['players'][idx] + card = players_data[player]['played'] + if card.value == "excuse": + continue + if suit_asked is None: + suit_asked = card.suit + if (card.suit == suit_asked or card.suit == "atout") and card > strongest: + strongest = card + winner = player + assert winner + return winner + + def __excuse_hack(self, game_data, played, winner): + """give a low card to other team and keep excuse if trick is lost + @param game_data: data of the game + @param played: cards currently on the table + @param winner: nick of the trick winner""" + # TODO: manage the case where excuse is played on the last trick (and lost) + players_data = game_data['players_data'] + excuse = TarotCard(("atout", "excuse")) + + # we first check if the Excuse was already played + # and if somebody is waiting for a card + for player in game_data['players']: + if players_data[player]['wait_for_low']: + # the excuse owner has to give a card to somebody + if winner == player: + # the excuse owner win the trick, we check if we have something to give + for card in played: + if card.points == 0.5: + pl_waiting = players_data[player]['wait_for_low'] + played.remove(card) + players_data[pl_waiting]['levees'].append(card) + log.debug(_(u'Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation') % {"excuse_owner": player, "card_waited": card, "player_waiting": pl_waiting}) + return + return + + if excuse not in played: + # the Excuse is not on the table, nothing to do + return + + excuse_player = None # Who has played the Excuse ? + for player in game_data['players']: + if players_data[player]['played'] == excuse: + excuse_player = player + break + + if excuse_player == winner: + return # the excuse player win the trick, nothing to do + + # first we remove the excuse from played cards + played.remove(excuse) + # then we give it back to the original owner + owner_levees = players_data[excuse_player]['levees'] + owner_levees.append(excuse) + # finally we give a low card to the trick winner + low_card = None + # We look backward in cards won by the Excuse owner to + # find a low value card + for card_idx in range(len(owner_levees) - 1, -1, -1): + if owner_levees[card_idx].points == 0.5: + low_card = owner_levees[card_idx] + del owner_levees[card_idx] + players_data[winner]['levees'].append(low_card) + log.debug(_(u'Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation') % {"excuse_owner": excuse_player, "card_waited": low_card, "player_waiting": winner}) + break + if not low_card: # The player has no low card yet + # TODO: manage case when player never win a trick with low card + players_data[excuse_player]['wait_for_low'] = winner + log.debug(_(u"%(excuse_owner)s keep the Excuse but has not card to give, %(winner)s is waiting for one") % {'excuse_owner': excuse_player, 'winner': winner}) + + def __draw_game(self, game_data): + """The game is draw, no score change + @param game_data: data of the game + @return: tuple with (string victory message, list of winners, list of loosers)""" + players_data = game_data['players_data'] + scores_str = _('Draw game') + scores_str += '\n' + for player in game_data['players']: + scores_str += _(u"\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i") % {'player': player, 'score_game': 0, 'total_score': players_data[player]['score']} + log.debug(scores_str) + + return (scores_str, [], []) + + def __calculate_scores(self, game_data): + """The game is finished, time to know who won :) + @param game_data: data of the game + @return: tuple with (string victory message, list of winners, list of loosers)""" + players_data = game_data['players_data'] + levees = players_data[game_data['attaquant']]['levees'] + score = 0 + nb_bouts = 0 + bouts = [] + for card in levees: + if card.bout: + nb_bouts += 1 + bouts.append(card.value) + score += card.points + + # We do a basic check on score calculation + check_score = 0 + defenseurs = game_data['players'][:] + defenseurs.remove(game_data['attaquant']) + for defenseur in defenseurs: + for card in players_data[defenseur]['levees']: + check_score += card.points + if game_data['contrat'] == "Garde Contre": + for card in game_data['chien']: + check_score += card.points + assert (score + check_score == 91) + + point_limit = None + if nb_bouts == 3: + point_limit = 36 + elif nb_bouts == 2: + point_limit = 41 + elif nb_bouts == 1: + point_limit = 51 + else: + point_limit = 56 + if game_data['contrat'] == 'Petite': + contrat_mult = 1 + elif game_data['contrat'] == 'Garde': + contrat_mult = 2 + elif game_data['contrat'] == 'Garde Sans': + contrat_mult = 4 + elif game_data['contrat'] == 'Garde Contre': + contrat_mult = 6 + else: + log.error(_('INTERNAL ERROR: contrat not managed (mispelled ?)')) + assert(False) + + victory = (score >= point_limit) + margin = abs(score - point_limit) + points_defenseur = (margin + 25) * contrat_mult * (-1 if victory else 1) + winners = [] + loosers = [] + player_score = {} + for player in game_data['players']: + # TODO: adjust this for 3 and 5 players variants + # TODO: manage bonuses (petit au bout, poignée, chelem) + player_score[player] = points_defenseur if player != game_data['attaquant'] else points_defenseur * -3 + players_data[player]['score'] += player_score[player] # we add score of this game to the global score + if player_score[player] > 0: + winners.append(player) + else: + loosers.append(player) + + scores_str = _(u'The attacker (%(attaquant)s) makes %(points)i and needs to make %(point_limit)i (%(nb_bouts)s oulder%(plural)s%(separator)s%(bouts)s): (s)he %(victory)s') % {'attaquant': game_data['attaquant'], 'points': score, 'point_limit': point_limit, 'nb_bouts': nb_bouts, 'plural': 's' if nb_bouts > 1 else '', 'separator': ': ' if nb_bouts != 0 else '', 'bouts': ','.join(map(str, bouts)), 'victory': 'wins' if victory else 'looses'} + scores_str += '\n' + for player in game_data['players']: + scores_str += _(u"\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i") % {'player': player, 'score_game': player_score[player], 'total_score': players_data[player]['score']} + log.debug(scores_str) + + return (scores_str, winners, loosers) + + def __invalid_cards(self, game_data, cards): + """Checks that the player has the right to play what he wants to + @param game_data: Game data + @param cards: cards the player want to play + @return forbidden_cards cards or empty list if cards are ok""" + forbidden_cards = [] + if game_data['stage'] == 'ecart': + for card in cards: + if card.bout or card.value == "roi": + forbidden_cards.append(card) + # TODO: manage case where atouts (trumps) are in the dog + elif game_data['stage'] == 'play': + biggest_atout = None + suit_asked = None + players = game_data['players'] + players_data = game_data['players_data'] + idx = players.index(game_data['first_player']) + current_idx = game_data['current_player'] + current_player = players[current_idx] + if idx == current_idx: + # the player is the first to play, he can play what he wants + return forbidden_cards + while (idx != current_idx): + player = players[idx] + played_card = players_data[player]['played'] + if not suit_asked and played_card.value != "excuse": + suit_asked = played_card.suit + if played_card.suit == "atout" and played_card > biggest_atout: + biggest_atout = played_card + idx = (idx + 1) % len(players) + has_suit = False # True if there is one card of the asked suit in the hand of the player + has_atout = False + biggest_hand_atout = None + + for hand_card in game_data['hand'][current_player]: + if hand_card.suit == suit_asked: + has_suit = True + if hand_card.suit == "atout": + has_atout = True + if hand_card.suit == "atout" and hand_card > biggest_hand_atout: + biggest_hand_atout = hand_card + + assert len(cards) == 1 + card = cards[0] + if card.suit != suit_asked and has_suit and card.value != "excuse": + forbidden_cards.append(card) + return forbidden_cards + if card.suit != suit_asked and card.suit != "atout" and has_atout: + forbidden_cards.append(card) + return forbidden_cards + if card.suit == "atout" and card < biggest_atout and biggest_hand_atout > biggest_atout and card.value != "excuse": + forbidden_cards.append(card) + else: + log.error(_('Internal error: unmanaged game stage')) + return forbidden_cards + + def __start_play(self, room_jid, game_data, profile): + """Start the game (tell to the first player after dealer to play""" + game_data['stage'] = "play" + next_player_idx = game_data['current_player'] = (game_data['init_player'] + 1) % len(game_data['players']) # the player after the dealer start + game_data['first_player'] = next_player = game_data['players'][next_player_idx] + to_jid = jid.JID(room_jid.userhost() + "/" + next_player) # FIXME: gof: + self.send(to_jid, 'your_turn', profile=profile) + + def _contratChoosed(self, raw_data, profile): + """Will be called when the contrat is selected + @param raw_data: contains the choosed session id and the chosen contrat + @param profile_key: profile + """ + try: + session_data = self._sessions.profileGet(raw_data["session_id"], profile) + except KeyError: + log.warning(_("session id doesn't exist, session has probably expired")) + # TODO: send error dialog + return defer.succeed({}) + + room_jid = session_data['room_jid'] + referee_jid = self.games[room_jid]['referee'] + player = self.host.plugins["XEP-0045"].getRoomNick(room_jid, profile) + data = xml_tools.XMLUIResult2DataFormResult(raw_data) + contrat = data['contrat'] + log.debug(_(u'contrat [%(contrat)s] choosed by %(profile)s') % {'contrat': contrat, 'profile': profile}) + d = self.send(referee_jid, ('', 'contrat_choosed'), {'player': player}, content=contrat, profile=profile) + d.addCallback(lambda ignore: {}) + del self._sessions[raw_data["session_id"]] + return d + + def _scoreShowed(self, raw_data, profile): + """Will be called when the player closes the score dialog + @param raw_data: nothing to retrieve from here but the session id + @param profile_key: profile + """ + try: + session_data = self._sessions.profileGet(raw_data["session_id"], profile) + except KeyError: + log.warning(_("session id doesn't exist, session has probably expired")) + # TODO: send error dialog + return defer.succeed({}) + + room_jid_s = session_data['room_jid'].userhost() + # XXX: empty hand means to the frontend "reset the display"... + self.host.bridge.tarotGameNew(room_jid_s, [], profile) + del self._sessions[raw_data["session_id"]] + return defer.succeed({}) + + def play_cards(self, player, referee, cards, profile_key=C.PROF_KEY_NONE): + """Must be call by player when the contrat is selected + @param player: player's name + @param referee: arbiter jid + @cards: cards played (list of tuples) + @profile_key: profile + """ + profile = self.host.memory.getProfileName(profile_key) + if not profile: + log.error(_(u"profile %s is unknown") % profile_key) + return + log.debug(_(u'Cards played by %(profile)s: [%(cards)s]') % {'profile': profile, 'cards': cards}) + elem = self.__card_list_to_xml(TarotCard.from_tuples(cards), 'cards_played') + self.send(jid.JID(referee), elem, {'player': player}, profile=profile) + + def newRound(self, room_jid, profile): + game_data = self.games[room_jid] + players = game_data['players'] + game_data['first_player'] = None # first player for the current trick + game_data['contrat'] = None + common_data = {'contrat': None, + 'levees': [], # cards won + 'played': None, # card on the table + 'wait_for_low': None # Used when a player wait for a low card because of excuse + } + + hand = game_data['hand'] = {} + hand_size = game_data['hand_size'] + chien = game_data['chien'] = [] + deck = self.deck_ordered[:] + random.shuffle(deck) + for i in range(4): + hand[players[i]] = deck[0:hand_size] + del deck[0:hand_size] + chien.extend(deck) + del(deck[:]) + msg_elts = {} + for player in players: + msg_elts[player] = self.__card_list_to_xml(hand[player], 'hand') + + RoomGame.newRound(self, room_jid, (common_data, msg_elts), profile) + + pl_idx = game_data['current_player'] = (game_data['init_player'] + 1) % len(players) # the player after the dealer start + player = players[pl_idx] + to_jid = jid.JID(room_jid.userhost() + "/" + player) # FIXME: gof: + self.send(to_jid, self.__ask_contrat(), profile=profile) + + def room_game_cmd(self, mess_elt, profile): + """ + @param mess_elt: instance of twisted.words.xish.domish.Element + """ + client = self.host.getClient(profile) + from_jid = jid.JID(mess_elt['from']) + room_jid = jid.JID(from_jid.userhost()) + nick = self.host.plugins["XEP-0045"].getRoomNick(client, room_jid) + + game_elt = mess_elt.firstChildElement() + game_data = self.games[room_jid] + is_player = self.isPlayer(room_jid, nick) + if 'players_data' in game_data: + players_data = game_data['players_data'] + + for elt in game_elt.elements(): + if not is_player and (elt.name not in ('started', 'players')): + continue # user is in the room but not playing + + if elt.name in ('started', 'players'): # new game created and/or players list updated + players = [] + for player in elt.elements(): + players.append(unicode(player)) + signal = self.host.bridge.tarotGameStarted if elt.name == 'started' else self.host.bridge.tarotGamePlayers + signal(room_jid.userhost(), from_jid.full(), players, profile) + + elif elt.name == 'player_ready': # ready to play + player = elt['player'] + status = self.games[room_jid]['status'] + nb_players = len(self.games[room_jid]['players']) + status[player] = 'ready' + log.debug(_(u'Player %(player)s is ready to start [status: %(status)s]') % {'player': player, 'status': status}) + if status.values().count('ready') == nb_players: # everybody is ready, we can start the game + self.newRound(room_jid, profile) + + elif elt.name == 'hand': # a new hand has been received + self.host.bridge.tarotGameNew(room_jid.userhost(), self.__xml_to_list(elt), profile) + + elif elt.name == 'contrat': # it's time to choose contrat + form = data_form.Form.fromElement(elt.firstChildElement()) + session_id, session_data = self._sessions.newSession(profile=profile) + session_data["room_jid"] = room_jid + xml_data = xml_tools.dataForm2XMLUI(form, self.__choose_contrat_id, session_id).toXml() + self.host.bridge.tarotGameChooseContrat(room_jid.userhost(), xml_data, profile) + + elif elt.name == 'contrat_choosed': + # TODO: check we receive the contrat from the right person + # TODO: use proper XEP-0004 way for answering form + player = elt['player'] + players_data[player]['contrat'] = unicode(elt) + contrats = [players_data[p]['contrat'] for p in game_data['players']] + if contrats.count(None): + # not everybody has choosed his contrat, it's next one turn + player = self.__next_player(game_data) + to_jid = jid.JID(room_jid.userhost() + "/" + player) # FIXME: gof: + self.send(to_jid, self.__ask_contrat(), profile=profile) + else: + best_contrat = [None, "Passe"] + for player in game_data['players']: + contrat = players_data[player]['contrat'] + idx_best = self.contrats.index(best_contrat[1]) + idx_pl = self.contrats.index(contrat) + if idx_pl > idx_best: + best_contrat[0] = player + best_contrat[1] = contrat + if best_contrat[1] == "Passe": + log.debug(_("Everybody is passing, round ended")) + to_jid = jid.JID(room_jid.userhost()) + self.send(to_jid, self.__give_scores(*self.__draw_game(game_data)), profile=profile) + game_data['init_player'] = (game_data['init_player'] + 1) % len(game_data['players']) # we change the dealer + for player in game_data['players']: + game_data['status'][player] = "init" + return + log.debug(_(u"%(player)s win the bid with %(contrat)s") % {'player': best_contrat[0], 'contrat': best_contrat[1]}) + game_data['contrat'] = best_contrat[1] + + if game_data['contrat'] == "Garde Sans" or game_data['contrat'] == "Garde Contre": + self.__start_play(room_jid, game_data, profile) + game_data['attaquant'] = best_contrat[0] + else: + # Time to show the chien to everybody + to_jid = jid.JID(room_jid.userhost()) # FIXME: gof: + elem = self.__card_list_to_xml(game_data['chien'], 'chien') + self.send(to_jid, elem, {'attaquant': best_contrat[0]}, profile=profile) + # the attacker (attaquant) get the chien + game_data['hand'][best_contrat[0]].extend(game_data['chien']) + del game_data['chien'][:] + + if game_data['contrat'] == "Garde Sans": + # The chien go into attaquant's (attacker) levees + players_data[best_contrat[0]]['levees'].extend(game_data['chien']) + del game_data['chien'][:] + + elif elt.name == 'chien': # we have received the chien + log.debug(_("tarot: chien received")) + data = {"attaquant": elt['attaquant']} + game_data['stage'] = "ecart" + game_data['attaquant'] = elt['attaquant'] + self.host.bridge.tarotGameShowCards(room_jid.userhost(), "chien", self.__xml_to_list(elt), data, profile) + + elif elt.name == 'cards_played': + if game_data['stage'] == "ecart": + # TODO: show atouts (trumps) if player put some in écart + assert (game_data['attaquant'] == elt['player']) # TODO: throw an xml error here + list_cards = TarotCard.from_tuples(self.__xml_to_list(elt)) + # we now check validity of card + invalid_cards = self.__invalid_cards(game_data, list_cards) + if invalid_cards: + elem = self.__invalid_cards_elt(list_cards, invalid_cards, game_data['stage']) + self.send(jid.JID(room_jid.userhost() + '/' + elt['player']), elem, profile=profile) + return + + # FIXME: gof: manage Garde Sans & Garde Contre cases + players_data[elt['player']]['levees'].extend(list_cards) # we add the chien to attaquant's levées + for card in list_cards: + game_data['hand'][elt['player']].remove(card) + + self.__start_play(room_jid, game_data, profile) + + elif game_data['stage'] == "play": + current_player = game_data['players'][game_data['current_player']] + cards = TarotCard.from_tuples(self.__xml_to_list(elt)) + + if mess_elt['type'] == 'groupchat': + self.host.bridge.tarotGameCardsPlayed(room_jid.userhost(), elt['player'], self.__xml_to_list(elt), profile) + else: + # we first check validity of card + invalid_cards = self.__invalid_cards(game_data, cards) + if invalid_cards: + elem = self.__invalid_cards_elt(cards, invalid_cards, game_data['stage']) + self.send(jid.JID(room_jid.userhost() + '/' + current_player), elem, profile=profile) + return + # the card played is ok, we forward it to everybody + # first we remove it from the hand and put in on the table + game_data['hand'][current_player].remove(cards[0]) + players_data[current_player]['played'] = cards[0] + + # then we forward the message + self.send(room_jid, elt, profile=profile) + + # Did everybody played ? + played = [players_data[player]['played'] for player in game_data['players']] + if all(played): + # everybody has played + winner = self.__winner(game_data) + log.debug(_(u'The winner of this trick is %s') % winner) + # the winner win the trick + self.__excuse_hack(game_data, played, winner) + players_data[elt['player']]['levees'].extend(played) + # nothing left on the table + for player in game_data['players']: + players_data[player]['played'] = None + if len(game_data['hand'][current_player]) == 0: + # no card left: the game is finished + elem = self.__give_scores(*self.__calculate_scores(game_data)) + self.send(room_jid, elem, profile=profile) + game_data['init_player'] = (game_data['init_player'] + 1) % len(game_data['players']) # we change the dealer + for player in game_data['players']: + game_data['status'][player] = "init" + return + # next player is the winner + next_player = game_data['first_player'] = self.__next_player(game_data, winner) + else: + next_player = self.__next_player(game_data) + + # finally, we tell to the next player to play + to_jid = jid.JID(room_jid.userhost() + "/" + next_player) + self.send(to_jid, 'your_turn', profile=profile) + + elif elt.name == 'your_turn': + self.host.bridge.tarotGameYourTurn(room_jid.userhost(), profile) + + elif elt.name == 'score': + form_elt = elt.elements(name='x', uri='jabber:x:data').next() + winners = [] + loosers = [] + for winner in elt.elements(name='winner', uri=NS_CG): + winners.append(unicode(winner)) + for looser in elt.elements(name='looser', uri=NS_CG): + loosers.append(unicode(looser)) + form = data_form.Form.fromElement(form_elt) + session_id, session_data = self._sessions.newSession(profile=profile) + session_data["room_jid"] = room_jid + xml_data = xml_tools.dataForm2XMLUI(form, self.__score_id, session_id).toXml() + self.host.bridge.tarotGameScore(room_jid.userhost(), xml_data, winners, loosers, profile) + elif elt.name == 'error': + if elt['type'] == 'invalid_cards': + played_cards = self.__xml_to_list(elt.elements(name='played', uri=NS_CG).next()) + invalid_cards = self.__xml_to_list(elt.elements(name='invalid', uri=NS_CG).next()) + self.host.bridge.tarotGameInvalidCards(room_jid.userhost(), elt['phase'], played_cards, invalid_cards, profile) + else: + log.error(_(u'Unmanaged error type: %s') % elt['type']) + else: + log.error(_(u'Unmanaged card game element: %s') % elt.name) + + def getSyncDataForPlayer(self, room_jid, nick): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_text_commands.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_text_commands.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,408 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for managing text commands +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.python import failure +from collections import OrderedDict + +PLUGIN_INFO = { + C.PI_NAME: "Text commands", + C.PI_IMPORT_NAME: C.TEXT_CMDS, + C.PI_TYPE: "Misc", + C.PI_PROTOCOLS: ["XEP-0245"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "TextCommands", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""IRC like text commands""") +} + + +class InvalidCommandSyntax(Exception): + """Throwed while parsing @command in docstring if syntax is invalid""" + pass + + +CMD_KEY = "@command" +CMD_TYPES = ('group', 'one2one', 'all') +FEEDBACK_INFO_TYPE = "TEXT_CMD" + + +class TextCommands(object): + #FIXME: doc strings for commands have to be translatable + # plugins need a dynamic translation system (translation + # should be downloadable independently) + + HELP_SUGGESTION = _("Type '/help' to get a list of the available commands. If you didn't want to use a command, please start your message with '//' to escape the slash.") + + def __init__(self, host): + log.info(_("Text commands initialization")) + self.host = host + # this is internal command, so we set high priority + host.trigger.add("sendMessage", self.sendMessageTrigger, priority=1000000) + self._commands = {} + self._whois = [] + self.registerTextCommands(self) + + def _parseDocString(self, cmd, cmd_name): + """Parse a docstring to get text command data + + @param cmd: function or method callback for the command, + its docstring will be used for self documentation in the following way: + - first line is the command short documentation, shown with /help + - @command keyword can be used, see http://wiki.goffi.org/wiki/Coding_style/en for documentation + @return (dict): dictionary with parsed data where key can be: + - "doc_short_help" (default: ""): the untranslated short documentation + - "type" (default "all"): the command type as specified in documentation + - "args" (default: ""): the arguments available, using syntax specified in documentation. + - "doc_arg_[name]": the doc of [name] argument + """ + data = OrderedDict([('doc_short_help', ""), + ('type', 'all'), + ('args', '')]) + docstring = cmd.__doc__ + if docstring is None: + log.warning(u"Not docstring found for command {}".format(cmd_name)) + docstring = "" + + doc_data = docstring.split("\n") + data["doc_short_help"] = doc_data[0] + + try: + cmd_indent = 0 # >0 when @command is found are we are parsing it + + for line in doc_data: + stripped = line.strip() + if cmd_indent and line[cmd_indent:cmd_indent+5] == " -": + colon_idx = line.find(":") + if colon_idx == -1: + raise InvalidCommandSyntax("No colon found in argument description") + arg_name = line[cmd_indent+6:colon_idx].strip() + if not arg_name: + raise InvalidCommandSyntax("No name found in argument description") + arg_help = line[colon_idx+1:].strip() + data["doc_arg_{}".format(arg_name)] = arg_help + elif cmd_indent: + # we are parsing command and indent level is not good, it's finished + break + elif stripped.startswith(CMD_KEY): + cmd_indent = line.find(CMD_KEY) + + # type + colon_idx = stripped.find(":") + if colon_idx == -1: + raise InvalidCommandSyntax("missing colon") + type_data = stripped[len(CMD_KEY):colon_idx].strip() + if len(type_data) == 0: + type_data="(all)" + elif len(type_data) <= 2 or type_data[0] != '(' or type_data[-1] != ')': + raise InvalidCommandSyntax("Bad type data syntax") + type_ = type_data[1:-1] + if type_ not in CMD_TYPES: + raise InvalidCommandSyntax("Unknown type {}".format(type_)) + data["type"] = type_ + + #args + data["args"] = stripped[colon_idx+1:].strip() + except InvalidCommandSyntax as e: + log.warning(u"Invalid command syntax for command {command}: {message}".format(command=cmd_name, message=e.message)) + + return data + + + def registerTextCommands(self, instance): + """ Add a text command + + @param instance: instance of a class containing text commands + """ + for attr in dir(instance): + if attr.startswith('cmd_'): + cmd = getattr(instance, attr) + if not callable(cmd): + log.warning(_(u"Skipping not callable [%s] attribute") % attr) + continue + cmd_name = attr[4:] + if not cmd_name: + log.warning(_("Skipping cmd_ method")) + if cmd_name in self._commands: + suff=2 + while (cmd_name + str(suff)) in self._commands: + suff+=1 + new_name = cmd_name + str(suff) + log.warning(_(u"Conflict for command [{old_name}], renaming it to [{new_name}]").format(old_name=cmd_name, new_name=new_name)) + cmd_name = new_name + self._commands[cmd_name] = cmd_data = OrderedDict({'callback':cmd}) # We use an Ordered dict to keep documenation order + cmd_data.update(self._parseDocString(cmd, cmd_name)) + log.info(_("Registered text command [%s]") % cmd_name) + + def addWhoIsCb(self, callback, priority=0): + """Add a callback which give information to the /whois command + + @param callback: a callback which will be called with the following arguments + - whois_msg: list of information strings to display, callback need to append its own strings to it + - target_jid: full jid from whom we want information + - profile: %(doc_profile)s + @param priority: priority of the information to show (the highest priority will be displayed first) + """ + self._whois.append((priority, callback)) + self._whois.sort(key=lambda item: item[0], reverse=True) + + def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """Install SendMessage command hook """ + pre_xml_treatments.addCallback(self._sendMessageCmdHook, client) + return True + + def _sendMessageCmdHook(self, mess_data, client): + """ Check text commands in message, and react consequently + + msg starting with / are potential command. If a command is found, it is executed, else and help message is sent + msg starting with // are escaped: they are sent with a single / + commands can abord message sending (if they return anything evaluating to False), or continue it (if they return True), eventually after modifying the message + an "unparsed" key is added to message, containing part of the message not yet parsed + commands can be deferred or not + @param mess_data(dict): data comming from sendMessage trigger + @param profile: %(doc_profile)s + """ + try: + msg = mess_data["message"][''] + msg_lang = '' + except KeyError: + try: + # we have not default message, we try to take the first found + msg_lang, msg = mess_data["message"].iteritems().next() + except StopIteration: + log.debug(u"No message found, skipping text commands") + return mess_data + + try: + if msg[:2] == '//': + # we have a double '/', it's the escape sequence + mess_data["message"][msg_lang] = msg[1:] + return mess_data + if msg[0] != '/': + return mess_data + except IndexError: + return mess_data + + # we have a command + d = None + command = msg[1:].partition(' ')[0].lower() + if command.isalpha(): + # looks like an actual command, we try to call the corresponding method + def retHandling(ret): + """ Handle command return value: + if ret is True, normally send message (possibly modified by command) + else, abord message sending + """ + if ret: + return mess_data + else: + log.debug(u"text command detected ({})".format(command)) + raise failure.Failure(exceptions.CancelError()) + + def genericErrback(failure): + try: + msg = u"with condition {}".format(failure.value.condition) + except AttributeError: + msg = u"with error {}".format(failure.value) + self.feedBack(client, u"Command failed {}".format(msg), mess_data) + return False + + mess_data["unparsed"] = msg[1 + len(command):] # part not yet parsed of the message + try: + cmd_data = self._commands[command] + except KeyError: + self.feedBack(client, _("Unknown command /%s. ") % command + self.HELP_SUGGESTION, mess_data) + log.debug("text command help message") + raise failure.Failure(exceptions.CancelError()) + else: + if not self._contextValid(mess_data, cmd_data): + # The command is not launched in the right context, we throw a message with help instructions + context_txt = _("group discussions") if cmd_data["type"] == "group" else _("one to one discussions") + feedback = _("/{command} command only applies in {context}.").format(command=command, context=context_txt) + self.feedBack(client, u"{} {}".format(feedback, self.HELP_SUGGESTION), mess_data) + log.debug("text command invalid message") + raise failure.Failure(exceptions.CancelError()) + else: + d = defer.maybeDeferred(cmd_data["callback"], client, mess_data) + d.addErrback(genericErrback) + d.addCallback(retHandling) + + return d or mess_data # if a command is detected, we should have a deferred, else we send the message normally + + def _contextValid(self, mess_data, cmd_data): + """Tell if a command can be used in the given context + + @param mess_data(dict): message data as given in sendMessage trigger + @param cmd_data(dict): command data as returned by self._parseDocString + @return (bool): True if command can be used in this context + """ + if ((cmd_data['type'] == "group" and mess_data["type"] != "groupchat") or + (cmd_data['type'] == 'one2one' and mess_data["type"] == "groupchat")): + return False + return True + + def getRoomJID(self, arg, service_jid): + """Return a room jid with a shortcut + + @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org) + or a shortcut (e.g.: sat or sat@ for sat on current service) + @param service_jid: jid of the current service (e.g.: chat.jabberfr.org) + """ + nb_arobas = arg.count('@') + if nb_arobas == 1: + if arg[-1] != '@': + return jid.JID(arg) + return jid.JID(arg + service_jid) + return jid.JID(u"%s@%s" % (arg, service_jid)) + + def feedBack(self, client, message, mess_data, info_type=FEEDBACK_INFO_TYPE): + """Give a message back to the user""" + if mess_data["type"] == 'groupchat': + to_ = mess_data["to"].userhostJID() + else: + to_ = client.jid + + # we need to invert send message back, so sender need to original destinee + mess_data["from"] = mess_data["to"] + mess_data["to"] = to_ + mess_data["type"] = C.MESS_TYPE_INFO + mess_data["message"] = {'': message} + mess_data["extra"]["info_type"] = info_type + client.messageSendToBridge(mess_data) + + def cmd_whois(self, client, mess_data): + """show informations on entity + + @command: [JID|ROOM_NICK] + - JID: entity to request + - ROOM_NICK: nick of the room to request + """ + log.debug("Catched whois command") + + entity = mess_data["unparsed"].strip() + + if mess_data['type'] == "groupchat": + room = mess_data["to"].userhostJID() + try: + if self.host.plugins["XEP-0045"].isNickInRoom(client, room, entity): + entity = u"%s/%s" % (room, entity) + except KeyError: + log.warning("plugin XEP-0045 is not present") + + if not entity: + target_jid = mess_data["to"] + else: + try: + target_jid = jid.JID(entity) + if not target_jid.user or not target_jid.host: + raise jid.InvalidFormat + except (RuntimeError, jid.InvalidFormat, AttributeError): + self.feedBack(client, _("Invalid jid, can't whois"), mess_data) + return False + + if not target_jid.resource: + target_jid.resource = self.host.memory.getMainResource(client, target_jid) + + whois_msg = [_(u"whois for %(jid)s") % {'jid': target_jid}] + + d = defer.succeed(None) + for ignore, callback in self._whois: + d.addCallback(lambda ignore: callback(client, whois_msg, mess_data, target_jid)) + + def feedBack(ignore): + self.feedBack(client, u"\n".join(whois_msg), mess_data) + return False + + d.addCallback(feedBack) + return d + + def _getArgsHelp(self, cmd_data): + """Return help string for args of cmd_name, according to docstring data + + @param cmd_data: command data + @return (list[unicode]): help strings + """ + strings = [] + for doc_name, doc_help in cmd_data.iteritems(): + if doc_name.startswith("doc_arg_"): + arg_name = doc_name[8:] + strings.append(u"- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help))) + + return strings + + def cmd_me(self, client, mess_data): + """display a message at third person + + @command (all): message + - message: message to show at third person + e.g.: "/me clenches his fist" will give "[YOUR_NICK] clenches his fist" + """ + # We just ignore the command as the match is done on receiption by clients + return True + + def cmd_whoami(self, client, mess_data): + """give your own jid""" + self.feedBack(client, client.jid.full(), mess_data) + + def cmd_help(self, client, mess_data): + """show help on available commands + + @command: [cmd_name] + - cmd_name: name of the command for detailed help + """ + cmd_name = mess_data["unparsed"].strip() + if cmd_name and cmd_name[0] == "/": + cmd_name = cmd_name[1:] + if cmd_name and cmd_name not in self._commands: + self.feedBack(client, _(u"Invalid command name [{}]\n".format(cmd_name)), mess_data) + cmd_name = "" + if not cmd_name: + # we show the global help + longuest = max([len(command) for command in self._commands]) + help_cmds = [] + + for command in sorted(self._commands): + cmd_data = self._commands[command] + if not self._contextValid(mess_data, cmd_data): + continue + spaces = (longuest - len(command)) * ' ' + help_cmds.append(" /{command}: {spaces} {short_help}".format( + command=command, + spaces=spaces, + short_help=cmd_data["doc_short_help"] + )) + + help_mess = _(u"Text commands available:\n%s") % (u'\n'.join(help_cmds), ) + else: + # we show detailled help for a command + cmd_data = self._commands[cmd_name] + syntax = cmd_data["args"] + help_mess = _(u"/{name}: {short_help}\n{syntax}{args_help}").format( + name=cmd_name, + short_help=cmd_data['doc_short_help'], + syntax=_(" "*4+"syntax: {}\n").format(syntax) if syntax else "", + args_help=u'\n'.join([u" "*8+"{}".format(line) for line in self._getArgsHelp(cmd_data)])) + + self.feedBack(client, help_mess, mess_data) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_misc_text_syntaxes.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_misc_text_syntaxes.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,295 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing various text syntaxes +# 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 . + +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.internet.threads import deferToThread +from sat.core import exceptions +try: + from lxml import html + from lxml.html import clean +except ImportError: + raise exceptions.MissingModule(u"Missing module lxml, please download/install it from http://lxml.de/") +from cgi import escape +import re + + +CATEGORY = D_("Composition") +NAME = "Syntax" +_SYNTAX_XHTML = "XHTML" +_SYNTAX_CURRENT = "@CURRENT@" + +# TODO: check/adapt following list +# list initialy based on feedparser list (http://pythonhosted.org/feedparser/html-sanitization.html) +STYLES_WHITELIST = ("azimuth", "background-color", "border-bottom-color", "border-collapse", "border-color", "border-left-color", "border-right-color", "border-top-color", "clear", "color", "cursor", "direction", "display", "elevation", "float", "font", "font-family", "font-size", "font-style", "font-variant", "font-weight", "height", "letter-spacing", "line-height", "overflow", "pause", "pause-after", "pause-before", "pitch", "pitch-range", "richness", "speak", "speak-header", "speak-numeral", "speak-punctuation", "speech-rate", "stress", "text-align", "text-decoration", "text-indent", "unicode-bidi", "vertical-align", "voice-family", "volume", "white-space", "width") + +SAFE_ATTRS = html.defs.safe_attrs.union(('style', 'poster', 'controls')) +STYLES_VALUES_REGEX = r'^(' + '|'.join(['([a-z-]+)', # alphabetical names + '(#[0-9a-f]+)', # hex value + '(\d+(.\d+)? *(|%|em|ex|px|in|cm|mm|pt|pc))', # values with units (or not) + 'rgb\( *((\d+(.\d+)?), *){2}(\d+(.\d+)?) *\)', # rgb function + 'rgba\( *((\d+(.\d+)?), *){3}(\d+(.\d+)?) *\)', # rgba function + ]) + ') *(!important)?$' # we accept "!important" at the end +STYLES_ACCEPTED_VALUE = re.compile(STYLES_VALUES_REGEX) + +PLUGIN_INFO = { + C.PI_NAME: "Text syntaxes", + C.PI_IMPORT_NAME: "TEXT-SYNTAXES", + C.PI_TYPE: "MISC", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "TextSyntaxes", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Management of various text syntaxes (XHTML-IM, Markdown, etc)""") +} + + +class TextSyntaxes(object): + """ Text conversion class + XHTML utf-8 is used as intermediate language for conversions + """ + + OPT_DEFAULT = "DEFAULT" + OPT_HIDDEN = "HIDDEN" + OPT_NO_THREAD = "NO_THREAD" + SYNTAX_XHTML = _SYNTAX_XHTML + SYNTAX_MARKDOWN = "markdown" + SYNTAX_TEXT = "text" + syntaxes = {} + default_syntax = SYNTAX_XHTML + + params = """ + + + + + %(options)s + + + + + """ + + params_data = { + 'category_name': CATEGORY, + 'category_label': _(CATEGORY), + 'name': NAME, + 'label': _(NAME), + 'syntaxes': syntaxes, + } + + def __init__(self, host): + log.info(_("Text syntaxes plugin initialization")) + self.host = host + self.addSyntax(self.SYNTAX_XHTML, lambda xhtml: defer.succeed(xhtml), lambda xhtml: defer.succeed(xhtml), + TextSyntaxes.OPT_NO_THREAD) + # TODO: text => XHTML should add to url like in frontends + # it's probably best to move sat_frontends.tools.strings to sat.tools.common or similar + self.addSyntax(self.SYNTAX_TEXT, lambda text: escape(text), lambda xhtml: self._removeMarkups(xhtml), [TextSyntaxes.OPT_HIDDEN]) + try: + import markdown, html2text + + def _html2text(html, baseurl=''): + h = html2text.HTML2Text(baseurl=baseurl) + h.body_width = 0 # do not truncate the lines, it breaks the long URLs + return h.handle(html) + self.addSyntax(self.SYNTAX_MARKDOWN, markdown.markdown, _html2text, [TextSyntaxes.OPT_DEFAULT]) + except ImportError: + log.warning(u"markdown or html2text not found, can't use Markdown syntax") + log.info(u"You can download/install them from https://pythonhosted.org/Markdown/ and https://github.com/Alir3z4/html2text/") + host.bridge.addMethod("syntaxConvert", ".plugin", in_sign='sssbs', out_sign='s', + async=True, method=self.convert) + host.bridge.addMethod("syntaxGet", ".plugin", in_sign='s', out_sign='s', + method=self.getSyntax) + + def _updateParamOptions(self): + data_synt = TextSyntaxes.syntaxes + default_synt = TextSyntaxes.default_syntax + syntaxes = [] + + for syntax in data_synt.keys(): + flags = data_synt[syntax]["flags"] + if TextSyntaxes.OPT_HIDDEN not in flags: + syntaxes.append(syntax) + + syntaxes.sort(key=lambda synt: synt.lower()) + options = [] + + for syntax in syntaxes: + selected = 'selected="true"' if syntax == default_synt else '' + options.append(u'
+            pre_elt.addContent('\n')
+        pre_elt.addContent(string)
+
+    def parser_quote(self, string, parent):
+        blockquote_elt = parent.blockquote
+        if blockquote_elt is None:
+            blockquote_elt = parent.addElement('blockquote')
+        p_elt = blockquote_elt.p
+        if p_elt is None:
+            p_elt = blockquote_elt.addElement('p')
+        else:
+            string = u'\n' + string
+
+        self._parse(string, p_elt)
+
+    def parser_emphasis(self, string, parent):
+        em_elt = parent.addElement('em')
+        self._parse(string, em_elt)
+
+    def parser_strong_emphasis(self, string, parent):
+        strong_elt = parent.addElement('strong')
+        self._parse(string, strong_elt)
+
+    def parser_line_break(self, string, parent):
+        parent.addElement('br')
+
+    def parser_insertion(self, string, parent):
+        ins_elt = parent.addElement('ins')
+        self._parse(string, ins_elt)
+
+    def parser_deletion(self, string, parent):
+        del_elt = parent.addElement('del')
+        self._parse(string, del_elt)
+
+    def parser_link(self, string, parent):
+        url_data = string.split(u'|')
+        a_elt = parent.addElement('a')
+        length = len(url_data)
+        if length == 1:
+            url = url_data[0]
+            a_elt['href'] = url
+            a_elt.addContent(url)
+        else:
+            name = url_data[0]
+            url = url_data[1]
+            a_elt['href'] = url
+            a_elt.addContent(name)
+            if length >= 3:
+                a_elt['lang'] = url_data[2]
+            if length >= 4:
+                a_elt['title'] = url_data[3]
+            if length > 4:
+                log.warning(u"too much data for url, ignoring extra data")
+
+    def parser_image(self, string, parent):
+        image_data = string.split(u'|')
+        img_elt = parent.addElement('img')
+
+        for idx, attribute in enumerate(('src', 'alt', 'position', 'longdesc')):
+            try:
+                data = image_data[idx]
+            except IndexError:
+                break
+
+            if attribute != 'position':
+                img_elt[attribute] = data
+            else:
+                data = data.lower()
+                if data in ('l', 'g'):
+                    img_elt['style'] = "display:block; float:left; margin:0 1em 1em 0"
+                elif data in ('r', 'd'):
+                    img_elt['style'] = "display:block; float:right; margin:0 0 1em 1em"
+                elif data == 'c':
+                    img_elt['style'] = "display:block; margin-left:auto; margin-right:auto"
+                else:
+                    log.warning(u"bad position argument for image, ignoring it")
+
+    def parser_anchor(self, string, parent):
+        a_elt = parent.addElement('a')
+        a_elt['id'] = string
+
+    def parser_acronym(self, string, parent):
+        acronym, title = string.split(u'|',1)
+        acronym_elt = parent.addElement('acronym', content=acronym)
+        acronym_elt['title'] = title
+
+    def parser_inline_quote(self, string, parent):
+        quote_data = string.split(u'|')
+        quote = quote_data[0]
+        q_elt = parent.addElement('q', content=quote)
+        for idx, attribute in enumerate(('lang', 'cite'), 1):
+            try:
+                data = quote_data[idx]
+            except IndexError:
+                break
+            q_elt[attribute] = data
+
+    def parser_code(self, string, parent):
+        parent.addElement('code', content=string)
+
+    def parser_footnote(self, string, parent):
+        idx = len(self._footnotes) + 1
+        note_txt = NOTE_TPL.format(idx)
+        sup_elt = parent.addElement('sup')
+        sup_elt['class'] = 'note'
+        a_elt = sup_elt.addElement('a', content=note_txt)
+        a_elt['id'] = NOTE_A_REV_TPL.format(idx)
+        a_elt['href'] = u'#{}'.format(NOTE_A_TPL.format(idx))
+
+        p_elt = domish.Element((None, 'p'))
+        a_elt = p_elt.addElement('a', content=note_txt)
+        a_elt['id'] = NOTE_A_TPL.format(idx)
+        a_elt['href'] = u'#{}'.format(NOTE_A_REV_TPL.format(idx))
+        self._parse(string, p_elt)
+        # footnotes are actually added at the end of the parsing
+        self._footnotes.append(p_elt)
+
+    def parser_text(self, string, parent):
+        parent.addContent(string)
+
+    def _parse(self, string, parent, block_level=False):
+        regex = wiki_block_level_re if block_level else wiki_re
+
+        for match in regex.finditer(string):
+            if match.lastgroup is None:
+                parent.addContent(string)
+                return
+            matched = match.group(match.lastgroup)
+            try:
+                parser = getattr(self, 'parser_{}'.format(match.lastgroup))
+            except AttributeError:
+                log.warning(u"No parser found for {}".format(match.lastgroup))
+                # parent.addContent(string)
+                continue
+            parser(matched, parent)
+
+    def parse(self, string):
+        self._footnotes = []
+        div_elt = domish.Element((None, 'div'))
+        self._parse(string, parent=div_elt, block_level=True)
+        if self._footnotes:
+            foot_div_elt = div_elt.addElement('div')
+            foot_div_elt['class'] = 'footnotes'
+            # we add a simple horizontal rule which can be customized
+            # with footnotes class, instead of a text which would need
+            # to be translated
+            foot_div_elt.addElement('hr')
+            for elt in self._footnotes:
+                foot_div_elt.addChild(elt)
+        return div_elt
+
+
+class XHTMLParser(object):
+
+    def __init__(self):
+        self.flags = None
+        self.toto = 0
+        self.footnotes = None # will hold a map from url to buffer id
+        for i in xrange(1,6):
+            setattr(self,
+                'parser_h{}'.format(i),
+                lambda elt, buf, level=i: self.parserHeading(elt, buf, level)
+                )
+
+    def parser_a(self, elt, buf):
+        try:
+            url = elt['href']
+        except KeyError:
+            # probably an anchor
+            try:
+                id_ = elt['id']
+                if not id_:
+                    # we don't want empty values
+                    raise KeyError
+            except KeyError:
+                self.parserGeneric(elt, buf)
+            else:
+                buf.append(u'~~{}~~'.format(id_))
+            return
+
+        link_data = [url]
+        name = unicode(elt)
+        if name != url:
+            link_data.insert(0, name)
+
+        lang = elt.getAttribute('lang')
+        title = elt.getAttribute('title')
+        if lang is not None:
+            link_data.append(lang)
+        elif title is not None:
+            link_data.appand(u'')
+        if title is not None:
+            link_data.append(title)
+        buf.append(u'[')
+        buf.append(u'|'.join(link_data))
+        buf.append(u']')
+
+    def parser_acronym(self, elt, buf):
+        try:
+            title = elt['title']
+        except KeyError:
+            log.debug(u"Acronyme without title, using generic parser")
+            self.parserGeneric(elt, buf)
+            return
+        buf.append(u'??{}|{}??'.format(unicode(elt), title))
+
+    def parser_blockquote(self, elt, buf):
+        # we remove wrapping 

to avoid empty line with "> " + children = list([child for child in elt.children if unicode(child).strip() not in ('', '\n')]) + if len(children) == 1 and children[0].name == 'p': + elt = children[0] + tmp_buf = [] + self.parseChildren(elt, tmp_buf) + blockquote = u'> ' + u'\n> '.join(u''.join(tmp_buf).split('\n')) + buf.append(blockquote) + + def parser_br(self, elt, buf): + buf.append(u'%%%') + + def parser_code(self, elt, buf): + buf.append(u'@@') + self.parseChildren(elt, buf) + buf.append(u'@@') + + def parser_del(self, elt, buf): + buf.append(u'--') + self.parseChildren(elt, buf) + buf.append(u'--') + + def parser_div(self, elt, buf): + if elt.getAttribute('class') == 'footnotes': + self.parserFootnote(elt, buf) + else: + self.parseChildren(elt, buf, block=True) + + def parser_em(self, elt, buf): + buf.append(u"''") + self.parseChildren(elt, buf) + buf.append(u"''") + + def parser_h6(self, elt, buf): + # XXX:

heading is not managed by wiki syntax + # so we handle it with a
+ elt = copy.copy(elt) # we don't want to change to original element + elt.name = 'h5' + self._parse(elt, buf) + + def parser_hr(self, elt, buf): + buf.append(u'\n----\n') + + def parser_img(self, elt, buf): + try: + url = elt['src'] + except KeyError: + log.warning(u"Ignoring without src") + return + + image_data=[url] + + alt = elt.getAttribute('alt') + style = elt.getAttribute('style', '') + desc = elt.getAttribute('longdesc') + + if '0 1em 1em 0' in style: + position = 'L' + elif '0 0 1em 1em' in style: + position = 'R' + elif 'auto' in style: + position = 'C' + else: + position = None + + if alt: + image_data.append(alt) + elif position or desc: + image_data.append(u'') + + if position: + image_data.append(position) + elif desc: + image_data.append(u'') + + if desc: + image_data.append(desc) + + buf.append(u'((') + buf.append(u'|'.join(image_data)) + buf.append(u'))') + + def parser_ins(self, elt, buf): + buf.append(u'++') + self.parseChildren(elt, buf) + buf.append(u'++') + + def parser_li(self, elt, buf): + flag = None + current_flag = None + bullets = [] + for flag in reversed(self.flags): + if flag in (FLAG_UL, FLAG_OL): + if current_flag is None: + current_flag = flag + if flag == current_flag: + bullets.append(u'*' if flag == FLAG_UL else u'#') + else: + break + + if flag != current_flag and buf[-1] == u' ': + # this trick is to avoid a space when we switch + # from (un)ordered to the other type on the same row + # e.g. *# unorder + ordered item + del buf[-1] + + buf.extend(bullets) + + buf.append(u' ') + self.parseChildren(elt, buf) + buf.append(u'\n') + + def parser_ol(self, elt, buf): + self.parserList(elt, buf, FLAG_OL) + + def parser_p(self, elt, buf): + self.parseChildren(elt, buf) + buf.append(u'\n\n') + + def parser_pre(self, elt, buf): + pre = u''.join([child.toXml() if domish.IElement.providedBy(child) else unicode(child) for child in elt.children]) + pre = u' ' + u'\n '.join(pre.split('\n')) + buf.append(pre) + + def parser_q(self, elt, buf): + quote_data=[unicode(elt)] + + lang = elt.getAttribute('lang') + cite = elt.getAttribute('url') + + if lang: + quote_data.append(lang) + elif cite: + quote_data.append(u'') + + if cite: + quote_data.append(cite) + + buf.append(u'{{') + buf.append(u'|'.join(quote_data)) + buf.append(u'}}') + + def parser_span(self, elt, buf): + self.parseChildren(elt, buf, block=True) + + def parser_strong(self, elt, buf): + buf.append(u'__') + self.parseChildren(elt, buf) + buf.append(u'__') + + def parser_sup(self, elt, buf): + # sup is mainly used for footnotes, so we check if we have an anchor inside + children = list([child for child in elt.children if unicode(child).strip() not in ('', '\n')]) + if (len(children) == 1 and domish.IElement.providedBy(children[0]) + and children[0].name == 'a' and '#' in children[0].getAttribute('href', '')): + url = children[0]['href'] + note_id = url[url.find('#')+1:] + if not note_id: + log.warning("bad link found in footnote") + self.parserGeneric(elt, buf) + return + # this looks like a footnote + buf.append(u'$$') + buf.append(u' ') # placeholder + self.footnotes[note_id] = len(buf) - 1 + buf.append(u'$$') + else: + self.parserGeneric(elt, buf) + + def parser_ul(self, elt, buf): + self.parserList(elt, buf, FLAG_UL) + + def parserList(self, elt, buf, type_): + self.flags.append(type_) + self.parseChildren(elt, buf, block=True) + idx = 0 + for flag in reversed(self.flags): + idx -= 1 + if flag == type_: + del self.flags[idx] + break + + if idx == 0: + raise exceptions.InternalError(u"flag has been removed by an other parser") + + def parserHeading(self, elt, buf, level): + buf.append((6-level) * u'!') + for child in elt.children: + # we ignore other elements for a Hx title + self.parserText(child, buf) + buf.append(u'\n') + + def parserFootnote(self, elt, buf): + for elt in elt.elements(): + # all children other than

are ignored + if elt.name == 'p': + a_elt = elt.a + if a_elt is None: + log.warning(u"

element doesn't contain in footnote, ignoring it") + continue + try: + note_idx = self.footnotes[a_elt['id']] + except KeyError: + log.warning(u"Note id doesn't match any known note, ignoring it") + # we create a dummy element to parse all children after the + dummy_elt = domish.Element((None, 'note')) + a_idx = elt.children.index(a_elt) + dummy_elt.children = elt.children[a_idx+1:] + note_buf = [] + self.parseChildren(dummy_elt, note_buf) + # now we can replace the placeholder + buf[note_idx] = u''.join(note_buf) + + def parserText(self, txt, buf, keep_whitespaces=False): + txt = unicode(txt) + if not keep_whitespaces: + # we get text and only let one inter word space + txt = u' '.join(txt.split()) + txt = re.sub(ESCAPE_CHARS, r'\\\1', txt) + if txt: + buf.append(txt) + return txt + + def parserGeneric(self, elt, buf): + # as dotclear wiki syntax handle arbitrary XHTML code + # we use this feature to add elements that we don't know + buf.append(u"\n\n///html\n{}\n///\n\n".format(elt.toXml())) + + def parseChildren(self, elt, buf, block=False): + first_visible = True + for child in elt.children: + if not block and not first_visible and buf and buf[-1][-1] not in (' ','\n'): + # we add separation if it isn't already there + buf.append(u' ') + if domish.IElement.providedBy(child): + self._parse(child, buf) + first_visible = False + else: + appended = self.parserText(child, buf) + if appended: + first_visible = False + + def _parse(self, elt, buf): + elt_name = elt.name.lower() + style = elt.getAttribute('style') + if style and elt_name not in ELT_WITH_STYLE: + # if we have style we use generic parser to put raw HTML + # to avoid losing it + parser = self.parserGeneric + else: + try: + parser = getattr(self, "parser_{}".format(elt_name)) + except AttributeError: + log.debug("Can't find parser for {} element, using generic one".format(elt.name)) + parser = self.parserGeneric + parser(elt, buf) + + def parse(self, elt): + self.flags = [] + self.footnotes = {} + buf = [] + self._parse(elt, buf) + return u''.join(buf) + + def parseString(self, string): + wrapped_html = u"

".format(string) + try: + div_elt = xml_tools.ElementParser()(wrapped_html) + except domish.ParserError as e: + log.warning(u"Error while parsing HTML content: {}".format(e)) + return + children = list(div_elt.elements()) + if len(children) == 1 and children[0].name == 'div': + div_elt = children[0] + return self.parse(div_elt) + + +class DCWikiSyntax(object): + SYNTAX_NAME = "wiki_dotclear" + + def __init__(self, host): + log.info(_(u"Dotclear wiki syntax plugin initialization")) + self.host = host + self._dc_parser = DCWikiParser() + self._xhtml_parser = XHTMLParser() + self._stx = self.host.plugins["TEXT-SYNTAXES"] + self._stx.addSyntax(self.SYNTAX_NAME, self.parseWiki, self.parseXHTML, [self._stx.OPT_NO_THREAD]) + + def parseWiki(self, wiki_stx): + div_elt = self._dc_parser.parse(wiki_stx) + return div_elt.toXml() + + def parseXHTML(self, xhtml): + return self._xhtml_parser.parseString(xhtml) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_tickets_import.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_tickets_import.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,168 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for import external ticketss +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import defer +from sat.tools.common import uri +from sat.tools import utils + + +PLUGIN_INFO = { + C.PI_NAME: "tickets import", + C.PI_IMPORT_NAME: "TICKETS_IMPORT", + C.PI_TYPE: C.PLUG_TYPE_IMPORT, + C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "PUBSUB_SCHEMA"], + C.PI_MAIN: "TicketsImportPlugin", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _(u"""Tickets import management: +This plugin manage the different tickets importers which can register to it, and handle generic importing tasks.""") +} + +OPT_MAPPING = 'mapping' +FIELDS_LIST = (u'labels', u'cc_emails') # fields which must have a list as value +FIELDS_DATE = (u'created', u'updated') + +NS_TICKETS = 'org.salut-a-toi.tickets:0' + + +class TicketsImportPlugin(object): + BOOL_OPTIONS = () + JSON_OPTIONS = (OPT_MAPPING,) + OPT_DEFAULTS = {} + + def __init__(self, host): + log.info(_("plugin Tickets Import initialization")) + self.host = host + self._importers = {} + self._p = host.plugins['XEP-0060'] + self._m = host.plugins['XEP-0277'] + self._s = host.plugins['PUBSUB_SCHEMA'] + host.plugins['IMPORT'].initialize(self, u'tickets') + + @defer.inlineCallbacks + def importItem(self, client, item_import_data, session, options, return_data, service, node): + """ + + @param item_import_data(dict): no key is mandatory, but if a key doesn't exists in dest form, it will be ignored. + Following names are recommendations which should be used where suitable in importers. + except if specified in description, values are unicode + 'id': unique id (must be unique in the node) of the ticket + 'title': title (or short description/summary) of the ticket + 'body': main description of the ticket + 'created': date of creation (unix time) + 'updated': date of last update (unix time) + 'author': full name of reporter + 'author_jid': jid of reporter + 'author_email': email of reporter + 'assigned_to_name': full name of person working on it + 'assigned_to_email': email of person working on it + 'cc_emails': list of emails subscribed to the ticket + 'priority': priority of the ticket + 'severity': severity of the ticket + 'labels': list of unicode values to use as label + 'product': product concerned by this ticket + 'component': part of the product concerned by this ticket + 'version': version of the product/component concerned by this ticket + 'platform': platform converned by this ticket + 'os': operating system concerned by this ticket + 'status': current status of the ticket, values: + - "queued": ticket is waiting + - "started": progress is ongoing + - "review": ticket is fixed and waiting for review + - "closed": ticket is finished or invalid + 'milestone': target milestone for this ticket + 'comments': list of microblog data (comment metadata, check [XEP_0277.send] data argument) + @param options(dict, None): Below are the generic options, + tickets importer can have specific ones. All options are serialized unicode values + generic options: + - OPT_MAPPING (json): dict of imported ticket key => exported ticket key + e.g.: if you want to map "component" to "labels", you can specify: + {'component': 'labels'} + If you specify several import ticket key to the same dest key, + the values will be joined with line feeds + """ + if 'comments_uri' in item_import_data: + raise exceptions.DataError(_(u'comments_uri key will be generated and must not be used by importer')) + for key in FIELDS_LIST: + if not isinstance(item_import_data.get(key, []), list): + raise exceptions.DataError(_(u'{key} must be a list').format(key=key)) + for key in FIELDS_DATE: + try: + item_import_data[key] = utils.xmpp_date(item_import_data[key]) + except KeyError: + continue + if session[u'root_node'] is None: + session[u'root_node'] = NS_TICKETS + if not 'schema' in session: + session['schema'] = yield self._s.getSchemaForm(client, service, node or session[u'root_node']) + defer.returnValue(item_import_data) + + @defer.inlineCallbacks + def importSubItems(self, client, item_import_data, ticket_data, session, options): + # TODO: force "open" permission (except if private, check below) + # TODO: handle "private" metadata, to have non public access for node + # TODO: node access/publish model should be customisable + comments = ticket_data.get('comments', []) + service = yield self._m.getCommentsService(client) + node = self._m.getCommentsNode(session['root_node'] + u'_' + ticket_data['id']) + node_options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, + self._p.OPT_PERSIST_ITEMS: 1, + self._p.OPT_MAX_ITEMS: -1, + self._p.OPT_DELIVER_PAYLOADS: 1, + self._p.OPT_SEND_ITEM_SUBSCRIBE: 1, + self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN, + } + yield self._p.createIfNewNode(client, service, node, options=node_options) + ticket_data['comments_uri'] = uri.buildXMPPUri(u'pubsub', subtype='microblog', path=service.full(), node=node) + for comment in comments: + if 'updated' not in comment and 'published' in comment: + # we don't want an automatic update date + comment['updated'] = comment['published'] + yield self._m.send(client, comment, service, node) + + def publishItem(self, client, ticket_data, service, node, session): + if node is None: + node = NS_TICKETS + id_ = ticket_data.pop('id', None) + log.debug(u"uploading item [{id}]: {title}".format(id=id_, title=ticket_data.get('title',''))) + return self._s.sendDataFormItem(client, service, node, ticket_data, session['schema'], id_) + + def itemFilters(self, client, ticket_data, session, options): + mapping = options.get(OPT_MAPPING) + if mapping is not None: + if not isinstance(mapping, dict): + raise exceptions.DataError(_(u'mapping option must be a dictionary')) + + for source, dest in mapping.iteritems(): + if not isinstance(source, unicode) or not isinstance(dest, unicode): + raise exceptions.DataError(_(u'keys and values of mapping must be sources and destinations ticket fields')) + if source in ticket_data: + value = ticket_data.pop(source) + if dest in FIELDS_LIST: + values = ticket_data[dest] = ticket_data.get(dest, []) + values.append(value) + else: + if dest in ticket_data: + ticket_data[dest] = ticket_data[dest] + u'\n' + value + else: + ticket_data[dest] = value diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_tickets_import_bugzilla.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_tickets_import_bugzilla.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,132 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for import external blogs +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +# from twisted.internet import threads +from twisted.internet import defer +import os.path +from lxml import etree +from sat.tools import utils + + +PLUGIN_INFO = { + C.PI_NAME: "Bugzilla import", + C.PI_IMPORT_NAME: "IMPORT_BUGZILLA", + C.PI_TYPE: C.PLUG_TYPE_BLOG, + C.PI_DEPENDENCIES: ["TICKETS_IMPORT"], + C.PI_MAIN: "BugzillaImport", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Tickets importer for Bugzilla""") +} + +SHORT_DESC = D_(u"import tickets from Bugzilla xml export file") + +LONG_DESC = D_(u"""This importer handle Bugzilla xml export file. + +To use it, you'll need to export tickets using XML. +Tickets will be uploaded with the same ID as for Bugzilla, any existing ticket with this ID will be replaced. + +location: you must use the absolute path to your .xml file +""") + +STATUS_MAP = { + 'NEW': 'queued', + 'ASSIGNED': 'started', + 'RESOLVED': 'review', + 'CLOSED': 'closed', + 'REOPENED': 'started' # we loose data here because there is no need on basic workflow to have a reopened status +} + + +class BugzillaParser(object): + # TODO: add a way to reassign values + + def parse(self, file_path): + tickets = [] + root = etree.parse(file_path) + + for bug in root.xpath('bug'): + ticket = {} + ticket['id'] = bug.findtext('bug_id') + ticket['created'] = utils.date_parse(bug.findtext('creation_ts')) + ticket['updated'] = utils.date_parse(bug.findtext('delta_ts')) + ticket['title'] = bug.findtext('short_desc') + reporter_elt = bug.find('reporter') + ticket['author'] = reporter_elt.get('name') + if ticket['author'] is None: + if '@' in reporter_elt.text: + ticket['author'] = reporter_elt.text[:reporter_elt.text.find('@')].title() + else: + ticket['author'] = u'no name' + ticket['author_email'] = reporter_elt.text + assigned_to_elt = bug.find('assigned_to') + ticket['assigned_to_name'] = assigned_to_elt.get('name') + ticket['assigned_to_email'] = assigned_to_elt.text + ticket['cc_emails'] = [e.text for e in bug.findall('cc')] + ticket['priority'] = bug.findtext('priority').lower().strip() + ticket['severity'] = bug.findtext('bug_severity').lower().strip() + ticket['product'] = bug.findtext('product') + ticket['component'] = bug.findtext('component') + ticket['version'] = bug.findtext('version') + ticket['platform'] = bug.findtext('rep_platform') + ticket['os'] = bug.findtext('op_sys') + ticket['status'] = STATUS_MAP.get(bug.findtext('bug_status'), 'queued') + ticket['milestone'] = bug.findtext('target_milestone') + + + body = None + comments = [] + for longdesc in bug.findall('long_desc'): + if body is None: + body = longdesc.findtext('thetext') + else: + who = longdesc.find('who') + comment = {'id': longdesc.findtext('commentid'), + 'author_email': who.text, + 'published': utils.date_parse(longdesc.findtext('bug_when')), + 'author': who.get('name', who.text), + 'content': longdesc.findtext('thetext')} + comments.append(comment) + + ticket['body'] = body + ticket['comments'] = comments + tickets.append(ticket) + + tickets.sort(key = lambda t: int(t['id'])) + return (tickets, len(tickets)) + + +class BugzillaImport(object): + + def __init__(self, host): + log.info(_(u"Bugilla Import plugin initialization")) + self.host = host + host.plugins['TICKETS_IMPORT'].register('bugzilla', self.Import, SHORT_DESC, LONG_DESC) + + def Import(self, client, location, options=None): + if not os.path.isabs(location): + raise exceptions.DataError(u"An absolute path to XML data need to be given as location") + bugzilla_parser = BugzillaParser() + # d = threads.deferToThread(bugzilla_parser.parse, location) + d = defer.maybeDeferred(bugzilla_parser.parse, location) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_tmp_directory_subscription.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_tmp_directory_subscription.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,68 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for directory subscription +# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2015, 2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) + + +PLUGIN_INFO = { + C.PI_NAME: "Directory subscription plugin", + C.PI_IMPORT_NAME: "DIRECTORY-SUBSCRIPTION", + C.PI_TYPE: "TMP", + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0055"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "DirectorySubscription", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of directory subscription""") +} + + +NS_COMMANDS = "http://jabber.org/protocol/commands" +CMD_UPDATE_SUBSCRIBTION = "update" + + +class DirectorySubscription(object): + + def __init__(self, host): + log.info(_("Directory subscription plugin initialization")) + self.host = host + host.importMenu((D_("Service"), D_("Directory subscription")), self.subscribe, security_limit=1, help_string=D_("User directory subscription")) + + def subscribe(self, raw_data, profile): + """Request available commands on the jabber search service associated to profile's host. + + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred dict{unicode: unicode} + """ + d = self.host.plugins["XEP-0055"]._getHostServices(profile) + + def got_services(services): + service_jid = services[0] + session_id, session_data = self.host.plugins["XEP-0050"].requesting.newSession(profile=profile) + session_data["jid"] = service_jid + session_data["node"] = CMD_UPDATE_SUBSCRIBTION + data = {"session_id": session_id} + return self.host.plugins["XEP-0050"]._requestingEntity(data, profile) + + return d.addCallback(got_services) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0020.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0020.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,157 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0020 +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.words.xish import domish + +from zope.interface import implements + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +from wokkel import disco, iwokkel, data_form + +NS_FEATURE_NEG = 'http://jabber.org/protocol/feature-neg' + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0020 Plugin", + C.PI_IMPORT_NAME: "XEP-0020", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0020"], + C.PI_MAIN: "XEP_0020", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Feature Negotiation""") +} + + +class XEP_0020(object): + + def __init__(self, host): + log.info(_("Plugin XEP_0020 initialization")) + + def getHandler(self, client): + return XEP_0020_handler() + + def getFeatureElt(self, elt): + """Check element's children to find feature elements + + @param elt(domish.Element): parent element of the feature element + @return: feature elements + @raise exceptions.NotFound: no feature element found + """ + try: + feature_elt = elt.elements(NS_FEATURE_NEG, 'feature').next() + except StopIteration: + raise exceptions.NotFound + return feature_elt + + def _getForm(self, elt, namespace): + """Return the first child data form + + @param elt(domish.Element): parent of the data form + @param namespace (None, unicode): form namespace or None to ignore + @return (None, data_form.Form): data form or None is nothing is found + """ + if namespace is None: + try: + form_elt = elt.elements(data_form.NS_X_DATA).next() + except StopIteration: + return None + else: + return data_form.Form.fromElement(form_elt) + else: + return data_form.findForm(elt, namespace) + + def getChoosedOptions(self, feature_elt, namespace): + """Return choosed feature for feature element + + @param feature_elt(domish.Element): feature domish element + @param namespace (None, unicode): form namespace or None to ignore + @return (dict): feature name as key, and choosed option as value + @raise exceptions.NotFound: not data form is found + """ + form = self._getForm(feature_elt, namespace) + if form is None: + raise exceptions.NotFound + result = {} + for field in form.fields: + values = form.fields[field].values + result[field] = values[0] if values else None + if len(values) > 1: + log.warning(_(u"More than one value choosed for {}, keeping the first one").format(field)) + return result + + def negotiate(self, feature_elt, name, negotiable_values, namespace): + """Negotiate the feature options + + @param feature_elt(domish.Element): feature element + @param name: the option name (i.e. field's var attribute) to negotiate + @param negotiable_values(iterable): acceptable values for this negotiation + first corresponding value will be returned + @param namespace (None, unicode): form namespace or None to ignore + @raise KeyError: name is not found in data form fields + """ + form = self._getForm(feature_elt, namespace) + options = [option.value for option in form.fields[name].options] + for value in negotiable_values: + if value in options: + return value + return None + + def chooseOption(self, options, namespace): + """Build a feature element with choosed options + + @param options(dict): dict with feature as key and choosed option as value + @param namespace (None, unicode): form namespace or None to ignore + """ + feature_elt = domish.Element((NS_FEATURE_NEG, 'feature')) + x_form = data_form.Form('submit', formNamespace=namespace) + x_form.makeFields(options) + feature_elt.addChild(x_form.toElement()) + return feature_elt + + def proposeFeatures(self, options_dict, namespace): + """Build a feature element with options to propose + + @param options_dict(dict): dict with feature as key and iterable of acceptable options as value + @param namespace(None, unicode): feature namespace + """ + feature_elt = domish.Element((NS_FEATURE_NEG, 'feature')) + x_form = data_form.Form('form', formNamespace=namespace) + for field in options_dict: + x_form.addField(data_form.Field('list-single', field, + options=[data_form.Option(option) for option in options_dict[field]])) + feature_elt.addChild(x_form.toElement()) + return feature_elt + + +class XEP_0020_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_FEATURE_NEG)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0033.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0033.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,201 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Extended Stanza Addressing (xep-0033) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from wokkel import disco, iwokkel +from zope.interface import implements +from twisted.words.protocols.jabber.jid import JID +from twisted.python import failure +import copy +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from twisted.words.xish import domish +from twisted.internet import defer + +from sat.tools import trigger +from time import time + +# TODO: fix Prosody "addressing" plugin to leave the concerned bcc according to the spec: +# +# http://xmpp.org/extensions/xep-0033.html#addr-type-bcc +# "This means that the server MUST remove these addresses before the stanza is delivered to anyone other than the given bcc addressee or the multicast service of the bcc addressee." +# +# http://xmpp.org/extensions/xep-0033.html#multicast +# "Each 'bcc' recipient MUST receive only the
associated with that addressee." + +# TODO: fix Prosody "addressing" plugin to determine itself if remote servers supports this XEP + + +NS_XMPP_CLIENT = "jabber:client" +NS_ADDRESS = "http://jabber.org/protocol/address" +ATTRIBUTES = ["jid", "uri", "node", "desc", "delivered", "type"] +ADDRESS_TYPES = ["to", "cc", "bcc", "replyto", "replyroom", "noreply"] + +PLUGIN_INFO = { + C.PI_NAME: "Extended Stanza Addressing Protocol Plugin", + C.PI_IMPORT_NAME: "XEP-0033", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0033"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0033", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Extended Stanza Addressing""") +} + + +class XEP_0033(object): + """ + Implementation for XEP 0033 + """ + def __init__(self, host): + log.info(_("Extended Stanza Addressing plugin initialization")) + self.host = host + self.internal_data = {} + host.trigger.add("sendMessage", self.sendMessageTrigger, trigger.TriggerManager.MIN_PRIORITY) + host.trigger.add("MessageReceived", self.messageReceivedTrigger) + + def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """Process the XEP-0033 related data to be sent""" + profile = client.profile + + def treatment(mess_data): + if not 'address' in mess_data['extra']: + return mess_data + + def discoCallback(entities): + if not entities: + log.warning(_("XEP-0033 is being used but the server doesn't support it!")) + raise failure.Failure(exceptions.CancelError(u'Cancelled by XEP-0033')) + if mess_data["to"] not in entities: + expected = _(' or ').join([entity.userhost() for entity in entities]) + log.warning(_(u"Stanzas using XEP-0033 should be addressed to %(expected)s, not %(current)s!") % {'expected': expected, 'current': mess_data["to"]}) + log.warning(_(u"TODO: addressing has been fixed by the backend... fix it in the frontend!")) + mess_data["to"] = list(entities)[0].userhostJID() + element = mess_data['xml'].addElement('addresses', NS_ADDRESS) + entries = [entry.split(':') for entry in mess_data['extra']['address'].split('\n') if entry != ''] + for type_, jid_ in entries: + element.addChild(domish.Element((None, 'address'), None, {'type': type_, 'jid': jid_})) + # when the prosody plugin is completed, we can immediately return mess_data from here + self.sendAndStoreMessage(mess_data, entries, profile) + log.debug("XEP-0033 took over") + raise failure.Failure(exceptions.CancelError(u'Cancelled by XEP-0033')) + d = self.host.findFeaturesSet(client, [NS_ADDRESS]) + d.addCallbacks(discoCallback, lambda dummy: discoCallback(None)) + return d + + post_xml_treatments.addCallback(treatment) + return True + + def sendAndStoreMessage(self, mess_data, entries, profile): + """Check if target servers support XEP-0033, send and store the messages + @return: a friendly failure to let the core know that we sent the message already + + Later we should be able to remove this method because: + # XXX: sending the messages should be done by the local server + # FIXME: for now we duplicate the messages in the history for each recipient, this should change + # FIXME: for now we duplicate the echoes to the sender, this should also change + Ideas: + - fix Prosody plugin to check if target server support the feature + - redesign the database to save only one entry to the database + - change the messageNew signal to eventually pass more than one recipient + """ + client = self.host.getClient(profile) + def send(mess_data, skip_send=False): + d = defer.Deferred() + if not skip_send: + d.addCallback(client.sendMessageData) + d.addCallback(client.messageAddToHistory) + d.addCallback(client.messageSendToBridge) + d.addErrback(lambda failure: failure.trap(exceptions.CancelError)) + return d.callback(mess_data) + + def discoCallback(entities, to_jid_s): + history_data = copy.deepcopy(mess_data) + history_data['to'] = JID(to_jid_s) + history_data['xml']['to'] = to_jid_s + if entities: + if entities not in self.internal_data[timestamp]: + sent_data = copy.deepcopy(mess_data) + sent_data['to'] = JID(JID(to_jid_s).host) + sent_data['xml']['to'] = JID(to_jid_s).host + send(sent_data) + self.internal_data[timestamp].append(entities) + # we still need to fill the history and signal the echo... + send(history_data, skip_send=True) + else: + # target server misses the addressing feature + send(history_data) + + def errback(failure, to_jid): + discoCallback(None, to_jid) + + timestamp = time() + self.internal_data[timestamp] = [] + defer_list = [] + for type_, jid_ in entries: + d = defer.Deferred() + d.addCallback(self.host.findFeaturesSet, client=client, jid_=JID(JID(jid_).host)) + d.addCallbacks(discoCallback, errback, callbackArgs=[jid_], errbackArgs=[jid_]) + d.callback([NS_ADDRESS]) + defer_list.append(d) + d = defer.Deferred().addCallback(lambda dummy: self.internal_data.pop(timestamp)) + defer.DeferredList(defer_list).chainDeferred(d) + + def messageReceivedTrigger(self, client, message, post_treat): + """In order to save the addressing information in the history""" + def post_treat_addr(data, addresses): + data['extra']['addresses'] = "" + for address in addresses: + # Depending how message has been constructed, we could get here + # some noise like "\n " instead of an address element. + if isinstance(address, domish.Element): + data['extra']['addresses'] += '%s:%s\n' % (address['type'], address['jid']) + return data + + try: + addresses = message.elements(NS_ADDRESS, 'addresses').next() + except StopIteration: + pass # no addresses + else: + post_treat.addCallback(post_treat_addr, addresses.children) + return True + + def getHandler(self, client): + return XEP_0033_handler(self, client.profile) + + +class XEP_0033_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_ADDRESS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0045.py --- /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 . + +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 [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0047.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0047.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,357 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing gateways (xep-0047) +# 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 . + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C +from sat.core import exceptions +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import error +from twisted.internet import reactor +from twisted.internet import defer +from twisted.python import failure + +from wokkel import disco, iwokkel + +from zope.interface import implements + +import base64 + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +MESSAGE = '/message' +IQ_SET = '/iq[@type="set"]' +NS_IBB = 'http://jabber.org/protocol/ibb' +IBB_OPEN = IQ_SET + '/open[@xmlns="' + NS_IBB + '"]' +IBB_CLOSE = IQ_SET + '/close[@xmlns="' + NS_IBB + '" and @sid="{}"]' +IBB_IQ_DATA = IQ_SET + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]' +IBB_MESSAGE_DATA = MESSAGE + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]' +TIMEOUT = 120 # timeout for workflow +DEFER_KEY = 'finished' # key of the deferred used to track session end + +PLUGIN_INFO = { + C.PI_NAME: "In-Band Bytestream Plugin", + C.PI_IMPORT_NAME: "XEP-0047", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0047"], + C.PI_MAIN: "XEP_0047", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of In-Band Bytestreams""") +} + + +class XEP_0047(object): + NAMESPACE = NS_IBB + BLOCK_SIZE = 4096 + + def __init__(self, host): + log.info(_("In-Band Bytestreams plugin initialization")) + self.host = host + + def getHandler(self, client): + return XEP_0047_handler(self) + + def profileConnected(self, client): + client.xep_0047_current_stream = {} # key: stream_id, value: data(dict) + + def _timeOut(self, sid, client): + """Delete current_stream id, called after timeout + + @param sid(unicode): session id of client.xep_0047_current_stream + @param client: %(doc_client)s + """ + log.info(u"In-Band Bytestream: TimeOut reached for id {sid} [{profile}]" + .format(sid=sid, profile=client.profile)) + self._killSession(sid, client, "TIMEOUT") + + def _killSession(self, sid, client, failure_reason=None): + """Delete a current_stream id, clean up associated observers + + @param sid(unicode): session id + @param client: %(doc_client)s + @param failure_reason(None, unicode): if None the session is successful + else, will be used to call failure_cb + """ + try: + session = client.xep_0047_current_stream[sid] + except KeyError: + log.warning(u"kill id called on a non existant id") + return + + try: + observer_cb = session['observer_cb'] + except KeyError: + pass + else: + client.xmlstream.removeObserver(session["event_data"], observer_cb) + + if session['timer'].active(): + session['timer'].cancel() + + del client.xep_0047_current_stream[sid] + + success = failure_reason is None + stream_d = session[DEFER_KEY] + + if success: + stream_d.callback(None) + else: + stream_d.errback(failure.Failure(exceptions.DataError(failure_reason))) + + def createSession(self, *args, **kwargs): + """like [_createSession] but return the session deferred instead of the whole session + + session deferred is fired when transfer is finished + """ + return self._createSession(*args, **kwargs)[DEFER_KEY] + + def _createSession(self, client, stream_object, to_jid, sid): + """Called when a bytestream is imminent + + @param stream_object(IConsumer): stream object where data will be written + @param to_jid(jid.JId): jid of the other peer + @param sid(unicode): session id + @return (dict): session data + """ + if sid in client.xep_0047_current_stream: + raise exceptions.ConflictError(u'A session with this id already exists !') + session_data = client.xep_0047_current_stream[sid] = \ + {'id': sid, + DEFER_KEY: defer.Deferred(), + 'to': to_jid, + 'stream_object': stream_object, + 'seq': -1, + 'timer': reactor.callLater(TIMEOUT, self._timeOut, sid, client), + } + + return session_data + + def _onIBBOpen(self, iq_elt, client): + """"Called when an IBB element is received + + @param iq_elt(domish.Element): the whole stanza + """ + log.debug(_(u"IBB stream opening")) + iq_elt.handled = True + open_elt = iq_elt.elements(NS_IBB, 'open').next() + block_size = open_elt.getAttribute('block-size') + sid = open_elt.getAttribute('sid') + stanza = open_elt.getAttribute('stanza', 'iq') + if not sid or not block_size or int(block_size) > 65535: + return self._sendError('not-acceptable', sid or None, iq_elt, client) + if not sid in client.xep_0047_current_stream: + log.warning(_(u"Ignoring unexpected IBB transfer: %s" % sid)) + return self._sendError('not-acceptable', sid or None, iq_elt, client) + session_data = client.xep_0047_current_stream[sid] + if session_data["to"] != jid.JID(iq_elt['from']): + log.warning(_("sended jid inconsistency (man in the middle attack attempt ?)")) + return self._sendError('not-acceptable', sid, iq_elt, client) + + # at this stage, the session looks ok and will be accepted + + # we reset the timeout: + session_data["timer"].reset(TIMEOUT) + + # we save the xmlstream, events and observer data to allow observer removal + session_data["event_data"] = event_data = (IBB_MESSAGE_DATA if stanza == 'message' else IBB_IQ_DATA).format(sid) + session_data["observer_cb"] = observer_cb = self._onIBBData + event_close = IBB_CLOSE.format(sid) + # we now set the stream observer to look after data packet + # FIXME: if we never get the events, the observers stay. + # would be better to have generic observer and check id once triggered + client.xmlstream.addObserver(event_data, observer_cb, client=client) + client.xmlstream.addOnetimeObserver(event_close, self._onIBBClose, client=client) + # finally, we send the accept stanza + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + client.send(iq_result_elt) + + def _onIBBClose(self, iq_elt, client): + """"Called when an IBB element is received + + @param iq_elt(domish.Element): the whole stanza + """ + iq_elt.handled = True + log.debug(_("IBB stream closing")) + close_elt = iq_elt.elements(NS_IBB, 'close').next() + # XXX: this observer is only triggered on valid sid, so we don't need to check it + sid = close_elt['sid'] + + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + client.send(iq_result_elt) + self._killSession(sid, client) + + def _onIBBData(self, element, client): + """Observer called on or stanzas with data element + + Manage the data elelement (check validity and write to the stream_object) + @param element(domish.Element): or stanza + """ + element.handled = True + data_elt = element.elements(NS_IBB, 'data').next() + sid = data_elt['sid'] + + try: + session_data = client.xep_0047_current_stream[sid] + except KeyError: + log.warning(_(u"Received data for an unknown session id")) + return self._sendError('item-not-found', None, element, client) + + from_jid = session_data["to"] + stream_object = session_data["stream_object"] + + if from_jid.full() != element['from']: + log.warning(_(u"sended jid inconsistency (man in the middle attack attempt ?)\ninitial={initial}\ngiven={given}").format(initial=from_jid, given=element['from'])) + if element.name == 'iq': + self._sendError('not-acceptable', sid, element, client) + return + + session_data["seq"] = (session_data["seq"] + 1) % 65535 + if int(data_elt.getAttribute("seq", -1)) != session_data["seq"]: + log.warning(_(u"Sequence error")) + if element.name == 'iq': + reason = 'not-acceptable' + self._sendError(reason, sid, element, client) + self.terminateStream(session_data, client, reason) + return + + # we reset the timeout: + session_data["timer"].reset(TIMEOUT) + + # we can now decode the data + try: + stream_object.write(base64.b64decode(str(data_elt))) + except TypeError: + # The base64 data is invalid + log.warning(_(u"Invalid base64 data")) + if element.name == 'iq': + self._sendError('not-acceptable', sid, element, client) + self.terminateStream(session_data, client, reason) + return + + # we can now ack success + if element.name == 'iq': + iq_result_elt = xmlstream.toResponse(element, 'result') + client.send(iq_result_elt) + + def _sendError(self, error_condition, sid, iq_elt, client): + """Send error stanza + + @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys + @param sid(unicode,None): jingle session id, or None, if session must not be destroyed + @param iq_elt(domish.Element): full stanza + @param client: %(doc_client)s + """ + iq_elt = error.StanzaError(error_condition).toResponse(iq_elt) + log.warning(u"Error while managing in-band bytestream session, cancelling: {}".format(error_condition)) + if sid is not None: + self._killSession(sid, client, error_condition) + client.send(iq_elt) + + def startStream(self, client, stream_object, to_jid, sid, block_size=None): + """Launch the stream workflow + + @param stream_object(ifaces.IStreamProducer): stream object to send + @param to_jid(jid.JID): JID of the recipient + @param sid(unicode): Stream session id + @param block_size(int, None): size of the block (or None for default) + """ + session_data = self._createSession(client, stream_object, to_jid, sid) + + if block_size is None: + block_size = XEP_0047.BLOCK_SIZE + assert block_size <= 65535 + session_data["block_size"] = block_size + + iq_elt = client.IQ() + iq_elt['to'] = to_jid.full() + open_elt = iq_elt.addElement((NS_IBB, 'open')) + open_elt['block-size'] = str(block_size) + open_elt['sid'] = sid + open_elt['stanza'] = 'iq' # TODO: manage stanza ? + args = [session_data, client] + d = iq_elt.send() + d.addCallbacks(self._IQDataStreamCb, self._IQDataStreamEb, args, None, args) + return session_data[DEFER_KEY] + + def _IQDataStreamCb(self, iq_elt, session_data, client): + """Called during the whole data streaming + + @param iq_elt(domish.Element): iq result + @param session_data(dict): data of this streaming session + @param client: %(doc_client)s + """ + session_data["timer"].reset(TIMEOUT) + + buffer_ = session_data["stream_object"].read(session_data["block_size"]) + if buffer_: + next_iq_elt = client.IQ() + next_iq_elt['to'] = session_data["to"].full() + data_elt = next_iq_elt.addElement((NS_IBB, 'data')) + seq = session_data['seq'] = (session_data['seq'] + 1) % 65535 + data_elt['seq'] = unicode(seq) + data_elt['sid'] = session_data['id'] + data_elt.addContent(base64.b64encode(buffer_)) + args = [session_data, client] + d = next_iq_elt.send() + d.addCallbacks(self._IQDataStreamCb, self._IQDataStreamEb, args, None, args) + else: + self.terminateStream(session_data, client) + + def _IQDataStreamEb(self, failure, session_data, client): + if failure.check(error.StanzaError): + log.warning(u"IBB transfer failed: {}".format(failure.value)) + else: + log.error(u"IBB transfer failed: {}".format(failure.value)) + self.terminateStream(session_data, client, "IQ_ERROR") + + def terminateStream(self, session_data, client, failure_reason=None): + """Terminate the stream session + + @param session_data(dict): data of this streaming session + @param client: %(doc_client)s + @param failure_reason(unicode, None): reason of the failure, or None if steam was successful + """ + iq_elt = client.IQ() + iq_elt['to'] = session_data["to"].full() + close_elt = iq_elt.addElement((NS_IBB, 'close')) + close_elt['sid'] = session_data['id'] + iq_elt.send() + self._killSession(session_data['id'], client, failure_reason) + + +class XEP_0047_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, parent): + self.plugin_parent = parent + + def connectionInitialized(self): + self.xmlstream.addObserver(IBB_OPEN, self.plugin_parent._onIBBOpen, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_IBB)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0048.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0048.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,448 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Bookmarks (xep-0048) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.memory.persistent import PersistentBinaryDict +from sat.tools import xml_tools +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber.error import StanzaError + +from twisted.internet import defer + +NS_BOOKMARKS = 'storage:bookmarks' + +PLUGIN_INFO = { + C.PI_NAME: "Bookmarks", + C.PI_IMPORT_NAME: "XEP-0048", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0048"], + C.PI_DEPENDENCIES: ["XEP-0045"], + C.PI_RECOMMENDATIONS: ["XEP-0049"], + C.PI_MAIN: "XEP_0048", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of bookmarks""") +} + + +class XEP_0048(object): + MUC_TYPE = 'muc' + URL_TYPE = 'url' + MUC_KEY = 'jid' + URL_KEY = 'url' + MUC_ATTRS = ('autojoin', 'name') + URL_ATTRS = ('name',) + + def __init__(self, host): + log.info(_("Bookmarks plugin initialization")) + self.host = host + # self.__menu_id = host.registerCallback(self._bookmarksMenu, with_data=True) + self.__bm_save_id = host.registerCallback(self._bookmarksSaveCb, with_data=True) + host.importMenu((D_("Groups"), D_("Bookmarks")), self._bookmarksMenu, security_limit=0, help_string=D_("Use and manage bookmarks")) + self.__selected_id = host.registerCallback(self._bookmarkSelectedCb, with_data=True) + host.bridge.addMethod("bookmarksList", ".plugin", in_sign='sss', out_sign='a{sa{sa{ss}}}', method=self._bookmarksList, async=True) + host.bridge.addMethod("bookmarksRemove", ".plugin", in_sign='ssss', out_sign='', method=self._bookmarksRemove, async=True) + host.bridge.addMethod("bookmarksAdd", ".plugin", in_sign='ssa{ss}ss', out_sign='', method=self._bookmarksAdd, async=True) + try: + self.private_plg = self.host.plugins["XEP-0049"] + except KeyError: + self.private_plg = None + try: + self.host.plugins[C.TEXT_CMDS].registerTextCommands(self) + except KeyError: + log.info(_("Text commands not available")) + + @defer.inlineCallbacks + def profileConnected(self, client): + local = client.bookmarks_local = PersistentBinaryDict(NS_BOOKMARKS, client.profile) + yield local.load() + if not local: + local[XEP_0048.MUC_TYPE] = dict() + local[XEP_0048.URL_TYPE] = dict() + private = yield self._getServerBookmarks('private', client.profile) + pubsub = client.bookmarks_pubsub = None + + for bookmarks in (local, private, pubsub): + if bookmarks is not None: + for (room_jid, data) in bookmarks[XEP_0048.MUC_TYPE].items(): + if data.get('autojoin', 'false') == 'true': + nick = data.get('nick', client.jid.user) + self.host.plugins['XEP-0045'].join(client, room_jid, nick, {}) + + @defer.inlineCallbacks + def _getServerBookmarks(self, storage_type, profile): + """Get distants bookmarks + + update also the client.bookmarks_[type] key, with None if service is not available + @param storage_type: storage type, can be: + - 'private': XEP-0049 storage + - 'pubsub': XEP-0223 storage + @param profile: %(doc_profile)s + @return: data dictionary, or None if feature is not available + """ + client = self.host.getClient(profile) + if storage_type == 'private': + try: + bookmarks_private_xml = yield self.private_plg.privateXMLGet('storage', NS_BOOKMARKS, profile) + data = client.bookmarks_private = self._bookmarkElt2Dict(bookmarks_private_xml) + except (StanzaError, AttributeError): + log.info(_("Private XML storage not available")) + data = client.bookmarks_private = None + elif storage_type == 'pubsub': + raise NotImplementedError + else: + raise ValueError("storage_type must be 'private' or 'pubsub'") + defer.returnValue(data) + + @defer.inlineCallbacks + def _setServerBookmarks(self, storage_type, bookmarks_elt, profile): + """Save bookmarks on server + + @param storage_type: storage type, can be: + - 'private': XEP-0049 storage + - 'pubsub': XEP-0223 storage + @param bookmarks_elt (domish.Element): bookmarks XML + @param profile: %(doc_profile)s + """ + if storage_type == 'private': + yield self.private_plg.privateXMLStore(bookmarks_elt, profile) + elif storage_type == 'pubsub': + raise NotImplementedError + else: + raise ValueError("storage_type must be 'private' or 'pubsub'") + + def _bookmarkElt2Dict(self, storage_elt): + """Parse bookmarks to get dictionary + @param storage_elt (domish.Element): bookmarks storage + @return (dict): bookmark data (key: bookmark type, value: list) where key can be: + - XEP_0048.MUC_TYPE + - XEP_0048.URL_TYPE + - value (dict): data as for addBookmark + """ + conf_data = {} + url_data = {} + + conference_elts = storage_elt.elements(NS_BOOKMARKS, 'conference') + for conference_elt in conference_elts: + try: + room_jid = jid.JID(conference_elt[XEP_0048.MUC_KEY]) + except KeyError: + log.warning ("invalid bookmark found, igoring it:\n%s" % conference_elt.toXml()) + continue + + data = conf_data[room_jid] = {} + + for attr in XEP_0048.MUC_ATTRS: + if conference_elt.hasAttribute(attr): + data[attr] = conference_elt[attr] + try: + data['nick'] = unicode(conference_elt.elements(NS_BOOKMARKS, 'nick').next()) + except StopIteration: + pass + # TODO: manage password (need to be secured, see XEP-0049 §4) + + url_elts = storage_elt.elements(NS_BOOKMARKS, 'url') + for url_elt in url_elts: + try: + url = url_elt[XEP_0048.URL_KEY] + except KeyError: + log.warning ("invalid bookmark found, igoring it:\n%s" % url_elt.toXml()) + continue + data = url_data[url] = {} + for attr in XEP_0048.URL_ATTRS: + if url_elt.hasAttribute(attr): + data[attr] = url_elt[attr] + + return {XEP_0048.MUC_TYPE: conf_data, XEP_0048.URL_TYPE: url_data} + + def _dict2BookmarkElt(self, type_, data): + """Construct a bookmark element from a data dict + @param data (dict): bookmark data (key: bookmark type, value: list) where key can be: + - XEP_0048.MUC_TYPE + - XEP_0048.URL_TYPE + - value (dict): data as for addBookmark + @return (domish.Element): bookmark element + """ + rooms_data = data.get(XEP_0048.MUC_TYPE, {}) + urls_data = data.get(XEP_0048.URL_TYPE, {}) + storage_elt = domish.Element((NS_BOOKMARKS, 'storage')) + for room_jid in rooms_data: + conference_elt = storage_elt.addElement('conference') + conference_elt[XEP_0048.MUC_KEY] = room_jid.full() + for attr in XEP_0048.MUC_ATTRS: + try: + conference_elt[attr] = rooms_data[room_jid][attr] + except KeyError: + pass + try: + conference_elt.addElement('nick', content=rooms_data[room_jid]['nick']) + except KeyError: + pass + + for url in urls_data: + url_elt = storage_elt.addElement('url') + url_elt[XEP_0048.URL_KEY] = url + for attr in XEP_0048.URL_ATTRS: + try: + url_elt[attr] = url[attr] + except KeyError: + pass + + return storage_elt + + def _bookmarkSelectedCb(self, data, profile): + try: + room_jid_s, nick = data['index'].split(' ', 1) + room_jid = jid.JID(room_jid_s) + except (KeyError, RuntimeError): + log.warning(_("No room jid selected")) + return {} + + client = self.host.getClient(profile) + d = self.host.plugins['XEP-0045'].join(client, room_jid, nick, {}) + def join_eb(failure): + log.warning(u"Error while trying to join room: {}".format(failure)) + # FIXME: failure are badly managed in plugin XEP-0045. Plugin XEP-0045 need to be fixed before managing errors correctly here + return {} + d.addCallbacks(lambda dummy: {}, join_eb) + return d + + def _bookmarksMenu(self, data, profile): + """ XMLUI activated by menu: return Gateways UI + @param profile: %(doc_profile)s + + """ + client = self.host.getClient(profile) + xmlui = xml_tools.XMLUI(title=_('Bookmarks manager')) + adv_list = xmlui.changeContainer('advanced_list', columns=3, selectable='single', callback_id=self.__selected_id) + for bookmarks in (client.bookmarks_local, client.bookmarks_private, client.bookmarks_pubsub): + if bookmarks is None: + continue + for (room_jid, data) in sorted(bookmarks[XEP_0048.MUC_TYPE].items(), key=lambda item: item[1].get('name',item[0].user)): + room_jid_s = room_jid.full() + adv_list.setRowIndex(u'%s %s' % (room_jid_s, data.get('nick') or client.jid.user)) + xmlui.addText(data.get('name','')) + xmlui.addJid(room_jid) + if data.get('autojoin', 'false') == 'true': + xmlui.addText('autojoin') + else: + xmlui.addEmpty() + adv_list.end() + xmlui.addDivider('dash') + xmlui.addText(_("add a bookmark")) + xmlui.changeContainer("pairs") + xmlui.addLabel(_('Name')) + xmlui.addString('name') + xmlui.addLabel(_('jid')) + xmlui.addString('jid') + xmlui.addLabel(_('Nickname')) + xmlui.addString('nick', client.jid.user) + xmlui.addLabel(_('Autojoin')) + xmlui.addBool('autojoin') + xmlui.changeContainer("vertical") + xmlui.addButton(self.__bm_save_id, _("Save"), ('name', 'jid', 'nick', 'autojoin')) + return {'xmlui': xmlui.toXml()} + + def _bookmarksSaveCb(self, data, profile): + bm_data = xml_tools.XMLUIResult2DataFormResult(data) + try: + location = jid.JID(bm_data.pop('jid')) + except KeyError: + raise exceptions.InternalError("Can't find mandatory key") + d = self.addBookmark(XEP_0048.MUC_TYPE, location, bm_data, profile_key=profile) + d.addCallback(lambda dummy: {}) + return d + + @defer.inlineCallbacks + def addBookmark(self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE): + """Store a new bookmark + + @param type_: bookmark type, one of: + - XEP_0048.MUC_TYPE: Multi-User chat room + - XEP_0048.URL_TYPE: web page URL + @param location: dependeding on type_, can be a MUC room jid or an url + @param data (dict): depending on type_, can contains the following keys: + - name: human readable name of the bookmark + - nick: user preferred room nick (default to user part of profile's jid) + - autojoin: "true" if room must be automatically joined on connection + - password: unused yet TODO + @param storage_type: where the bookmark will be stored, can be: + - "auto": find best available option: pubsub, private, local in that order + - "pubsub": PubSub private storage (XEP-0223) + - "private": Private XML storage (XEP-0049) + - "local": Store in SàT database + @param profile_key: %(doc_profile_key)s + """ + assert storage_type in ('auto', 'pubsub', 'private', 'local') + if type_ == XEP_0048.URL_TYPE and {'autojoin', 'nick'}.intersection(data.keys()): + raise ValueError("autojoin or nick can't be used with URLs") + client = self.host.getClient(profile_key) + if storage_type == 'auto': + if client.bookmarks_pubsub is not None: + storage_type = 'pubsub' + elif client.bookmarks_private is not None: + storage_type = 'private' + else: + storage_type = 'local' + log.warning(_("Bookmarks will be local only")) + log.info(_('Type selected for "auto" storage: %s') % storage_type) + + if storage_type == 'local': + client.bookmarks_local[type_][location] = data + yield client.bookmarks_local.force(type_) + else: + bookmarks = yield self._getServerBookmarks(storage_type, client.profile) + bookmarks[type_][location] = data + bookmark_elt = self._dict2BookmarkElt(type_, bookmarks) + yield self._setServerBookmarks(storage_type, bookmark_elt, client.profile) + + @defer.inlineCallbacks + def removeBookmark(self, type_, location, storage_type="all", profile_key=C.PROF_KEY_NONE): + """Remove a stored bookmark + + @param type_: bookmark type, one of: + - XEP_0048.MUC_TYPE: Multi-User chat room + - XEP_0048.URL_TYPE: web page URL + @param location: dependeding on type_, can be a MUC room jid or an url + @param storage_type: where the bookmark is stored, can be: + - "all": remove from everywhere + - "pubsub": PubSub private storage (XEP-0223) + - "private": Private XML storage (XEP-0049) + - "local": Store in SàT database + @param profile_key: %(doc_profile_key)s + """ + assert storage_type in ('all', 'pubsub', 'private', 'local') + client = self.host.getClient(profile_key) + + if storage_type in ('all', 'local'): + try: + del client.bookmarks_local[type_][location] + yield client.bookmarks_local.force(type_) + except KeyError: + log.debug("Bookmark is not present in local storage") + + if storage_type in ('all', 'private'): + bookmarks = yield self._getServerBookmarks('private', client.profile) + try: + del bookmarks[type_][location] + bookmark_elt = self._dict2BookmarkElt(type_, bookmarks) + yield self._setServerBookmarks('private', bookmark_elt, client.profile) + except KeyError: + log.debug("Bookmark is not present in private storage") + + if storage_type == 'pubsub': + raise NotImplementedError + + def _bookmarksList(self, type_, storage_location, profile_key=C.PROF_KEY_NONE): + """Return stored bookmarks + + @param type_: bookmark type, one of: + - XEP_0048.MUC_TYPE: Multi-User chat room + - XEP_0048.URL_TYPE: web page URL + @param storage_location: can be: + - 'all' + - 'local' + - 'private' + - 'pubsub' + @param profile_key: %(doc_profile_key)s + @param return (dict): (key: storage_location, value dict) with: + - value (dict): (key: bookmark_location, value: bookmark data) + """ + client = self.host.getClient(profile_key) + ret = {} + ret_d = defer.succeed(ret) + + def fillBookmarks(dummy, _storage_location): + bookmarks_ori = getattr(client, "bookmarks_" + _storage_location) + if bookmarks_ori is None: + return ret + data = bookmarks_ori[type_] + for bookmark in data: + ret[_storage_location][bookmark.full()] = data[bookmark].copy() + return ret + + for _storage_location in ('local', 'private', 'pubsub'): + if storage_location in ('all', _storage_location): + ret[_storage_location] = {} + if _storage_location in ('private',): + # we update distant bookmarks, just in case an other client added something + d = self._getServerBookmarks(_storage_location, client.profile) + else: + d = defer.succeed(None) + d.addCallback(fillBookmarks, _storage_location) + ret_d.addCallback(lambda dummy: d) + + return ret_d + + def _bookmarksRemove(self, type_, location, storage_location, profile_key=C.PROF_KEY_NONE): + """Return stored bookmarks + + @param type_: bookmark type, one of: + - XEP_0048.MUC_TYPE: Multi-User chat room + - XEP_0048.URL_TYPE: web page URL + @param location: dependeding on type_, can be a MUC room jid or an url + @param storage_location: can be: + - "all": remove from everywhere + - "pubsub": PubSub private storage (XEP-0223) + - "private": Private XML storage (XEP-0049) + - "local": Store in SàT database + @param profile_key: %(doc_profile_key)s + """ + if type_ == XEP_0048.MUC_TYPE: + location = jid.JID(location) + return self.removeBookmark(type_, location, storage_location, profile_key) + + def _bookmarksAdd(self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE): + if type_ == XEP_0048.MUC_TYPE: + location = jid.JID(location) + return self.addBookmark(type_, location, data, storage_type, profile_key) + + def cmd_bookmark(self, client, mess_data): + """(Un)bookmark a MUC room + + @command (group): [autojoin | remove] + - autojoin: join room automatically on connection + - remove: remove bookmark(s) for this room + """ + txt_cmd = self.host.plugins[C.TEXT_CMDS] + + options = mess_data["unparsed"].strip().split() + if options and options[0] not in ('autojoin', 'remove'): + txt_cmd.feedBack(client, _("Bad arguments"), mess_data) + return False + + room_jid = mess_data["to"].userhostJID() + + if "remove" in options: + self.removeBookmark(XEP_0048.MUC_TYPE, room_jid, profile_key = client.profile) + txt_cmd.feedBack(client, _("All [%s] bookmarks are being removed") % room_jid.full(), mess_data) + return False + + data = { "name": room_jid.user, + "nick": client.jid.user, + "autojoin": "true" if "autojoin" in options else "false", + } + self.addBookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=client.profile) + txt_cmd.feedBack(client, _("Bookmark added"), mess_data) + + return False diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0049.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0049.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,81 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0049 +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from wokkel import compat +from twisted.words.xish import domish + + + +PLUGIN_INFO = { + C.PI_NAME: "XEP-0049 Plugin", + C.PI_IMPORT_NAME: "XEP-0049", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0049"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0049", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of private XML storage""") +} + + +class XEP_0049(object): + NS_PRIVATE = 'jabber:iq:private' + + def __init__(self, host): + log.info(_("Plugin XEP-0049 initialization")) + self.host = host + + def privateXMLStore(self, element, profile_key): + """Store private data + @param element: domish.Element to store (must have a namespace) + @param profile_key: %(doc_profile_key)s + + """ + assert isinstance(element, domish.Element) + client = self.host.getClient(profile_key) + # XXX: feature announcement in disco#info is not mandatory in XEP-0049, so we have to try to use private XML, and react according to the answer + iq_elt = compat.IQ(client.xmlstream) + query_elt = iq_elt.addElement('query', XEP_0049.NS_PRIVATE) + query_elt.addChild(element) + return iq_elt.send() + + def privateXMLGet(self, node_name, namespace, profile_key): + """Store private data + @param node_name: name of the node to get + @param namespace: namespace of the node to get + @param profile_key: %(doc_profile_key)s + @return (domish.Element): a deferred which fire the stored data + + """ + client = self.host.getClient(profile_key) + # XXX: see privateXMLStore note about feature checking + iq_elt = compat.IQ(client.xmlstream, 'get') + query_elt = iq_elt.addElement('query', XEP_0049.NS_PRIVATE) + query_elt.addElement(node_name, namespace) + def getCb(answer_iq_elt): + answer_query_elt = answer_iq_elt.elements(XEP_0049.NS_PRIVATE, 'query').next() + return answer_query_elt.firstChildElement() + d = iq_elt.send() + d.addCallback(getCb) + return d + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0050.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0050.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,597 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Ad-Hoc Commands (XEP-0050) +# 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 . + +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.words.protocols.jabber import jid +from twisted.words.protocols import jabber +from twisted.words.xish import domish +from twisted.internet import defer +from wokkel import disco, iwokkel, data_form, compat +from sat.core import exceptions +from sat.memory.memory import Sessions +from uuid import uuid4 +from sat.tools import xml_tools + +from zope.interface import implements + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +from collections import namedtuple + +try: + from collections import OrderedDict # only available from python 2.7 +except ImportError: + from ordereddict import OrderedDict + +IQ_SET = '/iq[@type="set"]' +NS_COMMANDS = "http://jabber.org/protocol/commands" +ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list") +ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node") +CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]' + +SHOWS = OrderedDict([('default', _('Online')), + ('away', _('Away')), + ('chat', _('Free for chat')), + ('dnd', _('Do not disturb')), + ('xa', _('Left')), + ('disconnect', _('Disconnect'))]) + +PLUGIN_INFO = { + C.PI_NAME: "Ad-Hoc Commands", + C.PI_IMPORT_NAME: "XEP-0050", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0050"], + C.PI_MAIN: "XEP_0050", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands""") +} + + +class AdHocError(Exception): + + def __init__(self, error_const): + """ Error to be used from callback + @param error_const: one of XEP_0050.ERROR + """ + assert error_const in XEP_0050.ERROR + self.callback_error = error_const + +class AdHocCommand(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, parent, callback, label, node, features, timeout, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client): + self.parent = parent + self.callback = callback + self.label = label + self.node = node + self.features = [disco.DiscoFeature(feature) for feature in features] + self.allowed_jids, self.allowed_groups, self.allowed_magics, self.forbidden_jids, self.forbidden_groups = allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups + self.client = client + self.sessions = Sessions(timeout=timeout) + + def getName(self, xml_lang=None): + return self.label + + def isAuthorised(self, requestor): + if '@ALL@' in self.allowed_magics: + return True + forbidden = set(self.forbidden_jids) + for group in self.forbidden_groups: + forbidden.update(self.client.roster.getJidsFromGroup(group)) + if requestor.userhostJID() in forbidden: + return False + allowed = set(self.allowed_jids) + for group in self.allowed_groups: + try: + allowed.update(self.client.roster.getJidsFromGroup(group)) + except exceptions.UnknownGroupError: + log.warning(_(u"The groups [%(group)s] is unknown for profile [%(profile)s])" % {'group':group, 'profile':self.client.profile})) + if requestor.userhostJID() in allowed: + return True + return False + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + if nodeIdentifier != NS_COMMANDS: # FIXME: we should manage other disco nodes here + return [] + # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME + return [disco.DiscoFeature(NS_COMMANDS)] + self.features + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + + def _sendAnswer(self, callback_data, session_id, request): + """ Send result of the command + @param callback_data: tuple (payload, status, actions, note) with: + - payload (domish.Element) usualy containing data form + - status: current status, see XEP_0050.STATUS + - actions: list of allowed actions (see XEP_0050.ACTION). First action is the default one. Default to EXECUTE + - note: optional additional note: either None or a tuple with (note type, human readable string), + note type being in XEP_0050.NOTE + @param session_id: current session id + @param request: original request (domish.Element) + @return: deferred + """ + payload, status, actions, note = callback_data + assert(isinstance(payload, domish.Element) or payload is None) + assert(status in XEP_0050.STATUS) + if not actions: + actions = [XEP_0050.ACTION.EXECUTE] + result = domish.Element((None, 'iq')) + result['type'] = 'result' + result['id'] = request['id'] + result['to'] = request['from'] + command_elt = result.addElement('command', NS_COMMANDS) + command_elt['sessionid'] = session_id + command_elt['node'] = self.node + command_elt['status'] = status + + if status != XEP_0050.STATUS.CANCELED: + if status != XEP_0050.STATUS.COMPLETED: + actions_elt = command_elt.addElement('actions') + actions_elt['execute'] = actions[0] + for action in actions: + actions_elt.addElement(action) + + if note is not None: + note_type, note_mess = note + note_elt = command_elt.addElement('note', content=note_mess) + note_elt['type'] = note_type + + if payload is not None: + command_elt.addChild(payload) + + self.client.send(result) + if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED): + del self.sessions[session_id] + + def _sendError(self, error_constant, session_id, request): + """ Send error stanza + @param error_constant: one of XEP_OO50.ERROR + @param request: original request (domish.Element) + """ + xmpp_condition, cmd_condition = error_constant + iq_elt = jabber.error.StanzaError(xmpp_condition).toResponse(request) + if cmd_condition: + error_elt = iq_elt.elements(None, "error").next() + error_elt.addElement(cmd_condition, NS_COMMANDS) + self.client.send(iq_elt) + del self.sessions[session_id] + + def onRequest(self, command_elt, requestor, action, session_id): + if not self.isAuthorised(requestor): + return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent) + if session_id: + try: + session_data = self.sessions[session_id] + except KeyError: + return self._sendError(XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent) + if session_data['requestor'] != requestor: + return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent) + else: + session_id, session_data = self.sessions.newSession() + session_data['requestor'] = requestor + if action == XEP_0050.ACTION.CANCEL: + d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None)) + else: + d = defer.maybeDeferred(self.callback, command_elt, session_data, action, self.node, self.client.profile) + d.addCallback(self._sendAnswer, session_id, command_elt.parent) + d.addErrback(lambda failure, request: self._sendError(failure.value.callback_error, session_id, request), command_elt.parent) + + +class XEP_0050(object): + STATUS = namedtuple('Status', ('EXECUTING', 'COMPLETED', 'CANCELED'))('executing', 'completed', 'canceled') + ACTION = namedtuple('Action', ('EXECUTE', 'CANCEL', 'NEXT', 'PREV'))('execute', 'cancel', 'next', 'prev') + NOTE = namedtuple('Note', ('INFO','WARN','ERROR'))('info','warn','error') + ERROR = namedtuple('Error', ('MALFORMED_ACTION', 'BAD_ACTION', 'BAD_LOCALE', 'BAD_PAYLOAD', 'BAD_SESSIONID', 'SESSION_EXPIRED', + 'FORBIDDEN', 'ITEM_NOT_FOUND', 'FEATURE_NOT_IMPLEMENTED', 'INTERNAL'))(('bad-request', 'malformed-action'), + ('bad-request', 'bad-action'), ('bad-request', 'bad-locale'), ('bad-request','bad-payload'), + ('bad-request','bad-sessionid'), ('not-allowed','session-expired'), ('forbidden', None), + ('item-not-found', None), ('feature-not-implemented', None), ('internal-server-error', None)) # XEP-0050 §4.4 Table 5 + + def __init__(self, host): + log.info(_("plugin XEP-0050 initialization")) + self.host = host + self.requesting = Sessions() + self.answering = {} + host.bridge.addMethod("adHocRun", ".plugin", in_sign='sss', out_sign='s', + method=self._run, + async=True) + host.bridge.addMethod("adHocList", ".plugin", in_sign='ss', out_sign='s', + method=self._list, + async=True) + self.__requesting_id = host.registerCallback(self._requestingEntity, with_data=True) + host.importMenu((D_("Service"), D_("Commands")), self._commandsMenu, security_limit=2, help_string=D_("Execute ad-hoc commands")) + + def getHandler(self, client): + return XEP_0050_handler(self) + + def profileConnected(self, client): + self.addAdHocCommand(self._statusCallback, _("Status"), profile_key=client.profile) + + def profileDisconnected(self, client): + try: + del self.answering[client.profile] + except KeyError: + pass + + def _items2XMLUI(self, items, no_instructions): + """ Convert discovery items to XMLUI dialog """ + # TODO: manage items on different jids + form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) + + if not no_instructions: + form_ui.addText(_("Please select a command"), 'instructions') + + options = [(item.nodeIdentifier, item.name) for item in items] + form_ui.addList("node", options) + return form_ui + + def _getDataLvl(self, type_): + """Return the constant corresponding to type attribute value + + @param type_: note type (see XEP-0050 §4.3) + @return: a C.XMLUI_DATA_LVL_* constant + """ + if type_ == 'error': + return C.XMLUI_DATA_LVL_ERROR + elif type_ == 'warn': + return C.XMLUI_DATA_LVL_WARNING + else: + if type_ != 'info': + log.warning(_(u"Invalid note type [%s], using info") % type_) + return C.XMLUI_DATA_LVL_INFO + + def _mergeNotes(self, notes): + """Merge notes with level prefix (e.g. "ERROR: the message") + + @param notes (list): list of tuple (level, message) + @return: list of messages + """ + lvl_map = {C.XMLUI_DATA_LVL_INFO: '', + C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"), + C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR") + } + return [u"%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes] + + def _commandsAnswer2XMLUI(self, iq_elt, session_id, session_data): + """ + Convert command answer to an ui for frontend + @param iq_elt: command result + @param session_id: id of the session used with the frontend + @param profile_key: %(doc_profile_key)s + + """ + command_elt = iq_elt.elements(NS_COMMANDS, "command").next() + status = command_elt.getAttribute('status', XEP_0050.STATUS.EXECUTING) + if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]: + # the command session is finished, we purge our session + del self.requesting[session_id] + if status == XEP_0050.STATUS.COMPLETED: + session_id = None + else: + return None + remote_session_id = command_elt.getAttribute('sessionid') + if remote_session_id: + session_data['remote_id'] = remote_session_id + notes = [] + for note_elt in command_elt.elements(NS_COMMANDS, 'note'): + notes.append((self._getDataLvl(note_elt.getAttribute('type', 'info')), + unicode(note_elt))) + try: + data_elt = command_elt.elements(data_form.NS_X_DATA, 'x').next() + except StopIteration: + if status != XEP_0050.STATUS.COMPLETED: + log.warning(_("No known payload found in ad-hoc command result, aborting")) + del self.requesting[session_id] + return xml_tools.XMLUI(C.XMLUI_DIALOG, + dialog_opt = {C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, + C.XMLUI_DATA_MESS: _("No payload found"), + C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR, + } + ) + if not notes: + # the status is completed, and we have no note to show + return None + + # if we have only one note, we show a dialog with the level of the note + # if we have more, we show a dialog with "info" level, and all notes merged + dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO + return xml_tools.XMLUI( + C.XMLUI_DIALOG, + dialog_opt = {C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, + C.XMLUI_DATA_MESS: u'\n'.join(self._mergeNotes(notes)), + C.XMLUI_DATA_LVL: dlg_level, + }, + session_id = session_id + ) + + if session_id is None: + return xml_tools.dataFormEltResult2XMLUI(data_elt) + form = data_form.Form.fromElement(data_elt) + # we add any present note to the instructions + form.instructions.extend(self._mergeNotes(notes)) + return xml_tools.dataForm2XMLUI(form, self.__requesting_id, session_id=session_id) + + def _requestingEntity(self, data, profile): + def serialise(ret_data): + if 'xmlui' in ret_data: + ret_data['xmlui'] = ret_data['xmlui'].toXml() + return ret_data + + d = self.requestingEntity(data, profile) + d.addCallback(serialise) + return d + + def requestingEntity(self, data, profile): + """ + request and entity and create XMLUI accordingly + @param data: data returned by previous XMLUI (first one must come from self._commandsMenu) + @param profile: %(doc_profile)s + @return: callback dict result (with "xmlui" corresponding to the answering dialog, or empty if it's finished without error) + + """ + if C.bool(data.get('cancelled', C.BOOL_FALSE)): + return defer.succeed({}) + client = self.host.getClient(profile) + # TODO: cancel, prev and next are not managed + # TODO: managed answerer errors + # TODO: manage nodes with a non data form payload + if "session_id" not in data: + # we just had the jid, we now request it for the available commands + session_id, session_data = self.requesting.newSession(profile=client.profile) + entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX+'jid']) + session_data['jid'] = entity + d = self.list(client, entity) + + def sendItems(xmlui): + xmlui.session_id = session_id # we need to keep track of the session + return {'xmlui': xmlui} + + d.addCallback(sendItems) + else: + # we have started a several forms sessions + try: + session_data = self.requesting.profileGet(data["session_id"], client.profile) + except KeyError: + log.warning ("session id doesn't exist, session has probably expired") + # TODO: send error dialog + return defer.succeed({}) + session_id = data["session_id"] + entity = session_data['jid'] + try: + session_data['node'] + # node has already been received + except KeyError: + # it's the first time we know the node, we save it in session data + session_data['node'] = data[xml_tools.SAT_FORM_PREFIX+'node'] + + # we request execute node's command + iq_elt = compat.IQ(client.xmlstream, 'set') + iq_elt['to'] = entity.full() + command_elt = iq_elt.addElement("command", NS_COMMANDS) + command_elt['node'] = session_data['node'] + command_elt['action'] = XEP_0050.ACTION.EXECUTE + try: + # remote_id is the XEP_0050 sessionid used by answering command + # while session_id is our own session id used with the frontend + command_elt['sessionid'] = session_data['remote_id'] + except KeyError: + pass + + command_elt.addChild(xml_tools.XMLUIResultToElt(data)) # We add the XMLUI result to the command payload + d = iq_elt.send() + d.addCallback(self._commandsAnswer2XMLUI, session_id, session_data) + d.addCallback(lambda xmlui: {'xmlui': xmlui} if xmlui is not None else {}) + + return d + + def _commandsMenu(self, menu_data, profile): + """ First XMLUI activated by menu: ask for target jid + @param profile: %(doc_profile)s + + """ + form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) + form_ui.addText(_("Please enter target jid"), 'instructions') + form_ui.changeContainer("pairs") + form_ui.addLabel("jid") + form_ui.addString("jid", value=self.host.getClient(profile).jid.host) + return {'xmlui': form_ui.toXml()} + + def _statusCallback(self, command_elt, session_data, action, node, profile): + """ Ad-hoc command used to change the "show" part of status """ + actions = session_data.setdefault('actions',[]) + actions.append(action) + + if len(actions) == 1: + # it's our first request, we ask the desired new status + status = XEP_0050.STATUS.EXECUTING + form = data_form.Form('form', title=_('status selection')) + show_options = [data_form.Option(name, label) for name, label in SHOWS.items()] + field = data_form.Field('list-single', 'show', options=show_options, required=True) + form.addField(field) + + payload = form.toElement() + note = None + + elif len(actions) == 2: + # we should have the answer here + try: + x_elt = command_elt.elements(data_form.NS_X_DATA,'x').next() + answer_form = data_form.Form.fromElement(x_elt) + show = answer_form['show'] + except (KeyError, StopIteration): + raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD) + if show not in SHOWS: + raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD) + if show == "disconnect": + self.host.disconnect(profile) + else: + self.host.setPresence(show=show, profile_key=profile) + + # job done, we can end the session + form = data_form.Form('form', title=_(u'Updated')) + form.addField(data_form.Field('fixed', u'Status updated')) + status = XEP_0050.STATUS.COMPLETED + payload = None + note = (self.NOTE.INFO, _(u"Status updated")) + else: + raise AdHocError(XEP_0050.ERROR.INTERNAL) + + return (payload, status, None, note) + + def _run(self, service_jid_s='', node='', profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service_jid = jid.JID(service_jid_s) if service_jid_s else None + d = self.run(client, service_jid, node or None) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + @defer.inlineCallbacks + def run(self, client, service_jid=None, node=None): + """run an ad-hoc command + + @param service_jid(jid.JID, None): jid of the ad-hoc service + None to use profile's server + @param node(unicode, None): node of the ad-hoc commnad + None to get initial list + @return(unicode): command page XMLUI + """ + if service_jid is None: + service_jid = jid.JID(client.jid.host) + session_id, session_data = self.requesting.newSession(profile=client.profile) + session_data['jid'] = service_jid + if node is None: + xmlui = yield self.list(client, service_jid) + else: + session_data['node'] = node + cb_data = yield self.requestingEntity({'session_id': session_id}, client.profile) + xmlui = cb_data['xmlui'] + + xmlui.session_id = session_id + defer.returnValue(xmlui) + + def _list(self, to_jid_s, profile_key): + client = self.host.getClient(profile_key) + to_jid = jid.JID(to_jid_s) if to_jid_s else None + d = self.list(client, to_jid, no_instructions=True) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def list(self, client, to_jid, no_instructions=False): + """Request available commands + + @param to_jid(jid.JID, None): the entity answering the commands + None to use profile's server + @param no_instructions(bool): if True, don't add instructions widget + """ + d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS) + d.addCallback(self._items2XMLUI, no_instructions) + return d + + def addAdHocCommand(self, callback, label, node=None, features=None, timeout=600, allowed_jids=None, allowed_groups=None, + allowed_magics=None, forbidden_jids=None, forbidden_groups=None, profile_key=C.PROF_KEY_NONE): + """Add an ad-hoc command for the current profile + + @param callback: method associated with this ad-hoc command which return the payload data (see AdHocCommand._sendAnswer), can return a deferred + @param label: label associated with this command on the main menu + @param node: disco item node associated with this command. None to use autogenerated node + @param features: features associated with the payload (list of strings), usualy data form + @param timeout: delay between two requests before canceling the session (in seconds) + @param allowed_jids: list of allowed entities + @param allowed_groups: list of allowed roster groups + @param allowed_magics: list of allowed magic keys, can be: + @ALL@: allow everybody + @PROFILE_BAREJID@: allow only the jid of the profile + @param forbidden_jids: black list of entities which can't access this command + @param forbidden_groups: black list of groups which can't access this command + @param profile_key: profile key associated with this command, @ALL@ means can be accessed with every profiles + @return: node of the added command, useful to remove the command later + """ + # FIXME: "@ALL@" for profile_key seems useless and dangerous + + if node is None: + node = "%s_%s" % ('COMMANDS', uuid4()) + + if features is None: + features = [data_form.NS_X_DATA] + + if allowed_jids is None: + allowed_jids = [] + if allowed_groups is None: + allowed_groups = [] + if allowed_magics is None: + allowed_magics = ['@PROFILE_BAREJID@'] + if forbidden_jids is None: + forbidden_jids = [] + if forbidden_groups is None: + forbidden_groups = [] + + for client in self.host.getClients(profile_key): + #TODO: manage newly created/removed profiles + _allowed_jids = (allowed_jids + [client.jid.userhostJID()]) if '@PROFILE_BAREJID@' in allowed_magics else allowed_jids + ad_hoc_command = AdHocCommand(self, callback, label, node, features, timeout, _allowed_jids, + allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client) + ad_hoc_command.setHandlerParent(client) + profile_commands = self.answering.setdefault(client.profile, {}) + profile_commands[node] = ad_hoc_command + + def onCmdRequest(self, request, profile): + request.handled = True + requestor = jid.JID(request['from']) + command_elt = request.elements(NS_COMMANDS, 'command').next() + action = command_elt.getAttribute('action', self.ACTION.EXECUTE) + node = command_elt.getAttribute('node') + if not node: + raise exceptions.DataError + sessionid = command_elt.getAttribute('sessionid') + try: + command = self.answering[profile][node] + except KeyError: + raise exceptions.DataError + command.onRequest(command_elt, requestor, action, sessionid) + + +class XEP_0050_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + + def connectionInitialized(self): + self.xmlstream.addObserver(CMD_REQUEST, self.plugin_parent.onCmdRequest, profile=self.parent.profile) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + identities = [] + if nodeIdentifier == NS_COMMANDS and self.plugin_parent.answering.get(self.parent.profile): # we only add the identity if we have registred commands + identities.append(ID_CMD_LIST) + return [disco.DiscoFeature(NS_COMMANDS)] + identities + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + ret = [] + if nodeIdentifier == NS_COMMANDS: + for command in self.plugin_parent.answering.get(self.parent.profile,{}).values(): + if command.isAuthorised(requestor): + ret.append(disco.DiscoItem(self.parent.jid, command.node, command.getName())) #TODO: manage name language + return ret diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0054.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0054.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,591 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0054 +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) + +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import threads, defer +from twisted.words.protocols.jabber import jid, error +from twisted.words.xish import domish +from twisted.python.failure import Failure + +from zope.interface import implements + +from wokkel import disco, iwokkel + +from base64 import b64decode, b64encode +from hashlib import sha1 +from sat.core import exceptions +from sat.memory import persistent +import mimetypes +try: + from PIL import Image +except: + raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io") +from cStringIO import StringIO + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +AVATAR_PATH = "avatars" +AVATAR_DIM = (64, 64) # FIXME: dim are not adapted to modern resolutions ! + +IQ_GET = '/iq[@type="get"]' +NS_VCARD = 'vcard-temp' +VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests + +PRESENCE = '/presence' +NS_VCARD_UPDATE = 'vcard-temp:x:update' +VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' + +CACHED_DATA = {'avatar', 'nick'} +MAX_AGE = 60 * 60 * 24 * 365 + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0054 Plugin", + C.PI_IMPORT_NAME: "XEP-0054", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0045"], + C.PI_MAIN: "XEP_0054", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of vcard-temp""") +} + + +class XEP_0054(object): + #TODO: - check that nickname is ok + # - refactor the code/better use of Wokkel + # - get missing values + + def __init__(self, host): + log.info(_(u"Plugin XEP_0054 initialization")) + self.host = host + host.bridge.addMethod(u"avatarGet", u".plugin", in_sign=u'sbbs', out_sign=u's', method=self._getAvatar, async=True) + host.bridge.addMethod(u"avatarSet", u".plugin", in_sign=u'ss', out_sign=u'', method=self._setAvatar, async=True) + host.trigger.add(u"presence_available", self.presenceAvailableTrigger) + host.memory.setSignalOnUpdate(u"avatar") + host.memory.setSignalOnUpdate(u"nick") + + def getHandler(self, client): + return XEP_0054_handler(self) + + def isRoom(self, client, entity_jid): + """Tell if a jid is a MUC one + + @param entity_jid(jid.JID): full or bare jid of the entity check + @return (bool): True if the bare jid of the entity is a room jid + """ + try: + muc_plg = self.host.plugins['XEP-0045'] + except KeyError: + return False + + try: + muc_plg.checkRoomJoined(client, entity_jid.userhostJID()) + except exceptions.NotFound: + return False + else: + return True + + def getBareOrFull(self, client, jid_): + """use full jid if jid_ is an occupant of a room, bare jid else + + @param jid_(jid.JID): entity to test + @return (jid.JID): bare or full jid + """ + if jid_.resource: + if not self.isRoom(client, jid_): + return jid_.userhostJID() + return jid_ + + def presenceAvailableTrigger(self, presence_elt, client): + if client.jid.userhost() in client._cache_0054: + try: + avatar_hash = client._cache_0054[client.jid.userhost()]['avatar'] + except KeyError: + log.info(u"No avatar in cache for {}".format(client.jid.userhost())) + return True + x_elt = domish.Element((NS_VCARD_UPDATE, 'x')) + x_elt.addElement('photo', content=avatar_hash) + presence_elt.addChild(x_elt) + return True + + @defer.inlineCallbacks + def profileConnecting(self, client): + client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile) + yield client._cache_0054.load() + self._fillCachedValues(client.profile) + + def _fillCachedValues(self, profile): + #FIXME: this may need to be reworked + # the current naive approach keeps a map between all jids + # in persistent cache, then put avatar hashs in memory. + # Hashes should be shared between profiles (or not ? what + # if the avatar is different depending on who is requesting it + # this is not possible with vcard-tmp, but it is with XEP-0084). + # Loading avatar on demand per jid may be a option to investigate. + client = self.host.getClient(profile) + for jid_s, data in client._cache_0054.iteritems(): + jid_ = jid.JID(jid_s) + for name in CACHED_DATA: + try: + value = data[name] + if value is None: + log.error(u"{name} value for {jid_} is None, ignoring".format(name=name, jid_=jid_)) + continue + self.host.memory.updateEntityData(jid_, name, data[name], silent=True, profile_key=profile) + except KeyError: + pass + + def updateCache(self, client, jid_, name, value): + """update cache value + + save value in memory in case of change + @param jid_(jid.JID): jid of the owner of the vcard + @param name(str): name of the item which changed + @param value(unicode, None): new value of the item + None to delete + """ + jid_ = self.getBareOrFull(client, jid_) + jid_s = jid_.full() + + if value is None: + try: + self.host.memory.delEntityDatum(jid_, name, client.profile) + except (KeyError, exceptions.UnknownEntityError): + pass + if name in CACHED_DATA: + try: + del client._cache_0054[jid_s][name] + except KeyError: + pass + else: + client._cache_0054.force(jid_s) + else: + self.host.memory.updateEntityData(jid_, name, value, profile_key=client.profile) + if name in CACHED_DATA: + client._cache_0054.setdefault(jid_s, {})[name] = value + client._cache_0054.force(jid_s) + + def getCache(self, client, entity_jid, name): + """return cached value for jid + + @param entity_jid(jid.JID): target contact + @param name(unicode): name of the value ('nick' or 'avatar') + @return(unicode, None): wanted value or None""" + entity_jid = self.getBareOrFull(client, entity_jid) + try: + data = self.host.memory.getEntityData(entity_jid, [name], client.profile) + except exceptions.UnknownEntityError: + return None + return data.get(name) + + def savePhoto(self, client, photo_elt, entity_jid): + """Parse a photo_elt and save the picture""" + # XXX: this method is launched in a separate thread + try: + mime_type = unicode(photo_elt.elements(NS_VCARD, 'TYPE').next()) + except StopIteration: + log.warning(u"no MIME type found, assuming image/png") + mime_type = u"image/png" + else: + if not mime_type: + log.warning(u"empty MIME type, assuming image/png") + mime_type = u"image/png" + elif mime_type not in ("image/gif", "image/jpeg", "image/png"): + if mime_type == "image/x-png": + # XXX: this old MIME type is still used by some clients + mime_type = "image/png" + else: + # TODO: handle other image formats (svg?) + log.warning(u"following avatar image format is not handled: {type} [{jid}]".format( + type=mime_type, jid=entity_jid.full())) + raise Failure(exceptions.DataError()) + + ext = mimetypes.guess_extension(mime_type, strict=False) + assert ext is not None + if ext == u'.jpe': + ext = u'.jpg' + log.debug(u'photo of type {type} with extension {ext} found [{jid}]'.format( + type=mime_type, ext=ext, jid=entity_jid.full())) + try: + buf = str(photo_elt.elements(NS_VCARD, 'BINVAL').next()) + except StopIteration: + log.warning(u"BINVAL element not found") + raise Failure(exceptions.NotFound()) + if not buf: + log.warning(u"empty avatar for {jid}".format(jid=entity_jid.full())) + raise Failure(exceptions.NotFound()) + log.debug(_(u'Decoding binary')) + decoded = b64decode(buf) + del buf + image_hash = sha1(decoded).hexdigest() + with client.cache.cacheData( + PLUGIN_INFO['import_name'], + image_hash, + mime_type, + # we keep in cache for 1 year + MAX_AGE + ) as f: + f.write(decoded) + return image_hash + + @defer.inlineCallbacks + def vCard2Dict(self, client, vcard, entity_jid): + """Convert a VCard to a dict, and save binaries""" + log.debug((u"parsing vcard")) + vcard_dict = {} + + for elem in vcard.elements(): + if elem.name == 'FN': + vcard_dict['fullname'] = unicode(elem) + elif elem.name == 'NICKNAME': + vcard_dict['nick'] = unicode(elem) + self.updateCache(client, entity_jid, 'nick', vcard_dict['nick']) + elif elem.name == 'URL': + vcard_dict['website'] = unicode(elem) + elif elem.name == 'EMAIL': + vcard_dict['email'] = unicode(elem) + elif elem.name == 'BDAY': + vcard_dict['birthday'] = unicode(elem) + elif elem.name == 'PHOTO': + # TODO: handle EXTVAL + try: + avatar_hash = yield threads.deferToThread( + self.savePhoto, client, elem, entity_jid) + except (exceptions.DataError, exceptions.NotFound) as e: + avatar_hash = '' + vcard_dict['avatar'] = avatar_hash + except Exception as e: + log.error(u"avatar saving error: {}".format(e)) + avatar_hash = None + else: + vcard_dict['avatar'] = avatar_hash + self.updateCache(client, entity_jid, 'avatar', avatar_hash) + else: + log.debug(u'FIXME: [{}] VCard tag is not managed yet'.format(elem.name)) + + # if a data in cache doesn't exist anymore, we need to delete it + # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys) + # and we reset them + for datum in CACHED_DATA.difference(vcard_dict.keys()): + log.debug(u"reseting vcard datum [{datum}] for {entity}".format(datum=datum, entity=entity_jid.full())) + self.updateCache(client, entity_jid, datum, None) + + defer.returnValue(vcard_dict) + + def _vCardCb(self, vcard_elt, to_jid, client): + """Called after the first get IQ""" + log.debug(_("VCard found")) + iq_elt = vcard_elt.parent + try: + from_jid = jid.JID(iq_elt["from"]) + except KeyError: + from_jid = client.jid.userhostJID() + d = self.vCard2Dict(client, vcard_elt, from_jid) + return d + + def _vCardEb(self, failure_, to_jid, client): + """Called when something is wrong with registration""" + log.warning(u"Can't get vCard for {jid}: {failure}".format(jid=to_jid.full, failure=failure_)) + self.updateCache(client, to_jid, "avatar", None) + + def _getVcardElt(self, iq_elt): + return iq_elt.elements(NS_VCARD, "vCard").next() + + def getCardRaw(self, client, entity_jid): + """get raw vCard XML + + params are as in [getCard] + """ + entity_jid = self.getBareOrFull(client, entity_jid) + log.debug(u"Asking for {}'s VCard".format(entity_jid.full())) + reg_request = client.IQ('get') + reg_request["from"] = client.jid.full() + reg_request["to"] = entity_jid.full() + reg_request.addElement('vCard', NS_VCARD) + d = reg_request.send(entity_jid.full()) + d.addCallback(self._getVcardElt) + return d + + def getCard(self, client, entity_jid): + """Ask server for VCard + + @param entity_jid(jid.JID): jid from which we want the VCard + @result: id to retrieve the profile + """ + d = self.getCardRaw(client, entity_jid) + d.addCallbacks(self._vCardCb, self._vCardEb, callbackArgs=[entity_jid, client], errbackArgs=[entity_jid, client]) + return d + + def _getCardCb(self, dummy, client, entity): + try: + return client._cache_0054[entity.full()]['avatar'] + except KeyError: + raise Failure(exceptions.NotFound()) + + def _getAvatar(self, entity, cache_only, hash_only, profile): + client = self.host.getClient(profile) + d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only) + d.addErrback(lambda dummy: '') + + return d + + def getAvatar(self, client, entity, cache_only=True, hash_only=False): + """get avatar full path or hash + + if avatar is not in local cache, it will be requested to the server + @param entity(jid.JID): entity to get avatar from + @param cache_only(bool): if False, will request vCard if avatar is + not in cache + @param hash_only(bool): if True only return hash, not full path + @raise exceptions.NotFound: no avatar found + """ + if not entity.resource and self.isRoom(client, entity): + raise exceptions.NotFound + entity = self.getBareOrFull(client, entity) + full_path = None + + try: + # we first check if we have avatar in cache + avatar_hash = client._cache_0054[entity.full()]['avatar'] + if avatar_hash: + # avatar is known and exists + full_path = client.cache.getFilePath(avatar_hash) + if full_path is None: + # cache file is not available (probably expired) + raise KeyError + else: + # avatar has already been checked but it is not set + full_path = u'' + except KeyError: + # avatar is not in cache + if cache_only: + return defer.fail(Failure(exceptions.NotFound())) + # we request vCard to get avatar + d = self.getCard(client, entity) + d.addCallback(self._getCardCb, client, entity) + else: + # avatar is in cache, we can return hash + d = defer.succeed(avatar_hash) + + if not hash_only: + # full path is requested + if full_path is None: + d.addCallback(client.cache.getFilePath) + else: + d.addCallback(lambda dummy: full_path) + return d + + @defer.inlineCallbacks + def getNick(self, client, entity): + """get nick from cache, or check vCard + + @param entity(jid.JID): entity to get nick from + @return(unicode, None): nick or None if not found + """ + nick = self.getCache(client, entity, u'nick') + if nick is not None: + defer.returnValue(nick) + yield self.getCard(client, entity) + defer.returnValue(self.getCache(client, entity, u'nick')) + + @defer.inlineCallbacks + def setNick(self, client, nick): + """update our vCard and set a nickname + + @param nick(unicode): new nickname to use + """ + jid_ = client.jid.userhostJID() + try: + vcard_elt = yield self.getCardRaw(client, jid_) + except error.StanzaError as e: + if e.condition == 'item-not-found': + vcard_elt = domish.Element((NS_VCARD, 'vCard')) + else: + raise e + try: + nickname_elt = next(vcard_elt.elements(NS_VCARD, u'NICKNAME')) + except StopIteration: + pass + else: + vcard_elt.children.remove(nickname_elt) + + nickname_elt = vcard_elt.addElement((NS_VCARD, u'NICKNAME'), content=nick) + iq_elt = client.IQ() + vcard_elt = iq_elt.addChild(vcard_elt) + yield iq_elt.send() + self.updateCache(client, jid_, u'nick', unicode(nick)) + + def _buildSetAvatar(self, client, vcard_elt, file_path): + # XXX: this method is executed in a separate thread + try: + img = Image.open(file_path) + except IOError: + return Failure(exceptions.DataError(u"Can't open image")) + + if img.size != AVATAR_DIM: + img.thumbnail(AVATAR_DIM) + if img.size[0] != img.size[1]: # we need to crop first + left, upper = (0, 0) + right, lower = img.size + offset = abs(right - lower) / 2 + if right == min(img.size): + upper += offset + lower -= offset + else: + left += offset + right -= offset + img = img.crop((left, upper, right, lower)) + img_buf = StringIO() + img.save(img_buf, 'PNG') + + photo_elt = vcard_elt.addElement('PHOTO') + photo_elt.addElement('TYPE', content='image/png') + photo_elt.addElement('BINVAL', content=b64encode(img_buf.getvalue())) + image_hash = sha1(img_buf.getvalue()).hexdigest() + with client.cache.cacheData( + PLUGIN_INFO['import_name'], + image_hash, + "image/png", + MAX_AGE + ) as f: + f.write(img_buf.getvalue()) + return image_hash + + def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.setAvatar(client, file_path) + + @defer.inlineCallbacks + def setAvatar(self, client, file_path): + """Set avatar of the profile + + @param file_path: path of the image of the avatar + """ + try: + # we first check if a vcard already exists, to keep data + vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID()) + except error.StanzaError as e: + if e.condition == 'item-not-found': + vcard_elt = domish.Element((NS_VCARD, 'vCard')) + else: + raise e + else: + # the vcard exists, we need to remove PHOTO element as we'll make a new one + try: + photo_elt = next(vcard_elt.elements(NS_VCARD, u'PHOTO')) + except StopIteration: + pass + else: + vcard_elt.children.remove(photo_elt) + + iq_elt = client.IQ() + iq_elt.addChild(vcard_elt) + image_hash = yield threads.deferToThread(self._buildSetAvatar, client, vcard_elt, file_path) + # image is now at the right size/format + + self.updateCache(client, client.jid.userhostJID(), 'avatar', image_hash) + yield iq_elt.send() + client.presence.available() # FIXME: should send the current presence, not always "available" ! + + +class XEP_0054_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + self.xmlstream.addObserver(VCARD_UPDATE, self.update) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_VCARD)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + + def _checkAvatarHash(self, dummy, client, entity, given_hash): + """check that hash in cash (i.e. computed hash) is the same as given one""" + # XXX: if they differ, the avater will be requested on each connection + # TODO: try to avoid re-requesting avatar in this case + computed_hash = self.plugin_parent.getCache(client, entity, 'avatar') + if computed_hash != given_hash: + log.warning(u"computed hash differs from given hash for {entity}:\n" + "computed: {computed}\ngiven: {given}".format( + entity=entity, computed=computed_hash, given=given_hash)) + + def update(self, presence): + """Called on stanza with vcard data + + Check for avatar information, and get VCard if needed + @param presend(domish.Element): stanza + """ + client = self.parent + entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence['from'])) + #FIXME: wokkel's data_form should be used here + try: + x_elt = presence.elements(NS_VCARD_UPDATE, 'x').next() + except StopIteration: + return + + try: + photo_elt = x_elt.elements(NS_VCARD_UPDATE, 'photo').next() + except StopIteration: + return + + hash_ = unicode(photo_elt).strip() + if hash_ == C.HASH_SHA1_EMPTY: + hash_ = u'' + old_avatar = self.plugin_parent.getCache(client, entity_jid, 'avatar') + + if old_avatar == hash_: + # no change, we can return... + if hash_: + # ...but we double check that avatar is in cache + file_path = client.cache.getFilePath(hash_) + if file_path is None: + log.error(u"Avatar for [{}] should be in cache but it is not! We get it".format(entity_jid.full())) + self.plugin_parent.getCard(client, entity_jid) + else: + log.debug(u"avatar for {} already in cache".format(entity_jid.full())) + return + + if not hash_: + # the avatar has been removed + # XXX: we use empty string instead of None to indicate that we took avatar + # but it is empty on purpose + self.plugin_parent.updateCache(client, entity_jid, 'avatar', '') + return + + file_path = client.cache.getFilePath(hash_) + if file_path is not None: + log.debug(u"New avatar found for [{}], it's already in cache, we use it".format(entity_jid.full())) + self.plugin_parent.updateCache(client, entity_jid, 'avatar', hash_) + else: + log.debug(u'New avatar found for [{}], requesting vcard'.format(entity_jid.full())) + d = self.plugin_parent.getCard(client, entity_jid) + d.addCallback(self._checkAvatarHash, client, entity_jid, hash_) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0055.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0055.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,461 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Jabber Search (xep-0055) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.log import getLogger +log = getLogger(__name__) + +from twisted.words.protocols.jabber.xmlstream import IQ +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from wokkel import data_form +from sat.core.constants import Const as C +from sat.core.exceptions import DataError +from sat.tools import xml_tools + +from wokkel import disco, iwokkel +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implements + + +NS_SEARCH = 'jabber:iq:search' + +PLUGIN_INFO = { + C.PI_NAME: "Jabber Search", + C.PI_IMPORT_NAME: "XEP-0055", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0055"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0059"], + C.PI_MAIN: "XEP_0055", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of Jabber Search""") +} + +# config file parameters +CONFIG_SECTION = "plugin search" +CONFIG_SERVICE_LIST = "service_list" + +DEFAULT_SERVICE_LIST = ["salut.libervia.org"] + +FIELD_SINGLE = "field_single" # single text field for the simple search +FIELD_CURRENT_SERVICE = "current_service_jid" # read-only text field for the advanced search + +class XEP_0055(object): + + def __init__(self, host): + log.info(_("Jabber search plugin initialization")) + self.host = host + + # default search services (config file + hard-coded lists) + self.services = [jid.JID(entry) for entry in host.memory.getConfig(CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST)] + + host.bridge.addMethod("searchGetFieldsUI", ".plugin", in_sign='ss', out_sign='s', + method=self._getFieldsUI, + async=True) + host.bridge.addMethod("searchRequest", ".plugin", in_sign='sa{ss}s', out_sign='s', + method=self._searchRequest, + async=True) + + self.__search_menu_id = host.registerCallback(self._getMainUI, with_data=True) + host.importMenu((D_("Contacts"), D_("Search directory")), self._getMainUI, security_limit=1, help_string=D_("Search user directory")) + + def _getHostServices(self, profile): + """Return the jabber search services associated to the user host. + + @param profile (unicode): %(doc_profile)s + @return: list[jid.JID] + """ + client = self.host.getClient(profile) + d = self.host.findFeaturesSet(client, [NS_SEARCH]) + return d.addCallback(lambda set_: list(set_)) + + + ## Main search UI (menu item callback) ## + + + def _getMainUI(self, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # check if the user's server offers some search services + d = self._getHostServices(profile) + return d.addCallback(lambda services: self.getMainUI(services, raw_data, profile)) + + def getMainUI(self, services, raw_data, profile): + """Get the XMLUI for selecting a service and searching the directory. + + @param services (list[jid.JID]): search services offered by the user server + @param raw_data (dict): data received from the frontend + @param profile (unicode): %(doc_profile)s + @return: a deferred XMLUI string representation + """ + # extend services offered by user's server with the default services + services.extend([service for service in self.services if service not in services]) + data = xml_tools.XMLUIResult2DataFormResult(raw_data) + main_ui = xml_tools.XMLUI(C.XMLUI_WINDOW, container="tabs", title=_("Search users"), submit_id=self.__search_menu_id) + + d = self._addSimpleSearchUI(services, main_ui, data, profile) + d.addCallback(lambda dummy: self._addAdvancedSearchUI(services, main_ui, data, profile)) + return d.addCallback(lambda dummy: {'xmlui': main_ui.toXml()}) + + def _addSimpleSearchUI(self, services, main_ui, data, profile): + """Add to the main UI a tab for the simple search. + + Display a single input field and search on the main service (it actually does one search per search field and then compile the results). + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a dummy Deferred + """ + service_jid = services[0] # TODO: search on all the given services, not only the first one + + form = data_form.Form('form', formNamespace=NS_SEARCH) + form.addField(data_form.Field('text-single', FIELD_SINGLE, label=_('Search for'), value=data.get(FIELD_SINGLE, ''))) + + sub_cont = main_ui.main_container.addTab("simple_search", label=_("Simple search"), container=xml_tools.VerticalContainer) + main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui))) + xml_tools.dataForm2Widgets(main_ui, form) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,)) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + + simple_data = {key: value for key, value in data.iteritems() if key in (FIELD_SINGLE,)} + if simple_data: + log.debug("Simple search with %s on %s" % (simple_data, service_jid)) + sub_cont.parent.setSelected(True) + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider('dash') + d = self.searchRequest(service_jid, simple_data, profile) + d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage())) + return d + + return defer.succeed(None) + + def _addAdvancedSearchUI(self, services, main_ui, data, profile): + """Add to the main UI a tab for the advanced search. + + Display a service selector and allow to search on all the fields that are implemented by the selected service. + + @param services (list[jid.JID]): search services offered by the user server + @param main_ui (XMLUI): the main XMLUI instance + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a dummy Deferred + """ + sub_cont = main_ui.main_container.addTab("advanced_search", label=_("Advanced search"), container=xml_tools.VerticalContainer) + service_selection_fields = ['service_jid', 'service_jid_extra'] + + if 'service_jid_extra' in data: + # refresh button has been pushed, select the tab + sub_cont.parent.setSelected(True) + # get the selected service + service_jid_s = data.get('service_jid_extra', '') + if not service_jid_s: + service_jid_s = data.get('service_jid', unicode(services[0])) + log.debug("Refreshing search fields for %s" % service_jid_s) + else: + service_jid_s = data.get(FIELD_CURRENT_SERVICE, unicode(services[0])) + services_s = [unicode(service) for service in services] + if service_jid_s not in services_s: + services_s.append(service_jid_s) + + main_ui.changeContainer(sub_cont.append(xml_tools.PairsContainer(main_ui))) + main_ui.addLabel(_("Search on")) + main_ui.addList('service_jid', options=services_s, selected=service_jid_s) + main_ui.addLabel(_("Other service")) + main_ui.addString(name='service_jid_extra') + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Refresh fields"), service_selection_fields) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + main_ui.addLabel(_("Displaying the search form for")) + main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True) + main_ui.addDivider('dash') + main_ui.addDivider('dash') + + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + service_jid = jid.JID(service_jid_s) + d = self.getFieldsUI(service_jid, profile) + d.addCallbacks(self._addAdvancedForm, lambda failure: main_ui.addText(failure.getErrorMessage()), + [service_jid, main_ui, sub_cont, data, profile]) + return d + + def _addAdvancedForm(self, form_elt, service_jid, main_ui, sub_cont, data, profile): + """Add the search form and the search results (if there is some to display). + + @param form_elt (domish.Element): form element listing the fields + @param service_jid (jid.JID): current search service + @param main_ui (XMLUI): the main XMLUI instance + @param sub_cont (Container): the container of the current tab + @param data (dict): form data without SAT_FORM_PREFIX + @param profile (unicode): %(doc_profile)s + + @return: a dummy Deferred + """ + field_list = data_form.Form.fromElement(form_elt).fieldList + adv_fields = [field.var for field in field_list if field.var] + adv_data = {key: value for key, value in data.iteritems() if key in adv_fields} + + xml_tools.dataForm2Widgets(main_ui, data_form.Form.fromElement(form_elt)) + + # refill the submitted values + # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now + for widget in main_ui.current_container.elem.childNodes: + name = widget.getAttribute("name") + if adv_data.get(name): + widget.setAttribute("value", adv_data[name]) + + # FIXME: add colspan attribute to divider? (we are in a PairsContainer) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # here we added a blank line before the button + main_ui.addDivider('blank') + main_ui.addButton(self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE]) + main_ui.addDivider('blank') + main_ui.addDivider('blank') # a blank line again after the button + + if adv_data: # display the search results + log.debug("Advanced search with %s on %s" % (adv_data, service_jid)) + sub_cont.parent.setSelected(True) + main_ui.changeContainer(sub_cont.append(xml_tools.VerticalContainer(main_ui))) + main_ui.addDivider('dash') + d = self.searchRequest(service_jid, adv_data, profile) + d.addCallbacks(lambda elt: self._displaySearchResult(main_ui, elt), + lambda failure: main_ui.addText(failure.getErrorMessage())) + return d + + return defer.succeed(None) + + + def _displaySearchResult(self, main_ui, elt): + """Display the search results. + + @param main_ui (XMLUI): the main XMLUI instance + @param elt (domish.Element): form result element + """ + if [child for child in elt.children if child.name == "item"]: + headers, xmlui_data = xml_tools.dataFormEltResult2XMLUIData(elt) + if "jid" in headers: # use XMLUI JidsListWidget to display the results + values = {} + for i in range(len(xmlui_data)): + header = headers.keys()[i % len(headers)] + widget_type, widget_args, widget_kwargs = xmlui_data[i] + value = widget_args[0] + values.setdefault(header, []).append(jid.JID(value) if header == "jid" else value) + main_ui.addJidsList(jids=values["jid"], name=D_(u"Search results")) + # TODO: also display the values other than JID + else: + xml_tools.XMLUIData2AdvancedList(main_ui, headers, xmlui_data) + else: + main_ui.addText(D_("The search gave no result")) + + + ## Retrieve the search fields ## + + + def _getFieldsUI(self, to_jid_s, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI instance + """ + d = self.getFieldsUI(jid.JID(to_jid_s), profile_key) + d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml()) + return d + + def getFieldsUI(self, to_jid, profile_key): + """Ask a service to send us the list of the form fields it manages. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + client = self.host.getClient(profile_key) + fields_request = IQ(client.xmlstream, 'get') + fields_request["from"] = client.jid.full() + fields_request["to"] = to_jid.full() + fields_request.addElement('query', NS_SEARCH) + d = fields_request.send(to_jid.full()) + d.addCallbacks(self._getFieldsUICb, self._getFieldsUIEb) + return d + + def _getFieldsUICb(self, answer): + """Callback for self.getFieldsUI. + + @param answer (domish.Element): search query element + @return: domish.Element + """ + try: + query_elts = answer.elements('jabber:iq:search', 'query').next() + except StopIteration: + log.info(_("No query element found")) + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + try: + form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next() + except StopIteration: + log.info(_("No data form found")) + raise NotImplementedError("Only search through data form is implemented so far") + return form_elt + + def _getFieldsUIEb(self, failure): + """Errback to self.getFieldsUI. + + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Fields request failure: %s") % unicode(failure.getErrorMessage())) + raise failure + + + ## Do the search ## + + + def _searchRequest(self, to_jid_s, search_data, profile_key): + """Actually do a search, according to filled data. + + @param to_jid_s (unicode): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred XMLUI string representation + """ + d = self.searchRequest(jid.JID(to_jid_s), search_data, profile_key) + d.addCallback(lambda form: xml_tools.dataFormEltResult2XMLUI(form).toXml()) + return d + + def searchRequest(self, to_jid, search_data, profile_key): + """Actually do a search, according to filled data. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param search_data (dict): filled data, corresponding to the form obtained in getFieldsUI + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + if FIELD_SINGLE in search_data: + value = search_data[FIELD_SINGLE] + d = self.getFieldsUI(to_jid, profile_key) + d.addCallback(lambda elt: self.searchRequestMulti(to_jid, value, elt, profile_key)) + return d + + client = self.host.getClient(profile_key) + search_request = IQ(client.xmlstream, 'set') + search_request["from"] = client.jid.full() + search_request["to"] = to_jid.full() + query_elt = search_request.addElement('query', NS_SEARCH) + x_form = data_form.Form('submit', formNamespace=NS_SEARCH) + x_form.makeFields(search_data) + query_elt.addChild(x_form.toElement()) + # TODO: XEP-0059 could be used here (with the needed new method attributes) + d = search_request.send(to_jid.full()) + d.addCallbacks(self._searchOk, self._searchErr) + return d + + def searchRequestMulti(self, to_jid, value, form_elt, profile_key): + """Search for a value simultaneously in all fields, returns the results compilation. + + @param to_jid (jid.JID): XEP-0055 compliant search entity + @param value (unicode): value to search + @param form_elt (domish.Element): form element listing the fields + @param profile_key (unicode): %(doc_profile_key)s + @return: a deferred domish.Element + """ + form = data_form.Form.fromElement(form_elt) + d_list = [] + + for field in [field.var for field in form.fieldList if field.var]: + d_list.append(self.searchRequest(to_jid, {field: value}, profile_key)) + + def cb(result): # return the results compiled in one domish element + result_elt = None + for success, form_elt in result: + if not success: + continue + if result_elt is None: # the result element is built over the first answer + result_elt = form_elt + continue + for item_elt in form_elt.elements('jabber:x:data', 'item'): + result_elt.addChild(item_elt) + if result_elt is None: + raise defer.failure.Failure(DataError(_("The search could not be performed"))) + return result_elt + + return defer.DeferredList(d_list).addCallback(cb) + + def _searchOk(self, answer): + """Callback for self.searchRequest. + + @param answer (domish.Element): search query element + @return: domish.Element + """ + try: + query_elts = answer.elements('jabber:iq:search', 'query').next() + except StopIteration: + log.info(_("No query element found")) + raise DataError # FIXME: StanzaError is probably more appropriate, check the RFC + try: + form_elt = query_elts.elements(data_form.NS_X_DATA, 'x').next() + except StopIteration: + log.info(_("No data form found")) + raise NotImplementedError("Only search through data form is implemented so far") + return form_elt + + def _searchErr(self, failure): + """Errback to self.searchRequest. + + @param failure (defer.failure.Failure): twisted failure + @raise: the unchanged defer.failure.Failure + """ + log.info(_("Search request failure: %s") % unicode(failure.getErrorMessage())) + raise failure + + +class XEP_0055_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_SEARCH)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0059.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0059.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,62 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Result Set Management (XEP-0059) +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) + +from wokkel import disco +from wokkel import iwokkel +from wokkel import rsm + +from twisted.words.protocols.jabber import xmlstream +from zope.interface import implements + + +PLUGIN_INFO = { + C.PI_NAME: "Result Set Management", + C.PI_IMPORT_NAME: "XEP-0059", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0059"], + C.PI_MAIN: "XEP_0059", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Result Set Management""") +} + + +class XEP_0059(object): + # XXX: RSM management is done directly in Wokkel. + + def __init__(self, host): + log.info(_("Result Set Management plugin initialization")) + + def getHandler(self, client): + return XEP_0059_handler() + + +class XEP_0059_handler(xmlstream.XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(rsm.NS_RSM)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0060.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0060.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,976 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Publish-Subscribe (xep-0060) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools import sat_defer + +from twisted.words.protocols.jabber import jid, error +from twisted.internet import defer +from wokkel import disco +from wokkel import data_form +from zope.interface import implements +from collections import namedtuple +import urllib +import datetime +from dateutil import tz +# XXX: sat_tmp.wokkel.pubsub is actually use instead of wokkel version +# mam and rsm come from sat_tmp.wokkel too +from wokkel import pubsub +from wokkel import rsm +from wokkel import mam + + +PLUGIN_INFO = { + C.PI_NAME: "Publish-Subscribe", + C.PI_IMPORT_NAME: "XEP-0060", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0060"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0313"], + C.PI_MAIN: "XEP_0060", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of PubSub Protocol""") +} + +UNSPECIFIED = "unspecified error" +MAM_FILTER = "mam_filter_" + + +Extra = namedtuple('Extra', ('rsm_request', 'extra')) +# rsm_request is the rsm.RSMRequest build with rsm_ prefixed keys, or None +# extra is a potentially empty dict + + +class XEP_0060(object): + OPT_ACCESS_MODEL = 'pubsub#access_model' + OPT_PERSIST_ITEMS = 'pubsub#persist_items' + OPT_MAX_ITEMS = 'pubsub#max_items' + OPT_DELIVER_PAYLOADS = 'pubsub#deliver_payloads' + OPT_SEND_ITEM_SUBSCRIBE = 'pubsub#send_item_subscribe' + OPT_NODE_TYPE = 'pubsub#node_type' + OPT_SUBSCRIPTION_TYPE = 'pubsub#subscription_type' + OPT_SUBSCRIPTION_DEPTH = 'pubsub#subscription_depth' + OPT_ROSTER_GROUPS_ALLOWED = 'pubsub#roster_groups_allowed' + OPT_PUBLISH_MODEL = 'pubsub#publish_model' + ACCESS_OPEN = 'open' + ACCESS_PRESENCE = 'presence' + ACCESS_ROSTER = 'roster' + ACCESS_PUBLISHER_ROSTER = 'publisher-roster' + ACCESS_AUTHORIZE = 'authorize' + ACCESS_WHITELIST = 'whitelist' + + def __init__(self, host): + log.info(_(u"PubSub plugin initialization")) + self.host = host + self._mam = host.plugins.get('XEP-0313') + self._node_cb = {} # dictionnary of callbacks for node (key: node, value: list of callbacks) + self.rt_sessions = sat_defer.RTDeferredSessions() + host.bridge.addMethod("psNodeCreate", ".plugin", in_sign='ssa{ss}s', out_sign='s', method=self._createNode, async=True) + host.bridge.addMethod("psNodeConfigurationGet", ".plugin", in_sign='sss', out_sign='a{ss}', method=self._getNodeConfiguration, async=True) + host.bridge.addMethod("psNodeConfigurationSet", ".plugin", in_sign='ssa{ss}s', out_sign='', method=self._setNodeConfiguration, async=True) + host.bridge.addMethod("psNodeAffiliationsGet", ".plugin", in_sign='sss', out_sign='a{ss}', method=self._getNodeAffiliations, async=True) + host.bridge.addMethod("psNodeAffiliationsSet", ".plugin", in_sign='ssa{ss}s', out_sign='', method=self._setNodeAffiliations, async=True) + host.bridge.addMethod("psNodeSubscriptionsGet", ".plugin", in_sign='sss', out_sign='a{ss}', method=self._getNodeSubscriptions, async=True) + host.bridge.addMethod("psNodeSubscriptionsSet", ".plugin", in_sign='ssa{ss}s', out_sign='', method=self._setNodeSubscriptions, async=True) + host.bridge.addMethod("psNodeDelete", ".plugin", in_sign='sss', out_sign='', method=self._deleteNode, async=True) + host.bridge.addMethod("psNodeWatchAdd", ".plugin", in_sign='sss', out_sign='', method=self._addWatch, async=False) + host.bridge.addMethod("psNodeWatchRemove", ".plugin", in_sign='sss', out_sign='', method=self._removeWatch, async=False) + host.bridge.addMethod("psAffiliationsGet", ".plugin", in_sign='sss', out_sign='a{ss}', method=self._getAffiliations, async=True) + host.bridge.addMethod("psItemsGet", ".plugin", in_sign='ssiassa{ss}s', out_sign='(asa{ss})', method=self._getItems, async=True) + host.bridge.addMethod("psItemSend", ".plugin", in_sign='ssssa{ss}s', out_sign='s', method=self._sendItem, async=True) + host.bridge.addMethod("psRetractItem", ".plugin", in_sign='sssbs', out_sign='', method=self._retractItem, async=True) + host.bridge.addMethod("psRetractItems", ".plugin", in_sign='ssasbs', out_sign='', method=self._retractItems, async=True) + host.bridge.addMethod("psSubscribe", ".plugin", in_sign='ssa{ss}s', out_sign='s', method=self._subscribe, async=True) + host.bridge.addMethod("psUnsubscribe", ".plugin", in_sign='sss', out_sign='', method=self._unsubscribe, async=True) + host.bridge.addMethod("psSubscriptionsGet", ".plugin", in_sign='sss', out_sign='aa{ss}', method=self._subscriptions, async=True) + host.bridge.addMethod("psSubscribeToMany", ".plugin", in_sign='a(ss)sa{ss}s', out_sign='s', method=self._subscribeToMany) + host.bridge.addMethod("psGetSubscribeRTResult", ".plugin", in_sign='ss', out_sign='(ua(sss))', method=self._manySubscribeRTResult, async=True) + host.bridge.addMethod("psGetFromMany", ".plugin", in_sign='a(ss)ia{ss}s', out_sign='s', method=self._getFromMany) + host.bridge.addMethod("psGetFromManyRTResult", ".plugin", in_sign='ss', out_sign='(ua(sssasa{ss}))', method=self._getFromManyRTResult, async=True) + + # high level observer method + host.bridge.addSignal("psEvent", ".plugin", signature='ssssa{ss}s') # args: category, service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), data, profile + + # low level observer method, used if service/node is in watching list (see psNodeWatch* methods) + host.bridge.addSignal("psEventRaw", ".plugin", signature='sssass') # args: service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), list of item_xml, profile + + def getHandler(self, client): + client.pubsub_client = SatPubSubClient(self.host, self) + return client.pubsub_client + + @defer.inlineCallbacks + def profileConnected(self, client): + client.pubsub_watching = set() + try: + client.pubsub_service = jid.JID(self.host.memory.getConfig('', 'pubsub_service')) + except RuntimeError: + log.info(_(u"Can't retrieve pubsub_service from conf, we'll use first one that we find")) + client.pubsub_service = yield self.host.findServiceEntity(client, "pubsub", "service") + + def getFeatures(self, profile): + try: + client = self.host.getClient(profile) + except exceptions.ProfileNotSetError: + return {} + try: + return {'service': client.pubsub_service.full() if client.pubsub_service is not None else ''} + except AttributeError: + if self.host.isConnected(profile): + log.debug("Profile is not connected, service is not checked yet") + else: + log.error("Service should be available !") + return {} + + def parseExtra(self, extra): + """Parse extra dictionnary + + used bridge's extra dictionnaries + @param extra(dict): extra data used to configure request + @return(Extra): filled Extra instance + """ + if extra is None: + rsm_request = None + extra = {} + else: + # rsm + rsm_args = {} + for arg in ('max', 'after', 'before', 'index'): + try: + argname = "max_" if arg == 'max' else arg + rsm_args[argname] = extra.pop('rsm_{}'.format(arg)) + except KeyError: + continue + + if rsm_args: + rsm_request = rsm.RSMRequest(**rsm_args) + else: + rsm_request = None + + # mam + mam_args = {} + for arg in ('start', 'end'): + try: + mam_args[arg] = datetime.datetime.fromtimestamp(int(extra.pop('{}{}'.format(MAM_FILTER, arg))), tz.tzutc()) + except (TypeError, ValueError): + log.warning(u"Bad value for {} filter".format(arg)) + except KeyError: + continue + + try: + mam_args['with_jid'] = jid.JID(extra.pop('{}jid'.format(MAM_FILTER))) + except (jid.InvalidFormat): + log.warning(u"Bad value for jid filter") + except KeyError: + pass + + for name, value in extra.iteritems(): + if name.startswith(MAM_FILTER): + var = name[len(MAM_FILTER):] + extra_fields = mam_args.setdefault('extra_fields', []) + extra_fields.append(data_form.Field(var=var, value=value)) + + if mam_args: + assert 'mam' not in extra + extra['mam'] = mam.MAMRequest(mam.buildForm(**mam_args)) + return Extra(rsm_request, extra) + + def addManagedNode(self, node, **kwargs): + """Add a handler for a node + + @param node(unicode): node to monitor + all node *prefixed* with this one will be triggered + @param **kwargs: method(s) to call when the node is found + the method must be named after PubSub constants in lower case + and suffixed with "_cb" + e.g.: "items_cb" for C.PS_ITEMS, "delete_cb" for C.PS_DELETE + """ + assert node is not None + assert kwargs + callbacks = self._node_cb.setdefault(node, {}) + for event, cb in kwargs.iteritems(): + event_name = event[:-3] + assert event_name in C.PS_EVENTS + callbacks.setdefault(event_name,[]).append(cb) + + def removeManagedNode(self, node, *args): + """Add a handler for a node + + @param node(unicode): node to monitor + @param *args: callback(s) to remove + """ + assert args + try: + registred_cb = self._node_cb[node] + except KeyError: + pass + else: + for callback in args: + for event, cb_list in registred_cb.iteritems(): + try: + cb_list.remove(callback) + except ValueError: + pass + else: + log.debug(u"removed callback {cb} for event {event} on node {node}".format( + cb=callback, event=event, node=node)) + if not cb_list: + del registred_cb[event] + if not registred_cb: + del self._node_cb[node] + return + log.error(u"Trying to remove inexistant callback {cb} for node {node}".format(cb=callback, node=node)) + + # def listNodes(self, service, nodeIdentifier='', profile=C.PROF_KEY_NONE): + # """Retrieve the name of the nodes that are accessible on the target service. + + # @param service (JID): target service + # @param nodeIdentifier (str): the parent node name (leave empty to retrieve first-level nodes) + # @param profile (str): %(doc_profile)s + # @return: deferred which fire a list of nodes + # """ + # client = self.host.getClient(profile) + # d = self.host.getDiscoItems(client, service, nodeIdentifier) + # d.addCallback(lambda result: [item.getAttribute('node') for item in result.toElement().children if item.hasAttribute('node')]) + # return d + + # def listSubscribedNodes(self, service, nodeIdentifier='', filter_='subscribed', profile=C.PROF_KEY_NONE): + # """Retrieve the name of the nodes to which the profile is subscribed on the target service. + + # @param service (JID): target service + # @param nodeIdentifier (str): the parent node name (leave empty to retrieve all subscriptions) + # @param filter_ (str): filter the result according to the given subscription type: + # - None: do not filter + # - 'pending': subscription has not been approved yet by the node owner + # - 'unconfigured': subscription options have not been configured yet + # - 'subscribed': subscription is complete + # @param profile (str): %(doc_profile)s + # @return: Deferred list[str] + # """ + # d = self.subscriptions(service, nodeIdentifier, profile_key=profile) + # d.addCallback(lambda subs: [sub.getAttribute('node') for sub in subs if sub.getAttribute('subscription') == filter_]) + # return d + + def _sendItem(self, service, nodeIdentifier, payload, item_id=None, extra=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + d = self.sendItem(client, service, nodeIdentifier, payload, item_id or None, extra) + d.addCallback(lambda ret: ret or u'') + return d + + def _getPublishedItemId(self, iq_elt, original_id): + """return item of published id if found in answer + + if not found original_id is returned, or empty string if it is None or empty string + """ + try: + item_id = iq_elt.pubsub.publish.item['id'] + except (AttributeError, KeyError): + item_id = None + return item_id or original_id + + def sendItem(self, client, service, nodeIdentifier, payload, item_id=None, extra=None): + """high level method to send one item + + @param service(jid.JID, None): service to send the item to + None to use PEP + @param NodeIdentifier(unicode): PubSub node to use + @param item_id(unicode, None): id to use or None to create one + @param payload(domish.Element, unicode): payload of the item to send + @param extra(dict, None): extra option, not used yet + @return (unicode, None): id of the created item + """ + item_elt = pubsub.Item(id=item_id, payload=payload) + d = self.publish(client, service, nodeIdentifier, [item_elt]) + d.addCallback(self._getPublishedItemId, item_id) + return d + + def publish(self, client, service, nodeIdentifier, items=None): + return client.pubsub_client.publish(service, nodeIdentifier, items, client.pubsub_client.parent.jid) + + def _unwrapMAMMessage(self, message_elt): + try: + item_elt = (message_elt.elements(mam.NS_MAM, 'result').next() + .elements(C.NS_FORWARD, 'forwarded').next() + .elements(C.NS_CLIENT, 'message').next() + .elements('http://jabber.org/protocol/pubsub#event', 'event').next() + .elements('http://jabber.org/protocol/pubsub#event', 'items').next() + .elements('http://jabber.org/protocol/pubsub#event', 'item').next()) + except StopIteration: + raise exceptions.DataError(u"Can't find Item in MAM message element") + return item_elt + + def _getItems(self, service='', node='', max_items=10, item_ids=None, sub_id=None, extra_dict=None, profile_key=C.PROF_KEY_NONE): + """Get items from pubsub node + + @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit + """ + client = self.host.getClient(profile_key) + service = jid.JID(service) if service else None + max_items = None if max_items == C.NO_LIMIT else max_items + extra = self.parseExtra(extra_dict) + d = self.getItems(client, service, node or None, max_items or None, item_ids, sub_id or None, extra.rsm_request, extra.extra) + d.addCallback(self.serItemsData) + return d + + def getItems(self, client, service, node, max_items=None, item_ids=None, sub_id=None, rsm_request=None, extra=None): + """Retrieve pubsub items from a node. + + @param service (JID, None): pubsub service. + @param node (str): node id. + @param max_items (int): optional limit on the number of retrieved items. + @param item_ids (list[str]): identifiers of the items to be retrieved (can't be used with rsm_request). + @param sub_id (str): optional subscription identifier. + @param rsm_request (rsm.RSMRequest): RSM request data + @return: a deferred couple (list[dict], dict) containing: + - list of items + - metadata with the following keys: + - rsm_first, rsm_last, rsm_count, rsm_index: first, last, count and index value of RSMResponse + - service, node: service and node used + """ + if item_ids and max_items is not None: + max_items = None + if rsm_request and item_ids: + raise ValueError(u"items_id can't be used with rsm") + if extra is None: + extra = {} + try: + mam_query = extra['mam'] + except KeyError: + d = client.pubsub_client.items(service, node, max_items, item_ids, sub_id, None, rsm_request) + else: + # if mam is requested, we have to do a totally different query + if self._mam is None: + raise exceptions.NotFound(u"MAM (XEP-0313) plugin is not available") + if max_items is not None: + raise exceptions.DataError(u"max_items parameter can't be used with MAM") + if item_ids: + raise exceptions.DataError(u"items_ids parameter can't be used with MAM") + if mam_query.node is None: + mam_query.node = node + elif mam_query.node != node: + raise exceptions.DataError(u"MAM query node is incoherent with getItems's node") + if mam_query.rsm is None: + mam_query.rsm = rsm_request + else: + if mam_query.rsm != rsm_request: + raise exceptions.DataError(u"Conflict between RSM request and MAM's RSM request") + d = self._mam.getArchives(client, mam_query, service, self._unwrapMAMMessage) + + try: + subscribe = C.bool(extra['subscribe']) + except KeyError: + subscribe = False + + def subscribeEb(failure, service, node): + failure.trap(error.StanzaError) + log.warning(u"Could not subscribe to node {} on service {}: {}".format(node, unicode(service), unicode(failure.value))) + + def doSubscribe(items): + self.subscribe(service, node, profile_key=client.profile).addErrback(subscribeEb, service, node) + return items + + if subscribe: + d.addCallback(doSubscribe) + + def addMetadata(result): + items, rsm_response = result + service_jid = service if service else client.jid.userhostJID() + metadata = {'service': service_jid, + 'node': node, + 'uri': self.getNodeURI(service_jid, node), + } + if rsm_request is not None and rsm_response is not None: + metadata.update({'rsm_{}'.format(key): value for key, value in rsm_response.toDict().iteritems()}) + return (items, metadata) + + d.addCallback(addMetadata) + return d + + # @defer.inlineCallbacks + # def getItemsFromMany(self, service, data, max_items=None, sub_id=None, rsm=None, profile_key=C.PROF_KEY_NONE): + # """Massively retrieve pubsub items from many nodes. + # @param service (JID): target service. + # @param data (dict): dictionnary binding some arbitrary keys to the node identifiers. + # @param max_items (int): optional limit on the number of retrieved items *per node*. + # @param sub_id (str): optional subscription identifier. + # @param rsm (dict): RSM request data + # @param profile_key (str): %(doc_profile_key)s + # @return: a deferred dict with: + # - key: a value in (a subset of) data.keys() + # - couple (list[dict], dict) containing: + # - list of items + # - RSM response data + # """ + # client = self.host.getClient(profile_key) + # found_nodes = yield self.listNodes(service, profile=client.profile) + # d_dict = {} + # for publisher, node in data.items(): + # if node not in found_nodes: + # log.debug(u"Skip the items retrieval for [{node}]: node doesn't exist".format(node=node)) + # continue # avoid pubsub "item-not-found" error + # d_dict[publisher] = self.getItems(service, node, max_items, None, sub_id, rsm, client.profile) + # defer.returnValue(d_dict) + + def getOptions(self, service, nodeIdentifier, subscriber, subscriptionIdentifier=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return client.pubsub_client.getOptions(service, nodeIdentifier, subscriber, subscriptionIdentifier) + + def setOptions(self, service, nodeIdentifier, subscriber, options, subscriptionIdentifier=None, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return client.pubsub_client.setOptions(service, nodeIdentifier, subscriber, options, subscriptionIdentifier) + + def _createNode(self, service_s, nodeIdentifier, options, profile_key): + client = self.host.getClient(profile_key) + return self.createNode(client, jid.JID(service_s) if service_s else None, nodeIdentifier, options) + + def createNode(self, client, service, nodeIdentifier=None, options=None): + """Create a new node + + @param service(jid.JID): PubSub service, + @param NodeIdentifier(unicode, None): node name + use None to create instant node (identifier will be returned by this method) + @param option(dict[unicode, unicode], None): node configuration options + @return (unicode): identifier of the created node (may be different from requested name) + """ + # TODO: if pubsub service doesn't hande publish-options, configure it in a second time + return client.pubsub_client.createNode(service, nodeIdentifier, options) + + @defer.inlineCallbacks + def createIfNewNode(self, client, service, nodeIdentifier, options=None): + """Helper method similar to createNode, but will not fail in case of conflict""" + try: + yield self.createNode(client, service, nodeIdentifier, options) + except error.StanzaError as e: + if e.condition == 'conflict': + pass + else: + raise e + + def _getNodeConfiguration(self, service_s, nodeIdentifier, profile_key): + client = self.host.getClient(profile_key) + d = self.getConfiguration(client, jid.JID(service_s) if service_s else None, nodeIdentifier) + def serialize(form): + # FIXME: better more generic dataform serialisation should be available in SàT + return {f.var: unicode(f.value) for f in form.fields.values()} + d.addCallback(serialize) + return d + + def getConfiguration(self, client, service, nodeIdentifier): + request = pubsub.PubSubRequest('configureGet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + + def cb(iq): + form = data_form.findForm(iq.pubsub.configure, + pubsub.NS_PUBSUB_NODE_CONFIG) + form.typeCheck() + return form + + d = request.send(client.xmlstream) + d.addCallback(cb) + return d + + def _setNodeConfiguration(self, service_s, nodeIdentifier, options, profile_key): + client = self.host.getClient(profile_key) + d = self.setConfiguration(client, jid.JID(service_s) if service_s else None, nodeIdentifier, options) + return d + + def setConfiguration(self, client, service, nodeIdentifier, options): + request = pubsub.PubSubRequest('configureSet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + + form = data_form.Form(formType='submit', + formNamespace=pubsub.NS_PUBSUB_NODE_CONFIG) + form.makeFields(options) + request.options = form + + d = request.send(client.xmlstream) + return d + + def _getAffiliations(self, service_s, nodeIdentifier, profile_key): + client = self.host.getClient(profile_key) + d = self.getAffiliations(client, jid.JID(service_s) if service_s else None, nodeIdentifier or None) + return d + + def getAffiliations(self, client, service, nodeIdentifier=None): + """Retrieve affiliations of an entity + + @param nodeIdentifier(unicode, None): node to get affiliation from + None to get all nodes affiliations for this service + """ + request = pubsub.PubSubRequest('affiliations') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + + def cb(iq_elt): + try: + affiliations_elt = next(iq_elt.pubsub.elements((pubsub.NS_PUBSUB, 'affiliations'))) + except StopIteration: + raise ValueError(_(u"Invalid result: missing element: {}").format(iq_elt.toXml)) + try: + return {e['node']: e['affiliation'] for e in affiliations_elt.elements((pubsub.NS_PUBSUB, 'affiliation'))} + except KeyError: + raise ValueError(_(u"Invalid result: bad element: {}").format(iq_elt.toXml)) + + d = request.send(client.xmlstream) + d.addCallback(cb) + return d + + def _getNodeAffiliations(self, service_s, nodeIdentifier, profile_key): + client = self.host.getClient(profile_key) + d = self.getNodeAffiliations(client, jid.JID(service_s) if service_s else None, nodeIdentifier) + d.addCallback(lambda affiliations: {j.full(): a for j, a in affiliations.iteritems()}) + return d + + def getNodeAffiliations(self, client, service, nodeIdentifier): + """Retrieve affiliations of a node owned by profile""" + request = pubsub.PubSubRequest('affiliationsGet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + + def cb(iq_elt): + try: + affiliations_elt = next(iq_elt.pubsub.elements((pubsub.NS_PUBSUB_OWNER, 'affiliations'))) + except StopIteration: + raise ValueError(_(u"Invalid result: missing element: {}").format(iq_elt.toXml)) + try: + return {jid.JID(e['jid']): e['affiliation'] for e in affiliations_elt.elements((pubsub.NS_PUBSUB_OWNER, 'affiliation'))} + except KeyError: + raise ValueError(_(u"Invalid result: bad element: {}").format(iq_elt.toXml)) + + d = request.send(client.xmlstream) + d.addCallback(cb) + return d + + def _setNodeAffiliations(self, service_s, nodeIdentifier, affiliations, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + affiliations = {jid.JID(jid_): affiliation for jid_, affiliation in affiliations.iteritems()} + d = self.setNodeAffiliations(client, jid.JID(service_s) if service_s else None, nodeIdentifier, affiliations) + return d + + def setNodeAffiliations(self, client, service, nodeIdentifier, affiliations): + """Update affiliations of a node owned by profile + + @param affiliations(dict[jid.JID, unicode]): affiliations to set + check https://xmpp.org/extensions/xep-0060.html#affiliations for a list of possible affiliations + """ + request = pubsub.PubSubRequest('affiliationsSet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + request.affiliations = affiliations + d = request.send(client.xmlstream) + return d + + def _deleteNode(self, service_s, nodeIdentifier, profile_key): + client = self.host.getClient(profile_key) + return self.deleteNode(client, jid.JID(service_s) if service_s else None, nodeIdentifier) + + def deleteNode(self, client, service, nodeIdentifier): + return client.pubsub_client.deleteNode(service, nodeIdentifier) + + def _addWatch(self, service_s, node, profile_key): + """watch modifications on a node + + This method should only be called from bridge + """ + client = self.host.getClient(profile_key) + service = jid.JID(service_s) if service_s else client.jid.userhostJID() + client.pubsub_watching.add((service, node)) + + def _removeWatch(self, service_s, node, profile_key): + """remove a node watch + + This method should only be called from bridge + """ + client = self.host.getClient(profile_key) + service = jid.JID(service_s) if service_s else client.jid.userhostJID() + client.pubsub_watching.remove((service, node)) + + def _retractItem(self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key): + return self._retractItems(service_s, nodeIdentifier, (itemIdentifier,), notify, profile_key) + + def _retractItems(self, service_s, nodeIdentifier, itemIdentifiers, notify, profile_key): + return self.retractItems(jid.JID(service_s) if service_s else None, nodeIdentifier, itemIdentifiers, notify, profile_key) + + def retractItems(self, service, nodeIdentifier, itemIdentifiers, notify=True, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return client.pubsub_client.retractItems(service, nodeIdentifier, itemIdentifiers, notify=True) + + def _subscribe(self, service, nodeIdentifier, options, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + d = self.subscribe(client, service, nodeIdentifier, options=options or None) + d.addCallback(lambda subscription: subscription.subscriptionIdentifier or u'') + return d + + def subscribe(self, client, service, nodeIdentifier, sub_jid=None, options=None): + # TODO: reimplement a subscribtion cache, checking that we have not subscription before trying to subscribe + return client.pubsub_client.subscribe(service, nodeIdentifier, sub_jid or client.jid.userhostJID(), options=options) + + def _unsubscribe(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + return self.unsubscribe(client, service, nodeIdentifier) + + def unsubscribe(self, client, service, nodeIdentifier, sub_jid=None, subscriptionIdentifier=None, sender=None): + return client.pubsub_client.unsubscribe(service, nodeIdentifier, sub_jid or client.jid.userhostJID(), subscriptionIdentifier, sender) + + def _subscriptions(self, service, nodeIdentifier='', profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + service = None if not service else jid.JID(service) + + def gotSubscriptions(subscriptions): + # we replace pubsub.Subscription instance by dict that we can serialize + for idx, sub in enumerate(subscriptions): + sub_dict = {'node': sub.nodeIdentifier, + 'subscriber': sub.subscriber.full(), + 'state': sub.state + } + if sub.subscriptionIdentifier is not None: + sub_dict['id'] = sub.subscriptionIdentifier + subscriptions[idx] = sub_dict + + return subscriptions + + d = self.subscriptions(client, service, nodeIdentifier or None) + d.addCallback(gotSubscriptions) + return d + + def subscriptions(self, client, service, nodeIdentifier=None): + """retrieve subscriptions from a service + + @param service(jid.JID): PubSub service + @param nodeIdentifier(unicode, None): node to check + None to get all subscriptions + """ + return client.pubsub_client.subscriptions(service, nodeIdentifier) + + ## misc tools ## + + def getNodeURI(self, service, node, item=None): + """Return XMPP URI of a PubSub node + + @param service(jid.JID): PubSub service + @param node(unicode): node + @return (unicode): URI of the node + """ + assert service is not None + # XXX: urllib.urlencode use "&" to separate value, while XMPP URL (cf. RFC 5122) + # use ";" as a separator. So if more than one value is used in query_data, + # urlencode MUST NOT BE USED. + query_data = [('node', node.encode('utf-8'))] + if item is not None: + query_data.append(('item', item.encode('utf-8'))) + return "xmpp:{service}?;{query}".format( + service=service.userhost(), + query=urllib.urlencode(query_data) + ).decode('utf-8') + + ## methods to manage several stanzas/jids at once ## + + # generic # + + def getRTResults(self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE): + return self.rt_sessions.getResults(session_id, on_success, on_error, profile) + + def serItemsData(self, items_data, item_cb=lambda item: item.toXml()): + """Helper method to serialise result from [getItems] + + the items_data must be a tuple(list[domish.Element], dict[unicode, unicode]) + as returned by [getItems]. metadata values are then casted to unicode and + each item is passed to items_cb + @param items_data(tuple): tuple returned by [getItems] + @param item_cb(callable): method to transform each item + @return (tuple): a serialised form ready to go throught bridge + """ + items, metadata = items_data + return [item_cb(item) for item in items], {key: unicode(value) for key, value in metadata.iteritems()} + + def serItemsDataD(self, items_data, item_cb): + """Helper method to serialise result from [getItems], deferred version + + the items_data must be a tuple(list[domish.Element], dict[unicode, unicode]) + as returned by [getItems]. metadata values are then casted to unicode and + each item is passed to items_cb + An errback is added to item_cb, and when it is fired the value is filtered from final items + @param items_data(tuple): tuple returned by [getItems] + @param item_cb(callable): method to transform each item (must return a deferred) + @return (tuple): a deferred which fire a serialised form ready to go throught bridge + """ + items, metadata = items_data + def eb(failure): + log.warning("Error while serialising/parsing item: {}".format(unicode(failure.value))) + d = defer.gatherResults([item_cb(item).addErrback(eb) for item in items]) + def finishSerialisation(serialised_items): + return [item for item in serialised_items if item is not None], {key: unicode(value) for key, value in metadata.iteritems()} + d.addCallback(finishSerialisation) + return d + + def serDList(self, results, failure_result=None): + """Serialise a DeferredList result + + @param results: DeferredList results + @param failure_result: value to use as value for failed Deferred + (default: empty tuple) + @return (list): list with: + - failure: empty in case of success, else error message + - result + """ + if failure_result is None: + failure_result = () + return [('', result) if success else (unicode(result.result) or UNSPECIFIED, failure_result) for success, result in results] + + # subscribe # + + def _getNodeSubscriptions(self, service_s, nodeIdentifier, profile_key): + client = self.host.getClient(profile_key) + d = self.getNodeSubscriptions(client, jid.JID(service_s) if service_s else None, nodeIdentifier) + d.addCallback(lambda subscriptions: {j.full(): a for j, a in subscriptions.iteritems()}) + return d + + def getNodeSubscriptions(self, client, service, nodeIdentifier): + """Retrieve subscriptions to a node + + @param nodeIdentifier(unicode): node to get subscriptions from + """ + if not nodeIdentifier: + raise exceptions.DataError("node identifier can't be empty") + request = pubsub.PubSubRequest('subscriptionsGet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + + def cb(iq_elt): + try: + subscriptions_elt = next(iq_elt.pubsub.elements((pubsub.NS_PUBSUB, 'subscriptions'))) + except StopIteration: + raise ValueError(_(u"Invalid result: missing element: {}").format(iq_elt.toXml)) + except AttributeError as e: + raise ValueError(_(u"Invalid result: {}").format(e)) + try: + return {jid.JID(s['jid']): s['subscription'] for s in subscriptions_elt.elements((pubsub.NS_PUBSUB, 'subscription'))} + except KeyError: + raise ValueError(_(u"Invalid result: bad element: {}").format(iq_elt.toXml)) + + d = request.send(client.xmlstream) + d.addCallback(cb) + return d + + def _setNodeSubscriptions(self, service_s, nodeIdentifier, subscriptions, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + subscriptions = {jid.JID(jid_): subscription for jid_, subscription in subscriptions.iteritems()} + d = self.setNodeSubscriptions(client, jid.JID(service_s) if service_s else None, nodeIdentifier, subscriptions) + return d + + def setNodeSubscriptions(self, client, service, nodeIdentifier, subscriptions): + """Set or update subscriptions of a node owned by profile + + @param subscriptions(dict[jid.JID, unicode]): subscriptions to set + check https://xmpp.org/extensions/xep-0060.html#substates for a list of possible subscriptions + """ + request = pubsub.PubSubRequest('subscriptionsSet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + request.subscriptions = {pubsub.Subscription(nodeIdentifier, jid_, state) for jid_, state in subscriptions.iteritems()} + d = request.send(client.xmlstream) + return d + + def _manySubscribeRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): + """Get real-time results for subcribeToManu session + + @param session_id: id of the real-time deferred session + @param return (tuple): (remaining, results) where: + - remaining is the number of still expected results + - results is a list of tuple(unicode, unicode, bool, unicode) with: + - service: pubsub service + - and node: pubsub node + - failure(unicode): empty string in case of success, error message else + @param profile_key: %(doc_profile_key)s + """ + profile = self.host.getClient(profile_key).profile + d = self.rt_sessions.getResults(session_id, on_success=lambda result:'', on_error=lambda failure:unicode(failure.value), profile=profile) + # we need to convert jid.JID to unicode with full() to serialise it for the bridge + d.addCallback(lambda ret: (ret[0], [(service.full(), node, '' if success else failure or UNSPECIFIED) + for (service, node), (success, failure) in ret[1].iteritems()])) + return d + + def _subscribeToMany(self, node_data, subscriber=None, options=None, profile_key=C.PROF_KEY_NONE): + return self.subscribeToMany([(jid.JID(service), unicode(node)) for service, node in node_data], jid.JID(subscriber), options, profile_key) + + def subscribeToMany(self, node_data, subscriber, options=None, profile_key=C.PROF_KEY_NONE): + """Subscribe to several nodes at once. + + @param node_data (iterable[tuple]): iterable of tuple (service, node) where: + - service (jid.JID) is the pubsub service + - node (unicode) is the node to subscribe to + @param subscriber (jid.JID): optional subscription identifier. + @param options (dict): subscription options + @param profile_key (str): %(doc_profile_key)s + @return (str): RT Deferred session id + """ + client = self.host.getClient(profile_key) + deferreds = {} + for service, node in node_data: + deferreds[(service, node)] = client.pubsub_client.subscribe(service, node, subscriber, options=options) + return self.rt_sessions.newSession(deferreds, client.profile) + # found_nodes = yield self.listNodes(service, profile=client.profile) + # subscribed_nodes = yield self.listSubscribedNodes(service, profile=client.profile) + # d_list = [] + # for nodeIdentifier in (set(nodeIdentifiers) - set(subscribed_nodes)): + # if nodeIdentifier not in found_nodes: + # log.debug(u"Skip the subscription to [{node}]: node doesn't exist".format(node=nodeIdentifier)) + # continue # avoid sat-pubsub "SubscriptionExists" error + # d_list.append(client.pubsub_client.subscribe(service, nodeIdentifier, sub_jid or client.pubsub_client.parent.jid.userhostJID(), options=options)) + # defer.returnValue(d_list) + + # get # + + def _getFromManyRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): + """Get real-time results for getFromMany session + + @param session_id: id of the real-time deferred session + @param profile_key: %(doc_profile_key)s + @param return (tuple): (remaining, results) where: + - remaining is the number of still expected results + - results is a list of tuple with + - service (unicode): pubsub service + - node (unicode): pubsub node + - failure (unicode): empty string in case of success, error message else + - items (list[s]): raw XML of items + - metadata(dict): serialised metadata + """ + profile = self.host.getClient(profile_key).profile + d = self.rt_sessions.getResults(session_id, + on_success=lambda result: ('', self.serItemsData(result)), + on_error=lambda failure: (unicode(failure.value) or UNSPECIFIED, ([],{})), + profile=profile) + d.addCallback(lambda ret: (ret[0], + [(service.full(), node, failure, items, metadata) + for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()])) + return d + + def _getFromMany(self, node_data, max_item=10, extra_dict=None, profile_key=C.PROF_KEY_NONE): + """ + @param max_item(int): maximum number of item to get, C.NO_LIMIT for no limit + """ + max_item = None if max_item == C.NO_LIMIT else max_item + extra = self.parseExtra(extra_dict) + return self.getFromMany([(jid.JID(service), unicode(node)) for service, node in node_data], max_item, extra.rsm_request, extra.extra, profile_key) + + def getFromMany(self, node_data, max_item=None, rsm_request=None, extra=None, profile_key=C.PROF_KEY_NONE): + """Get items from many nodes at once + + @param node_data (iterable[tuple]): iterable of tuple (service, node) where: + - service (jid.JID) is the pubsub service + - node (unicode) is the node to get items from + @param max_items (int): optional limit on the number of retrieved items. + @param rsm_request (RSMRequest): RSM request data + @param profile_key (unicode): %(doc_profile_key)s + @return (str): RT Deferred session id + """ + client = self.host.getClient(profile_key) + deferreds = {} + for service, node in node_data: + deferreds[(service, node)] = self.getItems(client, service, node, max_item, rsm_request=rsm_request, extra=extra) + return self.rt_sessions.newSession(deferreds, client.profile) + + +class SatPubSubClient(rsm.PubSubClient): + implements(disco.IDisco) + + def __init__(self, host, parent_plugin): + self.host = host + self.parent_plugin = parent_plugin + rsm.PubSubClient.__init__(self) + + def connectionInitialized(self): + rsm.PubSubClient.connectionInitialized(self) + + def _getNodeCallbacks(self, node, event): + """Generate callbacks from given node and event + + @param node(unicode): node used for the item + any registered node which prefix the node will match + @param event(unicode): one of C.PS_ITEMS, C.PS_RETRACT, C.PS_DELETE + @return (iterator[callable]): callbacks for this node/event + """ + for registered_node, callbacks_dict in self.parent_plugin._node_cb.iteritems(): + if not node.startswith(registered_node): + continue + try: + for callback in callbacks_dict[event]: + yield callback + except KeyError: + continue + + def itemsReceived(self, event): + log.debug(u"Pubsub items received") + for callback in self._getNodeCallbacks(event.nodeIdentifier, C.PS_ITEMS): + callback(self.parent, event) + client = self.parent + if (event.sender, event.nodeIdentifier) in client.pubsub_watching: + raw_items = [i.toXml() for i in event.items] + self.host.bridge.psEventRaw(event.sender.full(), event.nodeIdentifier, C.PS_ITEMS, raw_items, client.profile) + + def deleteReceived(self, event): + log.debug((u"Publish node deleted")) + for callback in self._getNodeCallbacks(event.nodeIdentifier, C.PS_DELETE): + callback(self.parent, event) + client = self.parent + if (event.sender, event.nodeIdentifier) in client.pubsub_watching: + self.host.bridge.psEventRaw(event.sender.full(), event.nodeIdentifier, C.PS_DELETE, [], client.profile) + + def subscriptions(self, service, nodeIdentifier, sender=None): + """Return the list of subscriptions to the given service and node. + + @param service: The publish subscribe service to retrieve the subscriptions from. + @type service: L{JID} + @param nodeIdentifier: The identifier of the node (leave empty to retrieve all subscriptions). + @type nodeIdentifier: C{unicode} + @return (list[pubsub.Subscription]): list of subscriptions + """ + request = pubsub.PubSubRequest('subscriptions') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + request.sender = sender + d = request.send(self.xmlstream) + + def cb(iq): + subs = [] + for subscription_elt in iq.pubsub.subscriptions.elements(pubsub.NS_PUBSUB, 'subscription'): + subscription = pubsub.Subscription(subscription_elt['node'], + jid.JID(subscription_elt['jid']), + subscription_elt['subscription'], + subscriptionIdentifier=subscription_elt.getAttribute('subid')) + subs.append(subscription) + return subs + + return d.addCallback(cb) + + def getDiscoInfo(self, requestor, service, nodeIdentifier=''): + disco_info = [] + self.host.trigger.point("PubSub Disco Info", disco_info, self.parent.profile) + return disco_info + + def getDiscoItems(self, requestor, service, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0065.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0065.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1258 @@ +#!/usr/bin/env python2 +#-*- coding: utf-8 -*- + +# SAT plugin for managing xep-0065 + +# Copyright (C) +# 2002, 2003, 2004 Dave Smith (dizzyd@jabber.org) +# 2007, 2008 Fabio Forno (xmpp:ff@jabber.bluendo.com) +# 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 . + +# -- + +# This module is based on proxy65 (http://code.google.com/p/proxy65), +# originaly written by David Smith and modified by Fabio Forno. +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (C) +# 2002-2004 Dave Smith (dizzyd@jabber.org) +# 2007-2008 Fabio Forno (xmpp:ff@jabber.bluendo.com) + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.tools import sat_defer +from twisted.internet import protocol +from twisted.internet import reactor +from twisted.internet import error as internet_error +from twisted.words.protocols.jabber import error as jabber_error +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import xmlstream +from twisted.internet import defer +from collections import namedtuple +import struct +import hashlib +import uuid + +from zope.interface import implements + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +from wokkel import disco, iwokkel + + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0065 Plugin", + C.PI_IMPORT_NAME: "XEP-0065", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0065"], + C.PI_DEPENDENCIES: ["IP"], + C.PI_RECOMMENDATIONS: ["NAT-PORT"], + C.PI_MAIN: "XEP_0065", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of SOCKS5 Bytestreams""") +} + +IQ_SET = '/iq[@type="set"]' +NS_BS = 'http://jabber.org/protocol/bytestreams' +BS_REQUEST = IQ_SET + '/query[@xmlns="' + NS_BS + '"]' +TIMER_KEY = 'timer' +DEFER_KEY = 'finished' # key of the deferred used to track session end +SERVER_STARTING_PORT = 0 # starting number for server port search (0 to ask automatic attribution) + +# priorities are candidates local priorities, must be a int between 0 and 65535 +PRIORITY_BEST_DIRECT = 10000 +PRIORITY_DIRECT = 5000 +PRIORITY_ASSISTED = 1000 +PRIORITY_PROXY = 0.2 # proxy is the last option for s5b +CANDIDATE_DELAY = 0.2 # see XEP-0260 §4 +CANDIDATE_DELAY_PROXY = 0.2 # additional time for proxy types (see XEP-0260 §4 note 3) + +TIMEOUT = 300 # maxium time between session creation and stream start + +# XXX: by default eveything is automatic +# TODO: use these params to force use of specific proxy/port/IP +# PARAMS = """ +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# """ + +(STATE_INITIAL, +STATE_AUTH, +STATE_REQUEST, +STATE_READY, +STATE_AUTH_USERPASS, +STATE_CLIENT_INITIAL, +STATE_CLIENT_AUTH, +STATE_CLIENT_REQUEST, +) = xrange(8) + +SOCKS5_VER = 0x05 + +ADDR_IPV4 = 0x01 +ADDR_DOMAINNAME = 0x03 +ADDR_IPV6 = 0x04 + +CMD_CONNECT = 0x01 +CMD_BIND = 0x02 +CMD_UDPASSOC = 0x03 + +AUTHMECH_ANON = 0x00 +AUTHMECH_USERPASS = 0x02 +AUTHMECH_INVALID = 0xFF + +REPLY_SUCCESS = 0x00 +REPLY_GENERAL_FAILUR = 0x01 +REPLY_CONN_NOT_ALLOWED = 0x02 +REPLY_NETWORK_UNREACHABLE = 0x03 +REPLY_HOST_UNREACHABLE = 0x04 +REPLY_CONN_REFUSED = 0x05 +REPLY_TTL_EXPIRED = 0x06 +REPLY_CMD_NOT_SUPPORTED = 0x07 +REPLY_ADDR_NOT_SUPPORTED = 0x08 + + +ProxyInfos = namedtuple("ProxyInfos", ['host', 'jid', 'port']) + + +class Candidate(object): + + def __init__(self, host, port, type_, priority, jid_, id_=None, priority_local=False, factory=None): + """ + @param host(unicode): host IP or domain + @param port(int): port + @param type_(unicode): stream type (one of XEP_0065.TYPE_*) + @param priority(int): priority + @param jid_(jid.JID): jid + @param id_(None, id_): Candidate ID, or None to generate + @param priority_local(bool): if True, priority is used as local priority, + else priority is used as global one (and local priority is set to 0) + """ + assert isinstance(jid_, jid.JID) + self.host, self.port, self.type, self.jid = ( + host, int(port), type_, jid_) + self.id = id_ if id_ is not None else unicode(uuid.uuid4()) + if priority_local: + self._local_priority = int(priority) + self._priority = self.calculatePriority() + else: + self._local_priority = 0 + self._priority = int(priority) + self.factory = factory + + def discard(self): + """Disconnect a candidate if it is connected + + Used to disconnect tryed client when they are discarded + """ + log.debug(u"Discarding {}".format(self)) + try: + self.factory.discard() + except AttributeError: + pass # no discard for Socks5ServerFactory + + @property + def local_priority(self): + return self._local_priority + + @property + def priority(self): + return self._priority + + def __str__(self): + # similar to __unicode__ but we don't show jid and we encode id + return "Candidate ({0.priority}): host={0.host} port={0.port} type={0.type}{id}".format( + self, + id=u" id={}".format(self.id if self.id is not None else u'').encode('utf-8', 'ignore'), + ) + + def __unicode__(self): + return u"Candidate ({0.priority}): host={0.host} port={0.port} jid={0.jid} type={0.type}{id}".format( + self, + id=u" id={}".format(self.id if self.id is not None else u''), + ) + + def __eq__(self, other): + # self.id is is not used in __eq__ as the same candidate can have + # different ids if proposed by initiator or responder + try: + return (self.host == other.host and + self.port == other.port and + self.jid == other.jid) + except (AttributeError, TypeError): + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def calculatePriority(self): + """Calculate candidate priority according to XEP-0260 §2.2 + + + @return (int): priority + """ + if self.type == XEP_0065.TYPE_DIRECT: + multiplier = 126 + elif self.type == XEP_0065.TYPE_ASSISTED: + multiplier = 120 + elif self.type == XEP_0065.TYPE_TUNEL: + multiplier = 110 + elif self.type == XEP_0065.TYPE_PROXY: + multiplier = 10 + else: + raise exceptions.InternalError(u"Unknown {} type !".format(self.type)) + return 2**16 * multiplier + self._local_priority + + def activate(self, sid, peer_jid, client): + """Activate the proxy candidate + + Send activation request as explained in XEP-0065 § 6.3.5 + Must only be used with proxy candidates + @param sid(unicode): session id (same as for getSessionHash) + @param peer_jid(jid.JID): jid of the other peer + @return (D(domish.Element)): IQ result (or error) + """ + assert self.type == XEP_0065.TYPE_PROXY + iq_elt = client.IQ() + iq_elt['to'] = self.jid.full() + query_elt = iq_elt.addElement((NS_BS, 'query')) + query_elt['sid'] = sid + query_elt.addElement('activate', content=peer_jid.full()) + return iq_elt.send() + + def startTransfer(self, session_hash=None): + if self.type == XEP_0065.TYPE_PROXY: + chunk_size = 4096 # Prosody's proxy reject bigger chunks by default + else: + chunk_size = None + self.factory.startTransfer(session_hash, chunk_size=chunk_size) + + +def getSessionHash(requester_jid, target_jid, sid): + """Calculate SHA1 Hash according to XEP-0065 §5.3.2 + + @param requester_jid(jid.JID): jid of the requester (the one which activate the proxy) + @param target_jid(jid.JID): jid of the target + @param sid(unicode): session id + @return (str): hash + """ + return hashlib.sha1((sid + requester_jid.full() + target_jid.full()).encode('utf-8')).hexdigest() + + +class SOCKSv5(protocol.Protocol): + CHUNK_SIZE = 2**16 + + def __init__(self, session_hash=None): + """ + @param session_hash(str): hash of the session + must only be used in client mode + """ + self.connection = defer.Deferred() # called when connection/auth is done + if session_hash is not None: + self.server_mode = False + self._session_hash = session_hash + self.state = STATE_CLIENT_INITIAL + else: + self.server_mode = True + self.state = STATE_INITIAL + self.buf = "" + self.supportedAuthMechs = [AUTHMECH_ANON] + self.supportedAddrs = [ADDR_DOMAINNAME] + self.enabledCommands = [CMD_CONNECT] + self.peersock = None + self.addressType = 0 + self.requestType = 0 + self._stream_object = None + self.active = False # set to True when protocol is actually used for transfer + # used by factories to know when the finished Deferred can be triggered + + @property + def stream_object(self): + if self._stream_object is None: + self._stream_object = self.getSession()['stream_object'] + if self.server_mode: + self._stream_object.registerProducer(self.transport, True) + return self._stream_object + + def getSession(self): + """Return session associated with this candidate + + @return (dict): session data + """ + if self.server_mode: + return self.factory.getSession(self._session_hash) + else: + return self.factory.getSession() + + def _startNegotiation(self): + log.debug("starting negotiation (client mode)") + self.state = STATE_CLIENT_AUTH + self.transport.write(struct.pack('!3B', SOCKS5_VER, 1, AUTHMECH_ANON)) + + def _parseNegotiation(self): + try: + # Parse out data + ver, nmethod = struct.unpack('!BB', self.buf[:2]) + methods = struct.unpack('%dB' % nmethod, self.buf[2:nmethod + 2]) + + # Ensure version is correct + if ver != 5: + self.transport.write(struct.pack('!BB', SOCKS5_VER, AUTHMECH_INVALID)) + self.transport.loseConnection() + return + + # Trim off front of the buffer + self.buf = self.buf[nmethod + 2:] + + # Check for supported auth mechs + for m in self.supportedAuthMechs: + if m in methods: + # Update internal state, according to selected method + if m == AUTHMECH_ANON: + self.state = STATE_REQUEST + elif m == AUTHMECH_USERPASS: + self.state = STATE_AUTH_USERPASS + # Complete negotiation w/ this method + self.transport.write(struct.pack('!BB', SOCKS5_VER, m)) + return + + # No supported mechs found, notify client and close the connection + log.warning(u"Unsupported authentication mechanism") + self.transport.write(struct.pack('!BB', SOCKS5_VER, AUTHMECH_INVALID)) + self.transport.loseConnection() + except struct.error: + pass + + def _parseUserPass(self): + try: + # Parse out data + ver, ulen = struct.unpack('BB', self.buf[:2]) + uname, = struct.unpack('%ds' % ulen, self.buf[2:ulen + 2]) + plen, = struct.unpack('B', self.buf[ulen + 2]) + password, = struct.unpack('%ds' % plen, self.buf[ulen + 3:ulen + 3 + plen]) + # Trim off fron of the buffer + self.buf = self.buf[3 + ulen + plen:] + # Fire event to authenticate user + if self.authenticateUserPass(uname, password): + # Signal success + self.state = STATE_REQUEST + self.transport.write(struct.pack('!BB', SOCKS5_VER, 0x00)) + else: + # Signal failure + self.transport.write(struct.pack('!BB', SOCKS5_VER, 0x01)) + self.transport.loseConnection() + except struct.error: + pass + + def sendErrorReply(self, errorcode): + # Any other address types are not supported + result = struct.pack('!BBBBIH', SOCKS5_VER, errorcode, 0, 1, 0, 0) + self.transport.write(result) + self.transport.loseConnection() + + def _parseRequest(self): + try: + # Parse out data and trim buffer accordingly + ver, cmd, rsvd, self.addressType = struct.unpack('!BBBB', self.buf[:4]) + + # Ensure we actually support the requested address type + if self.addressType not in self.supportedAddrs: + self.sendErrorReply(REPLY_ADDR_NOT_SUPPORTED) + return + + # Deal with addresses + if self.addressType == ADDR_IPV4: + addr, port = struct.unpack('!IH', self.buf[4:10]) + self.buf = self.buf[10:] + elif self.addressType == ADDR_DOMAINNAME: + nlen = ord(self.buf[4]) + addr, port = struct.unpack('!%dsH' % nlen, self.buf[5:]) + self.buf = self.buf[7 + len(addr):] + else: + # Any other address types are not supported + self.sendErrorReply(REPLY_ADDR_NOT_SUPPORTED) + return + + # Ensure command is supported + if cmd not in self.enabledCommands: + # Send a not supported error + self.sendErrorReply(REPLY_CMD_NOT_SUPPORTED) + return + + # Process the command + if cmd == CMD_CONNECT: + self.connectRequested(addr, port) + elif cmd == CMD_BIND: + self.bindRequested(addr, port) + else: + # Any other command is not supported + self.sendErrorReply(REPLY_CMD_NOT_SUPPORTED) + + except struct.error: + # The buffer is probably not complete, we need to wait more + return None + + def _makeRequest(self): + hash_ = self._session_hash + request = struct.pack('!5B%dsH' % len(hash_), SOCKS5_VER, CMD_CONNECT, 0, ADDR_DOMAINNAME, len(hash_), hash_, 0) + self.transport.write(request) + self.state = STATE_CLIENT_REQUEST + + def _parseRequestReply(self): + try: + ver, rep, rsvd, self.addressType = struct.unpack('!BBBB', self.buf[:4]) + # Ensure we actually support the requested address type + if self.addressType not in self.supportedAddrs: + self.sendErrorReply(REPLY_ADDR_NOT_SUPPORTED) + return + + # Deal with addresses + if self.addressType == ADDR_IPV4: + addr, port = struct.unpack('!IH', self.buf[4:10]) + self.buf = self.buf[10:] + elif self.addressType == ADDR_DOMAINNAME: + nlen = ord(self.buf[4]) + addr, port = struct.unpack('!%dsH' % nlen, self.buf[5:]) + self.buf = self.buf[7 + len(addr):] + else: + # Any other address types are not supported + self.sendErrorReply(REPLY_ADDR_NOT_SUPPORTED) + return + + # Ensure reply is OK + if rep != REPLY_SUCCESS: + self.loseConnection() + return + + self.state = STATE_READY + self.connection.callback(None) + + except struct.error: + # The buffer is probably not complete, we need to wait more + return None + + def connectionMade(self): + log.debug(u"Socks5 connectionMade (mode = {})".format("server" if self.state == STATE_INITIAL else "client")) + if self.state == STATE_CLIENT_INITIAL: + self._startNegotiation() + + def connectRequested(self, addr, port): + # Check that this session is expected + if not self.factory.addToSession(addr, self): + self.sendErrorReply(REPLY_CONN_REFUSED) + log.warning(u"Unexpected connection request received from {host}" + .format(host=self.transport.getPeer().host)) + return + self._session_hash = addr + self.connectCompleted(addr, 0) + + def startTransfer(self, chunk_size): + """Callback called when the result iq is received + + @param chunk_size(None, int): size of the buffer, or None for default + """ + self.active = True + if chunk_size is not None: + self.CHUNK_SIZE = chunk_size + log.debug(u"Starting file transfer") + d = self.stream_object.startStream(self.transport) + d.addCallback(self.streamFinished) + + def streamFinished(self, d): + log.info(_("File transfer completed, closing connection")) + self.transport.loseConnection() + + def connectCompleted(self, remotehost, remoteport): + if self.addressType == ADDR_IPV4: + result = struct.pack('!BBBBIH', SOCKS5_VER, REPLY_SUCCESS, 0, 1, remotehost, remoteport) + elif self.addressType == ADDR_DOMAINNAME: + result = struct.pack('!BBBBB%dsH' % len(remotehost), SOCKS5_VER, REPLY_SUCCESS, 0, + ADDR_DOMAINNAME, len(remotehost), remotehost, remoteport) + self.transport.write(result) + self.state = STATE_READY + + def bindRequested(self, addr, port): + pass + + def authenticateUserPass(self, user, passwd): + # FIXME: implement authentication and remove the debug printing a password + log.debug(u"User/pass: %s/%s" % (user, passwd)) + return True + + def dataReceived(self, buf): + if self.state == STATE_READY: + # Everything is set, we just have to write the incoming data + self.stream_object.write(buf) + if not self.active: + self.active = True + self.getSession()[TIMER_KEY].cancel() + return + + self.buf = self.buf + buf + if self.state == STATE_INITIAL: + self._parseNegotiation() + if self.state == STATE_AUTH_USERPASS: + self._parseUserPass() + if self.state == STATE_REQUEST: + self._parseRequest() + if self.state == STATE_CLIENT_REQUEST: + self._parseRequestReply() + if self.state == STATE_CLIENT_AUTH: + ver, method = struct.unpack('!BB', buf) + self.buf = self.buf[2:] + if ver != SOCKS5_VER or method != AUTHMECH_ANON: + self.transport.loseConnection() + else: + self._makeRequest() + + def connectionLost(self, reason): + log.debug(u"Socks5 connection lost: {}".format(reason.value)) + if self.state != STATE_READY: + self.connection.errback(reason) + if self.server_mode : + self.factory.removeFromSession(self._session_hash, self, reason) + + +class Socks5ServerFactory(protocol.ServerFactory): + protocol = SOCKSv5 + + def __init__(self, parent): + """ + @param parent(XEP_0065): XEP_0065 parent instance + """ + self.parent = parent + + def getSession(self, session_hash): + return self.parent.getSession(None, session_hash) + + def startTransfer(self, session_hash, chunk_size=None): + session = self.getSession(session_hash) + try: + protocol = session['protocols'][0] + except (KeyError, IndexError): + log.error(u"Can't start file transfer, can't find protocol") + else: + session[TIMER_KEY].cancel() + protocol.startTransfer(chunk_size) + + def addToSession(self, session_hash, protocol): + """Check is session_hash is valid, and associate protocol with it + + the session will be associated to the corresponding candidate + @param session_hash(str): hash of the session + @param protocol(SOCKSv5): protocol instance + @param return(bool): True if hash was valid (i.e. expected), False else + """ + try: + session_data = self.getSession(session_hash) + except KeyError: + return False + else: + session_data.setdefault('protocols', []).append(protocol) + return True + + def removeFromSession(self, session_hash, protocol, reason): + """Remove a protocol from session_data + + There can be several protocol instances while candidates are tried, they + have removed when candidate connection is closed + @param session_hash(str): hash of the session + @param protocol(SOCKSv5): protocol instance + @param reason(failure.Failure): reason of the removal + """ + try: + protocols = self.getSession(session_hash)['protocols'] + protocols.remove(protocol) + except (KeyError, ValueError): + log.error(u"Protocol not found in session while it should be there") + else: + if protocol.active: + # The active protocol has been removed, session is finished + if reason.check(internet_error.ConnectionDone): + self.getSession(session_hash)[DEFER_KEY].callback(None) + else: + self.getSession(session_hash)[DEFER_KEY].errback(reason) + + +class Socks5ClientFactory(protocol.ClientFactory): + protocol = SOCKSv5 + + def __init__(self, client, parent, session, session_hash): + """Init the Client Factory + + @param session(dict): session data + @param session_hash(unicode): hash used for peer_connection + hash is the same as hostname computed in XEP-0065 § 5.3.2 #1 + """ + self.session = session + self.session_hash = session_hash + self.client = client + self.connection = defer.Deferred() + self._protocol_instance = None + self.connector = None + + def discard(self): + """Disconnect the client + + Also set a discarded flag, which avoid to call the session Deferred + """ + self.connector.disconnect() + + def getSession(self): + return self.session + + def startTransfer(self, dummy=None, chunk_size=None): + self.session[TIMER_KEY].cancel() + self._protocol_instance.startTransfer(chunk_size) + + def clientConnectionFailed(self, connector, reason): + log.debug(u"Connection failed") + self.connection.errback(reason) + + def clientConnectionLost(self, connector, reason): + log.debug(_(u"Socks 5 client connection lost (reason: %s)") % reason.value) + if self._protocol_instance.active: + # This one was used for the transfer, than mean that + # the Socks5 session is finished + if reason.check(internet_error.ConnectionDone): + self.getSession()[DEFER_KEY].callback(None) + else: + self.getSession()[DEFER_KEY].errback(reason) + self._protocol_instance = None + + def buildProtocol(self, addr): + log.debug(("Socks 5 client connection started")) + p = self.protocol(session_hash=self.session_hash) + p.factory = self + p.connection.chainDeferred(self.connection) + self._protocol_instance = p + return p + + +class XEP_0065(object): + NAMESPACE = NS_BS + TYPE_DIRECT = 'direct' + TYPE_ASSISTED = 'assisted' + TYPE_TUNEL = 'tunel' + TYPE_PROXY = 'proxy' + Candidate = Candidate + + def __init__(self, host): + log.info(_("Plugin XEP_0065 initialization")) + self.host = host + + # session data + self.hash_clients_map = {} # key: hash of the transfer session, value: session data + self._cache_proxies = {} # key: server jid, value: proxy data + + # misc data + self._server_factory = None + self._external_port = None + + # plugins shortcuts + self._ip = self.host.plugins['IP'] + try: + self._np = self.host.plugins['NAT-PORT'] + except KeyError: + log.debug(u"NAT Port plugin not available") + self._np = None + + # parameters + # XXX: params are not used for now, but they may be used in the futur to force proxy/IP + # host.memory.updateParams(PARAMS) + + def getHandler(self, client): + return XEP_0065_handler(self) + + def profileConnected(self, client): + client.xep_0065_sid_session = {} # key: stream_id, value: session_data(dict) + client._s5b_sessions = {} + + def getSessionHash(self, from_jid, to_jid, sid): + return getSessionHash(from_jid, to_jid, sid) + + def getSocks5ServerFactory(self): + """Return server factory + + The server is created if it doesn't exists yet + self._server_factory_port is set on server creation + """ + + if self._server_factory is None: + self._server_factory = Socks5ServerFactory(self) + for port in xrange(SERVER_STARTING_PORT, 65356): + try: + listening_port = reactor.listenTCP(port, self._server_factory) + except internet_error.CannotListenError as e: + log.debug(u"Cannot listen on port {port}: {err_msg}{err_num}".format( + port=port, + err_msg=e.socketError.strerror, + err_num=u' (error code: {})'.format(e.socketError.errno), + )) + else: + self._server_factory_port = listening_port.getHost().port + break + + log.info(_("Socks5 Stream server launched on port {}").format(self._server_factory_port)) + return self._server_factory + + @defer.inlineCallbacks + def getProxy(self, client): + """Return the proxy available for this profile + + cache is used between clients using the same server + @return ((D)(ProxyInfos, None)): Found proxy infos, + or None if not acceptable proxy is found + """ + def notFound(server): + log.info(u"No proxy found on this server") + self._cache_proxies[server] = None + defer.returnValue(None) + server = client.jid.host + try: + defer.returnValue(self._cache_proxies[server]) + except KeyError: + pass + try: + proxy = (yield self.host.findServiceEntities(client, 'proxy', 'bytestreams')).pop() + except (defer.CancelledError, StopIteration, KeyError): + notFound(server) + iq_elt = client.IQ('get') + iq_elt['to'] = proxy.full() + iq_elt.addElement((NS_BS, 'query')) + + try: + result_elt = yield iq_elt.send() + except jabber_error.StanzaError as failure: + log.warning(u"Error while requesting proxy info on {jid}: {error}" + .format(proxy.full(), failure)) + notFound(server) + + try: + query_elt = result_elt.elements(NS_BS, 'query').next() + streamhost_elt = query_elt.elements(NS_BS, 'streamhost').next() + host = streamhost_elt['host'] + jid_ = streamhost_elt['jid'] + port = streamhost_elt['port'] + if not all((host, jid, port)): + raise KeyError + jid_ = jid.JID(jid_) + except (StopIteration, KeyError, RuntimeError, jid.InvalidFormat, AttributeError): + log.warning(u"Invalid proxy data received from {}".format(proxy.full())) + notFound(server) + + proxy_infos = self._cache_proxies[server] = ProxyInfos(host, jid_, port) + log.info(u"Proxy found: {}".format(proxy_infos)) + defer.returnValue(proxy_infos) + + @defer.inlineCallbacks + def _getNetworkData(self, client): + """Retrieve information about network + + @param client: %(doc_client)s + @return (D(tuple[local_port, external_port, local_ips, external_ip])): network data + """ + self.getSocks5ServerFactory() + local_port = self._server_factory_port + external_ip = yield self._ip.getExternalIP(client) + local_ips = yield self._ip.getLocalIPs(client) + + if external_ip is not None and self._external_port is None: + if external_ip != local_ips[0]: + log.info(u"We are probably behind a NAT") + if self._np is None: + log.warning(u"NAT port plugin not available, we can't map port") + else: + ext_port = yield self._np.mapPort(local_port, desc=u"SaT socks5 stream") + if ext_port is None: + log.warning(u"Can't map NAT port") + else: + self._external_port = ext_port + + defer.returnValue((local_port, self._external_port, local_ips, external_ip)) + + @defer.inlineCallbacks + def getCandidates(self, client): + """Return a list of our stream candidates + + @return (D(list[Candidate])): list of candidates, ordered by priority + """ + server_factory = yield self.getSocks5ServerFactory() + local_port, ext_port, local_ips, external_ip = yield self._getNetworkData(client) + proxy = yield self.getProxy(client) + + # its time to gather the candidates + candidates = [] + + # first the direct ones + + # the preferred direct connection + ip = local_ips.pop(0) + candidates.append(Candidate(ip, local_port, XEP_0065.TYPE_DIRECT, PRIORITY_BEST_DIRECT, client.jid, priority_local=True, factory=server_factory)) + for ip in local_ips: + candidates.append(Candidate(ip, local_port, XEP_0065.TYPE_DIRECT, PRIORITY_DIRECT, client.jid, priority_local=True, factory=server_factory)) + + # then the assisted one + if ext_port is not None: + candidates.append(Candidate(external_ip, ext_port, XEP_0065.TYPE_ASSISTED, PRIORITY_ASSISTED, client.jid, priority_local=True, factory=server_factory)) + + # finally the proxy + if proxy: + candidates.append(Candidate(proxy.host, proxy.port, XEP_0065.TYPE_PROXY, PRIORITY_PROXY, proxy.jid, priority_local=True)) + + # should be already sorted, but just in case the priorities get weird + candidates.sort(key=lambda c: c.priority, reverse=True) + defer.returnValue(candidates) + + def _addConnector(self, connector, candidate): + """Add connector used to connect to candidate, and return client factory's connection Deferred + + the connector can be used to disconnect the candidate, and returning the factory's connection Deferred allow to wait for connection completion + @param connector: a connector implementing IConnector + @param candidate(Candidate): candidate linked to the connector + @return (D): Deferred fired when factory connection is done or has failed + """ + candidate.factory.connector = connector + return candidate.factory.connection + + def connectCandidate(self, client, candidate, session_hash, peer_session_hash=None, delay=None): + """Connect to a candidate + + Connection will be done with a Socks5ClientFactory + @param candidate(Candidate): candidate to connect to + @param session_hash(unicode): hash of the session + hash is the same as hostname computed in XEP-0065 § 5.3.2 #1 + @param peer_session_hash(unicode, None): hash used with the peer + None to use session_hash. + None must be used in 2 cases: + - when XEP-0065 is used with XEP-0096 + - when a peer connect to a proxy *he proposed himself* + in practice, peer_session_hash is only used by tryCandidates + @param delay(None, float): optional delay to wait before connection, in seconds + @return (D): Deferred launched when TCP connection + Socks5 connection is done + """ + if peer_session_hash is None: + # for XEP-0065, only one hash is needed + peer_session_hash = session_hash + session = self.getSession(client, session_hash) + factory = Socks5ClientFactory(client, self, session, peer_session_hash) + candidate.factory = factory + if delay is None: + d = defer.succeed(candidate.host) + else: + d = sat_defer.DelayedDeferred(delay, candidate.host) + d.addCallback(reactor.connectTCP, candidate.port, factory) + d.addCallback(self._addConnector, candidate) + return d + + def tryCandidates(self, client, candidates, session_hash, peer_session_hash, connection_cb=None, connection_eb=None): + defers_list = [] + + for candidate in candidates: + delay = CANDIDATE_DELAY * len(defers_list) + if candidate.type == XEP_0065.TYPE_PROXY: + delay += CANDIDATE_DELAY_PROXY + d = self.connectCandidate(client, candidate, session_hash, peer_session_hash, delay) + if connection_cb is not None: + d.addCallback(lambda dummy, candidate=candidate, client=client: connection_cb(client, candidate)) + if connection_eb is not None: + d.addErrback(connection_eb, client, candidate) + defers_list.append(d) + + return defers_list + + def getBestCandidate(self, client, candidates, session_hash, peer_session_hash=None): + """Get best candidate (according to priority) which can connect + + @param candidates(iterable[Candidate]): candidates to test + @param session_hash(unicode): hash of the session + hash is the same as hostname computed in XEP-0065 § 5.3.2 #1 + @param peer_session_hash(unicode, None): hash of the other peer + only useful for XEP-0260, must be None for XEP-0065 streamhost candidates + @return (D(None, Candidate)): best candidate or None if none can connect + """ + defer_candidates = None + + def connectionCb(client, candidate): + log.info(u"Connection of {} successful".format(unicode(candidate))) + for idx, other_candidate in enumerate(candidates): + try: + if other_candidate.priority < candidate.priority: + log.debug(u"Cancelling {}".format(other_candidate)) + defer_candidates[idx].cancel() + except AttributeError: + assert other_candidate is None + + def connectionEb(failure, client, candidate): + if failure.check(defer.CancelledError): + log.debug(u"Connection of {} has been cancelled".format(candidate)) + else: + log.info(u"Connection of {candidate} Failed: {error}".format( + candidate = candidate, + error = failure.value)) + candidates[candidates.index(candidate)] = None + + def allTested(self): + log.debug(u"All candidates have been tested") + good_candidates = [c for c in candidates if c] + return good_candidates[0] if good_candidates else None + + defer_candidates = self.tryCandidates(client, candidates, session_hash, peer_session_hash, connectionCb, connectionEb) + d_list = defer.DeferredList(defer_candidates) + d_list.addCallback(allTested) + return d_list + + def _timeOut(self, session_hash, client): + """Called when stream was not started quickly enough + + @param session_hash(str): hash as returned by getSessionHash + @param client: %(doc_client)s + """ + log.info(u"Socks5 Bytestream: TimeOut reached") + session = self.getSession(client, session_hash) + session[DEFER_KEY].errback(exceptions.TimeOutError) + + def killSession(self, failure_, session_hash, sid, client): + """Clean the current session + + @param session_hash(str): hash as returned by getSessionHash + @param sid(None, unicode): session id + or None if self.xep_0065_sid_session was not used + @param client: %(doc_client)s + @param failure_(None, failure.Failure): None if eveything was fine, a failure else + @return (None, failure.Failure): failure_ is returned + """ + log.debug(u'Cleaning session with hash {hash}{id}: {reason}'.format( + hash=session_hash, + reason='' if failure_ is None else failure_.value, + id='' if sid is None else u' (id: {})'.format(sid), + )) + + try: + assert self.hash_clients_map[session_hash] == client + del self.hash_clients_map[session_hash] + except KeyError: + pass + + if sid is not None: + try: + del client.xep_0065_sid_session[sid] + except KeyError: + log.warning(u"Session id {} is unknown".format(sid)) + + try: + session_data = client._s5b_sessions[session_hash] + except KeyError: + log.warning(u"There is no session with this hash") + return + else: + del client._s5b_sessions[session_hash] + + try: + session_data['timer'].cancel() + except (internet_error.AlreadyCalled, internet_error.AlreadyCancelled): + pass + + return failure_ + + def startStream(self, client, stream_object, to_jid, sid): + """Launch the stream workflow + + @param streamProducer: stream_object to use + @param to_jid: JID of the recipient + @param sid: Stream session id + @param successCb: method to call when stream successfuly finished + @param failureCb: method to call when something goes wrong + @return (D): Deferred fired when session is finished + """ + session_data = self._createSession(client, stream_object, to_jid, sid, True) + + session_data[client] = client + + def gotCandidates(candidates): + session_data['candidates'] = candidates + iq_elt = client.IQ() + iq_elt["from"] = client.jid.full() + iq_elt["to"] = to_jid.full() + query_elt = iq_elt.addElement((NS_BS, 'query')) + query_elt['mode'] = 'tcp' + query_elt['sid'] = sid + + for candidate in candidates: + streamhost = query_elt.addElement('streamhost') + streamhost['host'] = candidate.host + streamhost['port'] = str(candidate.port) + streamhost['jid'] = candidate.jid.full() + log.debug(u"Candidate proposed: {}".format(candidate)) + + d = iq_elt.send() + args = [session_data, client] + d.addCallbacks(self._IQNegotiationCb, self._IQNegotiationEb, args, None, args) + + self.getCandidates(client).addCallback(gotCandidates) + return session_data[DEFER_KEY] + + def _IQNegotiationCb(self, iq_elt, session_data, client): + """Called when the result of open iq is received + + @param session_data(dict): data of the session + @param client: %(doc_client)s + @param iq_elt(domish.Element): result + """ + try: + query_elt = iq_elt.elements(NS_BS, 'query').next() + streamhost_used_elt = query_elt.elements(NS_BS, 'streamhost-used').next() + except StopIteration: + log.warning(u"No streamhost found in stream query") + # FIXME: must clean session + return + + streamhost_jid = jid.JID(streamhost_used_elt['jid']) + try: + candidate = (c for c in session_data['candidates'] if c.jid == streamhost_jid).next() + except StopIteration: + log.warning(u"Candidate [{jid}] is unknown !".format(jid=streamhost_jid.full())) + return + else: + log.info(u"Candidate choosed by target: {}".format(candidate)) + + if candidate.type == XEP_0065.TYPE_PROXY: + log.info(u"A Socks5 proxy is used") + d = self.connectCandidate(client, candidate, session_data['hash']) + d.addCallback(lambda dummy: candidate.activate(session_data['id'], session_data['peer_jid'], client)) + d.addErrback(self._activationEb) + else: + d = defer.succeed(None) + + d.addCallback(lambda dummy: candidate.startTransfer(session_data['hash'])) + + def _activationEb(self, failure): + log.warning(u"Proxy activation error: {}".format(failure.value)) + + def _IQNegotiationEb(self, stanza_err, session_data, client): + log.warning(u"Socks5 transfer failed: {}".format(stanza_err.value)) + # FIXME: must clean session + + def createSession(self, *args, **kwargs): + """like [_createSession] but return the session deferred instead of the whole session + + session deferred is fired when transfer is finished + """ + return self._createSession(*args, **kwargs)[DEFER_KEY] + + def _createSession(self, client, stream_object, to_jid, sid, requester=False): + """Called when a bytestream is imminent + + @param stream_object(iface.IStreamProducer): File object where data will be written + @param to_jid(jid.JId): jid of the other peer + @param sid(unicode): session id + @param initiator(bool): if True, this session is create by initiator + @return (dict): session data + """ + if sid in client.xep_0065_sid_session: + raise exceptions.ConflictError(u'A session with this id already exists !') + if requester: + session_hash = getSessionHash(client.jid, to_jid, sid) + session_data = self._registerHash(client, session_hash, stream_object) + else: + session_hash = getSessionHash(to_jid, client.jid, sid) + session_d = defer.Deferred() + session_d.addBoth(self.killSession, session_hash, sid, client) + session_data = client._s5b_sessions[session_hash] = { + DEFER_KEY: session_d, + TIMER_KEY: reactor.callLater(TIMEOUT, self._timeOut, session_hash, client), + } + client.xep_0065_sid_session[sid] = session_data + session_data.update( + {'id': sid, + 'peer_jid': to_jid, + 'stream_object': stream_object, + 'hash': session_hash, + }) + + return session_data + + def getSession(self, client, session_hash): + """Return session data + + @param session_hash(unicode): hash of the session + hash is the same as hostname computed in XEP-0065 § 5.3.2 #1 + @param client(None, SatXMPPClient): client of the peer + None is used only if client is unknown (this is only the case + for incoming request received by Socks5ServerFactory). None must + only be used by Socks5ServerFactory. + See comments below for details + @return (dict): session data + """ + if client is None: + try: + client = self.hash_clients_map[session_hash] + except KeyError as e: + log.warning(u"The requested session doesn't exists !") + raise e + return client._s5b_sessions[session_hash] + + def registerHash(self, *args, **kwargs): + """like [_registerHash] but return the session deferred instead of the whole session + session deferred is fired when transfer is finished + """ + return self._registerHash(*args, **kwargs)[DEFER_KEY] + + def _registerHash(self, client, session_hash, stream_object): + """Create a session_data associated to hash + + @param session_hash(str): hash of the session + @param stream_object(iface.IStreamProducer, IConsumer, None): file-like object + None if it will be filled later + return (dict): session data + """ + assert session_hash not in client._s5b_sessions + session_d = defer.Deferred() + session_d.addBoth(self.killSession, session_hash, None, client) + session_data = client._s5b_sessions[session_hash] = { + DEFER_KEY: session_d, + TIMER_KEY: reactor.callLater(TIMEOUT, self._timeOut, session_hash, client), + } + + if stream_object is not None: + session_data['stream_object'] = stream_object + + assert session_hash not in self.hash_clients_map + self.hash_clients_map[session_hash] = client + + return session_data + + def associateStreamObject(self, client, session_hash, stream_object): + """Associate a stream object with a session""" + session_data = self.getSession(client, session_hash) + assert 'stream_object' not in session_data + session_data['stream_object'] = stream_object + + def streamQuery(self, iq_elt, client): + log.debug(u"BS stream query") + + iq_elt.handled = True + + query_elt = iq_elt.elements(NS_BS, 'query').next() + try: + sid = query_elt['sid'] + except KeyError: + log.warning(u"Invalid bystreams request received") + return client.sendError(iq_elt, "bad-request") + + streamhost_elts = list(query_elt.elements(NS_BS, 'streamhost')) + if not streamhost_elts: + return client.sendError(iq_elt, "bad-request") + + try: + session_data = client.xep_0065_sid_session[sid] + except KeyError: + log.warning(u"Ignoring unexpected BS transfer: {}".format(sid)) + return client.sendError(iq_elt, 'not-acceptable') + + peer_jid = session_data["peer_jid"] = jid.JID(iq_elt["from"]) + + candidates = [] + nb_sh = len(streamhost_elts) + for idx, sh_elt in enumerate(streamhost_elts): + try: + host, port, jid_ = sh_elt['host'], sh_elt['port'], jid.JID(sh_elt['jid']) + except KeyError: + log.warning(u"malformed streamhost element") + return client.sendError(iq_elt, "bad-request") + priority = nb_sh - idx + if jid_.userhostJID() != peer_jid.userhostJID(): + type_ = XEP_0065.TYPE_PROXY + else: + type_ = XEP_0065.TYPE_DIRECT + candidates.append(Candidate(host, port, type_, priority, jid_)) + + for candidate in candidates: + log.info(u"Candidate proposed: {}".format(candidate)) + + d = self.getBestCandidate(client, candidates, session_data['hash']) + d.addCallback(self._ackStream, iq_elt, session_data, client) + + def _ackStream(self, candidate, iq_elt, session_data, client): + if candidate is None: + log.info("No streamhost candidate worked, we have to end negotiation") + return client.sendError(iq_elt, 'item-not-found') + log.info(u"We choose: {}".format(candidate)) + result_elt = xmlstream.toResponse(iq_elt, 'result') + query_elt = result_elt.addElement((NS_BS, 'query')) + query_elt['sid'] = session_data['id'] + streamhost_used_elt = query_elt.addElement('streamhost-used') + streamhost_used_elt['jid'] = candidate.jid.full() + client.send(result_elt) + + +class XEP_0065_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + self.xmlstream.addObserver(BS_REQUEST, self.plugin_parent.streamQuery, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_BS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0070.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0070.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,162 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0070 +# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk) + +# 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 . +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols import jabber +log = getLogger(__name__) +from sat.tools import xml_tools + +from wokkel import disco, iwokkel +from zope.interface import implements +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + + +NS_HTTP_AUTH = 'http://jabber.org/protocol/http-auth' + +IQ = 'iq' +IQ_GET = '/'+IQ+'[@type="get"]' +IQ_HTTP_AUTH_REQUEST = IQ_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]' + +MSG = 'message' +MSG_GET = '/'+MSG+'[@type="normal"]' +MSG_HTTP_AUTH_REQUEST = MSG_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]' + + +PLUGIN_INFO = { + C.PI_NAME: "XEP-0070 Plugin", + C.PI_IMPORT_NAME: "XEP-0070", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0070"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0070", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of HTTP Requests via XMPP""") +} + + +class XEP_0070(object): + """ + Implementation for XEP 0070. + """ + + def __init__(self, host): + log.info(_(u"Plugin XEP_0070 initialization")) + self.host = host + self._dictRequest = dict() + + def getHandler(self, client): + return XEP_0070_handler(self, client.profile) + + def onHttpAuthRequestIQ(self, iq_elt, client): + """This method is called on confirmation request received (XEP-0070 #4.5) + + @param iq_elt: IQ element + @param client: %(doc_client)s + """ + log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (iq)")) + self._treatHttpAuthRequest(iq_elt, IQ, client) + + def onHttpAuthRequestMsg(self, msg_elt, client): + """This method is called on confirmation request received (XEP-0070 #4.5) + + @param msg_elt: message element + @param client: %(doc_client)s + """ + log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (message)")) + self._treatHttpAuthRequest(msg_elt, MSG, client) + + def _treatHttpAuthRequest(self, elt, stanzaType, client): + elt.handled = True + auth_elt = elt.elements(NS_HTTP_AUTH, 'confirm').next() + auth_id = auth_elt['id'] + auth_method = auth_elt['method'] + auth_url = auth_elt['url'] + self._dictRequest[client] = (auth_id, auth_method, auth_url, stanzaType, elt) + + confirm_ui = xml_tools.XMLUI("form", title=D_(u"Auth confirmation"), submit_id='') + confirm_ui.addText(D_(u"{} needs to validate your identity, do you agree ?".format(auth_url))) + confirm_ui.addText(D_(u"Validation code : {}".format(auth_id))) + confirm_ui.addText(D_(u"Please check that this code is the same as on {}".format(auth_url))) + confirm_ui.addText(u"") + confirm_ui.addText(D_(u"Submit to authorize, cancel otherwise.")) + d = xml_tools.deferredUI(self.host, confirm_ui, chained=False) + d.addCallback(self._authRequestCallback, client.profile) + self.host.actionNew({u"xmlui": confirm_ui.toXml()}, profile=client.profile) + + def _authRequestCallback(self, result, profile): + client = self.host.getClient(profile) + try: + cancelled = result['cancelled'] + except KeyError: + cancelled = False + + authorized = False + + if cancelled: + auth_id, auth_method, auth_url, stanzaType, elt = self._dictRequest[client] + del self._dictRequest[client] + authorized = False + else: + try: + auth_id, auth_method, auth_url, stanzaType, elt = self._dictRequest[client] + del self._dictRequest[client] + authorized = True + except KeyError: + authorized = False + + if authorized: + if (stanzaType == IQ): + # iq + log.debug(_(u"XEP-0070 reply iq")) + iq_result_elt = xmlstream.toResponse(elt, 'result') + client.send(iq_result_elt) + elif (stanzaType == MSG): + # message + log.debug(_(u"XEP-0070 reply message")) + msg_result_elt = xmlstream.toResponse(elt, 'result') + msg_result_elt.addChild(elt.elements(NS_HTTP_AUTH, 'confirm').next()) + client.send(msg_result_elt) + else: + log.debug(_(u"XEP-0070 reply error")) + result_elt = jabber.error.StanzaError("not-authorized").toResponse(elt) + client.send(result_elt) + + +class XEP_0070_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def connectionInitialized(self): + self.xmlstream.addObserver(IQ_HTTP_AUTH_REQUEST, self.plugin_parent.onHttpAuthRequestIQ, client=self.parent) + self.xmlstream.addObserver(MSG_HTTP_AUTH_REQUEST, self.plugin_parent.onHttpAuthRequestMsg, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_HTTP_AUTH)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0071.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0071.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,280 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Publish-Subscribe (xep-0071) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools.common import data_format + +from twisted.internet import defer +from wokkel import disco, iwokkel +from zope.interface import implements +# from lxml import etree +try: + from lxml import html +except ImportError: + raise exceptions.MissingModule(u"Missing module lxml, please download/install it from http://lxml.de/") +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +NS_XHTML_IM = 'http://jabber.org/protocol/xhtml-im' +NS_XHTML = 'http://www.w3.org/1999/xhtml' + +PLUGIN_INFO = { + C.PI_NAME: "XHTML-IM Plugin", + C.PI_IMPORT_NAME: "XEP-0071", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0071"], + C.PI_DEPENDENCIES: ["TEXT-SYNTAXES"], + C.PI_MAIN: "XEP_0071", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of XHTML-IM""") +} + +allowed = { + "a": set(["href", "style", "type"]), + "blockquote": set(["style"]), + "body": set(["style"]), + "br": set([]), + "cite": set(["style"]), + "em": set([]), + "img": set(["alt", "height", "src", "style", "width"]), + "li": set(["style"]), + "ol": set(["style"]), + "p": set(["style"]), + "span": set(["style"]), + "strong": set([]), + "ul": set(["style"]), + } + +styles_allowed = ["background-color", "color", "font-family", "font-size", "font-style", "font-weight", "margin-left", "margin-right", "text-align", "text-decoration"] + +blacklist = ['script'] # tag that we have to kill (we don't keep content) + + +class XEP_0071(object): + SYNTAX_XHTML_IM = "XHTML-IM" + + def __init__(self, host): + log.info(_("XHTML-IM plugin initialization")) + self.host = host + self._s = self.host.plugins["TEXT-SYNTAXES"] + self._s.addSyntax(self.SYNTAX_XHTML_IM, lambda xhtml: xhtml, self.XHTML2XHTML_IM, [self._s.OPT_HIDDEN]) + host.trigger.add("MessageReceived", self.messageReceivedTrigger) + host.trigger.add("sendMessage", self.sendMessageTrigger) + + def getHandler(self, client): + return XEP_0071_handler(self) + + def _messagePostTreat(self, data, message_elt, body_elts, client): + """Callback which manage the post treatment of the message in case of XHTML-IM found + + @param data: data send by MessageReceived trigger through post_treat deferred + @param message_elt: whole stanza + @param body_elts: XHTML-IM body elements found + @return: the data with the extra parameter updated + """ + # TODO: check if text only body is empty, then try to convert XHTML-IM to pure text and show a warning message + def converted(xhtml, lang): + if lang: + data['extra']['xhtml_{}'.format(lang)] = xhtml + else: + data['extra']['xhtml'] = xhtml + + defers = [] + for body_elt in body_elts: + lang = body_elt.getAttribute((C.NS_XML, 'lang'), '') + treat_d = defer.succeed(None) # deferred used for treatments + if self.host.trigger.point("xhtml_post_treat", client, message_elt, body_elt, lang, treat_d): + continue + treat_d.addCallback(lambda dummy: self._s.convert(body_elt.toXml(), self.SYNTAX_XHTML_IM, safe=True)) + treat_d.addCallback(converted, lang) + defers.append(treat_d) + + d_list = defer.DeferredList(defers) + d_list.addCallback(lambda dummy: data) + return d_list + + def _fill_body_text(self, text, data, lang): + data['message'][lang or ''] = text + message_elt = data['xml'] + body_elt = message_elt.addElement("body", content=text) + if lang: + body_elt[(C.NS_XML, 'lang')] = lang + + def _check_body_text(self, data, lang, markup, syntax, defers): + """check if simple text message exists, and fill if needed""" + if not (lang or '') in data['message']: + d = self._s.convert(markup, syntax, self._s.SYNTAX_TEXT) + d.addCallback(self._fill_body_text, data, lang) + defers.append(d) + + def _sendMessageAddRich(self, data, client): + """ Construct XHTML-IM node and add it XML element + + @param data: message data as sended by sendMessage callback + """ + # at this point, either ['extra']['rich'] or ['extra']['xhtml'] exists + # but both can't exist at the same time + message_elt = data['xml'] + html_elt = message_elt.addElement((NS_XHTML_IM, 'html')) + + def syntax_converted(xhtml_im, lang): + body_elt = html_elt.addElement((NS_XHTML, 'body')) + if lang: + body_elt[(C.NS_XML, 'lang')] = lang + data['extra']['xhtml_{}'.format(lang)] = xhtml_im + else: + data['extra']['xhtml'] = xhtml_im + body_elt.addRawXml(xhtml_im) + + syntax = self._s.getCurrentSyntax(client.profile) + defers = [] + if u'xhtml' in data['extra']: + # we have directly XHTML + for lang, xhtml in data_format.getSubDict('xhtml', data['extra']): + self._check_body_text(data, lang, xhtml, self._s.SYNTAX_XHTML, defers) + d = self._s.convert(xhtml, self._s.SYNTAX_XHTML, self.SYNTAX_XHTML_IM) + d.addCallback(syntax_converted, lang) + defers.append(d) + elif u'rich' in data['extra']: + # we have rich syntax to convert + for lang, rich_data in data_format.getSubDict('rich', data['extra']): + self._check_body_text(data, lang, rich_data, syntax, defers) + d = self._s.convert(rich_data, syntax, self.SYNTAX_XHTML_IM) + d.addCallback(syntax_converted, lang) + defers.append(d) + else: + exceptions.InternalError(u"xhtml or rich should be present at this point") + d_list = defer.DeferredList(defers) + d_list.addCallback(lambda dummy: data) + return d_list + + def messageReceivedTrigger(self, client, message, post_treat): + """ Check presence of XHTML-IM in message + """ + try: + html_elt = message.elements(NS_XHTML_IM, 'html').next() + except StopIteration: + # No XHTML-IM + pass + else: + body_elts = html_elt.elements(NS_XHTML, 'body') + post_treat.addCallback(self._messagePostTreat, message, body_elts, client) + return True + + def sendMessageTrigger(self, client, data, pre_xml_treatments, post_xml_treatments): + """ Check presence of rich text in extra """ + rich = {} + xhtml = {} + for key, value in data['extra'].iteritems(): + if key.startswith('rich'): + rich[key[5:]] = value + elif key.startswith('xhtml'): + xhtml[key[6:]] = value + if rich and xhtml: + raise exceptions.DataError(_(u"Can't have XHTML and rich content at the same time")) + if rich or xhtml: + if rich: + data['rich'] = rich + else: + data['xhtml'] = xhtml + post_xml_treatments.addCallback(self._sendMessageAddRich, client) + return True + + def _purgeStyle(self, styles_raw): + """ Remove unauthorised styles according to the XEP-0071 + @param styles_raw: raw styles (value of the style attribute) + """ + purged = [] + + styles = [style.strip().split(':') for style in styles_raw.split(';')] + + for style_tuple in styles: + if len(style_tuple) != 2: + continue + name, value = style_tuple + name = name.strip() + if name not in styles_allowed: + continue + purged.append((name, value.strip())) + + return u'; '.join([u"%s: %s" % data for data in purged]) + + def XHTML2XHTML_IM(self, xhtml): + """ Convert XHTML document to XHTML_IM subset + @param xhtml: raw xhtml to convert + """ + # TODO: more clever tag replacement (replace forbidden tags with equivalents when possible) + + parser = html.HTMLParser(remove_comments=True, encoding='utf-8') + root = html.fromstring(xhtml, parser=parser) + body_elt = root.find('body') + if body_elt is None: + # we use the whole XML as body if no body element is found + body_elt = html.Element('body') + body_elt.append(root) + else: + body_elt.attrib.clear() + + allowed_tags = allowed.keys() + to_strip = [] + for elem in body_elt.iter(): + if elem.tag not in allowed_tags: + to_strip.append(elem) + else: + # we remove unallowed attributes + attrib = elem.attrib + att_to_remove = set(attrib).difference(allowed[elem.tag]) + for att in att_to_remove: + del(attrib[att]) + if "style" in attrib: + attrib["style"] = self._purgeStyle(attrib["style"]) + + for elem in to_strip: + if elem.tag in blacklist: + #we need to remove the element and all descendants + log.debug(u"removing black listed tag: %s" % (elem.tag)) + elem.drop_tree() + else: + elem.drop_tag() + if len(body_elt) !=1: + root_elt = body_elt + body_elt.tag = "p" + else: + root_elt = body_elt[0] + + return html.tostring(root_elt, encoding='unicode', method='xml') + +class XEP_0071_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_XHTML_IM)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0077.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0077.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,228 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0077 +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import xmlstream +from twisted.internet import defer, reactor +from sat.tools import xml_tools + +from wokkel import data_form + +NS_REG = 'jabber:iq:register' + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0077 Plugin", + C.PI_IMPORT_NAME: "XEP-0077", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0077"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0077", + C.PI_DESCRIPTION: _("""Implementation of in-band registration""") +} + +# FIXME: this implementation is incomplete + +class RegisteringAuthenticator(xmlstream.ConnectAuthenticator): + # FIXME: request IQ is not send to check available fields, while XEP recommand to use it + # FIXME: doesn't handle data form or oob + + def __init__(self, jid_, password, email=None): + xmlstream.ConnectAuthenticator.__init__(self, jid_.host) + self.jid = jid_ + self.password = password + self.email = email + self.registered = defer.Deferred() + log.debug(_(u"Registration asked for {jid}").format( + jid = jid_)) + + def connectionMade(self): + log.debug(_(u"Connection made with {server}".format(server=self.jid.host))) + self.xmlstream.otherEntity = jid.JID(self.jid.host) + self.xmlstream.namespace = C.NS_CLIENT + self.xmlstream.sendHeader() + + iq = XEP_0077.buildRegisterIQ(self.xmlstream, self.jid, self.password, self.email) + d = iq.send(self.jid.host).addCallbacks(self.registrationCb, self.registrationEb) + d.chainDeferred(self.registered) + + def registrationCb(self, answer): + log.debug(_(u"Registration answer: {}").format(answer.toXml())) + self.xmlstream.sendFooter() + + def registrationEb(self, failure_): + log.info(_("Registration failure: {}").format(unicode(failure_.value))) + self.xmlstream.sendFooter() + raise failure_ + + +class XEP_0077(object): + + def __init__(self, host): + log.info(_("Plugin XEP_0077 initialization")) + self.host = host + host.bridge.addMethod("inBandRegister", ".plugin", in_sign='ss', out_sign='', + method=self._inBandRegister, + async=True) + host.bridge.addMethod("inBandAccountNew", ".plugin", in_sign='ssssi', out_sign='', + method=self._registerNewAccount, + async=True) + host.bridge.addMethod("inBandUnregister", ".plugin", in_sign='ss', out_sign='', + method=self._unregister, + async=True) + host.bridge.addMethod("inBandPasswordChange", ".plugin", in_sign='ss', out_sign='', + method=self._changePassword, + async=True) + + @staticmethod + def buildRegisterIQ(xmlstream_, jid_, password, email=None): + iq_elt = xmlstream.IQ(xmlstream_, 'set') + iq_elt["to"] = jid_.host + query_elt = iq_elt.addElement(('jabber:iq:register', 'query')) + username_elt = query_elt.addElement('username') + username_elt.addContent(jid_.user) + password_elt = query_elt.addElement('password') + password_elt.addContent(password) + if email is not None: + email_elt = query_elt.addElement('email') + email_elt.addContent(email) + return iq_elt + + def _regCb(self, answer, client, post_treat_cb): + """Called after the first get IQ""" + try: + query_elt = answer.elements(NS_REG, 'query').next() + except StopIteration: + raise exceptions.DataError("Can't find expected query element") + + try: + x_elem = query_elt.elements(data_form.NS_X_DATA, 'x').next() + except StopIteration: + # XXX: it seems we have an old service which doesn't manage data forms + log.warning(_("Can't find data form")) + raise exceptions.DataError(_("This gateway can't be managed by SàT, sorry :(")) + + def submitForm(data, profile): + form_elt = xml_tools.XMLUIResultToElt(data) + + iq_elt = client.IQ() + iq_elt['id'] = answer['id'] + iq_elt['to'] = answer['from'] + query_elt = iq_elt.addElement("query", NS_REG) + query_elt.addChild(form_elt) + d = iq_elt.send() + d.addCallback(self._regSuccess, client, post_treat_cb) + d.addErrback(self._regFailure, client) + return d + + form = data_form.Form.fromElement(x_elem) + submit_reg_id = self.host.registerCallback(submitForm, with_data=True, one_shot=True) + return xml_tools.dataForm2XMLUI(form, submit_reg_id) + + def _regEb(self, failure, client): + """Called when something is wrong with registration""" + log.info(_("Registration failure: %s") % unicode(failure.value)) + raise failure + + def _regSuccess(self, answer, client, post_treat_cb): + log.debug(_(u"registration answer: %s") % answer.toXml()) + if post_treat_cb is not None: + post_treat_cb(jid.JID(answer['from']), client.profile) + return {} + + def _regFailure(self, failure, client): + log.info(_(u"Registration failure: %s") % unicode(failure.value)) + if failure.value.condition == 'conflict': + raise exceptions.ConflictError( _("Username already exists, please choose an other one")) + raise failure + + def _inBandRegister(self, to_jid_s, profile_key=C.PROF_KEY_NONE): + return self.inBandRegister, jid.JID(to_jid_s, profile_key) + + def inBandRegister(self, to_jid, post_treat_cb=None, profile_key=C.PROF_KEY_NONE): + """register to a service + + @param to_jid(jid.JID): jid of the service to register to + """ + # FIXME: this post_treat_cb arguments seems wrong, check it + client = self.host.getClient(profile_key) + log.debug(_(u"Asking registration for {}").format(to_jid.full())) + reg_request = client.IQ(u'get') + reg_request["from"] = client.jid.full() + reg_request["to"] = to_jid.full() + reg_request.addElement('query', NS_REG) + d = reg_request.send(to_jid.full()).addCallbacks(self._regCb, self._regEb, callbackArgs=[client, post_treat_cb], errbackArgs=[client]) + return d + + def _registerNewAccount(self, jid_, password, email, host, port): + kwargs = {} + if email: + kwargs['email'] = email + if host: + kwargs['host'] = host + if port: + kwargs['port'] = port + return self.registerNewAccount(jid.JID(jid_), password, **kwargs) + + def registerNewAccount(self, jid_, password, email=None, host=u"127.0.0.1", port=C.XMPP_C2S_PORT): + """register a new account on a XMPP server + + @param jid_(jid.JID): request jid to register + @param password(unicode): password of the account + @param email(unicode): email of the account + @param host(unicode): host of the server to register to + @param port(int): port of the server to register to + """ + authenticator = RegisteringAuthenticator(jid_, password, email) + registered_d = authenticator.registered + serverRegistrer = xmlstream.XmlStreamFactory(authenticator) + connector = reactor.connectTCP(host, port, serverRegistrer) + serverRegistrer.clientConnectionLost = lambda conn, reason: connector.disconnect() + return registered_d + + def _changePassword(self, new_password, profile_key): + client = self.host.getClient(profile_key) + return self.changePassword(client, new_password) + + def changePassword(self, client, new_password): + iq_elt = self.buildRegisterIQ(client.xmlstream, client.jid, new_password) + d = iq_elt.send(client.jid.host) + d.addCallback(lambda dummy: self.host.memory.setParam("Password", new_password, "Connection", profile_key=client.profile)) + return d + + def _unregister(self, to_jid_s, profile_key): + client = self.host.getClient(profile_key) + return self.unregister(client, jid.JID(to_jid_s)) + + def unregister(self, client, to_jid): + """remove registration from a server/service + + BEWARE! if you remove registration from profile own server, this will + DELETE THE XMPP ACCOUNT WITHOUT WARNING + @param to_jid(jid.JID): jid of the service or server + """ + iq_elt = client.IQ() + iq_elt['to'] = to_jid.full() + query_elt = iq_elt.addElement((NS_REG, u'query')) + query_elt.addElement(u'remove') + return iq_elt.send() diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0085.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0085.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,401 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Chat State Notifications Protocol (xep-0085) +# Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from wokkel import disco, iwokkel +from zope.interface import implements +from twisted.words.protocols.jabber.jid import JID +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from twisted.words.xish import domish +from twisted.internet import reactor +from twisted.internet import error as internet_error + +NS_XMPP_CLIENT = "jabber:client" +NS_CHAT_STATES = "http://jabber.org/protocol/chatstates" +CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"] +MESSAGE_TYPES = ["chat", "groupchat"] +PARAM_KEY = "Notifications" +PARAM_NAME = "Enable chat state notifications" +ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME +DELETE_VALUE = "DELETE" + +PLUGIN_INFO = { + C.PI_NAME: "Chat State Notifications Protocol Plugin", + C.PI_IMPORT_NAME: "XEP-0085", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0085"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0085", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol""") +} + + +# Describe the internal transitions that are triggered +# by a timer. Beside that, external transitions can be +# runned to target the states "active" or "composing". +# Delay is specified here in seconds. +TRANSITIONS = { + "active": {"next_state": "inactive", "delay": 120}, + "inactive": {"next_state": "gone", "delay": 480}, + "gone": {"next_state": "", "delay": 0}, + "composing": {"next_state": "paused", "delay": 30}, + "paused": {"next_state": "inactive", "delay": 450} +} + + +class UnknownChatStateException(Exception): + """ + This error is raised when an unknown chat state is used. + """ + pass + + +class XEP_0085(object): + """ + Implementation for XEP 0085 + """ + params = """ + + + + + + + + """ % { + 'category_name': PARAM_KEY, + 'category_label': _(PARAM_KEY), + 'param_name': PARAM_NAME, + 'param_label': _('Enable chat state notifications') + } + + def __init__(self, host): + log.info(_("Chat State Notifications plugin initialization")) + self.host = host + self.map = {} # FIXME: would be better to use client instead of mapping profile to data + + # parameter value is retrieved before each use + host.memory.updateParams(self.params) + + # triggers from core + host.trigger.add("MessageReceived", self.messageReceivedTrigger) + host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("paramUpdateTrigger", self.paramUpdateTrigger) + + # args: to_s (jid as string), profile + host.bridge.addMethod("chatStateComposing", ".plugin", in_sign='ss', + out_sign='', method=self.chatStateComposing) + + # args: from (jid as string), state in CHAT_STATES, profile + host.bridge.addSignal("chatStateReceived", ".plugin", signature='sss') + + def getHandler(self, client): + return XEP_0085_handler(self, client.profile) + + def profileDisconnected(self, client): + """Eventually send a 'gone' state to all one2one contacts.""" + profile = client.profile + if profile not in self.map: + return + for to_jid in self.map[profile]: + # FIXME: the "unavailable" presence stanza is received by to_jid + # before the chat state, so it will be ignored... find a way to + # actually defer the disconnection + self.map[profile][to_jid]._onEvent('gone') + del self.map[profile] + + def updateCache(self, entity_jid, value, profile): + """Update the entity data of the given profile for one or all contacts. + Reset the chat state(s) display if the notification has been disabled. + + @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts. + @param value: True, False or DELETE_VALUE to delete the entity data + @param profile: current profile + """ + if value == DELETE_VALUE: + self.host.memory.delEntityDatum(entity_jid, ENTITY_KEY, profile) + else: + self.host.memory.updateEntityData(entity_jid, ENTITY_KEY, value, profile_key=profile) + if not value or value == DELETE_VALUE: + # reinit chat state UI for this or these contact(s) + self.host.bridge.chatStateReceived(entity_jid.full(), "", profile) + + def paramUpdateTrigger(self, name, value, category, type_, profile): + """Reset all the existing chat state entity data associated with this profile after a parameter modification. + + @param name: parameter name + @param value: "true" to activate the notifications, or any other value to delete it + @param category: parameter category + @param type_: parameter type + """ + if (category, name) == (PARAM_KEY, PARAM_NAME): + self.updateCache(C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile) + return False + return True + + def messageReceivedTrigger(self, client, message, post_treat): + """ + Update the entity cache when we receive a message with body. + Check for a chat state in the message and signal frontends. + """ + profile = client.profile + if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): + return True + + from_jid = JID(message.getAttribute("from")) + if self._isMUC(from_jid, profile): + from_jid = from_jid.userhostJID() + else: # update entity data for one2one chat + # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource + try: + domish.generateElementsNamed(message.elements(), name="body").next() + try: + domish.generateElementsNamed(message.elements(), name="active").next() + # contact enabled Chat State Notifications + self.updateCache(from_jid, True, profile=profile) + except StopIteration: + if message.getAttribute('type') == 'chat': + # contact didn't enable Chat State Notifications + self.updateCache(from_jid, False, profile=profile) + return True + except StopIteration: + pass + + # send our next "composing" states to any MUC and to the contacts who enabled the feature + self._chatStateInit(from_jid, message.getAttribute("type"), profile) + + state_list = [child.name for child in message.elements() if + message.getAttribute("type") in MESSAGE_TYPES + and child.name in CHAT_STATES + and child.defaultUri == NS_CHAT_STATES] + for state in state_list: + # there must be only one state according to the XEP + if state != 'gone' or message.getAttribute('type') != 'groupchat': + self.host.bridge.chatStateReceived(message.getAttribute("from"), state, profile) + break + return True + + def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """ + Eventually add the chat state to the message and initiate + the state machine when sending an "active" state. + """ + profile = client.profile + def treatment(mess_data): + message = mess_data['xml'] + to_jid = JID(message.getAttribute("to")) + if not self._checkActivation(to_jid, forceEntityData=True, profile=profile): + return mess_data + try: + # message with a body always mean active state + domish.generateElementsNamed(message.elements(), name="body").next() + message.addElement('active', NS_CHAT_STATES) + # launch the chat state machine (init the timer) + if self._isMUC(to_jid, profile): + to_jid = to_jid.userhostJID() + self._chatStateActive(to_jid, mess_data["type"], profile) + except StopIteration: + if "chat_state" in mess_data["extra"]: + state = mess_data["extra"].pop("chat_state") + assert state in CHAT_STATES + message.addElement(state, NS_CHAT_STATES) + return mess_data + + post_xml_treatments.addCallback(treatment) + return True + + def _isMUC(self, to_jid, profile): + """Tell if that JID is a MUC or not + + @param to_jid (JID): full or bare JID to check + @param profile (str): %(doc_profile)s + @return: bool + """ + try: + type_ = self.host.memory.getEntityDatum(to_jid.userhostJID(), 'type', profile) + if type_ == 'chatroom': # FIXME: should not use disco instead ? + return True + except (exceptions.UnknownEntityError, KeyError): + pass + return False + + def _checkActivation(self, to_jid, forceEntityData, profile): + """ + @param to_jid: the contact's full JID (or bare if you know it's a MUC) + @param forceEntityData: if set to True, a non-existing + entity data will be considered to be True (and initialized) + @param: current profile + @return: True if the notifications should be sent to this JID. + """ + # check if the parameter is active + if not self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile): + return False + # check if notifications should be sent to this contact + if self._isMUC(to_jid, profile): + return True + # FIXME: this assertion crash when we want to send a message to an online bare jid + # assert to_jid.resource or not self.host.memory.isEntityAvailable(to_jid, profile) # must either have a resource, or talk to an offline contact + try: + return self.host.memory.getEntityDatum(to_jid, ENTITY_KEY, profile) + except (exceptions.UnknownEntityError, KeyError): + if forceEntityData: + # enable it for the first time + self.updateCache(to_jid, True, profile=profile) + return True + # wait for the first message before sending states + return False + + def _chatStateInit(self, to_jid, mess_type, profile): + """ + Data initialization for the chat state machine. + + @param to_jid (JID): full JID for one2one, bare JID for MUC + @param mess_type (str): "one2one" or "groupchat" + @param profile (str): %(doc_profile)s + """ + if mess_type is None: + return + profile_map = self.map.setdefault(profile, {}) + if to_jid not in profile_map: + machine = ChatStateMachine(self.host, to_jid, + mess_type, profile) + self.map[profile][to_jid] = machine + + def _chatStateActive(self, to_jid, mess_type, profile_key): + """ + Launch the chat state machine on "active" state. + + @param to_jid (JID): full JID for one2one, bare JID for MUC + @param mess_type (str): "one2one" or "groupchat" + @param profile (str): %(doc_profile)s + """ + profile = self.host.memory.getProfileName(profile_key) + if profile is None: + raise exceptions.ProfileUnknownError + self._chatStateInit(to_jid, mess_type, profile) + self.map[profile][to_jid]._onEvent("active") + + def chatStateComposing(self, to_jid_s, profile_key): + """Move to the "composing" state when required. + + Since this method is called from the front-end, it needs to check the + values of the parameter "Send chat state notifications" and the entity + data associated to the target JID. + + @param to_jid_s (str): contact full JID as a string + @param profile_key (str): %(doc_profile_key)s + """ + # TODO: try to optimize this method which is called often + client = self.host.getClient(profile_key) + to_jid = JID(to_jid_s) + if self._isMUC(to_jid, client.profile): + to_jid = to_jid.userhostJID() + elif not to_jid.resource: + to_jid.resource = self.host.memory.getMainResource(client, to_jid) + if not self._checkActivation(to_jid, forceEntityData=False, profile=client.profile): + return + try: + self.map[client.profile][to_jid]._onEvent("composing") + except (KeyError, AttributeError): + # no message has been sent/received since the notifications + # have been enabled, it's better to wait for a first one + pass + + +class ChatStateMachine(object): + """ + This class represents a chat state, between one profile and + one target contact. A timer is used to move from one state + to the other. The initialization is done through the "active" + state which is internally set when a message is sent. The state + "composing" can be set externally (through the bridge by a + frontend). Other states are automatically set with the timer. + """ + + def __init__(self, host, to_jid, mess_type, profile): + """ + Initialization need to store the target, message type + and a profile for sending later messages. + """ + self.host = host + self.to_jid = to_jid + self.mess_type = mess_type + self.profile = profile + self.state = None + self.timer = None + + def _onEvent(self, state): + """ + Move to the specified state, eventually send the + notification to the contact (the "active" state is + automatically sent with each message) and set the timer. + """ + assert state in TRANSITIONS + transition = TRANSITIONS[state] + assert "next_state" in transition and "delay" in transition + + if state != self.state and state != "active": + if state != 'gone' or self.mess_type != 'groupchat': + # send a new message without body + log.debug(u"sending state '{state}' to {jid}".format(state=state, jid=self.to_jid.full())) + client = self.host.getClient(self.profile) + mess_data = { + 'from': client.jid, + 'to': self.to_jid, + 'uid': '', + 'message': {}, + 'type': self.mess_type, + 'subject': {}, + 'extra': {}, + } + client.generateMessageXML(mess_data) + mess_data['xml'].addElement(state, NS_CHAT_STATES) + client.send(mess_data['xml']) + + self.state = state + try: + self.timer.cancel() + except (internet_error.AlreadyCalled, AttributeError): + pass + + if transition["next_state"] and transition["delay"] > 0: + self.timer = reactor.callLater(transition["delay"], self._onEvent, transition["next_state"]) + + +class XEP_0085_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_CHAT_STATES)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0092.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0092.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,122 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT plugin for Software Version (XEP-0092) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from twisted.internet import reactor, defer +from twisted.words.protocols.jabber import jid +from wokkel import compat +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) + +NS_VERSION = "jabber:iq:version" +TIMEOUT = 10 + +PLUGIN_INFO = { + C.PI_NAME: "Software Version Plugin", + C.PI_IMPORT_NAME: "XEP-0092", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0092"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: [C.TEXT_CMDS], + C.PI_MAIN: "XEP_0092", + C.PI_HANDLER: "no", # version is already handler in core.xmpp module + C.PI_DESCRIPTION: _("""Implementation of Software Version""") +} + + +class XEP_0092(object): + + def __init__(self, host): + log.info(_("Plugin XEP_0092 initialization")) + self.host = host + host.bridge.addMethod("getSoftwareVersion", ".plugin", in_sign='ss', out_sign='(sss)', method=self._getVersion, async=True) + try: + self.host.plugins[C.TEXT_CMDS].addWhoIsCb(self._whois, 50) + except KeyError: + log.info(_("Text commands not available")) + + def _getVersion(self, entity_jid_s, profile_key): + def prepareForBridge(data): + name, version, os = data + return (name or '', version or '', os or '') + d = self.getVersion(jid.JID(entity_jid_s), profile_key) + d.addCallback(prepareForBridge) + return d + + def getVersion(self, jid_, profile_key=C.PROF_KEY_NONE): + """ Ask version of the client that jid_ is running + @param jid_: jid from who we want to know client's version + @param profile_key: %(doc_profile_key)s + @return (tuple): a defered which fire a tuple with the following data (None if not available): + - name: Natural language name of the software + - version: specific version of the software + - os: operating system of the queried entity + """ + client = self.host.getClient(profile_key) + def getVersion(dummy): + iq_elt = compat.IQ(client.xmlstream, 'get') + iq_elt['to'] = jid_.full() + iq_elt.addElement("query", NS_VERSION) + d = iq_elt.send() + d.addCallback(self._gotVersion) + return d + d = self.host.checkFeature(client, NS_VERSION, jid_) + d.addCallback(getVersion) + reactor.callLater(TIMEOUT, d.cancel) # XXX: timeout needed because some clients don't answer the IQ + return d + + def _gotVersion(self, iq_elt): + try: + query_elt = iq_elt.elements(NS_VERSION, 'query').next() + except StopIteration: + raise exceptions.DataError + ret = [] + for name in ('name', 'version', 'os'): + try: + data_elt = query_elt.elements(NS_VERSION, name).next() + ret.append(unicode(data_elt)) + except StopIteration: + ret.append(None) + + return tuple(ret) + + + def _whois(self, client, whois_msg, mess_data, target_jid): + """ Add software/OS information to whois """ + def versionCb(version_data): + name, version, os = version_data + if name: + whois_msg.append(_("Client name: %s") % name) + if version: + whois_msg.append(_("Client version: %s") % version) + if os: + whois_msg.append(_("Operating system: %s") % os) + def versionEb(failure): + failure.trap(exceptions.FeatureNotFound, defer.CancelledError) + if failure.check(failure,exceptions.FeatureNotFound): + whois_msg.append(_("Software version not available")) + else: + whois_msg.append(_("Client software version request timeout")) + + d = self.getVersion(target_jid, client.profile) + d.addCallbacks(versionCb, versionEb) + return d + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0095.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0095.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,184 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0095 +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import error +from zope.interface import implements +from wokkel import disco +from wokkel import iwokkel +import uuid + + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0095 Plugin", + C.PI_IMPORT_NAME: "XEP-0095", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0095"], + C.PI_MAIN: "XEP_0095", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Stream Initiation""") +} + + +IQ_SET = '/iq[@type="set"]' +NS_SI = 'http://jabber.org/protocol/si' +SI_REQUEST = IQ_SET + '/si[@xmlns="' + NS_SI + '"]' +SI_PROFILE_HEADER = "http://jabber.org/protocol/si/profile/" +SI_ERROR_CONDITIONS = ('bad-profile', 'no-valid-streams') + + +class XEP_0095(object): + + def __init__(self, host): + log.info(_("Plugin XEP_0095 initialization")) + self.host = host + self.si_profiles = {} # key: SI profile, value: callback + + def getHandler(self, client): + return XEP_0095_handler(self) + + def registerSIProfile(self, si_profile, callback): + """Add a callback for a SI Profile + + @param si_profile(unicode): SI profile name (e.g. file-transfer) + @param callback(callable): method to call when the profile name is asked + """ + self.si_profiles[si_profile] = callback + + def unregisterSIProfile(self, si_profile): + try: + del self.si_profiles[si_profile] + except KeyError: + log.error(u"Trying to unregister SI profile [{}] which was not registered".format(si_profile)) + + def streamInit(self, iq_elt, client): + """This method is called on stream initiation (XEP-0095 #3.2) + + @param iq_elt: IQ element + """ + log.info(_("XEP-0095 Stream initiation")) + iq_elt.handled = True + si_elt = iq_elt.elements(NS_SI, 'si').next() + si_id = si_elt['id'] + si_mime_type = iq_elt.getAttribute('mime-type', 'application/octet-stream') + si_profile = si_elt['profile'] + si_profile_key = si_profile[len(SI_PROFILE_HEADER):] if si_profile.startswith(SI_PROFILE_HEADER) else si_profile + if si_profile_key in self.si_profiles: + #We know this SI profile, we call the callback + self.si_profiles[si_profile_key](client, iq_elt, si_id, si_mime_type, si_elt) + else: + #We don't know this profile, we send an error + self.sendError(client, iq_elt, 'bad-profile') + + def sendError(self, client, request, condition): + """Send IQ error as a result + + @param request(domish.Element): original IQ request + @param condition(str): error condition + """ + if condition in SI_ERROR_CONDITIONS: + si_condition = condition + condition = 'bad-request' + else: + si_condition = None + + iq_error_elt = error.StanzaError(condition).toResponse(request) + if si_condition is not None: + iq_error_elt.error.addElement((NS_SI, si_condition)) + + client.send(iq_error_elt) + + def acceptStream(self, client, iq_elt, feature_elt, misc_elts=None): + """Send the accept stream initiation answer + + @param iq_elt(domish.Element): initial SI request + @param feature_elt(domish.Element): 'feature' element containing stream method to use + @param misc_elts(list[domish.Element]): list of elements to add + """ + log.info(_("sending stream initiation accept answer")) + if misc_elts is None: + misc_elts = [] + result_elt = xmlstream.toResponse(iq_elt, 'result') + si_elt = result_elt.addElement((NS_SI, 'si')) + si_elt.addChild(feature_elt) + for elt in misc_elts: + si_elt.addChild(elt) + client.send(result_elt) + + def _parseOfferResult(self, iq_elt): + try: + si_elt = iq_elt.elements(NS_SI, "si").next() + except StopIteration: + log.warning(u"No element found in result while expected") + raise exceptions.DataError + return (iq_elt, si_elt) + + + def proposeStream(self, client, to_jid, si_profile, feature_elt, misc_elts, mime_type='application/octet-stream'): + """Propose a stream initiation + + @param to_jid(jid.JID): recipient + @param si_profile(unicode): Stream initiation profile (XEP-0095) + @param feature_elt(domish.Element): feature element, according to XEP-0020 + @param misc_elts(list[domish.Element]): list of elements to add + @param mime_type(unicode): stream mime type + @return (tuple): tuple with: + - session id (unicode) + - (D(domish_elt, domish_elt): offer deferred which returl a tuple + with iq_elt and si_elt + """ + offer = client.IQ() + sid = str(uuid.uuid4()) + log.debug(_(u"Stream Session ID: %s") % offer["id"]) + + offer["from"] = client.jid.full() + offer["to"] = to_jid.full() + si = offer.addElement('si', NS_SI) + si['id'] = sid + si["mime-type"] = mime_type + si["profile"] = si_profile + for elt in misc_elts: + si.addChild(elt) + si.addChild(feature_elt) + + offer_d = offer.send() + offer_d.addCallback(self._parseOfferResult) + return sid, offer_d + + +class XEP_0095_handler(xmlstream.XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + self.xmlstream.addObserver(SI_REQUEST, self.plugin_parent.streamInit, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_SI)] + [disco.DiscoFeature(u"http://jabber.org/protocol/si/profile/{}".format(profile_name)) for profile_name in self.plugin_parent.si_profiles] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0096.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0096.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,351 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0096 +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.tools import xml_tools +from sat.tools import stream +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import error +import os + + +NS_SI_FT = "http://jabber.org/protocol/si/profile/file-transfer" +IQ_SET = '/iq[@type="set"]' +SI_PROFILE_NAME = "file-transfer" +SI_PROFILE = "http://jabber.org/protocol/si/profile/" + SI_PROFILE_NAME + +PLUGIN_INFO = { + C.PI_NAME: "XEP-0096 Plugin", + C.PI_IMPORT_NAME: "XEP-0096", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0096"], + C.PI_DEPENDENCIES: ["XEP-0020", "XEP-0095", "XEP-0065", "XEP-0047", "FILE"], + C.PI_MAIN: "XEP_0096", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of SI File Transfer""") +} + + +class XEP_0096(object): + # TODO: call self._f.unregister when unloading order will be managing (i.e. when depenencies will be unloaded at the end) + + def __init__(self, host): + log.info(_("Plugin XEP_0096 initialization")) + self.host = host + self.managed_stream_m = [self.host.plugins["XEP-0065"].NAMESPACE, + self.host.plugins["XEP-0047"].NAMESPACE] # Stream methods managed + self._f = self.host.plugins["FILE"] + self._f.register(NS_SI_FT, self.sendFile, priority=0, method_name=u"Stream Initiation") + self._si = self.host.plugins["XEP-0095"] + self._si.registerSIProfile(SI_PROFILE_NAME, self._transferRequest) + host.bridge.addMethod("siSendFile", ".plugin", in_sign='sssss', out_sign='s', method=self._sendFile) + + def unload(self): + self._si.unregisterSIProfile(SI_PROFILE_NAME) + + def _badRequest(self, client, iq_elt, message=None): + """Send a bad-request error + + @param iq_elt(domish.Element): initial element of the SI request + @param message(None, unicode): informational message to display in the logs + """ + if message is not None: + log.warning(message) + self._si.sendError(client, iq_elt, 'bad-request') + + def _parseRange(self, parent_elt, file_size): + """find and parse element + + @param parent_elt(domish.Element): direct parent of the element + @return (tuple[bool, int, int]): a tuple with + - True if range is required + - range_offset + - range_length + """ + try: + range_elt = parent_elt.elements(NS_SI_FT, 'range').next() + except StopIteration: + range_ = False + range_offset = None + range_length = None + else: + range_ = True + + try: + range_offset = int(range_elt['offset']) + except KeyError: + range_offset = 0 + + try: + range_length = int(range_elt['length']) + except KeyError: + range_length = file_size + + if range_offset != 0 or range_length != file_size: + raise NotImplementedError # FIXME + + return range_, range_offset, range_length + + def _transferRequest(self, client, iq_elt, si_id, si_mime_type, si_elt): + """Called when a file transfer is requested + + @param iq_elt(domish.Element): initial element of the SI request + @param si_id(unicode): Stream Initiation session id + @param si_mime_type("unicode"): Mime type of the file (or default "application/octet-stream" if unknown) + @param si_elt(domish.Element): request + """ + log.info(_("XEP-0096 file transfer requested")) + peer_jid = jid.JID(iq_elt['from']) + + try: + file_elt = si_elt.elements(NS_SI_FT, "file").next() + except StopIteration: + return self._badRequest(client, iq_elt, "No element found in SI File Transfer request") + + try: + feature_elt = self.host.plugins["XEP-0020"].getFeatureElt(si_elt) + except exceptions.NotFound: + return self._badRequest(client, iq_elt, "No element found in SI File Transfer request") + + try: + filename = file_elt["name"] + file_size = int(file_elt["size"]) + except (KeyError, ValueError): + return self._badRequest(client, iq_elt, "Malformed SI File Transfer request") + + file_date = file_elt.getAttribute("date") + file_hash = file_elt.getAttribute("hash") + + log.info(u"File proposed: name=[{name}] size={size}".format(name=filename, size=file_size)) + + try: + file_desc = unicode(file_elt.elements(NS_SI_FT, 'desc').next()) + except StopIteration: + file_desc = '' + + try: + range_, range_offset, range_length = self._parseRange(file_elt, file_size) + except ValueError: + return self._badRequest(client, iq_elt, "Malformed SI File Transfer request") + + try: + stream_method = self.host.plugins["XEP-0020"].negotiate(feature_elt, 'stream-method', self.managed_stream_m, namespace=None) + except KeyError: + return self._badRequest(client, iq_elt, "No stream method found") + + if stream_method: + if stream_method == self.host.plugins["XEP-0065"].NAMESPACE: + plugin = self.host.plugins["XEP-0065"] + elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE: + plugin = self.host.plugins["XEP-0047"] + else: + log.error(u"Unknown stream method, this should not happen at this stage, cancelling transfer") + else: + log.warning(u"Can't find a valid stream method") + self._si.sendError(client, iq_elt, 'not-acceptable') + return + + #if we are here, the transfer can start, we just need user's agreement + data = {"name": filename, "peer_jid": peer_jid, "size": file_size, "date": file_date, "hash": file_hash, "desc": file_desc, + "range": range_, "range_offset": range_offset, "range_length": range_length, + "si_id": si_id, "progress_id": si_id, "stream_method": stream_method, "stream_plugin": plugin} + + d = self._f.getDestDir(client, peer_jid, data, data, stream_object=True) + d.addCallback(self.confirmationCb, client, iq_elt, data) + + def confirmationCb(self, accepted, client, iq_elt, data): + """Called on confirmation answer + + @param accepted(bool): True if file transfer is accepted + @param iq_elt(domish.Element): initial SI request + @param data(dict): session data + """ + if not accepted: + log.info(u"File transfer declined") + self._si.sendError(client, iq_elt, 'forbidden') + return + # data, timeout, stream_method, failed_methods = client._xep_0096_waiting_for_approval[sid] + # can_range = data['can_range'] == "True" + # range_offset = 0 + # if timeout.active(): + # timeout.cancel() + # try: + # dest_path = frontend_data['dest_path'] + # except KeyError: + # log.error(_('dest path not found in frontend_data')) + # del client._xep_0096_waiting_for_approval[sid] + # return + # if stream_method == self.host.plugins["XEP-0065"].NAMESPACE: + # plugin = self.host.plugins["XEP-0065"] + # elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE: + # plugin = self.host.plugins["XEP-0047"] + # else: + # log.error(_("Unknown stream method, this should not happen at this stage, cancelling transfer")) + # del client._xep_0096_waiting_for_approval[sid] + # return + + # file_obj = self._getFileObject(dest_path, can_range) + # range_offset = file_obj.tell() + d = data['stream_plugin'].createSession(client, data['stream_object'], data['peer_jid'], data['si_id']) + d.addCallback(self._transferCb, client, data) + d.addErrback(self._transferEb, client, data) + + #we can send the iq result + feature_elt = self.host.plugins["XEP-0020"].chooseOption({'stream-method': data['stream_method']}, namespace=None) + misc_elts = [] + misc_elts.append(domish.Element((SI_PROFILE, "file"))) + # if can_range: + # range_elt = domish.Element((None, "range")) + # range_elt['offset'] = str(range_offset) + # #TODO: manage range length + # misc_elts.append(range_elt) + self._si.acceptStream(client, iq_elt, feature_elt, misc_elts) + + def _transferCb(self, dummy, client, data): + """Called by the stream method when transfer successfuly finished + + @param data: session data + """ + #TODO: check hash + data['stream_object'].close() + log.info(u'Transfer {si_id} successfuly finished'.format(**data)) + + def _transferEb(self, failure, client, data): + """Called when something went wrong with the transfer + + @param id: stream id + @param data: session data + """ + log.warning(u'Transfer {si_id} failed: {reason}'.format(reason=unicode(failure.value), **data)) + data['stream_object'].close() + + def _sendFile(self, peer_jid_s, filepath, name, desc, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + return self.sendFile(client, jid.JID(peer_jid_s), filepath, name or None, desc or None) + + def sendFile(self, client, peer_jid, filepath, name=None, desc=None, extra=None): + """Send a file using XEP-0096 + + @param peer_jid(jid.JID): recipient + @param filepath(str): absolute path to the file to send + @param name(unicode): name of the file to send + name must not contain "/" characters + @param desc: description of the file + @param extra: not used here + @return: an unique id to identify the transfer + """ + feature_elt = self.host.plugins["XEP-0020"].proposeFeatures({'stream-method': self.managed_stream_m}, namespace=None) + + file_transfer_elts = [] + + statinfo = os.stat(filepath) + file_elt = domish.Element((SI_PROFILE, 'file')) + file_elt['name'] = name or os.path.basename(filepath) + assert '/' not in file_elt['name'] + size = statinfo.st_size + file_elt['size'] = str(size) + if desc: + file_elt.addElement('desc', content=desc) + file_transfer_elts.append(file_elt) + + file_transfer_elts.append(domish.Element((None, 'range'))) + + sid, offer_d = self._si.proposeStream(client, peer_jid, SI_PROFILE, feature_elt, file_transfer_elts) + args = [filepath, sid, size, client] + offer_d.addCallbacks(self._fileCb, self._fileEb, args, None, args) + return sid + + def _fileCb(self, result_tuple, filepath, sid, size, client): + iq_elt, si_elt = result_tuple + + try: + feature_elt = self.host.plugins["XEP-0020"].getFeatureElt(si_elt) + except exceptions.NotFound: + log.warning(u"No element found in result while expected") + return + + choosed_options = self.host.plugins["XEP-0020"].getChoosedOptions(feature_elt, namespace=None) + try: + stream_method = choosed_options["stream-method"] + except KeyError: + log.warning(u"No stream method choosed") + return + + try: + file_elt = si_elt.elements(NS_SI_FT, "file").next() + except StopIteration: + pass + else: + range_, range_offset, range_length = self._parseRange(file_elt, size) + + if stream_method == self.host.plugins["XEP-0065"].NAMESPACE: + plugin = self.host.plugins["XEP-0065"] + elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE: + plugin = self.host.plugins["XEP-0047"] + else: + log.warning(u"Invalid stream method received") + return + + stream_object = stream.FileStreamObject(self.host, + client, + filepath, + uid=sid, + size=size, + ) + d = plugin.startStream(client, stream_object, jid.JID(iq_elt['from']), sid) + d.addCallback(self._sendCb, client, sid, stream_object) + d.addErrback(self._sendEb, client, sid, stream_object) + + def _fileEb(self, failure, filepath, sid, size, client): + if failure.check(error.StanzaError): + stanza_err = failure.value + if stanza_err.code == '403' and stanza_err.condition == 'forbidden': + from_s = stanza_err.stanza['from'] + log.info(u"File transfer refused by {}".format(from_s)) + msg = D_(u"The contact {} has refused your file").format(from_s) + title = D_(u"File refused") + xml_tools.quickNote(self.host, client, msg, title, C.XMLUI_DATA_LVL_INFO) + else: + log.warning(_(u"Error during file transfer")) + msg = D_(u"Something went wrong during the file transfer session initialisation: {reason}").format(reason=unicode(stanza_err)) + title = D_(u"File transfer error") + xml_tools.quickNote(self.host, client, msg, title, C.XMLUI_DATA_LVL_ERROR) + elif failure.check(exceptions.DataError): + log.warning(u'Invalid stanza received') + else: + log.error(u'Error while proposing stream: {}'.format(failure)) + + def _sendCb(self, dummy, client, sid, stream_object): + log.info(_(u'transfer {sid} successfuly finished [{profile}]').format( + sid=sid, + profile=client.profile)) + stream_object.close() + + def _sendEb(self, failure, client, sid, stream_object): + log.warning(_(u'transfer {sid} failed [{profile}]: {reason}').format( + sid=sid, + profile=client.profile, + reason=unicode(failure.value), + )) + stream_object.close() diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0100.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0100.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,209 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing gateways (xep-0100) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.tools import xml_tools +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.protocols.jabber import jid +from twisted.internet import reactor, defer + +PLUGIN_INFO = { + C.PI_NAME: "Gateways Plugin", + C.PI_IMPORT_NAME: "XEP-0100", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0100"], + C.PI_DEPENDENCIES: ["XEP-0077"], + C.PI_MAIN: "XEP_0100", + C.PI_DESCRIPTION: _("""Implementation of Gateways protocol""") +} + +WARNING_MSG = D_(u"""Be careful ! Gateways allow you to use an external IM (legacy IM), so you can see your contact as XMPP contacts. +But when you do this, all your messages go throught the external legacy IM server, it is a huge privacy issue (i.e.: all your messages throught the gateway can be monitored, recorded, analysed by the external server, most of time a private company).""") + +GATEWAY_TIMEOUT = 10 # time to wait before cancelling a gateway disco info, in seconds + +TYPE_DESCRIPTIONS = {'irc': D_("Internet Relay Chat"), + 'xmpp': D_("XMPP"), + 'qq': D_("Tencent QQ"), + 'simple': D_("SIP/SIMPLE"), + 'icq': D_("ICQ"), + 'yahoo': D_("Yahoo! Messenger"), + 'gadu-gadu': D_("Gadu-Gadu"), + 'aim': D_("AOL Instant Messenger"), + 'msn': D_("Windows Live Messenger"), + } + + +class XEP_0100(object): + + def __init__(self, host): + log.info(_("Gateways plugin initialization")) + self.host = host + self.__gateways = {} # dict used to construct the answer to findGateways. Key = target jid + host.bridge.addMethod("findGateways", ".plugin", in_sign='ss', out_sign='s', method=self._findGateways) + host.bridge.addMethod("gatewayRegister", ".plugin", in_sign='ss', out_sign='s', method=self._gatewayRegister) + self.__menu_id = host.registerCallback(self._gatewaysMenu, with_data=True) + self.__selected_id = host.registerCallback(self._gatewaySelectedCb, with_data=True) + host.importMenu((D_("Service"), D_("Gateways")), self._gatewaysMenu, security_limit=1, help_string=D_("Find gateways")) + + def _gatewaysMenu(self, data, profile): + """ XMLUI activated by menu: return Gateways UI + + @param profile: %(doc_profile)s + """ + client = self.host.getClient(profile) + try: + jid_ = jid.JID(data.get(xml_tools.formEscape('external_jid'), client.jid.host)) + except RuntimeError: + raise exceptions.DataError(_("Invalid JID")) + d = self.findGateways(jid_, profile) + d.addCallback(self._gatewaysResult2XMLUI, jid_) + d.addCallback(lambda xmlui: {'xmlui': xmlui.toXml()}) + return d + + def _gatewaysResult2XMLUI(self, result, entity): + xmlui = xml_tools.XMLUI(title=_('Gateways manager (%s)') % entity.full()) + xmlui.addText(_(WARNING_MSG)) + xmlui.addDivider('dash') + adv_list = xmlui.changeContainer('advanced_list', columns=3, selectable='single', callback_id=self.__selected_id) + for success, gateway_data in result: + if not success: + fail_cond, disco_item = gateway_data + xmlui.addJid(disco_item.entity) + xmlui.addText(_('Failed (%s)') % fail_cond) + xmlui.addEmpty() + else: + jid_, data = gateway_data + for datum in data: + identity, name = datum + adv_list.setRowIndex(jid_.full()) + xmlui.addJid(jid_) + xmlui.addText(name) + xmlui.addText(self._getIdentityDesc(identity)) + adv_list.end() + xmlui.addDivider('blank') + xmlui.changeContainer('advanced_list', columns=3) + xmlui.addLabel(_('Use external XMPP server')) + xmlui.addString('external_jid') + xmlui.addButton(self.__menu_id, _(u'Go !'), fields_back=('external_jid',)) + return xmlui + + def _gatewaySelectedCb(self, data, profile): + try: + target_jid = jid.JID(data['index']) + except (KeyError, RuntimeError): + log.warning(_("No gateway index selected")) + return {} + + d = self.gatewayRegister(target_jid, profile) + d.addCallback(lambda xmlui: {'xmlui': xmlui.toXml()}) + return d + + def _getIdentityDesc(self, identity): + """ Return a human readable description of identity + @param identity: tuple as returned by Disco identities (category, type) + + """ + category, type_ = identity + if category != 'gateway': + log.error(_(u'INTERNAL ERROR: identity category should always be "gateway" in _getTypeString, got "%s"') % category) + try: + return _(TYPE_DESCRIPTIONS[type_]) + except KeyError: + return _("Unknown IM") + + def _registrationSuccessful(self, jid_, profile): + """Called when in_band registration is ok, we must now follow the rest of procedure""" + log.debug(_("Registration successful, doing the rest")) + self.host.addContact(jid_, profile_key=profile) + self.host.setPresence(jid_, profile_key=profile) + + def _gatewayRegister(self, target_jid_s, profile_key=C.PROF_KEY_NONE): + d = self.gatewayRegister(jid.JID(target_jid_s), profile_key) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def gatewayRegister(self, target_jid, profile_key=C.PROF_KEY_NONE): + """Register gateway using in-band registration, then log-in to gateway""" + profile = self.host.memory.getProfileName(profile_key) + assert(profile) + d = self.host.plugins["XEP-0077"].inBandRegister(target_jid, self._registrationSuccessful, profile) + return d + + def _infosReceived(self, dl_result, items, target, client): + """Find disco infos about entity, to check if it is a gateway""" + + ret = [] + for idx, (success, result) in enumerate(dl_result): + if not success: + if isinstance(result.value, defer.CancelledError): + msg = _("Timeout") + else: + try: + msg = result.value.condition + except AttributeError: + msg = str(result) + ret.append((success, (msg, items[idx]))) + else: + entity = items[idx].entity + gateways = [(identity, result.identities[identity]) for identity in result.identities if identity[0] == 'gateway'] + if gateways: + log.info(_(u"Found gateway [%(jid)s]: %(identity_name)s") % {'jid': entity.full(), 'identity_name': ' - '.join([gateway[1] for gateway in gateways])}) + ret.append((success, (entity, gateways))) + else: + log.info(_(u"Skipping [%(jid)s] which is not a gateway") % {'jid': entity.full()}) + return ret + + def _itemsReceived(self, disco, target, client): + """Look for items with disco protocol, and ask infos for each one""" + + if len(disco._items) == 0: + log.debug(_("No gateway found")) + return [] + + _defers = [] + for item in disco._items: + log.debug(_(u"item found: %s") % item.entity) + _defers.append(client.disco.requestInfo(item.entity)) + dl = defer.DeferredList(_defers) + dl.addCallback(self._infosReceived, items=disco._items, target=target, client=client) + reactor.callLater(GATEWAY_TIMEOUT, dl.cancel) + return dl + + def _findGateways(self, target_jid_s, profile_key): + target_jid = jid.JID(target_jid_s) + profile = self.host.memory.getProfileName(profile_key) + if not profile: + raise exceptions.ProfileUnknownError + d = self.findGateways(target_jid, profile) + d.addCallback(self._gatewaysResult2XMLUI, target_jid) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def findGateways(self, target, profile): + """Find gateways in the target JID, using discovery protocol + """ + client = self.host.getClient(profile) + log.debug(_(u"find gateways (target = %(target)s, profile = %(profile)s)") % {'target': target.full(), 'profile': profile}) + d = client.disco.requestItems(target) + d.addCallback(self._itemsReceived, target=target, client=client) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0115.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0115.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,168 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0115 +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.internet import defer, error +from zope.interface import implements +from wokkel import disco, iwokkel + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +PRESENCE = '/presence' +NS_ENTITY_CAPABILITY = 'http://jabber.org/protocol/caps' +NS_CAPS_OPTIMIZE = 'http://jabber.org/protocol/caps#optimize' +CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]' + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0115 Plugin", + C.PI_IMPORT_NAME: "XEP-0115", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0115"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: "XEP_0115", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of entity capabilities""") +} + + +class XEP_0115(object): + cap_hash = None # capabilities hash is class variable as it is common to all profiles + + def __init__(self, host): + log.info(_("Plugin XEP_0115 initialization")) + self.host = host + host.trigger.add("Presence send", self._presenceTrigger) + + def getHandler(self, client): + return XEP_0115_handler(self, client.profile) + + @defer.inlineCallbacks + def _prepareCaps(self, client): + # we have to calculate hash for client + # because disco infos/identities may change between clients + + # optimize check + client._caps_optimize = yield self.host.hasFeature(client, NS_CAPS_OPTIMIZE) + if client._caps_optimize: + log.info(_(u"Caps optimisation enabled")) + client._caps_sent = False + else: + log.warning(_(u"Caps optimisation not available")) + + # hash generation + _infos = yield client.discoHandler.info(client.jid, client.jid, '') + disco_infos = disco.DiscoInfo() + for item in _infos: + disco_infos.append(item) + disco_infos = disco.DiscoInfo() + cap_hash = client._caps_hash = self.host.memory.disco.generateHash(disco_infos) + log.info("Our capability hash has been generated: [{cap_hash}]".format( + cap_hash = cap_hash)) + log.debug("Generating capability domish.Element") + c_elt = domish.Element((NS_ENTITY_CAPABILITY, 'c')) + c_elt['hash'] = 'sha-1' + c_elt['node'] = C.APP_URL + c_elt['ver'] = cap_hash + client._caps_elt = c_elt + if client._caps_optimize: + client._caps_sent = False + if cap_hash not in self.host.memory.disco.hashes: + self.host.memory.disco.hashes[cap_hash] = disco_infos + self.host.memory.updateEntityData(client.jid, C.ENTITY_CAP_HASH, cap_hash, profile_key=client.profile) + + def _presenceAddElt(self, client, obj): + if client._caps_optimize: + if client._caps_sent: + return + client.caps_sent = True + obj.addChild(client._caps_elt) + + def _presenceTrigger(self, client, obj, presence_d): + if not hasattr(client, "_caps_optimize"): + presence_d.addCallback(lambda __: self._prepareCaps(client)) + + presence_d.addCallback(lambda __: self._presenceAddElt(client, obj)) + return True + + +class XEP_0115_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def connectionInitialized(self): + self.xmlstream.addObserver(CAPABILITY_UPDATE, self.update) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_ENTITY_CAPABILITY), disco.DiscoFeature(NS_CAPS_OPTIMIZE)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + + def update(self, presence): + """ + Manage the capabilities of the entity + + Check if we know the version of this capabilities and get the capabilities if necessary + """ + from_jid = jid.JID(presence['from']) + c_elem = presence.elements(NS_ENTITY_CAPABILITY, 'c').next() + try: + c_ver = c_elem['ver'] + c_hash = c_elem['hash'] + c_node = c_elem['node'] + except KeyError: + log.warning(_(u'Received invalid capabilities tag: %s') % c_elem.toXml()) + return + + if c_ver in self.host.memory.disco.hashes: + # we already know the hash, we update the jid entity + log.debug(u"hash [%(hash)s] already in cache, updating entity [%(jid)s]" % {'hash': c_ver, 'jid': from_jid.full()}) + self.host.memory.updateEntityData(from_jid, C.ENTITY_CAP_HASH, c_ver, profile_key=self.profile) + return + + if c_hash != 'sha-1': # unknown hash method + log.warning(_(u'Unknown hash method for entity capabilities: [%(hash_method)s] (entity: %(jid)s, node: %(node)s)') % {'hash_method':c_hash, 'jid': from_jid, 'node': c_node}) + + def cb(dummy): + computed_hash = self.host.memory.getEntityDatum(from_jid, C.ENTITY_CAP_HASH, self.profile) + if computed_hash != c_ver: + log.warning(_(u'Computed hash differ from given hash:\ngiven: [%(given_hash)s]\ncomputed: [%(computed_hash)s]\n(entity: %(jid)s, node: %(node)s)') % {'given_hash':c_ver, 'computed_hash': computed_hash, 'jid': from_jid, 'node': c_node}) + + def eb(failure): + if isinstance(failure.value, error.ConnectionDone): + return + msg = failure.value.condition if hasattr(failure.value, 'condition') else failure.getErrorMessage() + log.error(_(u"Couldn't retrieve disco info for {jid}: {error}").format(jid=from_jid.full(), error=msg)) + + d = self.host.getDiscoInfos(self.parent, from_jid) + d.addCallbacks(cb, eb) + # TODO: me must manage the full algorithm described at XEP-0115 #5.4 part 3 diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0163.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0163.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,158 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Personal Eventing Protocol (xep-0163) +# 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 . + +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.words.xish import domish + +from wokkel import disco, pubsub +from wokkel.formats import Mood + +NS_USER_MOOD = 'http://jabber.org/protocol/mood' + +PLUGIN_INFO = { + C.PI_NAME: "Personal Eventing Protocol Plugin", + C.PI_IMPORT_NAME: "XEP-0163", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0163", "XEP-0107"], + C.PI_DEPENDENCIES: ["XEP-0060"], + C.PI_MAIN: "XEP_0163", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Implementation of Personal Eventing Protocol""") +} + + +class XEP_0163(object): + + def __init__(self, host): + log.info(_("PEP plugin initialization")) + self.host = host + self.pep_events = set() + self.pep_out_cb = {} + host.trigger.add("PubSub Disco Info", self.disoInfoTrigger) + host.bridge.addMethod("PEPSend", ".plugin", in_sign='sa{ss}s', out_sign='', method=self.PEPSend, async=True) # args: type(MOOD, TUNE, etc), data, profile_key; + self.addPEPEvent("MOOD", NS_USER_MOOD, self.userMoodCB, self.sendMood) + + def disoInfoTrigger(self, disco_info, profile): + """Add info from managed PEP + + @param disco_info: list of disco feature as returned by PubSub, + will be filled with PEP features + @param profile: profile we are handling + """ + disco_info.extend(map(disco.DiscoFeature, self.pep_events)) + return True + + def addPEPEvent(self, event_type, node, in_callback, out_callback=None, notify=True): + """Add a Personal Eventing Protocol event manager + + @param event_type(unicode): type of the event (always uppercase), can be MOOD, TUNE, etc + @param node(unicode): namespace of the node (e.g. http://jabber.org/protocol/mood for User Mood) + @param in_callback(callable): method to call when this event occur + the callable will be called with (itemsEvent, profile) as arguments + @param out_callback(callable,None): method to call when we want to publish this event (must return a deferred) + the callable will be called when sendPEPEvent is called + @param notify(bool): add autosubscribe (+notify) if True + """ + if out_callback: + self.pep_out_cb[event_type] = out_callback + self.pep_events.add(node) + if notify: + self.pep_events.add(node + "+notify") + def filterPEPEvent(client, itemsEvent): + """Ignore messages which are not coming from PEP (i.e. main server) + + @param itemsEvent(pubsub.ItemsEvent): pubsub event + """ + if itemsEvent.sender.user or itemsEvent.sender.resource: + log.debug("ignoring non PEP event from {} (profile={})".format(itemsEvent.sender.full(), client.profile)) + return + in_callback(itemsEvent, client.profile) + + self.host.plugins["XEP-0060"].addManagedNode(node, items_cb=filterPEPEvent) + + def sendPEPEvent(self, node, data, profile): + """Publish the event data + + @param node(unicode): node namespace + @param data: domish.Element to use as payload + @param profile: profile which send the data + """ + client = self.host.getClient(profile) + item = pubsub.Item(payload=data) + return self.host.plugins["XEP-0060"].publish(client, None, node, [item]) + + def PEPSend(self, event_type, data, profile_key=C.PROF_KEY_NONE): + """Send personal event after checking the data is alright + + @param event_type: type of event (eg: MOOD, TUNE), must be in self.pep_out_cb.keys() + @param data: dict of {string:string} of event_type dependant data + @param profile_key: profile who send the event + """ + profile = self.host.memory.getProfileName(profile_key) + if not profile: + log.error(_(u'Trying to send personal event with an unknown profile key [%s]') % profile_key) + raise exceptions.ProfileUnknownError + if not event_type in self.pep_out_cb.keys(): + log.error(_('Trying to send personal event for an unknown type')) + raise exceptions.DataError('Type unknown') + return self.pep_out_cb[event_type](data, profile) + + def userMoodCB(self, itemsEvent, profile): + if not itemsEvent.items: + log.debug(_("No item found")) + return + try: + mood_elt = [child for child in itemsEvent.items[0].elements() if child.name == "mood"][0] + except IndexError: + log.error(_("Can't find mood element in mood event")) + return + mood = Mood.fromXml(mood_elt) + if not mood: + log.debug(_("No mood found")) + return + self.host.bridge.psEvent(C.PS_PEP, itemsEvent.sender.full(), itemsEvent.nodeIdentifier, + "MOOD", {"mood": mood.value or "", "text": mood.text or ""}, profile) + + def sendMood(self, data, profile): + """Send XEP-0107's User Mood + + @param data: must include mood and text + @param profile: profile which send the mood""" + try: + value = data['mood'].lower() + text = data['text'] if 'text' in data else '' + except KeyError: + raise exceptions.DataError("Mood data must contain at least 'mood' key") + mood = UserMood(value, text) + return self.sendPEPEvent(NS_USER_MOOD, mood, profile) + + +class UserMood(Mood, domish.Element): + """Improved wokkel Mood which is also a domish.Element""" + + def __init__(self, value, text=None): + Mood.__init__(self, value, text) + domish.Element.__init__(self, (NS_USER_MOOD, 'mood')) + self.addElement(value) + if text: + self.addElement('text', content=text) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0166.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0166.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,983 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Jingle (XEP-0166) +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core.log import getLogger +from sat.tools import xml_tools +log = getLogger(__name__) +from sat.core import exceptions +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from twisted.internet import reactor +from wokkel import disco, iwokkel +from twisted.words.protocols.jabber import error +from twisted.words.protocols.jabber import xmlstream +from twisted.python import failure +from collections import namedtuple +import uuid +import time + +from zope.interface import implements + + + +IQ_SET = '/iq[@type="set"]' +NS_JINGLE = "urn:xmpp:jingle:1" +NS_JINGLE_ERROR = "urn:xmpp:jingle:errors:1" +JINGLE_REQUEST = IQ_SET + '/jingle[@xmlns="' + NS_JINGLE + '"]' +STATE_PENDING = "PENDING" +STATE_ACTIVE = "ACTIVE" +STATE_ENDED = "ENDED" +CONFIRM_TXT = D_("{entity} want to start a jingle session with you, do you accept ?") + +PLUGIN_INFO = { + C.PI_NAME: "Jingle", + C.PI_IMPORT_NAME: "XEP-0166", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0166"], + C.PI_MAIN: "XEP_0166", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Jingle""") +} + + +ApplicationData = namedtuple('ApplicationData', ('namespace', 'handler')) +TransportData = namedtuple('TransportData', ('namespace', 'handler', 'priority')) + + +class XEP_0166(object): + ROLE_INITIATOR = "initiator" + ROLE_RESPONDER = "responder" + TRANSPORT_DATAGRAM='UDP' + TRANSPORT_STREAMING='TCP' + REASON_SUCCESS='success' + REASON_DECLINE='decline' + REASON_FAILED_APPLICATION='failed-application' + REASON_FAILED_TRANSPORT='failed-transport' + REASON_CONNECTIVITY_ERROR='connectivity-error' + A_SESSION_INITIATE = "session-initiate" + A_SESSION_ACCEPT = "session-accept" + A_SESSION_TERMINATE = "session-terminate" + A_SESSION_INFO = "session-info" + A_TRANSPORT_REPLACE = "transport-replace" + A_TRANSPORT_ACCEPT = "transport-accept" + A_TRANSPORT_REJECT = "transport-reject" + A_TRANSPORT_INFO = "transport-info" + # non standard actions + A_PREPARE_INITIATOR = "prepare-initiator" # initiator must prepare tranfer + A_PREPARE_RESPONDER = "prepare-responder" # responder must prepare tranfer + A_ACCEPTED_ACK = "accepted-ack" # session accepted ack has been received from initiator + A_START = "start" # application can start + A_DESTROY = "destroy" # called when a transport is destroyed (e.g. because it is remplaced). Used to do cleaning operations + + def __init__(self, host): + log.info(_("plugin Jingle initialization")) + self.host = host + self._applications = {} # key: namespace, value: application data + self._transports = {} # key: namespace, value: transport data + # we also keep transports by type, they are then sorted by priority + self._type_transports = { XEP_0166.TRANSPORT_DATAGRAM: [], + XEP_0166.TRANSPORT_STREAMING: [], + } + + def profileConnected(self, client): + client.jingle_sessions = {} # key = sid, value = session_data + + def getHandler(self, client): + return XEP_0166_handler(self) + + def _delSession(self, client, sid): + try: + del client.jingle_sessions[sid] + except KeyError: + log.debug(u"Jingle session id [{}] is unknown, nothing to delete".format(sid)) + else: + log.debug(u"Jingle session id [{}] deleted".format(sid)) + + ## helpers methods to build stanzas ## + + def _buildJingleElt(self, client, session, action): + iq_elt = client.IQ('set') + iq_elt['from'] = client.jid.full() + iq_elt['to'] = session['peer_jid'].full() + jingle_elt = iq_elt.addElement("jingle", NS_JINGLE) + jingle_elt["sid"] = session['id'] + jingle_elt['action'] = action + return iq_elt, jingle_elt + + def sendError(self, client, error_condition, sid, request, jingle_condition=None): + """Send error stanza + + @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys + @param sid(unicode,None): jingle session id, or None, if session must not be destroyed + @param request(domish.Element): original request + @param jingle_condition(None, unicode): if not None, additional jingle-specific error information + """ + iq_elt = error.StanzaError(error_condition).toResponse(request) + if jingle_condition is not None: + iq_elt.error.addElement((NS_JINGLE_ERROR, jingle_condition)) + if error.STANZA_CONDITIONS[error_condition]['type'] == 'cancel' and sid: + self._delSession(client, sid) + log.warning(u"Error while managing jingle session, cancelling: {condition}".format(error_condition)) + client.send(iq_elt) + + def _terminateEb(self, failure_): + log.warning(_(u"Error while terminating session: {msg}").format(msg=failure_)) + + def terminate(self, client, reason, session): + """Terminate the session + + send the session-terminate action, and delete the session data + @param reason(unicode, list[domish.Element]): if unicode, will be transformed to an element + if a list of element, add them as children of the element + @param session(dict): data of the session + """ + iq_elt, jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_SESSION_TERMINATE) + reason_elt = jingle_elt.addElement('reason') + if isinstance(reason, basestring): + reason_elt.addElement(reason) + else: + for elt in reason: + reason_elt.addChild(elt) + self._delSession(client, session['id']) + d = iq_elt.send() + d.addErrback(self._terminateEb) + return d + + ## errors which doesn't imply a stanza sending ## + + def _iqError(self, failure_, sid, client): + """Called when we got an error + + @param failure_(failure.Failure): the exceptions raised + @param sid(unicode): jingle session id + """ + log.warning(u"Error while sending jingle stanza: {failure_}".format(failure_=failure_.value)) + self._delSession(client, sid) + + def _jingleErrorCb(self, fail, sid, request, client): + """Called when something is going wrong while parsing jingle request + + The error condition depend of the exceptions raised: + exceptions.DataError raise a bad-request condition + @param fail(failure.Failure): the exceptions raised + @param sid(unicode): jingle session id + @param request(domsih.Element): jingle request + @param client: %(doc_client)s + """ + log.warning("Error while processing jingle request") + if isinstance(fail, exceptions.DataError): + self.sendError(client, 'bad-request', sid, request) + else: + log.error("Unmanaged jingle exception") + self._delSession(client, sid) + raise fail + + ## methods used by other plugins ## + + def registerApplication(self, namespace, handler): + """Register an application plugin + + @param namespace(unicode): application namespace managed by the plugin + @param handler(object): instance of a class which manage the application. + May have the following methods: + - requestConfirmation(session, desc_elt, client): + - if present, it is called on when session must be accepted. + - if it return True the session is accepted, else rejected. + A Deferred can be returned + - if not present, a generic accept dialog will be used + - jingleSessionInit(client, self, session, content_name[, *args, **kwargs]): must return the domish.Element used for initial content + - jingleHandler(client, self, action, session, content_name, transport_elt): + called on several action to negociate the application or transport + - jingleTerminate: called on session terminate, with reason_elt + May be used to clean session + """ + if namespace in self._applications: + raise exceptions.ConflictError(u"Trying to register already registered namespace {}".format(namespace)) + self._applications[namespace] = ApplicationData(namespace=namespace, handler=handler) + log.debug(u"new jingle application registered") + + def registerTransport(self, namespace, transport_type, handler, priority=0): + """Register a transport plugin + + @param namespace(unicode): the XML namespace used for this transport + @param transport_type(unicode): type of transport to use (see XEP-0166 §8) + @param handler(object): instance of a class which manage the application. + Must have the following methods: + - jingleSessionInit(client, self, session, content_name[, *args, **kwargs]): must return the domish.Element used for initial content + - jingleHandler(client, self, action, session, content_name, transport_elt): + called on several action to negociate the application or transport + @param priority(int): priority of this transport + """ + assert transport_type in (XEP_0166.TRANSPORT_DATAGRAM, XEP_0166.TRANSPORT_STREAMING) + if namespace in self._transports: + raise exceptions.ConflictError(u"Trying to register already registered namespace {}".format(namespace)) + transport_data = TransportData(namespace=namespace, handler=handler, priority=priority) + self._type_transports[transport_type].append(transport_data) + self._type_transports[transport_type].sort(key=lambda transport_data: transport_data.priority, reverse=True) + self._transports[namespace] = transport_data + log.debug(u"new jingle transport registered") + + @defer.inlineCallbacks + def transportReplace(self, client, transport_ns, session, content_name): + """Replace a transport + + @param transport_ns(unicode): namespace of the new transport to use + @param session(dict): jingle session data + @param content_name(unicode): name of the content + """ + # XXX: for now we replace the transport before receiving confirmation from other peer + # this is acceptable because we terminate the session if transport is rejected. + # this behavious may change in the future. + content_data= session['contents'][content_name] + transport_data = content_data['transport_data'] + try: + transport = self._transports[transport_ns] + except KeyError: + raise exceptions.InternalError(u"Unkown transport") + yield content_data['transport'].handler.jingleHandler(client, XEP_0166.A_DESTROY, session, content_name, None) + content_data['transport'] = transport + transport_data.clear() + + iq_elt, jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_TRANSPORT_REPLACE) + content_elt = jingle_elt.addElement('content') + content_elt['name'] = content_name + content_elt['creator'] = content_data['creator'] + + transport_elt = transport.handler.jingleSessionInit(client, session, content_name) + content_elt.addChild(transport_elt) + iq_elt.send() + + def buildAction(self, client, action, session, content_name): + """Build an element according to requested action + + @param action(unicode): a jingle action (see XEP-0166 §7.2), + session-* actions are not managed here + transport-replace is managed in the dedicated [transportReplace] method + @param session(dict): jingle session data + @param content_name(unicode): name of the content + @return (tuple[domish.Element, domish.Element]): parent element, or element, according to action + """ + # we first build iq, jingle and content element which are the same in every cases + iq_elt, jingle_elt = self._buildJingleElt(client, session, action) + # FIXME: XEP-0260 § 2.3 Ex 5 has an initiator attribute, but it should not according to XEP-0166 §7.1 table 1, must be checked + content_data= session['contents'][content_name] + content_elt = jingle_elt.addElement('content') + content_elt['name'] = content_name + content_elt['creator'] = content_data['creator'] + + if action == XEP_0166.A_TRANSPORT_INFO: + context_elt = transport_elt = content_elt.addElement('transport', content_data['transport'].namespace) + transport_elt['sid'] = content_data['transport_data']['sid'] + else: + raise exceptions.InternalError(u"unmanaged action {}".format(action)) + + return iq_elt, context_elt + + def buildSessionInfo(self, client, session): + """Build a session-info action + + @param session(dict): jingle session data + @return (tuple[domish.Element, domish.Element]): parent element, element + """ + return self._buildJingleElt(client, session, XEP_0166.A_SESSION_INFO) + + @defer.inlineCallbacks + def initiate(self, client, peer_jid, contents): + """Send a session initiation request + + @param peer_jid(jid.JID): jid to establith session with + @param contents(list[dict]): list of contents to use: + The dict must have the following keys: + - app_ns(unicode): namespace of the application + the following keys are optional: + - transport_type(unicode): type of transport to use (see XEP-0166 §8) + default to TRANSPORT_STREAMING + - name(unicode): name of the content + - senders(unicode): One of XEP_0166.ROLE_INITIATOR, XEP_0166.ROLE_RESPONDER, both or none + default to BOTH (see XEP-0166 §7.3) + - app_args(list): args to pass to the application plugin + - app_kwargs(dict): keyword args to pass to the application plugin + @return D(unicode): jingle session id + """ + assert contents # there must be at least one content + if peer_jid == client.jid: + raise ValueError(_(u"You can't do a jingle session with yourself")) + initiator = client.jid + sid = unicode(uuid.uuid4()) + # TODO: session cleaning after timeout ? + session = client.jingle_sessions[sid] = {'id': sid, + 'state': STATE_PENDING, + 'initiator': initiator, + 'role': XEP_0166.ROLE_INITIATOR, + 'peer_jid': peer_jid, + 'started': time.time(), + 'contents': {} + } + iq_elt, jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_SESSION_INITIATE) + jingle_elt["initiator"] = initiator.full() + + contents_dict = session['contents'] + + for content in contents: + # we get the application plugin + app_ns = content['app_ns'] + try: + application = self._applications[app_ns] + except KeyError: + raise exceptions.InternalError(u"No application registered for {}".format(app_ns)) + + # and the transport plugin + transport_type = content.get('transport_type', XEP_0166.TRANSPORT_STREAMING) + try: + transport = self._type_transports[transport_type][0] + except IndexError: + raise exceptions.InternalError(u"No transport registered for {}".format(transport_type)) + + # we build the session data + content_data = {'application': application, + 'application_data': {}, + 'transport': transport, + 'transport_data': {}, + 'creator': XEP_0166.ROLE_INITIATOR, + 'senders': content.get('senders', 'both'), + } + try: + content_name = content['name'] + except KeyError: + content_name = unicode(uuid.uuid4()) + else: + if content_name in contents_dict: + raise exceptions.InternalError('There is already a content with this name') + contents_dict[content_name] = content_data + + # we construct the content element + content_elt = jingle_elt.addElement('content') + content_elt['creator'] = content_data['creator'] + content_elt['name'] = content_name + try: + content_elt['senders'] = content['senders'] + except KeyError: + pass + + # then the description element + app_args = content.get('app_args', []) + app_kwargs = content.get('app_kwargs', {}) + desc_elt = yield application.handler.jingleSessionInit(client, session, content_name, *app_args, **app_kwargs) + content_elt.addChild(desc_elt) + + # and the transport one + transport_elt = yield transport.handler.jingleSessionInit(client, session, content_name) + content_elt.addChild(transport_elt) + + try: + yield iq_elt.send() + except Exception as e: + failure_ = failure.Failure(e) + self._iqError(failure_, sid, client) + raise failure_ + + def delayedContentTerminate(self, *args, **kwargs): + """Put contentTerminate in queue but don't execute immediately + + This is used to terminate a content inside a handler, to avoid modifying contents + """ + reactor.callLater(0, self.contentTerminate, *args, **kwargs) + + def contentTerminate(self, client, session, content_name, reason=REASON_SUCCESS): + """Terminate and remove a content + + if there is no more content, then session is terminated + @param session(dict): jingle session + @param content_name(unicode): name of the content terminated + @param reason(unicode): reason of the termination + """ + contents = session['contents'] + del contents[content_name] + if not contents: + self.terminate(client, reason, session) + + ## defaults methods called when plugin doesn't have them ## + + def jingleRequestConfirmationDefault(self, client, action, session, content_name, desc_elt): + """This method request confirmation for a jingle session""" + log.debug(u"Using generic jingle confirmation method") + return xml_tools.deferConfirm(self.host, _(CONFIRM_TXT).format(entity=session['peer_jid'].full()), _('Confirm Jingle session'), profile=client.profile) + + ## jingle events ## + + def _onJingleRequest(self, request, client): + """Called when any jingle request is received + + The request will then be dispatched to appropriate method + according to current state + @param request(domish.Element): received IQ request + """ + request.handled = True + jingle_elt = request.elements(NS_JINGLE, 'jingle').next() + + # first we need the session id + try: + sid = jingle_elt['sid'] + if not sid: + raise KeyError + except KeyError: + log.warning(u"Received jingle request has no sid attribute") + self.sendError(client, 'bad-request', None, request) + return + + # then the action + try: + action = jingle_elt['action'] + if not action: + raise KeyError + except KeyError: + log.warning(u"Received jingle request has no action") + self.sendError(client, 'bad-request', None, request) + return + + peer_jid = jid.JID(request['from']) + + # we get or create the session + try: + session = client.jingle_sessions[sid] + except KeyError: + if action == XEP_0166.A_SESSION_INITIATE: + pass + elif action == XEP_0166.A_SESSION_TERMINATE: + log.debug(u"ignoring session terminate action (inexisting session id): {request_id} [{profile}]".format( + request_id=sid, + profile = client.profile)) + return + else: + log.warning(u"Received request for an unknown session id: {request_id} [{profile}]".format( + request_id=sid, + profile = client.profile)) + self.sendError(client, 'item-not-found', None, request, 'unknown-session') + return + + session = client.jingle_sessions[sid] = {'id': sid, + 'state': STATE_PENDING, + 'initiator': peer_jid, + 'role': XEP_0166.ROLE_RESPONDER, + 'peer_jid': peer_jid, + 'started': time.time(), + } + else: + if session['peer_jid'] != peer_jid: + log.warning(u"sid conflict ({}), the jid doesn't match. Can be a collision, a hack attempt, or a bad sid generation".format(sid)) + self.sendError(client, 'service-unavailable', sid, request) + return + if session['id'] != sid: + log.error(u"session id doesn't match") + self.sendError(client, 'service-unavailable', sid, request) + raise exceptions.InternalError + + if action == XEP_0166.A_SESSION_INITIATE: + self.onSessionInitiate(client, request, jingle_elt, session) + elif action == XEP_0166.A_SESSION_TERMINATE: + self.onSessionTerminate(client, request, jingle_elt, session) + elif action == XEP_0166.A_SESSION_ACCEPT: + self.onSessionAccept(client, request, jingle_elt, session) + elif action == XEP_0166.A_SESSION_INFO: + self.onSessionInfo(client, request, jingle_elt, session) + elif action == XEP_0166.A_TRANSPORT_INFO: + self.onTransportInfo(client, request, jingle_elt, session) + elif action == XEP_0166.A_TRANSPORT_REPLACE: + self.onTransportReplace(client, request, jingle_elt, session) + elif action == XEP_0166.A_TRANSPORT_ACCEPT: + self.onTransportAccept(client, request, jingle_elt, session) + elif action == XEP_0166.A_TRANSPORT_REJECT: + self.onTransportReject(client, request, jingle_elt, session) + else: + raise exceptions.InternalError(u"Unknown action {}".format(action)) + + ## Actions callbacks ## + + def _parseElements(self, jingle_elt, session, request, client, new=False, creator=ROLE_INITIATOR, with_application=True, with_transport=True): + """Parse contents elements and fill contents_dict accordingly + + after the parsing, contents_dict will containt handlers, "desc_elt" and "transport_elt" + @param jingle_elt(domish.Element): parent element, containing one or more + @param session(dict): session data + @param request(domish.Element): the whole request + @param client: %(doc_client)s + @param new(bool): True if the content is new and must be created, + else the content must exists, and session data will be filled + @param creator(unicode): only used if new is True: creating pear (see § 7.3) + @param with_application(bool): if True, raise an error if there is no element else ignore it + @param with_transport(bool): if True, raise an error if there is no element else ignore it + @raise exceptions.CancelError: the error is treated and the calling method can cancel the treatment (i.e. return) + """ + contents_dict = session['contents'] + content_elts = jingle_elt.elements(NS_JINGLE, 'content') + + for content_elt in content_elts: + name = content_elt['name'] + + if new: + # the content must not exist, we check it + if not name or name in contents_dict: + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + content_data = contents_dict[name] = {'creator': creator, + 'senders': content_elt.attributes.get('senders', 'both'), + } + else: + # the content must exist, we check it + try: + content_data = contents_dict[name] + except KeyError: + log.warning(u"Other peer try to access an unknown content") + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + + # application + if with_application: + desc_elt = content_elt.description + if not desc_elt: + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + + if new: + # the content is new, we need to check and link the application + app_ns = desc_elt.uri + if not app_ns or app_ns == NS_JINGLE: + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + + try: + application = self._applications[app_ns] + except KeyError: + log.warning(u"Unmanaged application namespace [{}]".format(app_ns)) + self.sendError(client, 'service-unavailable', session['id'], request) + raise exceptions.CancelError + + content_data['application'] = application + content_data['application_data'] = {} + else: + # the content exists, we check that we have not a former desc_elt + if 'desc_elt' in content_data: + raise exceptions.InternalError(u"desc_elt should not exist at this point") + + content_data['desc_elt'] = desc_elt + + # transport + if with_transport: + transport_elt = content_elt.transport + if not transport_elt: + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + + if new: + # the content is new, we need to check and link the transport + transport_ns = transport_elt.uri + if not app_ns or app_ns == NS_JINGLE: + self.sendError(client, 'bad-request', session['id'], request) + raise exceptions.CancelError + + try: + transport = self._transports[transport_ns] + except KeyError: + raise exceptions.InternalError(u"No transport registered for namespace {}".format(transport_ns)) + content_data['transport'] = transport + content_data['transport_data'] = {} + else: + # the content exists, we check that we have not a former transport_elt + if 'transport_elt' in content_data: + raise exceptions.InternalError(u"transport_elt should not exist at this point") + + content_data['transport_elt'] = transport_elt + + def _ignore(self, client, action, session, content_name, elt): + """Dummy method used when not exception must be raised if a method is not implemented in _callPlugins + + must be used as app_default_cb and/or transp_default_cb + """ + return elt + + def _callPlugins(self, client, action, session, app_method_name='jingleHandler', + transp_method_name='jingleHandler', + app_default_cb=None, transp_default_cb=None, delete=True, + elements=True, force_element=None): + """Call application and transport plugin methods for all contents + + @param action(unicode): jingle action name + @param session(dict): jingle session data + @param app_method_name(unicode, None): name of the method to call for applications + None to ignore + @param transp_method_name(unicode, None): name of the method to call for transports + None to ignore + @param app_default_cb(callable, None): default callback to use if plugin has not app_method_name + None to raise an exception instead + @param transp_default_cb(callable, None): default callback to use if plugin has not transp_method_name + None to raise an exception instead + @param delete(bool): if True, remove desc_elt and transport_elt from session + ignored if elements is False + @param elements(bool): True if elements(desc_elt and tranport_elt) must be managed + must be True if _callPlugins is used in a request, and False if it used after a request + (i.e. on result or error) + @param force_element(None, domish.Element, object): if elements is False, it is used as element parameter + else it is ignored + @return (list[defer.Deferred]): list of launched Deferred + @raise exceptions.NotFound: method is not implemented + """ + contents_dict = session['contents'] + defers_list = [] + for content_name, content_data in contents_dict.iteritems(): + for method_name, handler_key, default_cb, elt_name in ( + (app_method_name, 'application', app_default_cb, 'desc_elt'), + (transp_method_name, 'transport', transp_default_cb, 'transport_elt')): + if method_name is None: + continue + + handler = content_data[handler_key].handler + try: + method = getattr(handler, method_name) + except AttributeError: + if default_cb is None: + raise exceptions.NotFound(u'{} not implemented !'.format(method_name)) + else: + method = default_cb + if elements: + elt = content_data.pop(elt_name) if delete else content_data[elt_name] + else: + elt = force_element + d = defer.maybeDeferred(method, client, action, session, content_name, elt) + defers_list.append(d) + + return defers_list + + def onSessionInitiate(self, client, request, jingle_elt, session): + """Called on session-initiate action + + The "jingleRequestConfirmation" method of each application will be called + (or self.jingleRequestConfirmationDefault if the former doesn't exist). + The session is only accepted if all application are confirmed. + The application must manage itself multiple contents scenari (e.g. audio/video). + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): element + @param session(dict): session data + """ + if 'contents' in session: + raise exceptions.InternalError("Contents dict should not already exist at this point") + session['contents'] = contents_dict = {} + + try: + self._parseElements(jingle_elt, session, request, client, True, XEP_0166.ROLE_INITIATOR) + except exceptions.CancelError: + return + + if not contents_dict: + # there MUST be at least one content + self.sendError(client, 'bad-request', session['id'], request) + return + + # at this point we can send the result to confirm reception of the request + client.send(xmlstream.toResponse(request, 'result')) + + # we now request each application plugin confirmation + # and if all are accepted, we can accept the session + confirm_defers = self._callPlugins(client, XEP_0166.A_SESSION_INITIATE, session, 'jingleRequestConfirmation', None, self.jingleRequestConfirmationDefault, delete=False) + + confirm_dlist = defer.gatherResults(confirm_defers) + confirm_dlist.addCallback(self._confirmationCb, session, jingle_elt, client) + confirm_dlist.addErrback(self._jingleErrorCb, session['id'], request, client) + + def _confirmationCb(self, confirm_results, session, jingle_elt, client): + """Method called when confirmation from user has been received + + This method is only called for the responder + @param confirm_results(list[bool]): all True if session is accepted + @param session(dict): session data + @param jingle_elt(domish.Element): jingle data of this session + @param client: %(doc_client)s + """ + confirmed = all(confirm_results) + if not confirmed: + return self.terminate(client, XEP_0166.REASON_DECLINE, session) + + iq_elt, jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_SESSION_ACCEPT) + jingle_elt['responder'] = client.jid.full() + + # contents + + def addElement(domish_elt, content_elt): + content_elt.addChild(domish_elt) + + defers_list = [] + + for content_name, content_data in session['contents'].iteritems(): + content_elt = jingle_elt.addElement('content') + content_elt['creator'] = XEP_0166.ROLE_INITIATOR + content_elt['name'] = content_name + + application = content_data['application'] + app_session_accept_cb = application.handler.jingleHandler + + app_d = defer.maybeDeferred(app_session_accept_cb, client, + XEP_0166.A_SESSION_INITIATE, session, content_name, content_data.pop('desc_elt')) + app_d.addCallback(addElement, content_elt) + defers_list.append(app_d) + + transport = content_data['transport'] + transport_session_accept_cb = transport.handler.jingleHandler + + transport_d = defer.maybeDeferred(transport_session_accept_cb, client, + XEP_0166.A_SESSION_INITIATE, session, content_name, content_data.pop('transport_elt')) + transport_d.addCallback(addElement, content_elt) + defers_list.append(transport_d) + + d_list = defer.DeferredList(defers_list) + d_list.addCallback(lambda dummy: self._callPlugins(client, XEP_0166.A_PREPARE_RESPONDER, session, app_method_name=None, elements=False)) + d_list.addCallback(lambda dummy: iq_elt.send()) + def changeState(dummy, session): + session['state'] = STATE_ACTIVE + + d_list.addCallback(changeState, session) + d_list.addCallback(lambda dummy: self._callPlugins(client, XEP_0166.A_ACCEPTED_ACK, session, elements=False)) + d_list.addErrback(self._iqError, session['id'], client) + return d_list + + def onSessionTerminate(self, client, request, jingle_elt, session): + # TODO: check reason, display a message to user if needed + log.debug("Jingle Session {} terminated".format(session['id'])) + try: + reason_elt = jingle_elt.elements(NS_JINGLE, 'reason').next() + except StopIteration: + log.warning(u"No reason given for session termination") + reason_elt = jingle_elt.addElement('reason') + + terminate_defers = self._callPlugins(client, XEP_0166.A_SESSION_TERMINATE, session, 'jingleTerminate', 'jingleTerminate', self._ignore, self._ignore, elements=False, force_element=reason_elt) + terminate_dlist = defer.DeferredList(terminate_defers) + + terminate_dlist.addCallback(lambda dummy: self._delSession(client, session['id'])) + client.send(xmlstream.toResponse(request, 'result')) + + def onSessionAccept(self, client, request, jingle_elt, session): + """Method called once session is accepted + + This method is only called for initiator + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + log.debug(u"Jingle session {} has been accepted".format(session['id'])) + + try: + self._parseElements(jingle_elt, session, request, client) + except exceptions.CancelError: + return + + # at this point we can send the result to confirm reception of the request + client.send(xmlstream.toResponse(request, 'result')) + # and change the state + session['state'] = STATE_ACTIVE + + negociate_defers = [] + negociate_defers = self._callPlugins(client, XEP_0166.A_SESSION_ACCEPT, session) + + negociate_dlist = defer.DeferredList(negociate_defers) + + # after negociations we start the transfer + negociate_dlist.addCallback(lambda dummy: self._callPlugins(client, XEP_0166.A_START, session, app_method_name=None, elements=False)) + + def _onSessionCb(self, result, client, request, jingle_elt, session): + client.send(xmlstream.toResponse(request, 'result')) + + def _onSessionEb(self, failure_, client, request, jingle_elt, session): + log.error(u"Error while handling onSessionInfo: {}".format(failure_.value)) + # XXX: only error managed so far, maybe some applications/transports need more + self.sendError(client, 'feature-not-implemented', None, request, 'unsupported-info') + + def onSessionInfo(self, client, request, jingle_elt, session): + """Method called when a session-info action is received from other peer + + This method is only called for initiator + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + if not jingle_elt.children: + # this is a session ping, see XEP-0166 §6.8 + client.send(xmlstream.toResponse(request, 'result')) + return + + try: + # XXX: session-info is most likely only used for application, so we don't call transport plugins + # if a future transport use it, this behaviour must be adapted + defers = self._callPlugins(client, XEP_0166.A_SESSION_INFO, session, 'jingleSessionInfo', None, + elements=False, force_element=jingle_elt) + except exceptions.NotFound as e: + self._onSessionEb(failure.Failure(e), client, request, jingle_elt, session) + return + + dlist = defer.DeferredList(defers, fireOnOneErrback=True) + dlist.addCallback(self._onSessionCb, client, request, jingle_elt, session) + dlist.addErrback(self._onSessionCb, client, request, jingle_elt, session) + + @defer.inlineCallbacks + def onTransportReplace(self, client, request, jingle_elt, session): + """A transport change is requested + + The request is parsed, and jingleHandler is called on concerned transport plugin(s) + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + log.debug(u"Other peer wants to replace the transport") + try: + self._parseElements(jingle_elt, session, request, client, with_application=False) + except exceptions.CancelError: + defer.returnValue(None) + + client.send(xmlstream.toResponse(request, 'result')) + + content_name = None + to_replace = [] + + for content_name, content_data in session['contents'].iteritems(): + try: + transport_elt = content_data.pop('transport_elt') + except KeyError: + continue + transport_ns = transport_elt.uri + try: + transport = self._transports[transport_ns] + except KeyError: + log.warning(u"Other peer want to replace current transport with an unknown one: {}".format(transport_ns)) + content_name = None + break + to_replace.append((content_name, content_data, transport, transport_elt)) + + if content_name is None: + # wa can't accept the replacement + iq_elt, reject_jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_TRANSPORT_REJECT) + for child in jingle_elt.children: + reject_jingle_elt.addChild(child) + + iq_elt.send() + defer.returnValue(None) + + # at this point, everything is alright and we can replace the transport(s) + # this is similar to an session-accept action, but for transports only + iq_elt, accept_jingle_elt = self._buildJingleElt(client, session, XEP_0166.A_TRANSPORT_ACCEPT) + for content_name, content_data, transport, transport_elt in to_replace: + # we can now actually replace the transport + yield content_data['transport'].handler.jingleHandler(client, XEP_0166.A_DESTROY, session, content_name, None) + content_data['transport'] = transport + content_data['transport_data'].clear() + # and build the element + content_elt = accept_jingle_elt.addElement('content') + content_elt['name'] = content_name + content_elt['creator'] = content_data['creator'] + # we notify the transport and insert its in the answer + accept_transport_elt = yield transport.handler.jingleHandler(client, XEP_0166.A_TRANSPORT_REPLACE, session, content_name, transport_elt) + content_elt.addChild(accept_transport_elt) + # there is no confirmation needed here, so we can directly prepare it + yield transport.handler.jingleHandler(client, XEP_0166.A_PREPARE_RESPONDER, session, content_name, None) + + iq_elt.send() + + def onTransportAccept(self, client, request, jingle_elt, session): + """Method called once transport replacement is accepted + + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + log.debug(u"new transport has been accepted") + + try: + self._parseElements(jingle_elt, session, request, client, with_application=False) + except exceptions.CancelError: + return + + # at this point we can send the result to confirm reception of the request + client.send(xmlstream.toResponse(request, 'result')) + + negociate_defers = [] + negociate_defers = self._callPlugins(client, XEP_0166.A_TRANSPORT_ACCEPT, session, app_method_name=None) + + negociate_dlist = defer.DeferredList(negociate_defers) + + # after negociations we start the transfer + negociate_dlist.addCallback(lambda dummy: self._callPlugins(client, XEP_0166.A_START, session, app_method_name=None, elements=False)) + + def onTransportReject(self, client, request, jingle_elt, session): + """Method called when a transport replacement is refused + + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + # XXX: for now, we terminate the session in case of transport-reject + # this behaviour may change in the future + self.terminate(client, 'failed-transport', session) + + def onTransportInfo(self, client, request, jingle_elt, session): + """Method called when a transport-info action is received from other peer + + The request is parsed, and jingleHandler is called on concerned transport plugin(s) + @param client: %(doc_client)s + @param request(domish.Element): full request + @param jingle_elt(domish.Element): the element + @param session(dict): session data + """ + log.debug(u"Jingle session {} has been accepted".format(session['id'])) + + try: + self._parseElements(jingle_elt, session, request, client, with_application=False) + except exceptions.CancelError: + return + + # The parsing was OK, we send the result + client.send(xmlstream.toResponse(request, 'result')) + + for content_name, content_data in session['contents'].iteritems(): + try: + transport_elt = content_data.pop('transport_elt') + except KeyError: + continue + else: + content_data['transport'].handler.jingleHandler(client, XEP_0166.A_TRANSPORT_INFO, session, content_name, transport_elt) + + +class XEP_0166_handler(xmlstream.XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + + def connectionInitialized(self): + self.xmlstream.addObserver(JINGLE_REQUEST, self.plugin_parent._onJingleRequest, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_JINGLE)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0184.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0184.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0184 +# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk) + +# 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 . +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +from twisted.internet import reactor +from twisted.words.protocols.jabber import xmlstream, jid +from twisted.words.xish import domish +log = getLogger(__name__) + +from wokkel import disco, iwokkel +from zope.interface import implements +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + + +NS_MESSAGE_DELIVERY_RECEIPTS = 'urn:xmpp:receipts' + +MSG = 'message' + +MSG_CHAT = '/'+MSG+'[@type="chat"]' +MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST = MSG_CHAT+'/request[@xmlns="'+NS_MESSAGE_DELIVERY_RECEIPTS+'"]' +MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = MSG_CHAT+'/received[@xmlns="'+NS_MESSAGE_DELIVERY_RECEIPTS+'"]' + +MSG_NORMAL = '/'+MSG+'[@type="normal"]' +MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST = MSG_NORMAL+'/request[@xmlns="'+NS_MESSAGE_DELIVERY_RECEIPTS+'"]' +MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = MSG_NORMAL+'/received[@xmlns="'+NS_MESSAGE_DELIVERY_RECEIPTS+'"]' + + +PARAM_KEY = "Privacy" +PARAM_NAME = "Enable message delivery receipts" +ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME + + +PLUGIN_INFO = { +C.PI_NAME: "XEP-0184 Plugin", +C.PI_IMPORT_NAME: "XEP-0184", +C.PI_TYPE: "XEP", +C.PI_PROTOCOLS: ["XEP-0184"], +C.PI_DEPENDENCIES: [], +C.PI_MAIN: "XEP_0184", +C.PI_HANDLER: "yes", +C.PI_DESCRIPTION: _("""Implementation of Message Delivery Receipts""") +} + + +STATUS_MESSAGE_DELIVERY_RECEIVED = "delivered" +TEMPO_DELETE_WAITING_ACK_S = 300 # 5 min + + +class XEP_0184(object): + """ + Implementation for XEP 0184. + """ + params = """ + + + + + + + + """ % { + 'category_name': PARAM_KEY, + 'category_label': _(PARAM_KEY), + 'param_name': PARAM_NAME, + 'param_label': _('Enable message delivery receipts') + } + + def __init__(self, host): + log.info(_("Plugin XEP_0184 (message delivery receipts) initialization")) + self.host = host + self._dictRequest = dict() + + # parameter value is retrieved before each use + host.memory.updateParams(self.params) + + host.trigger.add("sendMessage", self.sendMessageTrigger) + host.bridge.addSignal("messageState", ".plugin", signature='sss') # message_uid, status, profile + + def getHandler(self, client): + return XEP_0184_handler(self, client.profile) + + def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """Install SendMessage command hook """ + def treatment(mess_data): + message = mess_data['xml'] + message_type = message.getAttribute("type") + + if self._isActif(client.profile) and (message_type == "chat" or message_type == "normal"): + message.addElement('request', NS_MESSAGE_DELIVERY_RECEIPTS) + uid = mess_data['uid'] + msg_id = message.getAttribute("id") + self._dictRequest[msg_id] = uid + reactor.callLater(TEMPO_DELETE_WAITING_ACK_S, self._clearDictRequest, msg_id) + log.debug(_("[XEP-0184] Request acknowledgment for message id {}".format(msg_id))) + + return mess_data + + post_xml_treatments.addCallback(treatment) + return True + + def onMessageDeliveryReceiptsRequest(self, msg_elt, client): + """This method is called on message delivery receipts **request** (XEP-0184 #7) + @param msg_elt: message element + @param client: %(doc_client)s""" + from_jid = jid.JID(msg_elt['from']) + + if self._isActif(client.profile) and client.roster.isPresenceAuthorised(from_jid): + received_elt_ret = domish.Element((NS_MESSAGE_DELIVERY_RECEIPTS, 'received')) + received_elt_ret["id"] = msg_elt["id"] + + msg_result_elt = xmlstream.toResponse(msg_elt, 'result') + msg_result_elt.addChild(received_elt_ret) + client.send(msg_result_elt) + + def onMessageDeliveryReceiptsReceived(self, msg_elt, client): + """This method is called on message delivery receipts **received** (XEP-0184 #7) + @param msg_elt: message element + @param client: %(doc_client)s""" + msg_elt.handled = True + rcv_elt = msg_elt.elements(NS_MESSAGE_DELIVERY_RECEIPTS, 'received').next() + msg_id = rcv_elt['id'] + + try: + uid = self._dictRequest[msg_id] + del self._dictRequest[msg_id] + self.host.bridge.messageState(uid, STATUS_MESSAGE_DELIVERY_RECEIVED, client.profile) + log.debug(_("[XEP-0184] Receive acknowledgment for message id {}".format(msg_id))) + except KeyError: + pass + + def _clearDictRequest(self, msg_id): + try: + del self._dictRequest[msg_id] + log.debug(_("[XEP-0184] Delete waiting acknowledgment for message id {}".format(msg_id))) + except KeyError: + pass + + def _isActif(self, profile): + return self.host.memory.getParamA(PARAM_NAME, PARAM_KEY, profile_key=profile) + +class XEP_0184_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def connectionInitialized(self): + self.xmlstream.addObserver(MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST, self.plugin_parent.onMessageDeliveryReceiptsRequest, client=self.parent) + self.xmlstream.addObserver(MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED, self.plugin_parent.onMessageDeliveryReceiptsReceived, client=self.parent) + + self.xmlstream.addObserver(MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST, self.plugin_parent.onMessageDeliveryReceiptsRequest, client=self.parent) + self.xmlstream.addObserver(MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED, self.plugin_parent.onMessageDeliveryReceiptsReceived, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_MESSAGE_DELIVERY_RECEIPTS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0203.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0203.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,85 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Delayed Delivery (XEP-0203) +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) + +from wokkel import disco, iwokkel, delay +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implements + + +NS_DD = 'urn:xmpp:delay' + +PLUGIN_INFO = { + C.PI_NAME: "Delayed Delivery", + C.PI_IMPORT_NAME: "XEP-0203", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0203"], + C.PI_MAIN: "XEP_0203", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Delayed Delivery""") +} + + +class XEP_0203(object): + + def __init__(self, host): + log.info(_("Delayed Delivery plugin initialization")) + self.host = host + + def getHandler(self, client): + return XEP_0203_handler(self, client.profile) + + def delay(self, stamp, sender=None, desc='', parent=None): + """Build a delay element, eventually append it to the given parent element. + + @param stamp (datetime): offset-aware timestamp of the original sending. + @param sender (JID): entity that originally sent or delayed the message. + @param desc (unicode): optional natural language description. + @param parent (domish.Element): add the delay element to this element. + @return: the delay element (domish.Element) + """ + elt = delay.Delay(stamp, sender).toElement() + if desc: + elt.addContent(desc) + if parent: + parent.addChild(elt) + return elt + + +class XEP_0203_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_DD)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0231.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0231.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,239 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Bit of Binary handling (XEP-0231) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import xml_tools +from wokkel import disco, iwokkel +from zope.interface import implements +from twisted.python import failure +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import error as jabber_error +from twisted.internet import defer +from functools import partial +import base64 +import time + + +PLUGIN_INFO = { + C.PI_NAME: "Bits of Binary", + C.PI_IMPORT_NAME: "XEP-0231", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0231"], + C.PI_MAIN: "XEP_0231", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of bits of binary (used for small images/files)""") +} + +NS_BOB = u'urn:xmpp:bob' +IQ_BOB_REQUEST = C.IQ_GET + '/data[@xmlns="' + NS_BOB + '"]' + + +class XEP_0231(object): + + def __init__(self, host): + log.info(_(u"plugin Bits of Binary initialization")) + self.host = host + host.registerNamespace('bob', NS_BOB) + host.trigger.add("xhtml_post_treat", self.XHTMLTrigger) + host.bridge.addMethod("bobGetFile", ".plugin", + in_sign='sss', out_sign='s', + method=self._getFile, + async=True) + + def dumpData(self, cache, data_elt, cid): + """save file encoded in data_elt to cache + + @param cache(memory.cache.Cache): cache to use to store the data + @param data_elt(domish.Element): as in XEP-0231 + @param cid(unicode): content-id + @return(unicode): full path to dumped file + """ + # FIXME: is it needed to use a separate thread? + # probably not with the little data expected with BoB + try: + max_age = int(data_elt['max-age']) + if max_age < 0: + raise ValueError + except (KeyError, ValueError): + log.warning(u'invalid max-age found') + max_age = None + + with cache.cacheData( + PLUGIN_INFO[C.PI_IMPORT_NAME], + cid, + data_elt.getAttribute('type'), + max_age) as f: + + file_path = f.name + f.write(base64.b64decode(str(data_elt))) + + return file_path + + def getHandler(self, client): + return XEP_0231_handler(self) + + def _requestCb(self, iq_elt, cache, cid): + for data_elt in iq_elt.elements(NS_BOB, u'data'): + if data_elt.getAttribute('cid') == cid: + file_path = self.dumpData(cache, data_elt, cid) + return file_path + + log.warning(u"invalid data stanza received, requested cid was not found:\n{iq_elt}\nrequested cid: {cid}".format( + iq_elt = iq_elt, + cid = cid + )) + raise failure.Failure(exceptions.DataError("missing data")) + + def _requestEb(self, failure_): + """Log the error and continue errback chain""" + log.warning(u"Can't get requested data:\n{reason}".format(reason=failure_)) + return failure_ + + def requestData(self, client, to_jid, cid, cache=None): + """Request data if we don't have it in cache + + @param to_jid(jid.JID): jid to request the data to + @param cid(unicode): content id + @param cache(memory.cache.Cache, None): cache to use + client.cache will be used if None + @return D(unicode): path to file with data + """ + if cache is None: + cache = client.cache + iq_elt = client.IQ('get') + iq_elt['to'] = to_jid.full() + data_elt = iq_elt.addElement((NS_BOB, 'data')) + data_elt['cid'] = cid + d = iq_elt.send() + d.addCallback(self._requestCb, cache, cid) + d.addErrback(self._requestEb) + return d + + def _setImgEltSrc(self, path, img_elt): + img_elt[u'src'] = u'file://{}'.format(path) + + def XHTMLTrigger(self, client, message_elt, body_elt, lang, treat_d): + for img_elt in xml_tools.findAll(body_elt, C.NS_XHTML, u'img'): + source = img_elt.getAttribute(u'src','') + if source.startswith(u'cid:'): + cid = source[4:] + file_path = client.cache.getFilePath(cid) + if file_path is not None: + # image is in cache, we change the url + img_elt[u'src'] = u'file://{}'.format(file_path) + continue + else: + # image is not in cache, is it given locally? + for data_elt in message_elt.elements(NS_BOB, u'data'): + if data_elt.getAttribute('cid') == cid: + file_path = self.dumpData(client.cache, data_elt, cid) + img_elt[u'src'] = u'file://{}'.format(file_path) + break + else: + # cid not found locally, we need to request it + # so we use the deferred + d = self.requestData(client, jid.JID(message_elt['from']), cid) + d.addCallback(partial(self._setImgEltSrc, img_elt=img_elt)) + treat_d.addCallback(lambda dummy: d) + + def onComponentRequest(self, iq_elt, client): + """cache data is retrieve from common cache for components""" + # FIXME: this is a security/privacy issue as no access check is done + # but this is mitigated by the fact that the cid must be known. + # An access check should be implemented though. + + iq_elt.handled = True + data_elt = next(iq_elt.elements(NS_BOB, 'data')) + try: + cid = data_elt[u'cid'] + except KeyError: + error_elt = jabber_error.StanzaError('not-acceptable').toResponse(iq_elt) + client.send(error_elt) + return + + metadata = self.host.common_cache.getMetadata(cid) + if metadata is None: + error_elt = jabber_error.StanzaError('item-not-found').toResponse(iq_elt) + client.send(error_elt) + return + + with open(metadata['path']) as f: + data = f.read() + + result_elt = xmlstream.toResponse(iq_elt, 'result') + data_elt = result_elt.addElement((NS_BOB, 'data'), content = data.encode('base64')) + data_elt[u'cid'] = cid + data_elt[u'type'] = metadata[u'mime_type'] + data_elt[u'max-age'] = unicode(int(max(0, metadata['eol'] - time.time()))) + client.send(result_elt) + + def _getFile(self, peer_jid_s, cid, profile): + peer_jid = jid.JID(peer_jid_s) + assert cid + client = self.host.getClient(profile) + return self.getFile(client, peer_jid, cid) + + def getFile(self, client, peer_jid, cid, parent_elt=None): + """Retrieve a file from it's content-id + + @param peer_jid(jid.JID): jid of the entity offering the data + @param cid(unicode): content-id of file data + @param parent_elt(domish.Element, None): if file is not in cache, + data will be looked after in children of this elements. + None to ignore + @return D(unicode): path to cached data + """ + file_path = client.cache.getFilePath(cid) + if file_path is not None: + # file is in cache + return defer.succeed(file_path) + else: + # file not in cache, is it given locally? + if parent_elt is not None: + for data_elt in parent_elt.elements(NS_BOB, u'data'): + if data_elt.getAttribute('cid') == cid: + return defer.succeed(self.dumpData(client.cache, data_elt, cid)) + + # cid not found locally, we need to request it + # so we use the deferred + return self.requestData(client, peer_jid, cid) + + +class XEP_0231_handler(xmlstream.XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + if self.parent.is_component: + self.xmlstream.addObserver(IQ_BOB_REQUEST, self.plugin_parent.onComponentRequest, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_BOB)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0234.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0234.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,633 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Jingle File Transfer (XEP-0234) +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from wokkel import disco, iwokkel +from zope.interface import implements +from sat.tools import utils +from sat.tools import stream +import os.path +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.python import failure +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.internet import defer +from twisted.internet import reactor +from twisted.internet import error as internet_error +from collections import namedtuple +from sat.tools.common import regex +import mimetypes + + +NS_JINGLE_FT = 'urn:xmpp:jingle:apps:file-transfer:5' + +PLUGIN_INFO = { + C.PI_NAME: "Jingle File Transfer", + C.PI_IMPORT_NAME: "XEP-0234", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0234"], + C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0300", "FILE"], + C.PI_MAIN: "XEP_0234", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer""") +} + +EXTRA_ALLOWED = {u'path', u'namespace', u'file_desc', u'file_hash'} +Range = namedtuple('Range', ('offset', 'length')) + + +class XEP_0234(object): + # TODO: assure everything is closed when file is sent or session terminate is received + # TODO: call self._f.unregister when unloading order will be managing (i.e. when dependencies will be unloaded at the end) + Range = Range # we copy the class here, so it can be used by other plugins + + def __init__(self, host): + log.info(_("plugin Jingle File Transfer initialization")) + self.host = host + host.registerNamespace('jingle-ft', NS_JINGLE_FT) + self._j = host.plugins["XEP-0166"] # shortcut to access jingle + self._j.registerApplication(NS_JINGLE_FT, self) + self._f = host.plugins["FILE"] + self._f.register(NS_JINGLE_FT, self.fileJingleSend, priority = 10000, method_name=u"Jingle") + self._hash = self.host.plugins["XEP-0300"] + host.bridge.addMethod("fileJingleSend", ".plugin", in_sign='ssssa{ss}s', out_sign='', method=self._fileJingleSend, async=True) + host.bridge.addMethod("fileJingleRequest", ".plugin", in_sign='sssssa{ss}s', out_sign='s', method=self._fileJingleRequest, async=True) + + def getHandler(self, client): + return XEP_0234_handler() + + def getProgressId(self, session, content_name): + """Return a unique progress ID + + @param session(dict): jingle session + @param content_name(unicode): name of the content + @return (unicode): unique progress id + """ + return u'{}_{}'.format(session['id'], content_name) + + # generic methods + + def buildFileElement(self, name, file_hash=None, hash_algo=None, size=None, mime_type=None, desc=None, + modified=None, transfer_range=None, path=None, namespace=None, file_elt=None, **kwargs): + """Generate a element with available metadata + + @param file_hash(unicode, None): hash of the file + empty string to set element + @param hash_algo(unicode, None): hash algorithm used + if file_hash is None and hash_algo is set, a element will be generated + @param transfer_range(Range, None): where transfer must start/stop + @param modified(int, unicode, None): date of last modification + 0 to use current date + int to use an unix timestamp + else must be an unicode string which will be used as it (it must be an XMPP time) + @param file_elt(domish.Element, None): element to use + None to create a new one + @param **kwargs: data for plugin extension (ignored by default) + @return (domish.Element): generated element + @trigger XEP-0234_buildFileElement(file_elt, extra_args): can be used to extend elements to add + """ + if file_elt is None: + file_elt = domish.Element((NS_JINGLE_FT, u'file')) + for name, value in ((u'name', name), (u'size', size), ('media-type', mime_type), + (u'desc', desc), (u'path', path), (u'namespace', namespace)): + if value is not None: + file_elt.addElement(name, content=unicode(value)) + + if modified is not None: + if isinstance(modified, int): + file_elt.addElement(u'date', utils.xmpp_date(modified or None)) + else: + file_elt.addElement(u'date', modified) + elif 'created' in kwargs: + file_elt.addElement(u'date', utils.xmpp_date(kwargs.pop('created'))) + + range_elt = file_elt.addElement(u'range') + if transfer_range is not None: + if transfer_range.offset is not None: + range_elt[u'offset'] = transfer_range.offset + if transfer_range.length is not None: + range_elt[u'length'] = transfer_range.length + if file_hash is not None: + if not file_hash: + file_elt.addChild(self._hash.buildHashUsedElt()) + else: + file_elt.addChild(self._hash.buildHashElt(file_hash, hash_algo)) + elif hash_algo is not None: + file_elt.addChild(self._hash.buildHashUsedElt(hash_algo)) + self.host.trigger.point(u'XEP-0234_buildFileElement', file_elt, extra_args=kwargs) + if kwargs: + for kw in kwargs: + log.debug('ignored keyword: {}'.format(kw)) + return file_elt + + def buildFileElementFromDict(self, file_data, **kwargs): + """like buildFileElement but get values from a file_data dict + + @param file_data(dict): metadata to use + @param **kwargs: data to override + """ + if kwargs: + file_data = file_data.copy() + file_data.update(kwargs) + return self. buildFileElement(**file_data) + + + def parseFileElement(self, file_elt, file_data=None, given=False, parent_elt=None, keep_empty_range=False): + """Parse a element and file dictionary accordingly + + @param file_data(dict, None): dict where the data will be set + following keys will be set (and overwritten if they already exist): + name, file_hash, hash_algo, size, mime_type, desc, path, namespace, range + if None, a new dict is created + @param given(bool): if True, prefix hash key with "given_" + @param parent_elt(domish.Element, None): parent of the file element + if set, file_elt must not be set + @param keep_empty_range(bool): if True, keep empty range (i.e. range when offset and length are None) + empty range are useful to know if a peer_jid can handle range + @return (dict): file_data + @trigger XEP-0234_parseFileElement(file_elt, file_data): can be used to parse new elements + @raise exceptions.NotFound: there is not element in parent_elt + @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT + """ + if parent_elt is not None: + if file_elt is not None: + raise exceptions.InternalError(u'file_elt must be None if parent_elt is set') + try: + file_elt = next(parent_elt.elements(NS_JINGLE_FT, u'file')) + except StopIteration: + raise exceptions.NotFound() + else: + if not file_elt or file_elt.uri != NS_JINGLE_FT: + raise exceptions.DataError(u'invalid element: {stanza}'.format(stanza = file_elt.toXml())) + + if file_data is None: + file_data = {} + + for name in (u'name', u'desc', u'path', u'namespace'): + try: + file_data[name] = unicode(next(file_elt.elements(NS_JINGLE_FT, name))) + except StopIteration: + pass + + + name = file_data.get(u'name') + if name == u'..': + # we don't want to go to parent dir when joining to a path + name = u'--' + file_data[u'name'] = name + elif name is not None and u'/' in name or u'\\' in name: + file_data[u'name'] = regex.pathEscape(name) + + try: + file_data[u'mime_type'] = unicode(next(file_elt.elements(NS_JINGLE_FT, u'media-type'))) + except StopIteration: + pass + + try: + file_data[u'size'] = int(unicode(next(file_elt.elements(NS_JINGLE_FT, u'size')))) + except StopIteration: + pass + + try: + file_data[u'modified'] = utils.date_parse(next(file_elt.elements(NS_JINGLE_FT, u'date'))) + except StopIteration: + pass + + try: + range_elt = file_elt.elements(NS_JINGLE_FT, u'range').next() + except StopIteration: + pass + else: + offset = range_elt.getAttribute('offset') + length = range_elt.getAttribute('length') + if offset or length or keep_empty_range: + file_data[u'transfer_range'] = Range(offset=offset, length=length) + + prefix = u'given_' if given else u'' + hash_algo_key, hash_key = u'hash_algo', prefix + u'file_hash' + try: + file_data[hash_algo_key], file_data[hash_key] = self._hash.parseHashElt(file_elt) + except exceptions.NotFound: + pass + + self.host.trigger.point(u'XEP-0234_parseFileElement', file_elt, file_data) + + return file_data + + # bridge methods + + def _fileJingleSend(self, peer_jid, filepath, name="", file_desc="", extra=None, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + return self.fileJingleSend(client, jid.JID(peer_jid), filepath, name or None, file_desc or None, extra or None) + + @defer.inlineCallbacks + def fileJingleSend(self, client, peer_jid, filepath, name, file_desc=None, extra=None): + """Send a file using jingle file transfer + + @param peer_jid(jid.JID): destinee jid + @param filepath(str): absolute path of the file + @param name(unicode, None): name of the file + @param file_desc(unicode, None): description of the file + @return (D(unicode)): progress id + """ + progress_id_d = defer.Deferred() + if extra is None: + extra = {} + if file_desc is not None: + extra['file_desc'] = file_desc + yield self._j.initiate(client, + peer_jid, + [{'app_ns': NS_JINGLE_FT, + 'senders': self._j.ROLE_INITIATOR, + 'app_kwargs': {'filepath': filepath, + 'name': name, + 'extra': extra, + 'progress_id_d': progress_id_d}, + }]) + progress_id = yield progress_id_d + defer.returnValue(progress_id) + + def _fileJingleRequest(self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + return self.fileJingleRequest(client, jid.JID(peer_jid), filepath, name or None, file_hash or None, hash_algo or None, extra or None) + + @defer.inlineCallbacks + def fileJingleRequest(self, client, peer_jid, filepath, name=None, file_hash=None, hash_algo=None, extra=None): + """Request a file using jingle file transfer + + @param peer_jid(jid.JID): destinee jid + @param filepath(str): absolute path of the file + @param name(unicode, None): name of the file + @param file_hash(unicode, None): hash of the file + @return (D(unicode)): progress id + """ + progress_id_d = defer.Deferred() + if extra is None: + extra = {} + if file_hash is not None: + if hash_algo is None: + raise ValueError(_(u"hash_algo must be set if file_hash is set")) + extra['file_hash'] = file_hash + extra['hash_algo'] = hash_algo + else: + if hash_algo is not None: + raise ValueError(_(u"file_hash must be set if hash_algo is set")) + yield self._j.initiate(client, + peer_jid, + [{'app_ns': NS_JINGLE_FT, + 'senders': self._j.ROLE_RESPONDER, + 'app_kwargs': {'filepath': filepath, + 'name': name, + 'extra': extra, + 'progress_id_d': progress_id_d}, + }]) + progress_id = yield progress_id_d + defer.returnValue(progress_id) + + # jingle callbacks + + def jingleSessionInit(self, client, session, content_name, filepath, name, extra, progress_id_d): + if extra is None: + extra = {} + else: + if not EXTRA_ALLOWED.issuperset(extra): + raise ValueError(_(u"only the following keys are allowed in extra: {keys}").format( + keys=u', '.join(EXTRA_ALLOWED))) + progress_id_d.callback(self.getProgressId(session, content_name)) + content_data = session['contents'][content_name] + application_data = content_data['application_data'] + assert 'file_path' not in application_data + application_data['file_path'] = filepath + file_data = application_data['file_data'] = {} + desc_elt = domish.Element((NS_JINGLE_FT, 'description')) + file_elt = desc_elt.addElement("file") + + if content_data[u'senders'] == self._j.ROLE_INITIATOR: + # we send a file + if name is None: + name = os.path.basename(filepath) + file_data[u'date'] = utils.xmpp_date() + file_data[u'desc'] = extra.pop(u'file_desc', u'') + file_data[u'name'] = name + mime_type = mimetypes.guess_type(name, strict=False)[0] + if mime_type is not None: + file_data[u'mime_type'] = mime_type + file_data[u'size'] = os.path.getsize(filepath) + if u'namespace' in extra: + file_data[u'namespace'] = extra[u'namespace'] + if u'path' in extra: + file_data[u'path'] = extra[u'path'] + self.buildFileElementFromDict(file_data, file_elt=file_elt, file_hash=u'') + else: + # we request a file + file_hash = extra.pop(u'file_hash', u'') + if not name and not file_hash: + raise ValueError(_(u'you need to provide at least name or file hash')) + if name: + file_data[u'name'] = name + if file_hash: + file_data[u'file_hash'] = file_hash + file_data[u'hash_algo'] = extra[u'hash_algo'] + else: + file_data[u'hash_algo'] = self._hash.getDefaultAlgo() + if u'namespace' in extra: + file_data[u'namespace'] = extra[u'namespace'] + if u'path' in extra: + file_data[u'path'] = extra[u'path'] + self.buildFileElementFromDict(file_data, file_elt=file_elt) + + return desc_elt + + def jingleRequestConfirmation(self, client, action, session, content_name, desc_elt): + """This method request confirmation for a jingle session""" + content_data = session['contents'][content_name] + senders = content_data[u'senders'] + if senders not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER): + log.warning(u"Bad sender, assuming initiator") + senders = content_data[u'senders'] = self._j.ROLE_INITIATOR + # first we grab file informations + try: + file_elt = desc_elt.elements(NS_JINGLE_FT, 'file').next() + except StopIteration: + raise failure.Failure(exceptions.DataError) + file_data = {'progress_id': self.getProgressId(session, content_name)} + + if senders == self._j.ROLE_RESPONDER: + # we send the file + return self._fileSendingRequestConf(client, session, content_data, content_name, file_data, file_elt) + else: + # we receive the file + return self._fileReceivingRequestConf(client, session, content_data, content_name, file_data, file_elt) + + @defer.inlineCallbacks + def _fileSendingRequestConf(self, client, session, content_data, content_name, file_data, file_elt): + """parse file_elt, and handle file retrieving/permission checking""" + self.parseFileElement(file_elt, file_data) + content_data['application_data']['file_data'] = file_data + finished_d = content_data['finished_d'] = defer.Deferred() + + # confirmed_d is a deferred returning confimed value (only used if cont is False) + cont, confirmed_d = self.host.trigger.returnPoint("XEP-0234_fileSendingRequest", client, session, content_data, content_name, file_data, file_elt) + if not cont: + confirmed = yield confirmed_d + if confirmed: + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + defer.returnValue(confirmed) + + log.warning(_(u'File continue is not implemented yet')) + defer.returnValue(False) + + def _fileReceivingRequestConf(self, client, session, content_data, content_name, file_data, file_elt): + """parse file_elt, and handle user permission/file opening""" + self.parseFileElement(file_elt, file_data, given=True) + try: + hash_algo, file_data['given_file_hash'] = self._hash.parseHashElt(file_elt) + except exceptions.NotFound: + try: + hash_algo = self._hash.parseHashUsedElt(file_elt) + except exceptions.NotFound: + raise failure.Failure(exceptions.DataError) + + if hash_algo is not None: + file_data['hash_algo'] = hash_algo + file_data['hash_hasher'] = hasher = self._hash.getHasher(hash_algo) + file_data['data_cb'] = lambda data: hasher.update(data) + + try: + file_data['size'] = int(file_data['size']) + except ValueError: + raise failure.Failure(exceptions.DataError) + + name = file_data['name'] + if '/' in name or '\\' in name: + log.warning(u"File name contain path characters, we replace them: {}".format(name)) + file_data['name'] = name.replace('/', '_').replace('\\', '_') + + content_data['application_data']['file_data'] = file_data + + # now we actualy request permission to user + def gotConfirmation(confirmed): + if confirmed: + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + return confirmed + + # deferred to track end of transfer + finished_d = content_data['finished_d'] = defer.Deferred() + d = self._f.getDestDir(client, session['peer_jid'], content_data, file_data, stream_object=True) + d.addCallback(gotConfirmation) + return d + + def jingleHandler(self, client, action, session, content_name, desc_elt): + content_data = session['contents'][content_name] + application_data = content_data['application_data'] + if action in (self._j.A_ACCEPTED_ACK,): + pass + elif action == self._j.A_SESSION_INITIATE: + file_elt = desc_elt.elements(NS_JINGLE_FT, 'file').next() + try: + file_elt.elements(NS_JINGLE_FT, 'range').next() + except StopIteration: + # initiator doesn't manage , but we do so we advertise it + # FIXME: to be checked + log.debug("adding element") + file_elt.addElement('range') + elif action == self._j.A_SESSION_ACCEPT: + assert not 'stream_object' in content_data + file_data = application_data['file_data'] + file_path = application_data['file_path'] + senders = content_data[u'senders'] + if senders != session[u'role']: + # we are receiving the file + try: + # did the responder specified the size of the file? + file_elt = next(desc_elt.elements(NS_JINGLE_FT, u'file')) + size_elt = next(file_elt.elements(NS_JINGLE_FT, u'size')) + size = int(unicode(size_elt)) + except (StopIteration, ValueError): + size = None + # XXX: hash security is not critical here, so we just take the higher mandatory one + hasher = file_data['hash_hasher'] = self._hash.getHasher() + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + mode='wb', + uid=self.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) + else: + # we are sending the file + size = file_data['size'] + # XXX: hash security is not critical here, so we just take the higher mandatory one + hasher = file_data['hash_hasher'] = self._hash.getHasher() + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + uid=self.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) + finished_d = content_data['finished_d'] = defer.Deferred() + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + else: + log.warning(u"FIXME: unmanaged action {}".format(action)) + return desc_elt + + def jingleSessionInfo(self, client, action, session, content_name, jingle_elt): + """Called on session-info action + + manage checksum, and ignore element + """ + # TODO: manage element + content_data = session['contents'][content_name] + elts = [elt for elt in jingle_elt.elements() if elt.uri == NS_JINGLE_FT] + if not elts: + return + for elt in elts: + if elt.name == 'received': + pass + elif elt.name == 'checksum': + # we have received the file hash, we need to parse it + if content_data['senders'] == session['role']: + log.warning(u"unexpected checksum received while we are the file sender") + raise exceptions.DataError + info_content_name = elt['name'] + if info_content_name != content_name: + # it was for an other content... + return + file_data = content_data['application_data']['file_data'] + try: + file_elt = elt.elements((NS_JINGLE_FT, 'file')).next() + except StopIteration: + raise exceptions.DataError + algo, file_data['given_file_hash'] = self._hash.parseHashElt(file_elt) + if algo != file_data.get('hash_algo'): + log.warning(u"Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo}) [{profile}]" + .format(peer_algo=algo, our_algo=file_data.get('hash_algo'), profile=client.profile)) + else: + self._receiverTryTerminate(client, session, content_name, content_data) + else: + raise NotImplementedError + + def jingleTerminate(self, client, action, session, content_name, jingle_elt): + if jingle_elt.decline: + # progress is the only way to tell to frontends that session has been declined + progress_id = self.getProgressId(session, content_name) + self.host.bridge.progressError(progress_id, C.PROGRESS_ERROR_DECLINED, client.profile) + + def _sendCheckSum(self, client, session, content_name, content_data): + """Send the session-info with the hash checksum""" + file_data = content_data['application_data']['file_data'] + hasher = file_data['hash_hasher'] + hash_ = hasher.hexdigest() + log.debug(u"Calculated hash: {}".format(hash_)) + iq_elt, jingle_elt = self._j.buildSessionInfo(client, session) + checksum_elt = jingle_elt.addElement((NS_JINGLE_FT, 'checksum')) + checksum_elt['creator'] = content_data['creator'] + checksum_elt['name'] = content_name + file_elt = checksum_elt.addElement('file') + file_elt.addChild(self._hash.buildHashElt(hash_)) + iq_elt.send() + + def _receiverTryTerminate(self, client, session, content_name, content_data, last_try=False): + """Try to terminate the session + + This method must only be used by the receiver. + It check if transfer is finished, and hash available, + if everything is OK, it check hash and terminate the session + @param last_try(bool): if True this mean than session must be terminated even given hash is not available + @return (bool): True if session was terminated + """ + if not content_data.get('transfer_finished', False): + return False + file_data = content_data['application_data']['file_data'] + given_hash = file_data.get('given_file_hash') + if given_hash is None: + if last_try: + log.warning(u"sender didn't sent hash checksum, we can't check the file [{profile}]".format(profile=client.profile)) + self._j.delayedContentTerminate(client, session, content_name) + content_data['stream_object'].close() + return True + return False + hasher = file_data['hash_hasher'] + hash_ = hasher.hexdigest() + + if hash_ == given_hash: + log.info(u"Hash checked, file was successfully transfered: {}".format(hash_)) + progress_metadata = {'hash': hash_, + 'hash_algo': file_data['hash_algo'], + 'hash_verified': C.BOOL_TRUE + } + error = None + else: + log.warning(u"Hash mismatch, the file was not transfered correctly") + progress_metadata=None + error = u"Hash mismatch: given={algo}:{given}, calculated={algo}:{our}".format( + algo = file_data['hash_algo'], + given = given_hash, + our = hash_) + + self._j.delayedContentTerminate(client, session, content_name) + content_data['stream_object'].close(progress_metadata, error) + # we may have the last_try timer still active, so we try to cancel it + try: + content_data['last_try_timer'].cancel() + except (KeyError, internet_error.AlreadyCalled): + pass + return True + + def _finishedCb(self, dummy, client, session, content_name, content_data): + log.info(u"File transfer terminated") + if content_data['senders'] != session['role']: + # we terminate the session only if we are the receiver, + # as recommanded in XEP-0234 §2 (after example 6) + content_data['transfer_finished'] = True + if not self._receiverTryTerminate(client, session, content_name, content_data): + # we have not received the hash yet, we wait 5 more seconds + content_data['last_try_timer'] = reactor.callLater( + 5, self._receiverTryTerminate, client, session, content_name, content_data, last_try=True) + else: + # we are the sender, we send the checksum + self._sendCheckSum(client, session, content_name, content_data) + content_data['stream_object'].close() + + def _finishedEb(self, failure, client, session, content_name, content_data): + log.warning(u"Error while streaming file: {}".format(failure)) + content_data['stream_object'].close() + self._j.contentTerminate(client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT) + + +class XEP_0234_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_JINGLE_FT)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0249.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0249.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,210 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0249 +# 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 . + +from sat.core.i18n import _, D_ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import xml_tools +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid + +from zope.interface import implements + +from wokkel import disco, iwokkel + + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +MESSAGE = '/message' +NS_DIRECT_MUC_INVITATION = 'jabber:x:conference' +DIRECT_MUC_INVITATION_REQUEST = MESSAGE + '/x[@xmlns="' + NS_DIRECT_MUC_INVITATION + '"]' +AUTOJOIN_KEY = "Misc" +AUTOJOIN_NAME = "Auto-join MUC on invitation" +AUTOJOIN_VALUES = ["ask", "always", "never"] + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0249 Plugin", + C.PI_IMPORT_NAME: "XEP-0249", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0249"], + C.PI_DEPENDENCIES: ["XEP-0045"], + C.PI_RECOMMENDATIONS: [C.TEXT_CMDS], + C.PI_MAIN: "XEP_0249", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Direct MUC Invitations""") +} + + +class XEP_0249(object): + + params = """ + + + + + %(param_options)s + + + + + """ % { + 'category_name': AUTOJOIN_KEY, + 'category_label': _("Misc"), + 'param_name': AUTOJOIN_NAME, + 'param_label': _("Auto-join MUC on invitation"), + 'param_options': '\n'.join(['
element, this is not standard !")) + if data_elt.uri != C.NS_XHTML: + raise failure.Failure(exceptions.DataError(_('Content of type XHTML must declare its namespace!'))) + key = check_conflict(u'{}_xhtml'.format(elem.name)) + data = data_elt.toXml() + microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].cleanXHTML(data) + else: + key = check_conflict(elem.name) + microblog_data[key] = unicode(elem) + + + id_ = item_elt.getAttribute('id', '') # there can be no id for transient nodes + microblog_data[u'id'] = id_ + if item_elt.uri not in (pubsub.NS_PUBSUB, NS_PUBSUB_EVENT): + msg = u"Unsupported namespace {ns} in pubsub item {id_}".format(ns=item_elt.uri, id_=id_) + log.warning(msg) + raise failure.Failure(exceptions.DataError(msg)) + + try: + entry_elt = item_elt.elements(NS_ATOM, 'entry').next() + except StopIteration: + msg = u'No atom entry found in the pubsub item {}'.format(id_) + raise failure.Failure(exceptions.DataError(msg)) + + # language + try: + microblog_data[u'language'] = entry_elt[(C.NS_XML, u'lang')].strip() + except KeyError: + pass + + # atom:id + try: + id_elt = entry_elt.elements(NS_ATOM, 'id').next() + except StopIteration: + msg = u'No atom id found in the pubsub item {}, this is not standard !'.format(id_) + log.warning(msg) + microblog_data[u'atom_id'] = "" + else: + microblog_data[u'atom_id'] = unicode(id_elt) + + # title/content(s) + + # FIXME: ATOM and XEP-0277 only allow 1 element + # but in the wild we have some blogs with several ones + # so we don't respect the standard for now (it doesn't break + # anything anyway), and we'll find a better option later + # try: + # title_elt = entry_elt.elements(NS_ATOM, 'title').next() + # except StopIteration: + # msg = u'No atom title found in the pubsub item {}'.format(id_) + # raise failure.Failure(exceptions.DataError(msg)) + title_elts = list(entry_elt.elements(NS_ATOM, 'title')) + if not title_elts: + msg = u'No atom title found in the pubsub item {}'.format(id_) + raise failure.Failure(exceptions.DataError(msg)) + for title_elt in title_elts: + yield parseElement(title_elt) + + # FIXME: as for <title/>, Atom only authorise at most 1 content + # but XEP-0277 allows several ones. So for no we handle as + # if more than one can be present + for content_elt in entry_elt.elements(NS_ATOM, 'content'): + yield parseElement(content_elt) + + # we check that text content is present + for key in ('title', 'content'): + if key not in microblog_data and ('{}_xhtml'.format(key)) in microblog_data: + log.warning(u"item {id_} provide a {key}_xhtml data but not a text one".format(id_=id_, key=key)) + # ... and do the conversion if it's not + microblog_data[key] = yield self.host.plugins["TEXT-SYNTAXES"].\ + convert(microblog_data[u'{}_xhtml'.format(key)], + self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML, + self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT, + False) + + if 'content' not in microblog_data: + # use the atom title data as the microblog body content + microblog_data[u'content'] = microblog_data[u'title'] + del microblog_data[u'title'] + if 'title_xhtml' in microblog_data: + microblog_data[u'content_xhtml'] = microblog_data[u'title_xhtml'] + del microblog_data[u'title_xhtml'] + + # published/updated dates + try: + updated_elt = entry_elt.elements(NS_ATOM, 'updated').next() + except StopIteration: + msg = u'No atom updated element found in the pubsub item {}'.format(id_) + raise failure.Failure(exceptions.DataError(msg)) + microblog_data[u'updated'] = unicode(calendar.timegm(dateutil.parser.parse(unicode(updated_elt)).utctimetuple())) + try: + published_elt = entry_elt.elements(NS_ATOM, 'published').next() + except StopIteration: + microblog_data[u'published'] = microblog_data[u'updated'] + else: + microblog_data[u'published'] = unicode(calendar.timegm(dateutil.parser.parse(unicode(published_elt)).utctimetuple())) + + # links + for link_elt in entry_elt.elements(NS_ATOM, 'link'): + if link_elt.getAttribute('rel') == 'replies' and link_elt.getAttribute('title') == 'comments': + key = check_conflict('comments', True) + microblog_data[key] = link_elt['href'] + try: + service, node = self.parseCommentUrl(microblog_data[key]) + except: + log.warning(u"Can't parse url {}".format(microblog_data[key])) + del microblog_data[key] + else: + microblog_data[u'{}_service'.format(key)] = service.full() + microblog_data[u'{}_node'.format(key)] = node + else: + rel = link_elt.getAttribute('rel','') + title = link_elt.getAttribute('title','') + href = link_elt.getAttribute('href','') + log.warning(u"Unmanaged link element: rel={rel} title={title} href={href}".format(rel=rel, title=title, href=href)) + + # author + try: + author_elt = entry_elt.elements(NS_ATOM, 'author').next() + except StopIteration: + log.debug(u"Can't find author element in item {}".format(id_)) + else: + publisher = item_elt.getAttribute("publisher") + # name + try: + name_elt = author_elt.elements(NS_ATOM, 'name').next() + except StopIteration: + log.warning(u"No name element found in author element of item {}".format(id_)) + else: + microblog_data[u'author'] = unicode(name_elt) + # uri + try: + uri_elt = author_elt.elements(NS_ATOM, 'uri').next() + except StopIteration: + log.debug(u"No uri element found in author element of item {}".format(id_)) + if publisher: + microblog_data[u'author_jid'] = publisher + else: + uri = unicode(uri_elt) + if uri.startswith("xmpp:"): + uri = uri[5:] + microblog_data[u'author_jid'] = uri + else: + microblog_data[u'author_jid'] = item_elt.getAttribute(u"publisher") or "" + + if not publisher: + log.debug(u"No publisher attribute, we can't verify author jid") + microblog_data[u'author_jid_verified'] = C.BOOL_FALSE + elif jid.JID(publisher).userhostJID() == jid.JID(uri).userhostJID(): + microblog_data[u'author_jid_verified'] = C.BOOL_TRUE + else: + log.warning(u"item atom:uri differ from publisher attribute, spoofing attempt ? atom:uri = {} publisher = {}".format(uri, item_elt.getAttribute("publisher"))) + microblog_data[u'author_jid_verified'] = C.BOOL_FALSE + # email + try: + email_elt = author_elt.elements(NS_ATOM, 'email').next() + except StopIteration: + pass + else: + microblog_data[u'author_email'] = unicode(email_elt) + + # categories + categories = (category_elt.getAttribute('term','') for category_elt in entry_elt.elements(NS_ATOM, 'category')) + data_format.iter2dict('tag', categories, microblog_data) + + ## the trigger ## + # if other plugins have things to add or change + yield self.host.trigger.point("XEP-0277_item2data", item_elt, entry_elt, microblog_data) + + defer.returnValue(microblog_data) + + @defer.inlineCallbacks + def data2entry(self, client, data, item_id, service, node): + """Convert a data dict to en entry usable to create an item + + @param data: data dict as given by bridge method. + @param item_id(unicode): id of the item to use + @param service(jid.JID, None): pubsub service where the item is sent + Needed to construct Atom id + @param node(unicode): pubsub node where the item is sent + Needed to construct Atom id + @return: deferred which fire domish.Element + """ + entry_elt = domish.Element((NS_ATOM, 'entry')) + + ## language ## + if u'language' in data: + entry_elt[(C.NS_XML, u'lang')] = data[u'language'].strip() + + ## content and title ## + synt = self.host.plugins["TEXT-SYNTAXES"] + + for elem_name in ('title', 'content'): + for type_ in ['', '_rich', '_xhtml']: + attr = "{}{}".format(elem_name, type_) + if attr in data: + elem = entry_elt.addElement(elem_name) + if type_: + if type_ == '_rich': # convert input from current syntax to XHTML + xml_content = yield synt.convert(data[attr], synt.getCurrentSyntax(client.profile), "XHTML") + if '{}_xhtml'.format(elem_name) in data: + raise failure.Failure(exceptions.DataError(_("Can't have xhtml and rich content at the same time"))) + else: + xml_content = data[attr] + + div_elt = xml_tools.ElementParser()(xml_content, namespace=C.NS_XHTML) + if div_elt.name != 'div' or div_elt.uri != C.NS_XHTML or div_elt.attributes: + # we need a wrapping <div/> at the top with XHTML namespace + wrap_div_elt = domish.Element((C.NS_XHTML, 'div')) + wrap_div_elt.addChild(div_elt) + div_elt = wrap_div_elt + elem.addChild(div_elt) + elem['type'] = 'xhtml' + if elem_name not in data: + # there is raw text content, which is mandatory + # so we create one from xhtml content + elem_txt = entry_elt.addElement(elem_name) + text_content = yield self.host.plugins["TEXT-SYNTAXES"].convert(xml_content, + self.host.plugins["TEXT-SYNTAXES"].SYNTAX_XHTML, + self.host.plugins["TEXT-SYNTAXES"].SYNTAX_TEXT, + False) + elem_txt.addContent(text_content) + elem_txt['type'] = 'text' + + else: # raw text only needs to be escaped to get HTML-safe sequence + elem.addContent(data[attr]) + elem['type'] = 'text' + + try: + entry_elt.elements(NS_ATOM, 'title').next() + except StopIteration: + # we have no title element which is mandatory + # so we transform content element to title + elems = list(entry_elt.elements(NS_ATOM, 'content')) + if not elems: + raise exceptions.DataError("There must be at least one content or title element") + for elem in elems: + elem.name = 'title' + + ## author ## + author_elt = entry_elt.addElement('author') + try: + author_name = data['author'] + except KeyError: + # FIXME: must use better name + author_name = client.jid.user + author_elt.addElement('name', content=author_name) + + try: + author_jid_s = data['author_jid'] + except KeyError: + author_jid_s = client.jid.userhost() + author_elt.addElement('uri', content="xmpp:{}".format(author_jid_s)) + + try: + author_jid_s = data['author_email'] + except KeyError: + pass + + ## published/updated time ## + current_time = time.time() + entry_elt.addElement('updated', + content = utils.xmpp_date(float(data.get('updated', current_time)))) + entry_elt.addElement('published', + content = utils.xmpp_date(float(data.get('published', current_time)))) + + ## categories ## + for tag in data_format.dict2iter("tag", data): + category_elt = entry_elt.addElement("category") + category_elt['term'] = tag + + ## id ## + entry_id = data.get('id', xmpp_uri.buildXMPPUri( + u'pubsub', + path=service.full() if service is not None else client.jid.userhost(), + node=node, + item=item_id)) + entry_elt.addElement('id', content=entry_id) # + + ## comments ## + if 'comments' in data: + link_elt = entry_elt.addElement('link') + link_elt['href'] = data['comments'] + link_elt['rel'] = 'replies' + link_elt['title'] = 'comments' + + ## final item building ## + item_elt = pubsub.Item(id=item_id, payload=entry_elt) + + ## the trigger ## + # if other plugins have things to add or change + yield self.host.trigger.point("XEP-0277_data2entry", client, data, entry_elt, item_elt) + + defer.returnValue(item_elt) + + ## publish ## + + def getCommentsNode(self, item_id): + """Generate comment node + + @param item_id(unicode): id of the parent item + @return (unicode): comment node to use + """ + return u"{}{}".format(NS_COMMENT_PREFIX, item_id) + + def getCommentsService(self, client, parent_service=None): + """Get prefered PubSub service to create comment node + + @param pubsub_service(jid.JID, None): PubSub service of the parent item + @param return((D)jid.JID, None): PubSub service to use + """ + if parent_service is not None: + if parent_service.user: + # we are on a PEP + if parent_service.host == client.jid.host: + # it's our server, we use already found client.pubsub_service below + pass + else: + # other server, let's try to find a non PEP service there + d = self.host.findServiceEntity(client, "pubsub", "service", parent_service) + d.addCallback(lambda entity: entity or parent_service) + else: + # parent is already on a normal Pubsub service, we re-use it + return defer.succeed(parent_service) + + return defer.succeed(client.pubsub_service if client.pubsub_service is not None else parent_service) + + @defer.inlineCallbacks + def _manageComments(self, client, mb_data, service, node, item_id, access=None): + """Check comments keys in mb_data and create comments node if necessary + + if mb_data['comments'] exists, it is used (or mb_data['comments_service'] and/or mb_data['comments_node']), + else it is generated (if allow_comments is True). + @param mb_data(dict): microblog mb_data + @param service(jid.JID, None): PubSub service of the parent item + @param node(unicode): node of the parent item + @param item_id(unicode): id of the parent item + @param access(unicode, None): access model + None to use same access model as parent item + """ + # FIXME: if 'comments' already exists in mb_data, it is not used to create the Node + allow_comments = C.bool(mb_data.pop("allow_comments", "false")) + if not allow_comments: + if 'comments' in mb_data: + log.warning(u"comments are not allowed but there is already a comments node, it may be lost: {uri}".format(uri=mb_data['comments'])) + del mb_data['comments'] + return + + if access is None: + # TODO: cache access models per service/node + parent_node_config = yield self._p.getConfiguration(client, service, node) + access = parent_node_config.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN) + + options = {self._p.OPT_ACCESS_MODEL: access, + self._p.OPT_PERSIST_ITEMS: 1, + self._p.OPT_MAX_ITEMS: -1, + self._p.OPT_DELIVER_PAYLOADS: 1, + self._p.OPT_SEND_ITEM_SUBSCRIBE: 1, + # FIXME: would it make sense to restrict publish model to subscribers? + self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN, + } + + # if other plugins need to change the options + yield self.host.trigger.point("XEP-0277_comments", client, mb_data, options) + + try: + comments_node = mb_data['comments_node'] + except KeyError: + comments_node = self.getCommentsNode(item_id) + else: + if not comments_node: + raise exceptions.DataError(u"if comments_node is present, it must not be empty") + + try: + comments_service = jid.JID(mb_data['comments_service']) + except KeyError: + comments_service = yield self.getCommentsService(client, service) + + try: + yield self._p.createNode(client, comments_service, comments_node, options) + except error.StanzaError as e: + if e.condition == 'conflict': + log.info(u"node {} already exists on service {}".format(comments_node, comments_service)) + else: + raise e + else: + if access == self._p.ACCESS_WHITELIST: + # for whitelist access we need to copy affiliations from parent item + comments_affiliations = yield self._p.getNodeAffiliations(client, service, node) + # …except for "member", that we transform to publisher + # because we wants members to be able to write to comments + for jid_, affiliation in comments_affiliations.items(): + if affiliation == 'member': + comments_affiliations[jid_] == 'publisher' + + yield self._p.setNodeAffiliations(client, comments_service, comments_node, comments_affiliations) + + if comments_service is None: + comments_service = client.jid.userhostJID() + + if 'comments' in mb_data: + if not mb_data['comments']: + raise exceptions.DataError(u"if comments is present, it must not be empty") + if 'comments_node' in mb_data or 'comments_service' in mb_data: + raise exceptions.DataError(u"You can't use comments_service/comments_node and comments at the same time") + else: + mb_data['comments'] = self._p.getNodeURI(comments_service, comments_node) + + def _mbSend(self, service, node, data, profile_key): + service = jid.JID(service) if service else None + node = node if node else NS_MICROBLOG + client = self.host.getClient(profile_key) + return self.send(client, data, service, node) + + @defer.inlineCallbacks + def send(self, client, data, service=None, node=NS_MICROBLOG): + """Send XEP-0277's microblog data + + @param data(dict): microblog data (must include at least a "content" or a "title" key). + see http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en for details + @param service(jid.JID, None): PubSub service where the microblog must be published + None to publish on profile's PEP + @param node(unicode, None): PubSub node to use (defaut to microblog NS) + None is equivalend as using default value + """ + # TODO: check that all data keys are used, this would avoid sending publicly a private message + # by accident (e.g. if group pluging is not loaded, and "grou*" key are not used) + if node is None: + node = NS_MICROBLOG + + item_id = data.get('id') or unicode(shortuuid.uuid()) + + try: + yield self._manageComments(client, data, service, node, item_id, access=None) + except error.StanzaError: + log.warning(u"Can't create comments node for item {}".format(item_id)) + item = yield self.data2entry(client, data, item_id, service, node) + ret = yield self._p.publish(client, service, node, [item]) + defer.returnValue(ret) + + ## retract ## + + def _mbRetract(self, service_jid_s, nodeIdentifier, itemIdentifier, profile_key): + """Call self._p._retractItem, but use default node if node is empty""" + return self._p._retractItem(service_jid_s, nodeIdentifier or NS_MICROBLOG, itemIdentifier, True, profile_key) + + ## get ## + + def _mbGet(self, service='', node='', max_items=10, item_ids=None, extra_dict=None, profile_key=C.PROF_KEY_NONE): + """ + @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit + @param item_ids (list[unicode]): list of item IDs + """ + client = self.host.getClient(profile_key) + service = jid.JID(service) if service else None + max_items = None if max_items == C.NO_LIMIT else max_items + extra = self._p.parseExtra(extra_dict) + return self.mbGet(client, service, node or None, max_items, item_ids, extra.rsm_request, extra.extra) + + + @defer.inlineCallbacks + def mbGet(self, client, service=None, node=None, max_items=10, item_ids=None, rsm_request=None, extra=None): + """Get some microblogs + + @param service(jid.JID, None): jid of the publisher + None to get profile's PEP + @param node(unicode, None): node to get (or microblog node if None) + @param max_items(int): maximum number of item to get, None for no limit + @param item_ids (list[unicode]): list of item IDs + @param rsm_request (rsm.RSMRequest): RSM request data + @param extra (dict): extra data + + @return: a deferred couple with the list of items and metadatas. + """ + if node is None: + node = NS_MICROBLOG + items_data = yield self._p.getItems(client, service, node, max_items=max_items, item_ids=item_ids, rsm_request=rsm_request, extra=extra) + serialised = yield self._p.serItemsDataD(items_data, self.item2mbdata) + defer.returnValue(serialised) + + def parseCommentUrl(self, node_url): + """Parse a XMPP URI + + Determine the fields comments_service and comments_node of a microblog data + from the href attribute of an entry's link element. For example this input: + xmpp:sat-pubsub.example.net?;node=urn%3Axmpp%3Acomments%3A_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn%3Axmpp%3Agroupblog%3Asomebody%40example.net + will return(JID(u'sat-pubsub.example.net'), 'urn:xmpp:comments:_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn:xmpp:groupblog:somebody@example.net') + @return (tuple[jid.JID, unicode]): service and node + """ + parsed_url = urlparse.urlparse(node_url, 'xmpp') + service = jid.JID(parsed_url.path) + parsed_queries = urlparse.parse_qs(parsed_url.query.encode('utf-8')) + node = parsed_queries.get('node', [''])[0].decode('utf-8') + + if not node: + raise failure.Failure(exceptions.DataError('Invalid comments link')) + + return (service, node) + + ## configure ## + + def mbSetAccess(self, access="presence", profile_key=C.PROF_KEY_NONE): + """Create a microblog node on PEP with given access + + If the node already exists, it change options + @param access: Node access model, according to xep-0060 #4.5 + @param profile_key: profile key + """ + # FIXME: check if this mehtod is need, deprecate it if not + client = self.host.getClient(profile_key) + + _options = {self._p.OPT_ACCESS_MODEL: access, self._p.OPT_PERSIST_ITEMS: 1, self._p.OPT_MAX_ITEMS: -1, self._p.OPT_DELIVER_PAYLOADS: 1, self._p.OPT_SEND_ITEM_SUBSCRIBE: 1} + + def cb(result): + #Node is created with right permission + log.debug(_(u"Microblog node has now access %s") % access) + + def fatal_err(s_error): + #Something went wrong + log.error(_(u"Can't set microblog access")) + raise NodeAccessChangeException() + + def err_cb(s_error): + #If the node already exists, the condition is "conflict", + #else we have an unmanaged error + if s_error.value.condition == 'conflict': + #d = self.host.plugins["XEP-0060"].deleteNode(client, client.jid.userhostJID(), NS_MICROBLOG) + #d.addCallback(lambda x: create_node().addCallback(cb).addErrback(fatal_err)) + change_node_options().addCallback(cb).addErrback(fatal_err) + else: + fatal_err(s_error) + + def create_node(): + return self._p.createNode(client, client.jid.userhostJID(), NS_MICROBLOG, _options) + + def change_node_options(): + return self._p.setOptions(client.jid.userhostJID(), NS_MICROBLOG, client.jid.userhostJID(), _options, profile_key=profile_key) + + create_node().addCallback(cb).addErrback(err_cb) + + ## methods to manage several stanzas/jids at once ## + + # common + + def _getClientAndNodeData(self, publishers_type, publishers, profile_key): + """Helper method to construct node_data from publishers_type/publishers + + @param publishers_type: type of the list of publishers, one of: + C.ALL: get all jids from roster, publishers is not used + C.GROUP: get jids from groups + C.JID: use publishers directly as list of jids + @param publishers: list of publishers, according to "publishers_type" (None, list of groups or list of jids) + @param profile_key: %(doc_profile_key)s + """ + client = self.host.getClient(profile_key) + if publishers_type == C.JID: + jids_set = set(publishers) + else: + jids_set = client.roster.getJidsSet(publishers_type, publishers) + if publishers_type == C.ALL: + try: # display messages from salut-a-toi@libervia.org or other PEP services + services = self.host.plugins["EXTRA-PEP"].getFollowedEntities(profile_key) + except KeyError: + pass # plugin is not loaded + else: + if services: + log.debug("Extra PEP followed entities: %s" % ", ".join([unicode(service) for service in services])) + jids_set.update(services) + + node_data = [] + for jid_ in jids_set: + node_data.append((jid_, NS_MICROBLOG)) + return client, node_data + + def _checkPublishers(self, publishers_type, publishers): + """Helper method to deserialise publishers coming from bridge + + publishers_type(unicode): type of the list of publishers, one of: + publishers: list of publishers according to type + @return: deserialised (publishers_type, publishers) tuple + """ + if publishers_type == C.ALL: + if publishers: + raise failure.Failure(ValueError("Can't use publishers with {} type".format(publishers_type))) + else: + publishers = None + elif publishers_type == C.JID: + publishers[:] = [jid.JID(publisher) for publisher in publishers] + return publishers_type, publishers + + # subscribe # + + def _mbSubscribeToMany(self, publishers_type, publishers, profile_key): + """ + + @return (str): session id: Use pubsub.getSubscribeRTResult to get the results + """ + publishers_type, publishers = self._checkPublishers(publishers_type, publishers) + return self.mbSubscribeToMany(publishers_type, publishers, profile_key) + + def mbSubscribeToMany(self, publishers_type, publishers, profile_key): + """Subscribe microblogs for a list of groups or jids + + @param publishers_type: type of the list of publishers, one of: + C.ALL: get all jids from roster, publishers is not used + C.GROUP: get jids from groups + C.JID: use publishers directly as list of jids + @param publishers: list of publishers, according to "publishers_type" (None, list of groups or list of jids) + @param profile: %(doc_profile)s + @return (str): session id + """ + client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) + return self._p.subscribeToMany(node_data, client.jid.userhostJID(), profile_key=profile_key) + + # get # + + def _mbGetFromManyRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): + """Get real-time results for mbGetFromMany session + + @param session_id: id of the real-time deferred session + @param return (tuple): (remaining, results) where: + - remaining is the number of still expected results + - results is a list of tuple with + - service (unicode): pubsub service + - node (unicode): pubsub node + - failure (unicode): empty string in case of success, error message else + - items_data(list): data as returned by [mbGet] + - items_metadata(dict): metadata as returned by [mbGet] + @param profile_key: %(doc_profile_key)s + """ + def onSuccess(items_data): + """convert items elements to list of microblog data in items_data""" + d = self._p.serItemsDataD(items_data, self.item2mbdata) + d.addCallback(lambda serialised:('', serialised)) + return d + + profile = self.host.getClient(profile_key).profile + d = self._p.getRTResults(session_id, + on_success = onSuccess, + on_error = lambda failure: (unicode(failure.value), ([],{})), + profile = profile) + d.addCallback(lambda ret: (ret[0], + [(service.full(), node, failure, items, metadata) + for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()])) + return d + + def _mbGetFromMany(self, publishers_type, publishers, max_items=10, extra_dict=None, profile_key=C.PROF_KEY_NONE): + """ + @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit + """ + max_items = None if max_items == C.NO_LIMIT else max_items + publishers_type, publishers = self._checkPublishers(publishers_type, publishers) + extra = self._p.parseExtra(extra_dict) + return self.mbGetFromMany(publishers_type, publishers, max_items, extra.rsm_request, extra.extra, profile_key) + + def mbGetFromMany(self, publishers_type, publishers, max_items=None, rsm_request=None, extra=None, profile_key=C.PROF_KEY_NONE): + """Get the published microblogs for a list of groups or jids + + @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL") + @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids) + @param max_items (int): optional limit on the number of retrieved items. + @param rsm_request (rsm.RSMRequest): RSM request data, common to all publishers + @param extra (dict): Extra data + @param profile_key: profile key + @return (str): RT Deferred session id + """ + # XXX: extra is unused here so far + client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) + return self._p.getFromMany(node_data, max_items, rsm_request, profile_key=profile_key) + + # comments # + + def _mbGetFromManyWithCommentsRTResult(self, session_id, profile_key=C.PROF_KEY_DEFAULT): + """Get real-time results for [mbGetFromManyWithComments] session + + @param session_id: id of the real-time deferred session + @param return (tuple): (remaining, results) where: + - remaining is the number of still expected results + - results is a list of 5-tuple with + - service (unicode): pubsub service + - node (unicode): pubsub node + - failure (unicode): empty string in case of success, error message else + - items(list[tuple(dict, list)]): list of 2-tuple with + - item(dict): item microblog data + - comments_list(list[tuple]): list of 5-tuple with + - service (unicode): pubsub service where the comments node is + - node (unicode): comments node + - failure (unicode): empty in case of success, else error message + - comments(list[dict]): list of microblog data + - comments_metadata(dict): metadata of the comment node + - metadata(dict): original node metadata + @param profile_key: %(doc_profile_key)s + """ + profile = self.host.getClient(profile_key).profile + d = self.rt_sessions.getResults(session_id, profile=profile) + d.addCallback(lambda ret: (ret[0], + [(service.full(), node, failure, items, metadata) + for (service, node), (success, (failure, (items, metadata))) in ret[1].iteritems()])) + return d + + def _mbGetFromManyWithComments(self, publishers_type, publishers, max_items=10, max_comments=C.NO_LIMIT, extra_dict=None, extra_comments_dict=None, profile_key=C.PROF_KEY_NONE): + """ + @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit + @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no limit + """ + max_items = None if max_items == C.NO_LIMIT else max_items + max_comments = None if max_comments == C.NO_LIMIT else max_comments + publishers_type, publishers = self._checkPublishers(publishers_type, publishers) + extra = self._p.parseExtra(extra_dict) + extra_comments = self._p.parseExtra(extra_comments_dict) + return self.mbGetFromManyWithComments(publishers_type, publishers, max_items, max_comments or None, + extra.rsm_request, + extra.extra, + extra_comments.rsm_request, + extra_comments.extra, + profile_key) + + def mbGetFromManyWithComments(self, publishers_type, publishers, max_items=None, max_comments=None, rsm_request=None, extra=None, rsm_comments=None, extra_comments=None, profile_key=C.PROF_KEY_NONE): + """Helper method to get the microblogs and their comments in one shot + + @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL") + @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids) + @param max_items (int): optional limit on the number of retrieved items. + @param max_comments (int): maximum number of comments to retrieve + @param rsm_request (rsm.RSMRequest): RSM request for initial items only + @param extra (dict): extra configuration for initial items only + @param rsm_comments (rsm.RSMRequest): RSM request for comments only + @param extra_comments (dict): extra configuration for comments only + @param profile_key: profile key + @return (str): RT Deferred session id + """ + # XXX: this method seems complicated because it do a couple of treatments + # to serialise and associate the data, but it make life in frontends side + # a lot easier + + client, node_data = self._getClientAndNodeData(publishers_type, publishers, profile_key) + + def getComments(items_data): + """Retrieve comments and add them to the items_data + + @param items_data: serialised items data + @return (defer.Deferred): list of items where each item is associated + with a list of comments data (service, node, list of items, metadata) + """ + items, metadata = items_data + items_dlist = [] # deferred list for items + for item in items: + dlist = [] # deferred list for comments + for key, value in item.iteritems(): + # we look for comments + if key.startswith('comments') and key.endswith('_service'): + prefix = key[:key.find('_')] + service_s = value + node = item["{}{}".format(prefix, "_node")] + # time to get the comments + d = self._p.getItems(client, jid.JID(service_s), node, max_comments, rsm_request=rsm_comments, extra=extra_comments) + # then serialise + d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata)) + # with failure handling + d.addCallback(lambda serialised_items_data: ('',) + serialised_items_data) + d.addErrback(lambda failure: (unicode(failure.value), [], {})) + # and associate with service/node (needed if there are several comments nodes) + d.addCallback(lambda serialised, service_s=service_s, node=node: (service_s, node) + serialised) + dlist.append(d) + # we get the comments + comments_d = defer.gatherResults(dlist) + # and add them to the item data + comments_d.addCallback(lambda comments_data, item=item: (item, comments_data)) + items_dlist.append(comments_d) + # we gather the items + comments in a list + items_d = defer.gatherResults(items_dlist) + # and add the metadata + items_d.addCallback(lambda items_completed: (items_completed, metadata)) + return items_d + + deferreds = {} + for service, node in node_data: + d = deferreds[(service, node)] = self._p.getItems(client, service, node, max_items, rsm_request=rsm_request, extra=extra) + d.addCallback(lambda items_data: self._p.serItemsDataD(items_data, self.item2mbdata)) + d.addCallback(getComments) + d.addCallback(lambda items_comments_data: ('', items_comments_data)) + d.addErrback(lambda failure: (unicode(failure.value), ([],{}))) + + return self.rt_sessions.newSession(deferreds, client.profile) + + +class XEP_0277_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_MICROBLOG)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0280.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0280.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,165 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0280 +# 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.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.xmpp import SatMessageProtocol +from twisted.words.protocols.jabber.error import StanzaError +from twisted.internet import defer +from wokkel import disco, iwokkel +from zope.interface import implements +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + + +PARAM_CATEGORY = "Misc" +PARAM_NAME = "carbon" +PARAM_LABEL = D_(u"Message carbons") +NS_CARBONS = 'urn:xmpp:carbons:2' + +PLUGIN_INFO = { + C.PI_NAME: u"XEP-0280 Plugin", + C.PI_IMPORT_NAME: u"XEP-0280", + C.PI_TYPE: u"XEP", + C.PI_PROTOCOLS: [u"XEP-0280"], + C.PI_DEPENDENCIES: [], + C.PI_MAIN: u"XEP_0280", + C.PI_HANDLER: u"yes", + C.PI_DESCRIPTION: D_(u"""Implementation of Message Carbons""") +} + + +class XEP_0280(object): + # TODO: param is only checked at profile connection + # activate carbons on param change even after profile connection + # TODO: chat state notifications are not handled yet (and potentially other XEPs?) + + params = """ + <params> + <individual> + <category name="{category_name}" label="{category_label}"> + <param name="{param_name}" label="{param_label}" value="true" type="bool" security="0" /> + </category> + </individual> + </params> + """.format( + category_name = PARAM_CATEGORY, + category_label = D_(PARAM_CATEGORY), + param_name = PARAM_NAME, + param_label = PARAM_LABEL, + ) + + def __init__(self, host): + log.info(_("Plugin XEP_0280 initialization")) + self.host = host + host.memory.updateParams(self.params) + host.trigger.add("MessageReceived", self.messageReceivedTrigger, priority=1000) + + def getHandler(self, client): + return XEP_0280_handler() + + def setPrivate(self, message_elt): + """Add a <private/> element to a message + + this method is intented to be called on final domish.Element by other plugins + (in particular end 2 end encryption plugins) + @param message_elt(domish.Element): <message> stanza + """ + if message_elt.name != u'message': + log.error(u"addPrivateElt must be used with <message> stanzas") + return + message_elt.addElement((NS_CARBONS, u'private')) + + @defer.inlineCallbacks + def profileConnected(self, client): + """activate message carbons on connection if possible and activated in config""" + activate = self.host.memory.getParamA(PARAM_NAME, PARAM_CATEGORY, profile_key=client.profile) + if not activate: + log.info(_(u"Not activating message carbons as requested in params")) + return + try: + yield self.host.checkFeatures(client, (NS_CARBONS,)) + except exceptions.FeatureNotFound: + log.warning(_(u"server doesn't handle message carbons")) + else: + log.info(_(u"message carbons available, enabling it")) + iq_elt = client.IQ() + iq_elt.addElement((NS_CARBONS, 'enable')) + try: + yield iq_elt.send() + except StanzaError as e: + log.warning(u"Can't activate message carbons: {}".format(e)) + else: + log.info(_(u"message carbons activated")) + + def messageReceivedTrigger(self, client, message_elt, post_treat): + """get message and handle it if carbons namespace is present""" + carbons_elt = None + for e in message_elt.elements(): + if e.uri == NS_CARBONS: + carbons_elt = e + break + + if carbons_elt is None: + # this is not a message carbons, + # we continue normal behaviour + return True + + if message_elt['from'] != client.jid.userhost(): + log.warning(u"The message carbon received is not from our server, hack attempt?\n{xml}".format( + xml = message_elt.toXml(), + )) + return + forwarded_elt = next(carbons_elt.elements(C.NS_FORWARD, 'forwarded')) + cc_message_elt = next(forwarded_elt.elements(C.NS_CLIENT, 'message')) + if carbons_elt.name == 'received': + # on receive we replace the wrapping message with the CCed one + # and continue the normal behaviour + message_elt['from'] = cc_message_elt['from'] + del message_elt.children[:] + for c in cc_message_elt.children: + message_elt.addChild(c) + return True + elif carbons_elt.name == 'sent': + # on send we parse the message and just add it to history + # and send it to frontends (without normal sending treatments) + mess_data = SatMessageProtocol.parseMessage(cc_message_elt, client) + if not mess_data['message'] and not mess_data['subject']: + return False + client.messageAddToHistory(mess_data) + client.messageSendToBridge(mess_data) + else: + log.warning(u"invalid message carbons received:\n{xml}".format( + xml = message_elt.toXml())) + return False + + +class XEP_0280_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_CARBONS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0297.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0297.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,120 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Stanza Forwarding (XEP-0297) +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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.constants import Const as C +from sat.core.i18n import _, D_ +from sat.core.log import getLogger +log = getLogger(__name__) + +from wokkel import disco, iwokkel +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implements + +from twisted.words.xish import domish + +NS_SF = C.NS_FORWARD + +PLUGIN_INFO = { + C.PI_NAME: u"Stanza Forwarding", + C.PI_IMPORT_NAME: u"XEP-0297", + C.PI_TYPE: u"XEP", + C.PI_PROTOCOLS: [u"XEP-0297"], + C.PI_MAIN: "XEP_0297", + C.PI_HANDLER: u"yes", + C.PI_DESCRIPTION: D_(u"""Implementation of Stanza Forwarding""") +} + + +class XEP_0297(object): + # FIXME: check this implementation which doesn't seems to be used + + def __init__(self, host): + log.info(_("Stanza Forwarding plugin initialization")) + self.host = host + + def getHandler(self, client): + return XEP_0297_handler(self, client.profile) + + @classmethod + def updateUri(cls, element, uri): + """Update recursively the element URI. + + @param element (domish.Element): element to update + @param uri (unicode): new URI + """ + # XXX: we need this because changing the URI of an existing element + # containing children doesn't update the children's blank URI. + element.uri = uri + element.defaultUri = uri + for child in element.children: + if isinstance(child, domish.Element) and not child.uri: + XEP_0297.updateUri(child, uri) + + def forward(self, stanza, to_jid, stamp, body='', profile_key=C.PROF_KEY_NONE): + """Forward a message to the given JID. + + @param stanza (domish.Element): original stanza to be forwarded. + @param to_jid (JID): recipient JID. + @param stamp (datetime): offset-aware timestamp of the original reception. + @param body (unicode): optional description. + @param profile_key (unicode): %(doc_profile_key)s + @return: a Deferred when the message has been sent + """ + # FIXME: this method is not used and doesn't use mess_data which should be used for client.sendMessageData + # should it be deprecated? A method constructing the element without sending it seems more natural + log.warning(u"THIS METHOD IS DEPRECATED") # FIXME: we use this warning until we check the method + msg = domish.Element((None, 'message')) + msg['to'] = to_jid.full() + msg['type'] = stanza['type'] + + body_elt = domish.Element((None, 'body')) + if body: + body_elt.addContent(body) + + forwarded_elt = domish.Element((NS_SF, 'forwarded')) + delay_elt = self.host.plugins['XEP-0203'].delay(stamp) + forwarded_elt.addChild(delay_elt) + if not stanza.uri: # None or '' + XEP_0297.updateUri(stanza, 'jabber:client') + forwarded_elt.addChild(stanza) + + msg.addChild(body_elt) + msg.addChild(forwarded_elt) + + client = self.host.getClient(profile_key) + return client.sendMessageData({u'xml': msg}) + + +class XEP_0297_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent, profile): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.profile = profile + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_SF)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0300.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0300.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,212 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Hash functions (XEP-0300) +# 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 _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.words.xish import domish +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.internet import threads +from twisted.internet import defer +from zope.interface import implements +from wokkel import disco, iwokkel +from collections import OrderedDict +import hashlib +import base64 + + +PLUGIN_INFO = { + C.PI_NAME: "Cryptographic Hash Functions", + C.PI_IMPORT_NAME: "XEP-0300", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0300"], + C.PI_MAIN: "XEP_0300", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Management of cryptographic hashes""") +} + +NS_HASHES = "urn:xmpp:hashes:2" +NS_HASHES_FUNCTIONS = u"urn:xmpp:hash-function-text-names:{}" +BUFFER_SIZE = 2**12 +ALGO_DEFAULT = 'sha-256' + + +class XEP_0300(object): + # TODO: add blake after moving to Python 3 + ALGOS = OrderedDict(( + (u'md5', hashlib.md5), + (u'sha-1', hashlib.sha1), + (u'sha-256', hashlib.sha256), + (u'sha-512', hashlib.sha512), + )) + + def __init__(self, host): + log.info(_("plugin Hashes initialization")) + host.registerNamespace('hashes', NS_HASHES) + + def getHandler(self, client): + return XEP_0300_handler() + + def getHasher(self, algo=ALGO_DEFAULT): + """Return hasher instance + + @param algo(unicode): one of the XEP_300.ALGOS keys + @return (hash object): same object s in hashlib. + update method need to be called for each chunh + diget or hexdigest can be used at the end + """ + return self.ALGOS[algo]() + + def getDefaultAlgo(self): + return ALGO_DEFAULT + + @defer.inlineCallbacks + def getBestPeerAlgo(self, to_jid, profile): + """Return the best available hashing algorith of other peer + + @param to_jid(jid.JID): peer jid + @parm profile: %(doc_profile)s + @return (D(unicode, None)): best available algorithm, + or None if hashing is not possible + """ + client = self.host.getClient(profile) + for algo in reversed(XEP_0300.ALGOS): + has_feature = yield self.host.hasFeature(client, NS_HASHES_FUNCTIONS.format(algo), to_jid) + if has_feature: + log.debug(u"Best hashing algorithm found for {jid}: {algo}".format( + jid=to_jid.full(), + algo=algo)) + defer.returnValue(algo) + + def _calculateHashBlocking(self, file_obj, hasher): + """Calculate hash in a blocking way + + /!\\ blocking method, please use calculateHash instead + @param file_obj(file): a file-like object + @param hasher(callable): the method to call to initialise hash object + @return (str): the hex digest of the hash + """ + hash_ = hasher() + while True: + buf = file_obj.read(BUFFER_SIZE) + if not buf: + break + hash_.update(buf) + return hash_.hexdigest() + + def calculateHash(self, file_obj, hasher): + return threads.deferToThread(self._calculateHashBlocking, file_obj, hasher) + + def calculateHashElt(self, file_obj=None, algo=ALGO_DEFAULT): + """Compute hash and build hash element + + @param file_obj(file, None): file-like object to use to calculate the hash + @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS + @return (D(domish.Element)): hash element + """ + def hashCalculated(hash_): + return self.buildHashElt(hash_, algo) + hasher = self.ALGOS[algo] + hash_d = self.calculateHash(file_obj, hasher) + hash_d.addCallback(hashCalculated) + return hash_d + + def buildHashUsedElt(self, algo=ALGO_DEFAULT): + hash_used_elt = domish.Element((NS_HASHES, 'hash-used')) + hash_used_elt['algo'] = algo + return hash_used_elt + + def parseHashUsedElt(self, parent): + """Find and parse a hash-used element + + @param (domish.Element): parent of <hash/> element + @return (unicode): hash algorithm used + @raise exceptions.NotFound: the element is not present + @raise exceptions.DataError: the element is invalid + """ + try: + hash_used_elt = next(parent.elements(NS_HASHES, 'hash-used')) + except StopIteration: + raise exceptions.NotFound + algo = hash_used_elt[u'algo'] + if not algo: + raise exceptions.DataError + return algo + + def buildHashElt(self, hash_, algo=ALGO_DEFAULT): + """Compute hash and build hash element + + @param hash_(str): hash to use + @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS + @return (domish.Element): computed hash + """ + assert hash_ + assert algo + hash_elt = domish.Element((NS_HASHES, 'hash')) + if hash_ is not None: + hash_elt.addContent(base64.b64encode(hash_)) + hash_elt['algo'] = algo + return hash_elt + + def parseHashElt(self, parent): + """Find and parse a hash element + + if multiple elements are found, the strongest managed one is returned + @param (domish.Element): parent of <hash/> element + @return (tuple[unicode, str]): (algo, hash) tuple + both values can be None if <hash/> is empty + @raise exceptions.NotFound: the element is not present + @raise exceptions.DataError: the element is invalid + """ + algos = XEP_0300.ALGOS.keys() + hash_elt = None + best_algo = None + best_value = None + for hash_elt in parent.elements(NS_HASHES, 'hash'): + algo = hash_elt.getAttribute('algo') + try: + idx = algos.index(algo) + except ValueError: + log.warning(u"Proposed {} algorithm is not managed".format(algo)) + algo = None + continue + + if best_algo is None or algos.index(best_algo) < idx: + best_algo = algo + best_value = base64.b64decode(unicode(hash_elt)) + + if not hash_elt: + raise exceptions.NotFound + if not best_algo or not best_value: + raise exceptions.DataError + return best_algo, best_value + + +class XEP_0300_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + hash_functions_names = [disco.DiscoFeature(NS_HASHES_FUNCTIONS.format(algo)) for algo in XEP_0300.ALGOS] + return [disco.DiscoFeature(NS_HASHES)] + hash_functions_names + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0313.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0313.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,150 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Message Archive Management (XEP-0313) +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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.constants import Const as C +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions + +from twisted.words.protocols.jabber import jid + +from zope.interface import implements + +from wokkel import disco +import uuid + +# XXX: mam and rsm come from sat_tmp.wokkel +from wokkel import rsm +from wokkel import mam + + +MESSAGE_RESULT = "/message/result[@xmlns='{mam_ns}' and @queryid='{query_id}']" + +PLUGIN_INFO = { + C.PI_NAME: "Message Archive Management", + C.PI_IMPORT_NAME: "XEP-0313", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0313"], + C.PI_MAIN: "XEP_0313", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Message Archive Management""") +} + + +class XEP_0313(object): + + def __init__(self, host): + log.info(_("Message Archive Management plugin initialization")) + self.host = host + + def getHandler(self, client): + mam_client = client._mam = SatMAMClient() + return mam_client + + def queryFields(self, client, service=None): + """Ask the server about supported fields. + + @param service: entity offering the MAM service (None for user archives) + @return (D(data_form.Form)): form with the implemented fields (cf XEP-0313 §4.1.5) + """ + return client._mam.queryFields(service) + + def queryArchive(self, client, mam_query, service=None): + """Query a user, MUC or pubsub archive. + + @param mam_query(mam.MAMRequest): MAM query instance + @param service(jid.JID, None): entity offering the MAM service + None for user server + @return (D(domish.Element)): <IQ/> result + """ + return client._mam.queryArchive(mam_query, service) + + def _appendMessage(self, elt_list, message_cb, message_elt): + if message_cb is not None: + elt_list.append(message_cb(message_elt)) + else: + elt_list.append(message_elt) + + def _queryFinished(self, iq_result, client, elt_list, event): + client.xmlstream.removeObserver(event, self._appendMessage) + try: + fin_elt = iq_result.elements(mam.NS_MAM, 'fin').next() + except StopIteration: + raise exceptions.DataError(u"Invalid MAM result") + + try: + rsm_response = rsm.RSMResponse.fromElement(fin_elt) + except rsm.RSMNotFoundError: + rsm_response = None + + return (elt_list, rsm_response) + + def getArchives(self, client, query, service=None, message_cb=None): + """Query archive then grab and return them all in the result + + """ + if query.query_id is None: + query.query_id = unicode(uuid.uuid4()) + elt_list = [] + event = MESSAGE_RESULT.format(mam_ns=mam.NS_MAM, query_id=query.query_id) + client.xmlstream.addObserver(event, self._appendMessage, 0, elt_list, message_cb) + d = self.queryArchive(client, query, service) + d.addCallback(self._queryFinished, client, elt_list, event) + return d + + def getPrefs(self, client, service=None): + """Retrieve the current user preferences. + + @param service: entity offering the MAM service (None for user archives) + @return: the server response as a Deferred domish.Element + """ + # http://xmpp.org/extensions/xep-0313.html#prefs + return client._mam.queryPrefs(service) + + def _setPrefs(self, service_s=None, default='roster', always=None, never=None, profile_key=C.PROF_KEY_NONE): + service = jid.JID(service_s) if service_s else None + always_jid = [jid.JID(entity) for entity in always] + never_jid = [jid.JID(entity) for entity in never] + #TODO: why not build here a MAMPrefs object instead of passing the args separately? + return self.setPrefs(service, default, always_jid, never_jid, profile_key) + + def setPrefs(self, client, service=None, default='roster', always=None, never=None): + """Set news user preferences. + + @param service: entity offering the MAM service (None for user archives) + @param default (unicode): a value in ('always', 'never', 'roster') + @param always (list): a list of JID instances + @param never (list): a list of JID instances + @param profile_key (unicode): %(doc_profile_key)s + @return: the server response as a Deferred domish.Element + """ + # http://xmpp.org/extensions/xep-0313.html#prefs + return client._mam.setPrefs(service, default, always, never) + + +class SatMAMClient(mam.MAMClient): + implements(disco.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(mam.NS_MAM)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0329.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0329.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,565 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for File Information Sharing (XEP-0329) +# 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 _ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.tools import stream +from sat.tools.common import regex +from wokkel import disco, iwokkel +from zope.interface import implements +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber import error as jabber_error +from twisted.internet import defer +import mimetypes +import json +import os + + +PLUGIN_INFO = { + C.PI_NAME: "File Information Sharing", + C.PI_IMPORT_NAME: "XEP-0329", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0329"], + C.PI_DEPENDENCIES: ["XEP-0234", "XEP-0300"], + C.PI_MAIN: "XEP_0329", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _(u"""Implementation of File Information Sharing""") +} + +NS_FIS = 'urn:xmpp:fis:0' + +IQ_FIS_REQUEST = C.IQ_GET + '/query[@xmlns="' + NS_FIS + '"]' +SINGLE_FILES_DIR = u"files" +TYPE_VIRTUAL= u'virtual' +TYPE_PATH = u'path' +SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL) +KEY_TYPE = u'type' + + +class ShareNode(object): + """node containing directory or files to share, virtual or real""" + host = None + + def __init__(self, name, parent, type_, access, path=None): + assert type_ in SHARE_TYPES + if name is not None: + if name == u'..' or u'/' in name or u'\\' in name: + log.warning(_(u'path change chars found in name [{name}], hack attempt?').format(name=name)) + if name == u'..': + name = u'--' + else: + name = regex.pathEscape(name) + self.name = name + self.children = {} + self.type = type_ + self.access = {} if access is None else access + assert isinstance(self.access, dict) + self.parent = None + if parent is not None: + assert name + parent.addChild(self) + else: + assert name is None + if path is not None: + if type_ != TYPE_PATH: + raise exceptions.InternalError(_(u"path can only be set on path nodes")) + self._path = path + + @property + def path(self): + return self._path + + def __getitem__(self, key): + return self.children[key] + + def __contains__(self, item): + return self.children.__contains__(item) + + def __iter__(self): + return self.children.__iter__ + + def iteritems(self): + return self.children.iteritems() + + def getOrCreate(self, name, type_=TYPE_VIRTUAL, access=None): + """get a node or create a virtual one and return it""" + if access is None: + access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}} + try: + return self.children[name] + except KeyError: + node = ShareNode(name, self, type_=type_, access=access) + return node + + def addChild(self, node): + if node.parent is not None: + raise exceptions.ConflictError(_(u"a node can't have several parents")) + node.parent = self + self.children[node.name] = node + + def _checkNodePermission(self, client, node, perms, peer_jid): + """Check access to this node for peer_jid + + @param node(SharedNode): node to check access + @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_* + @param peer_jid(jid.JID): entity which try to access the node + @return (bool): True if entity can access + """ + file_data = {u'access':self.access, u'owner': client.jid.userhostJID()} + try: + self.host.memory.checkFilePermission(file_data, peer_jid, perms) + except exceptions.PermissionError: + return False + else: + return True + + def checkPermissions(self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True): + """check that peer_jid can access this node and all its parents + + @param peer_jid(jid.JID): entrity trying to access the node + @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_* + @param check_parents(bool): if True, access of all parents of this node will be checked too + @return (bool): True if entity can access this node + """ + peer_jid = peer_jid.userhostJID() + if peer_jid == client.jid.userhostJID(): + return True + + parent = self + while parent != None: + if not self._checkNodePermission(client, parent, perms, peer_jid): + return False + parent = parent.parent + + return True + + @staticmethod + def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)): + """find node corresponding to a path + + @param path(unicode): path to the requested file or directory + @param peer_jid(jid.JID): entity trying to find the node + used to check permission + @return (dict, unicode): shared data, remaining path + @raise exceptions.PermissionError: user can't access this file + @raise exceptions.DataError: path is invalid + @raise NotFound: path lead to a non existing file/directory + """ + path_elts = filter(None, path.split(u'/')) + + if u'..' in path_elts: + log.warning(_(u'parent dir ("..") found in path, hack attempt? path is {path} [{profile}]').format( + path=path, profile=client.profile)) + raise exceptions.PermissionError(u"illegal path elements") + + if not path_elts: + raise exceptions.DataError(_(u'path is invalid: {path}').format(path=path)) + + node = client._XEP_0329_root_node + + while path_elts: + if node.type == TYPE_VIRTUAL: + try: + node = node[path_elts.pop(0)] + except KeyError: + raise exceptions.NotFound + elif node.type == TYPE_PATH: + break + + if not node.checkPermissions(client, peer_jid, perms = perms): + raise exceptions.PermissionError(u"permission denied") + + return node, u'/'.join(path_elts) + + +class XEP_0329(object): + + def __init__(self, host): + log.info(_("File Information Sharing initialization")) + self.host = host + ShareNode.host = host + self._h = host.plugins['XEP-0300'] + self._jf = host.plugins['XEP-0234'] + host.bridge.addMethod("FISList", ".plugin", in_sign='ssa{ss}s', out_sign='aa{ss}', method=self._listFiles, async=True) + host.bridge.addMethod("FISSharePath", ".plugin", in_sign='ssss', out_sign='s', method=self._sharePath) + host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger) + host.registerNamespace('fis', NS_FIS) + + def getHandler(self, client): + return XEP_0329_handler(self) + + def profileConnected(self, client): + if not client.is_component: + client._XEP_0329_root_node = ShareNode(None, None, TYPE_VIRTUAL, {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}) + client._XEP_0329_names_data = {} # name to share map + + def _fileSendingRequestTrigger(self, client, session, content_data, content_name, file_data, file_elt): + """this trigger check that a requested file is available, and fill suitable data if so + + path and name are used to retrieve the file. If path is missing, we try our luck with known names + """ + if client.is_component: + return True, None + + try: + name = file_data[u'name'] + except KeyError: + return True, None + assert u'/' not in name + + path = file_data.get(u'path') + if path is not None: + # we have a path, we can follow it to find node + try: + node, rem_path = ShareNode.find(client, path, session[u'peer_jid']) + except (exceptions.PermissionError, exceptions.NotFound): + # no file, or file not allowed, we continue normal workflow + return True, None + except exceptions.DataError: + log.warning(_(u'invalid path: {path}').format(path=path)) + return True, None + + if node.type == TYPE_VIRTUAL: + # we have a virtual node, so name must link to a path node + try: + path = node[name].path + except KeyError: + return True, None + elif node.type == TYPE_PATH: + # we have a path node, so we can retrieve the full path now + path = os.path.join(node.path, rem_path, name) + else: + raise exceptions.InternalError(u'unknown type: {type}'.format(type=node.type)) + if not os.path.exists(path): + return True, None + size = os.path.getsize(path) + else: + # we don't have the path, we try to find the file by its name + try: + name_data = client._XEP_0329_names_data[name] + except KeyError: + return True, None + + for path, shared_file in name_data.iteritems(): + if True: # FIXME: filters are here + break + else: + return True, None + parent_node = shared_file[u'parent'] + if not parent_node.checkPermissions(client, session[u'peer_jid']): + log.warning(_(u"{peer_jid} requested a file (s)he can't access [{profile}]").format( + peer_jid = session[u'peer_jid'], profile = client.profile)) + return True, None + size = shared_file[u'size'] + + file_data[u'size'] = size + file_elt.addElement(u'size', content=unicode(size)) + hash_algo = file_data[u'hash_algo'] = self._h.getDefaultAlgo() + hasher = file_data[u'hash_hasher'] = self._h.getHasher(hash_algo) + file_elt.addChild(self._h.buildHashUsedElt(hash_algo)) + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + path, + uid=self._jf.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) + return False, True + + # common methods + + def _requestHandler(self, client, iq_elt, root_nodes_cb, files_from_node_cb): + iq_elt.handled = True + owner = jid.JID(iq_elt['from']).userhostJID() + node = iq_elt.query.getAttribute('node') + if not node: + d = defer.maybeDeferred(root_nodes_cb, client, iq_elt, owner) + else: + d = defer.maybeDeferred(files_from_node_cb, client, iq_elt, owner, node) + d.addErrback(lambda failure_: log.error(_(u"error while retrieving files: {msg}").format(msg=failure_))) + + def _iqError(self, client, iq_elt, condition="item-not-found"): + error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt) + client.send(error_elt) + + # client + + def _addPathData(self, client, query_elt, path, parent_node): + """Fill query_elt with files/directories found in path""" + name = os.path.basename(path) + if os.path.isfile(path): + size = os.path.getsize(path) + mime_type = mimetypes.guess_type(path, strict=False)[0] + file_elt = self._jf.buildFileElement(name = name, + size = size, + mime_type = mime_type, + modified = os.path.getmtime(path)) + + query_elt.addChild(file_elt) + # we don't specify hash as it would be too resource intensive to calculate it for all files + # we add file to name_data, so users can request it later + name_data = client._XEP_0329_names_data.setdefault(name, {}) + if path not in name_data: + name_data[path] = {'size': size, + 'mime_type': mime_type, + 'parent': parent_node} + else: + # we have a directory + directory_elt = query_elt.addElement('directory') + directory_elt['name'] = name + + def _pathNodeHandler(self, client, iq_elt, query_elt, node, path): + """Fill query_elt for path nodes, i.e. physical directories""" + path = os.path.join(node.path, path) + + if not os.path.exists(path): + # path may have been moved since it has been shared + return self._iqError(client, iq_elt) + elif os.path.isfile(path): + self._addPathData(client, query_elt, path, node) + else: + for name in sorted(os.listdir(path.encode('utf-8')), key=lambda n: n.lower()): + try: + name = name.decode('utf-8', 'strict') + except UnicodeDecodeError as e: + log.warning(_(u"ignoring invalid unicode name ({name}): {msg}").format( + name = name.decode('utf-8', 'replace'), + msg = e)) + continue + full_path = os.path.join(path, name) + self._addPathData(client, query_elt, full_path, node) + + def _virtualNodeHandler(self, client, peer_jid, iq_elt, query_elt, node): + """Fill query_elt for virtual nodes""" + for name, child_node in node.iteritems(): + if not child_node.checkPermissions(client, peer_jid, check_parents=False): + continue + node_type = child_node.type + if node_type == TYPE_VIRTUAL: + directory_elt = query_elt.addElement('directory') + directory_elt['name'] = name + elif node_type == TYPE_PATH: + self._addPathData(client, query_elt, child_node.path, child_node) + else: + raise exceptions.InternalError(_(u'unexpected type: {type}').format(type=node_type)) + + def _getRootNodesCb(self, client, iq_elt, owner): + peer_jid = jid.JID(iq_elt['from']) + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + query_elt = iq_result_elt.addElement((NS_FIS, 'query')) + for name, node in client._XEP_0329_root_node.iteritems(): + if not node.checkPermissions(client, peer_jid, check_parents=False): + continue + directory_elt = query_elt.addElement('directory') + directory_elt['name'] = name + client.send(iq_result_elt) + + def _getFilesFromNodeCb(self, client, iq_elt, owner, node_path): + """Main method to retrieve files/directories from a node_path""" + peer_jid = jid.JID(iq_elt[u'from']) + try: + node, path = ShareNode.find(client, node_path, peer_jid) + except (exceptions.PermissionError, exceptions.NotFound): + return self._iqError(client, iq_elt) + except exceptions.DataError: + return self._iqError(client, iq_elt, condition='not-acceptable') + + node_type = node.type + peer_jid = jid.JID(iq_elt['from']) + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + query_elt = iq_result_elt.addElement((NS_FIS, 'query')) + query_elt[u'node'] = node_path + + # we now fill query_elt according to node_type + if node_type == TYPE_PATH: + # it's a physical path + self._pathNodeHandler(client, iq_elt, query_elt, node, path) + elif node_type == TYPE_VIRTUAL: + assert not path + self._virtualNodeHandler(client, peer_jid, iq_elt, query_elt, node) + else: + raise exceptions.InternalError(_(u'unknown node type: {type}').format(type=node_type)) + + client.send(iq_result_elt) + + def onRequest(self, iq_elt, client): + return self._requestHandler(client, iq_elt, self._getRootNodesCb, self._getFilesFromNodeCb) + + # Component + + @defer.inlineCallbacks + def _compGetRootNodesCb(self, client, iq_elt, owner): + peer_jid = jid.JID(iq_elt['from']) + files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, parent=u'', + type_=C.FILE_TYPE_DIRECTORY, owner=owner) + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + query_elt = iq_result_elt.addElement((NS_FIS, 'query')) + for file_data in files_data: + name = file_data[u'name'] + directory_elt = query_elt.addElement(u'directory') + directory_elt[u'name'] = name + client.send(iq_result_elt) + + @defer.inlineCallbacks + def _compGetFilesFromNodeCb(self, client, iq_elt, owner, node_path): + """retrieve files from local files repository according to permissions + + result stanza is then built and sent to requestor + @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path, files_data): can be used to add data/elements + """ + peer_jid = jid.JID(iq_elt['from']) + try: + files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, path=node_path, owner=owner) + except exceptions.NotFound: + self._iqError(client, iq_elt) + return + iq_result_elt = xmlstream.toResponse(iq_elt, 'result') + query_elt = iq_result_elt.addElement((NS_FIS, 'query')) + query_elt[u'node'] = node_path + if not self.host.trigger.point(u'XEP-0329_compGetFilesFromNode', client, iq_elt, owner, node_path, files_data): + return + for file_data in files_data: + file_elt = self._jf.buildFileElementFromDict(file_data, + modified=file_data.get(u'modified', file_data[u'created'])) + query_elt.addChild(file_elt) + client.send(iq_result_elt) + + def onComponentRequest(self, iq_elt, client): + return self._requestHandler(client, iq_elt, self._compGetRootNodesCb, self._compGetFilesFromNodeCb) + + def _parseResult(self, iq_elt): + query_elt = next(iq_elt.elements(NS_FIS, 'query')) + files = [] + + for elt in query_elt.elements(): + if elt.name == 'file': + # we have a file + try: + file_data = self._jf.parseFileElement(elt) + except exceptions.DataError: + continue + file_data[u'type'] = C.FILE_TYPE_FILE + elif elt.name == 'directory' and elt.uri == NS_FIS: + # we have a directory + + file_data = {'name': elt['name'], 'type': C.FILE_TYPE_DIRECTORY} + else: + log.warning(_(u"unexpected element, ignoring: {elt}").format(elt=elt.toXml())) + continue + files.append(file_data) + return files + + # file methods # + + def _serializeData(self, files_data): + for file_data in files_data: + for key, value in file_data.iteritems(): + file_data[key] = json.dumps(value) if key in ('extra',) else unicode(value) + return files_data + + def _listFiles(self, target_jid, path, extra, profile): + client = self.host.getClient(profile) + target_jid = client.jid.userhostJID() if not target_jid else jid.JID(target_jid) + d = self.listFiles(client, target_jid, path or None) + d.addCallback(self._serializeData) + return d + + def listFiles(self, client, target_jid, path=None, extra=None): + """List file shared by an entity + + @param target_jid(jid.JID): jid of the sharing entity + @param path(unicode, None): path to the directory containing shared files + None to get root directories + @param extra(dict, None): extra data + @return list(dict): shared files + """ + iq_elt = client.IQ('get') + iq_elt['to'] = target_jid.full() + query_elt = iq_elt.addElement((NS_FIS, 'query')) + if path: + query_elt['node'] = path + d = iq_elt.send() + d.addCallback(self._parseResult) + return d + + def _sharePath(self, name, path, access, profile): + client = self.host.getClient(profile) + access= json.loads(access) + return self.sharePath(client, name or None, path, access) + + def sharePath(self, client, name, path, access): + if client.is_component: + raise exceptions.ClientTypeError + if not os.path.exists(path): + raise ValueError(_(u"This path doesn't exist!")) + if not path or not path.strip(u' /'): + raise ValueError(_(u"A path need to be specified")) + + node = client._XEP_0329_root_node + node_type = TYPE_PATH + if os.path.isfile(path): + # we have a single file, the workflow is diferrent as we store all single files in the same dir + node = node.getOrCreate(SINGLE_FILES_DIR) + + if not name: + name = os.path.basename(path.rstrip(u' /')) + if not name: + raise exceptions.InternalError(_(u"Can't find a proper name")) + + if not isinstance(access, dict): + raise ValueError(_(u'access must be a dict')) + + if name in node or name == SINGLE_FILES_DIR: + idx = 1 + new_name = name + '_' + unicode(idx) + while new_name in node: + idx += 1 + new_name = name + '_' + unicode(idx) + name = new_name + log.info(_(u"A directory with this name is already shared, renamed to {new_name} [{profile}]".format( + new_name=new_name, profile=client.profile))) + + ShareNode(name=name, parent=node, type_=node_type, access=access, path=path) + return name + + +class XEP_0329_handler(xmlstream.XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + if self.parent.is_component: + self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onComponentRequest, client=self.parent) + else: + self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onRequest, client=self.parent) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_FIS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0334.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0334.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,117 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for Delayed Delivery (XEP-0334) +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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.log import getLogger +log = getLogger(__name__) +from sat.core.constants import Const as C + +from sat.tools.common import data_format + +from wokkel import disco, iwokkel +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler +from zope.interface import implements +from textwrap import dedent + + +PLUGIN_INFO = { + C.PI_NAME: u"Message Processing Hints", + C.PI_IMPORT_NAME: u"XEP-0334", + C.PI_TYPE: u"XEP", + C.PI_PROTOCOLS: [u"XEP-0334"], + C.PI_MAIN: "XEP_0334", + C.PI_HANDLER: u"yes", + C.PI_DESCRIPTION: D_(u"""Implementation of Message Processing Hints"""), + C.PI_USAGE: dedent(D_(u"""\ + Frontends can use HINT_* constants in mess_data['extra'] in a serialized 'hints' dict. + Internal plugins can use directly addHint([HINT_* constant]). + Will set mess_data['extra']['history'] to 'skipped' when no store is requested and message is not saved in history.""")) + +} + +NS_HINTS = u'urn:xmpp:hints' + + +class XEP_0334(object): + HINT_NO_PERMANENT_STORE = u'no-permanent-store' + HINT_NO_STORE = u'no-store' + HINT_NO_COPY = u'no-copy' + HINT_STORE = u'store' + HINTS = (HINT_NO_PERMANENT_STORE, HINT_NO_STORE, HINT_NO_COPY, HINT_STORE) + + def __init__(self, host): + log.info(_("Message Processing Hints plugin initialization")) + self.host = host + host.trigger.add("sendMessage", self.sendMessageTrigger) + host.trigger.add("MessageReceived", self.messageReceivedTrigger, priority=-1000) + + def getHandler(self, client): + return XEP_0334_handler() + + def addHint(self, mess_data, hint): + if hint == self.HINT_NO_COPY and not mess_data['to'].resource: + log.error(u"{hint} can only be used with full jids! Ignoring it.".format(hint=hint)) + return + hints = mess_data.setdefault('hints', set()) + if hint in self.HINTS: + hints.add(hint) + else: + log.error(u"Unknown hint: {}".format(hint)) + + def _sendPostXmlTreatment(self, mess_data): + if 'hints' in mess_data: + for hint in mess_data['hints']: + mess_data[u'xml'].addElement((NS_HINTS, hint)) + return mess_data + + def sendMessageTrigger(self, client, mess_data, pre_xml_treatments, post_xml_treatments): + """Add the hints element to the message to be sent""" + if u'hints' in mess_data[u'extra']: + for hint in data_format.dict2iter(u'hints', mess_data[u'extra'], pop=True): + self.addHint(hint) + + post_xml_treatments.addCallback(self._sendPostXmlTreatment) + return True + + def _receivedSkipHistory(self, mess_data): + mess_data[u'history'] = C.HISTORY_SKIP + return mess_data + + def messageReceivedTrigger(self, client, message_elt, post_treat): + """Check for hints in the received message""" + for elt in message_elt.elements(): + if elt.uri == NS_HINTS and elt.name in (self.HINT_NO_PERMANENT_STORE, self.HINT_NO_STORE): + log.debug(u"history will be skipped for this message, as requested") + post_treat.addCallback(self._receivedSkipHistory) + break + return True + + +class XEP_0334_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_HINTS)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/plugins/plugin_xep_0363.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0363.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,291 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for HTTP File Upload (XEP-0363) +# 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 _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from wokkel import disco, iwokkel +from zope.interface import implements +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.internet import reactor +from twisted.internet import defer +from twisted.internet import ssl +from twisted.internet.interfaces import IOpenSSLClientConnectionCreator +from twisted.web import client as http_client +from twisted.web import http_headers +from twisted.web import iweb +from twisted.python import failure +from collections import namedtuple +from zope.interface import implementer +from OpenSSL import SSL +import os.path +import mimetypes + + +PLUGIN_INFO = { + C.PI_NAME: "HTTP File Upload", + C.PI_IMPORT_NAME: "XEP-0363", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0363"], + C.PI_DEPENDENCIES: ["FILE", "UPLOAD"], + C.PI_MAIN: "XEP_0363", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of HTTP File Upload""") +} + +NS_HTTP_UPLOAD = 'urn:xmpp:http:upload' + + +Slot = namedtuple('Slot', ['put', 'get']) + + +@implementer(IOpenSSLClientConnectionCreator) +class NoCheckConnectionCreator(object): + + def __init__(self, hostname, ctx): + self._ctx = ctx + + def clientConnectionForTLS(self, tlsProtocol): + context = self._ctx + connection = SSL.Connection(context, None) + connection.set_app_data(tlsProtocol) + return connection + + +@implementer(iweb.IPolicyForHTTPS) +class NoCheckContextFactory(ssl.ClientContextFactory): + """Context factory which doesn't do TLS certificate check + + /!\\ it's obvisously a security flaw to use this class, + and it should be used only wiht explicite agreement from the end used + """ + + def creatorForNetloc(self, hostname, port): + log.warning(u"TLS check disabled for {host} on port {port}".format(host=hostname, port=port)) + certificateOptions = ssl.CertificateOptions(trustRoot=None) + return NoCheckConnectionCreator(hostname, certificateOptions.getContext()) + + +class XEP_0363(object): + + def __init__(self, host): + log.info(_("plugin HTTP File Upload initialization")) + self.host = host + host.bridge.addMethod("fileHTTPUpload", ".plugin", in_sign='sssbs', out_sign='', method=self._fileHTTPUpload) + host.bridge.addMethod("fileHTTPUploadGetSlot", ".plugin", in_sign='sisss', out_sign='(ss)', method=self._getSlot, async=True) + host.plugins['UPLOAD'].register(u"HTTP Upload", self.getHTTPUploadEntity, self.fileHTTPUpload) + + def getHandler(self, client): + return XEP_0363_handler() + + @defer.inlineCallbacks + def getHTTPUploadEntity(self, upload_jid=None, profile=C.PROF_KEY_NONE): + """Get HTTP upload capable entity + + upload_jid is checked, then its components + @param upload_jid(None, jid.JID): entity to check + @return(D(jid.JID)): first HTTP upload capable entity + @raise exceptions.NotFound: no entity found + """ + client = self.host.getClient(profile) + try: + entity = client.http_upload_service + except AttributeError: + found_entities = yield self.host.findFeaturesSet(client, (NS_HTTP_UPLOAD,)) + try: + entity = client.http_upload_service = iter(found_entities).next() + except StopIteration: + entity = client.http_upload_service = None + + if entity is None: + raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found')) + + defer.returnValue(entity) + + def _fileHTTPUpload(self, filepath, filename='', upload_jid='', ignore_tls_errors=False, profile=C.PROF_KEY_NONE): + assert os.path.isabs(filepath) and os.path.isfile(filepath) + progress_id_d, dummy = self.fileHTTPUpload(filepath, filename or None, jid.JID(upload_jid) if upload_jid else None, {'ignore_tls_errors': ignore_tls_errors}, profile) + return progress_id_d + + def fileHTTPUpload(self, filepath, filename=None, upload_jid=None, options=None, profile=C.PROF_KEY_NONE): + """upload a file through HTTP + + @param filepath(str): absolute path of the file + @param filename(None, unicode): name to use for the upload + None to use basename of the path + @param upload_jid(jid.JID, None): upload capable entity jid, + or None to use autodetected, if possible + @param options(dict): options where key can be: + - ignore_tls_errors(bool): if True, SSL certificate will not be checked + @param profile: %(doc_profile)s + @return (D(tuple[D(unicode), D(unicode)])): progress id and Deferred which fire download URL + """ + if options is None: + options = {} + ignore_tls_errors = options.get('ignore_tls_errors', False) + client = self.host.getClient(profile) + filename = filename or os.path.basename(filepath) + size = os.path.getsize(filepath) + progress_id_d = defer.Deferred() + download_d = defer.Deferred() + d = self.getSlot(client, filename, size, upload_jid=upload_jid) + d.addCallbacks(self._getSlotCb, self._getSlotEb, (client, progress_id_d, download_d, filepath, size, ignore_tls_errors), None, (client, progress_id_d, download_d)) + return progress_id_d, download_d + + def _getSlotEb(self, fail, client, progress_id_d, download_d): + """an error happened while trying to get slot""" + log.warning(u"Can't get upload slot: {reason}".format(reason=fail.value)) + progress_id_d.errback(fail) + download_d.errback(fail) + + def _getSlotCb(self, slot, client, progress_id_d, download_d, path, size, ignore_tls_errors=False): + """Called when slot is received, try to do the upload + + @param slot(Slot): slot instance with the get and put urls + @param progress_id_d(defer.Deferred): Deferred to call when progress_id is known + @param progress_id_d(defer.Deferred): Deferred to call with URL when upload is done + @param path(str): path to the file to upload + @param size(int): size of the file to upload + @param ignore_tls_errors(bool): ignore TLS certificate is True + @return (tuple + """ + log.debug(u"Got upload slot: {}".format(slot)) + sat_file = self.host.plugins['FILE'].File(self.host, client, path, size=size, auto_end_signals=False) + progress_id_d.callback(sat_file.uid) + file_producer = http_client.FileBodyProducer(sat_file) + if ignore_tls_errors: + agent = http_client.Agent(reactor, NoCheckContextFactory()) + else: + agent = http_client.Agent(reactor) + d = agent.request('PUT', slot.put.encode('utf-8'), http_headers.Headers({'User-Agent': [C.APP_NAME.encode('utf-8')]}), file_producer) + d.addCallbacks(self._uploadCb, self._uploadEb, (sat_file, slot, download_d), None, (sat_file, download_d)) + return d + + def _uploadCb(self, dummy, sat_file, slot, download_d): + """Called once file is successfully uploaded + + @param sat_file(SatFile): file used for the upload + should be closed, be is needed to send the progressFinished signal + @param slot(Slot): put/get urls + """ + log.info(u"HTTP upload finished") + sat_file.progressFinished({'url': slot.get}) + download_d.callback(slot.get) + + def _uploadEb(self, fail, sat_file, download_d): + """Called on unsuccessful upload + + @param sat_file(SatFile): file used for the upload + should be closed, be is needed to send the progressError signal + """ + download_d.errback(fail) + try: + wrapped_fail = fail.value.reasons[0] + except (AttributeError, IndexError): + sat_file.progressError(unicode(fail)) + raise fail + else: + if wrapped_fail.check(SSL.Error): + msg = u"TLS validation error, can't connect to HTTPS server" + log.warning(msg + ": " + unicode(wrapped_fail.value)) + sat_file.progressError(msg) + + def _gotSlot(self, iq_elt, client): + """Slot have been received + + This method convert the iq_elt result to a Slot instance + @param iq_elt(domish.Element): <IQ/> result as specified in XEP-0363 + """ + try: + slot_elt = iq_elt.elements(NS_HTTP_UPLOAD, 'slot').next() + put_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'put').next()) + get_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'get').next()) + except StopIteration: + raise exceptions.DataError(u"Incorrect stanza received from server") + slot = Slot(put=put_url, get=get_url) + return slot + + def _getSlot(self, filename, size, content_type, upload_jid, profile_key=C.PROF_KEY_NONE): + """Get a upload slot + + This method can be used when uploading is done by the frontend + @param filename(unicode): name of the file to upload + @param size(int): size of the file (must be non null) + @param upload_jid(jid.JID(), None, ''): HTTP upload capable entity + @param content_type(unicode, None): MIME type of the content + empty string or None to guess automatically + """ + filename = filename.replace('/', '_') + client = self.host.getClient(profile_key) + return self.getSlot(client, filename, size, content_type or None, upload_jid or None) + + def getSlot(self, client, filename, size, content_type=None, upload_jid=None): + """Get a slot (i.e. download/upload links) + + @param filename(unicode): name to use for the upload + @param size(int): size of the file to upload (must be >0) + @param content_type(None, unicode): MIME type of the content + None to autodetect + @param upload_jid(jid.JID, None): HTTP upload capable upload_jid + or None to use the server component (if any) + @param client: %(doc_client)s + @return (Slot): the upload (put) and download (get) URLs + @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found + """ + assert filename and size + if content_type is None: + # TODO: manage python magic for file guessing (in a dedicated plugin ?) + content_type = mimetypes.guess_type(filename, strict=False)[0] + + if upload_jid is None: + try: + upload_jid = client.http_upload_service + except AttributeError: + d = self.getHTTPUploadEntity(profile=client.profile) + d.addCallback(lambda found_entity: self.getSlot(client, filename, size, content_type, found_entity)) + return d + else: + if upload_jid is None: + raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found')) + + iq_elt = client.IQ('get') + iq_elt['to'] = upload_jid.full() + request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, 'request')) + request_elt.addElement('filename', content=filename) + request_elt.addElement('size', content=unicode(size)) + if content_type is not None: + request_elt.addElement('content-type', content=content_type) + + d = iq_elt.send() + d.addCallback(self._gotSlot, client) + + return d + + +class XEP_0363_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_HTTP_UPLOAD)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/stdui/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/stdui/ui_contact_list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/stdui/ui_contact_list.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,270 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT standard user interface for managing contacts +# 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.tools import xml_tools +from twisted.words.protocols.jabber import jid +from xml.dom.minidom import Element + + +class ContactList(object): + """Add, update and remove contacts.""" + + def __init__(self, host): + self.host = host + self.__add_id = host.registerCallback(self._addContact, with_data=True) + self.__update_id = host.registerCallback(self._updateContact, with_data=True) + self.__confirm_delete_id = host.registerCallback(self._getConfirmRemoveXMLUI, with_data=True) + + host.importMenu((D_("Contacts"), D_("Add contact")), self._getAddDialogXMLUI, security_limit=2, help_string=D_("Add contact")) + host.importMenu((D_("Contacts"), D_("Update contact")), self._getUpdateDialogXMLUI, security_limit=2, help_string=D_("Update contact")) + host.importMenu((D_("Contacts"), D_("Remove contact")), self._getRemoveDialogXMLUI, security_limit=2, help_string=D_("Remove contact")) + + # FIXME: a plugin should not be used here, and current profile's jid host would be better than installation wise host + if 'MISC-ACCOUNT' in self.host.plugins: + self.default_host = self.host.plugins['MISC-ACCOUNT'].getNewAccountDomain() + else: + self.default_host = 'example.net' + + def getContacts(self, profile): + """Return a sorted list of the contacts for that profile + + @param profile: %(doc_profile)s + @return: list[string] + """ + client = self.host.getClient(profile) + ret = [contact.full() for contact in client.roster.getJids()] + ret.sort() + return ret + + def getGroups(self, new_groups=None, profile=C.PROF_KEY_NONE): + """Return a sorted list of the groups for that profile + + @param new_group (list): add these groups to the existing ones + @param profile: %(doc_profile)s + @return: list[string] + """ + client = self.host.getClient(profile) + ret = client.roster.getGroups() + ret.sort() + ret.extend([group for group in new_groups if group not in ret]) + return ret + + def getGroupsOfContact(self, user_jid_s, profile): + """Return all the groups of the given contact + + @param user_jid_s (string) + @param profile: %(doc_profile)s + @return: list[string] + """ + client = self.host.getClient(profile) + return client.roster.getItem(jid.JID(user_jid_s)).groups + + def getGroupsOfAllContacts(self, profile): + """Return a mapping between the contacts and their groups + + @param profile: %(doc_profile)s + @return: dict (key: string, value: list[string]): + - key: the JID userhost + - value: list of groups + """ + client = self.host.getClient(profile) + return {item.jid.userhost(): item.groups for item in client.roster.getItems()} + + def _data2elts(self, data): + """Convert a contacts data dict to minidom Elements + + @param data (dict) + @return list[Element] + """ + elts = [] + for key in data: + key_elt = Element('jid') + key_elt.setAttribute('name', key) + for value in data[key]: + value_elt = Element('group') + value_elt.setAttribute('name', value) + key_elt.childNodes.append(value_elt) + elts.append(key_elt) + return elts + + def getDialogXMLUI(self, options, data, profile): + """Generic method to return the XMLUI dialog for adding or updating a contact + + @param options (dict): parameters for the dialog, with the keys: + - 'id': the menu callback id + - 'title': deferred localized string + - 'contact_text': deferred localized string + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + form_ui = xml_tools.XMLUI("form", title=options['title'], submit_id=options['id']) + if 'message' in data: + form_ui.addText(data['message']) + form_ui.addDivider('dash') + + form_ui.addText(options['contact_text']) + if options['id'] == self.__add_id: + contact = data.get(xml_tools.formEscape('contact_jid'), '@%s' % self.default_host) + form_ui.addString('contact_jid', value=contact) + elif options['id'] == self.__update_id: + contacts = self.getContacts(profile) + list_ = form_ui.addList('contact_jid', options=contacts, selected=contacts[0]) + elts = self._data2elts(self.getGroupsOfAllContacts(profile)) + list_.setInternalCallback('groups_of_contact', fields=['contact_jid', 'groups_list'], data_elts=elts) + + form_ui.addDivider('blank') + + form_ui.addText(_("Select in which groups your contact is:")) + selected_groups = [] + if 'selected_groups' in data: + selected_groups = data['selected_groups'] + elif options['id'] == self.__update_id: + try: + selected_groups = self.getGroupsOfContact(contacts[0], profile) + except IndexError: + pass + groups = self.getGroups(selected_groups, profile) + form_ui.addList('groups_list', options=groups, selected=selected_groups, styles=['multi']) + + adv_list = form_ui.changeContainer("advanced_list", columns=3, selectable='no') + form_ui.addLabel(D_("Add group")) + form_ui.addString("add_group") + button = form_ui.addButton('', value=D_('Add')) + button.setInternalCallback('move', fields=['add_group', 'groups_list']) + adv_list.end() + + form_ui.addDivider('blank') + return {'xmlui': form_ui.toXml()} + + def _getAddDialogXMLUI(self, data, profile): + """Get the dialog for adding contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + options = {'id': self.__add_id, + 'title': D_('Add contact'), + 'contact_text': D_("New contact identifier (JID):"), + } + return self.getDialogXMLUI(options, {}, profile) + + def _getUpdateDialogXMLUI(self, data, profile): + """Get the dialog for updating contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + if not self.getContacts(profile): + _dialog = xml_tools.XMLUI('popup', title=D_('Nothing to update')) + _dialog.addText(_('Your contact list is empty.')) + return {'xmlui': _dialog.toXml()} + + options = {'id': self.__update_id, + 'title': D_('Update contact'), + 'contact_text': D_("Which contact do you want to update?"), + } + return self.getDialogXMLUI(options, {}, profile) + + def _getRemoveDialogXMLUI(self, data, profile): + """Get the dialog for removing contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + if not self.getContacts(profile): + _dialog = xml_tools.XMLUI('popup', title=D_('Nothing to delete')) + _dialog.addText(_('Your contact list is empty.')) + return {'xmlui': _dialog.toXml()} + + form_ui = xml_tools.XMLUI("form", title=D_('Who do you want to remove from your contacts?'), submit_id=self.__confirm_delete_id) + form_ui.addList('contact_jid', options=self.getContacts(profile)) + return {'xmlui': form_ui.toXml()} + + def _getConfirmRemoveXMLUI(self, data, profile): + """Get the confirmation dialog for removing contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + if C.bool(data.get('cancelled', 'false')): + return {} + contact = data[xml_tools.formEscape('contact_jid')] + def delete_cb(data, profile): + if not C.bool(data.get('cancelled', 'false')): + self._deleteContact(jid.JID(contact), profile) + return {} + delete_id = self.host.registerCallback(delete_cb, with_data=True, one_shot=True) + form_ui = xml_tools.XMLUI("form", title=D_("Delete contact"), submit_id=delete_id) + form_ui.addText(D_("Are you sure you want to remove %s from your contact list?") % contact) + return {'xmlui': form_ui.toXml()} + + def _addContact(self, data, profile): + """Add the selected contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + if C.bool(data.get('cancelled', 'false')): + return {} + contact_jid_s = data[xml_tools.formEscape('contact_jid')] + try: + contact_jid = jid.JID(contact_jid_s) + except (RuntimeError, jid.InvalidFormat, AttributeError): + # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.onFormSubmitted) + data['selected_groups'] = data[xml_tools.formEscape('groups_list')].split('\t') + options = {'id': self.__add_id, + 'title': D_('Add contact'), + 'contact_text': D_('Please enter a valid JID (like "contact@%s"):') % self.default_host, + } + return self.getDialogXMLUI(options, data, profile) + self.host.addContact(contact_jid, profile_key=profile) + return self._updateContact(data, profile) # after adding, updating + + def _updateContact(self, data, profile): + """Update the selected contact + + @param data (dict) + @param profile: %(doc_profile)s + @return dict + """ + if C.bool(data.get('cancelled', 'false')): + return {} + contact_jid = jid.JID(data[xml_tools.formEscape('contact_jid')]) + # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.onFormSubmitted) + groups = data[xml_tools.formEscape('groups_list')].split('\t') + self.host.updateContact(contact_jid, name='', groups=groups, profile_key=profile) + return {} + + def _deleteContact(self, contact_jid, profile): + """Delete the selected contact + + @param contact_jid (JID) + @param profile: %(doc_profile)s + @return dict + """ + self.host.delContact(contact_jid, profile_key=profile) + return {} diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/stdui/ui_profile_manager.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/stdui/ui_profile_manager.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,115 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT standard user interface for managing contacts +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 sat.core import exceptions +from sat.tools import xml_tools +from sat.memory.memory import ProfileSessions +from twisted.words.protocols.jabber import jid + + +class ProfileManager(object): + """Manage profiles.""" + + def __init__(self, host): + self.host = host + self.profile_ciphers = {} + self._sessions = ProfileSessions() + host.registerCallback(self._authenticateProfile, force_id=C.AUTHENTICATE_PROFILE_ID, with_data=True) + host.registerCallback(self._changeXMPPPassword, force_id=C.CHANGE_XMPP_PASSWD_ID, with_data=True) + self.__new_xmpp_passwd_id = host.registerCallback(self._changeXMPPPasswordCb, with_data=True) + + def _startSessionEb(self, fail, first, profile): + """Errback method for startSession during profile authentication + + @param first(bool): if True, this is the first try and we have tryied empty password + in this case we ask for a password to the user. + @param profile(unicode, None): %(doc_profile)s + must only be used if first is True + """ + if first: + # first call, we ask for the password + form_ui = xml_tools.XMLUI("form", title=D_('Profile password for {}').format(profile), submit_id='') + form_ui.addPassword('profile_password', value='') + d = xml_tools.deferredUI(self.host, form_ui, chained=True) + d.addCallback(self._authenticateProfile, profile) + return {'xmlui': form_ui.toXml()} + + assert profile is None + + if fail.check(exceptions.PasswordError): + dialog = xml_tools.XMLUI('popup', title=D_('Connection error')) + dialog.addText(D_("The provided profile password doesn't match.")) + else: + log.error(u"Unexpected exceptions: {}".format(fail)) + dialog = xml_tools.XMLUI('popup', title=D_('Internal error')) + dialog.addText(D_(u"Internal error: {}".format(fail))) + return {'xmlui': dialog.toXml(), 'validated': C.BOOL_FALSE} + + def _authenticateProfile(self, data, profile): + if C.bool(data.get('cancelled', 'false')): + return {} + if self.host.memory.isSessionStarted(profile): + return {'validated': C.BOOL_TRUE} + try: + password = data[xml_tools.formEscape('profile_password')] + except KeyError: + # first request, we try empty password + password = '' + first = True + eb_profile = profile + else: + first = False + eb_profile = None + d = self.host.memory.startSession(password, profile) + d.addCallback(lambda dummy: {'validated': C.BOOL_TRUE}) + d.addErrback(self._startSessionEb, first, eb_profile) + return d + + def _changeXMPPPassword(self, data, profile): + session_data = self._sessions.profileGetUnique(profile) + if not session_data: + server = self.host.memory.getParamA(C.FORCE_SERVER_PARAM, "Connection", profile_key=profile) + if not server: + server = jid.parse(self.host.memory.getParamA('JabberID', "Connection", profile_key=profile))[1] + session_id, session_data = self._sessions.newSession({'count': 0, 'server': server}, profile=profile) + if session_data['count'] > 2: # 3 attempts with a new password after the initial try + self._sessions.profileDelUnique(profile) + _dialog = xml_tools.XMLUI('popup', title=D_('Connection error')) + _dialog.addText(D_("Can't connect to %s. Please check your connection details.") % session_data['server']) + return {'xmlui': _dialog.toXml()} + session_data['count'] += 1 + counter = ' (%d)' % session_data['count'] if session_data['count'] > 1 else '' + title = D_('XMPP password for %(profile)s%(counter)s') % {'profile': profile, 'counter': counter} + form_ui = xml_tools.XMLUI("form", title=title, submit_id=self.__new_xmpp_passwd_id) + form_ui.addText(D_("Can't connect to %s. Please check your connection details or try with another password.") % session_data['server']) + form_ui.addPassword('xmpp_password', value='') + return {'xmlui': form_ui.toXml()} + + def _changeXMPPPasswordCb(self, data, profile): + xmpp_password = data[xml_tools.formEscape('xmpp_password')] + d = self.host.memory.setParam("Password", xmpp_password, "Connection", profile_key=profile) + d.addCallback(lambda dummy: self.host.connect(profile)) + d.addCallback(lambda dummy: {}) + d.addErrback(lambda dummy: self._changeXMPPPassword({}, profile)) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/constants.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/constants.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,46 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# Primitivus: a SAT frontend +# 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 twisted.words.protocols.jabber import jid + + +class Const(object): + + PROF_KEY_NONE = '@NONE@' + + PROFILE = ['test_profile', 'test_profile2', 'test_profile3', 'test_profile4', 'test_profile5'] + JID_STR = [u"test@example.org/SàT", u"sender@example.net/house", u"sender@example.net/work", u"sender@server.net/res", u"xxx@server.net/res"] + JID = [jid.JID(jid_s) for jid_s in JID_STR] + + PROFILE_DICT = {} + for i in xrange(0, len(PROFILE)): + PROFILE_DICT[PROFILE[i]] = JID[i] + + MUC_STR = [u"room@chat.server.domain", u"sat_game@chat.server.domain"] + MUC = [jid.JID(jid_s) for jid_s in MUC_STR] + + NO_SECURITY_LIMIT = -1 + SECURITY_LIMIT = 0 + + # To test frontend parameters + APP_NAME = "dummy_frontend" + COMPOSITION_KEY = D_("Composition") + ENABLE_UNIBOX_PARAM = D_("Enable unibox") + PARAM_IN_QUOTES = D_("'Wysiwyg' edition") diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/helpers.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/helpers.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,482 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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/>. + + +## logging configuration for tests ## +from sat.core import log_config +log_config.satConfigure() + +import logging +from sat.core.log import getLogger +getLogger().setLevel(logging.WARNING) # put this to DEBUG when needed + +from sat.core import exceptions +from sat.tools import config as tools_config +from constants import Const as C +from wokkel.xmppim import RosterItem +from wokkel.generic import parseXml +from sat.core.xmpp import SatRosterProtocol +from sat.memory.memory import Params, Memory +from twisted.trial.unittest import FailTest +from twisted.trial import unittest +from twisted.internet import defer +from twisted.words.protocols.jabber.jid import JID +from twisted.words.xish import domish +from xml.etree import cElementTree as etree +from collections import Counter +import re + + +def b2s(value): + """Convert a bool to a unicode string used in bridge + @param value: boolean value + @return: unicode conversion, according to bridge convention + + """ + return u"True" if value else u"False" + + +def muteLogging(): + """Temporarily set the logging level to CRITICAL to not pollute the output with expected errors.""" + logger = getLogger() + logger.original_level = logger.getEffectiveLevel() + logger.setLevel(logging.CRITICAL) + + +def unmuteLogging(): + """Restore the logging level after it has been temporarily disabled.""" + logger = getLogger() + logger.setLevel(logger.original_level) + + +class DifferentArgsException(FailTest): + pass + + +class DifferentXMLException(FailTest): + pass + + +class DifferentListException(FailTest): + pass + + +class FakeSAT(object): + """Class to simulate a SAT instance""" + + def __init__(self): + self.bridge = FakeBridge() + self.memory = FakeMemory(self) + self.trigger = FakeTriggerManager() + self.profiles = {} + self.reinit() + + def reinit(self): + """This can be called by tests that check for sent and stored messages, + uses FakeClient or get/set some other data that need to be cleaned""" + for profile in self.profiles: + self.profiles[profile].reinit() + self.memory.reinit() + self.stored_messages = [] + self.plugins = {} + self.profiles = {} + + def delContact(self, to, profile_key): + #TODO + pass + + def registerCallback(self, callback, *args, **kwargs): + pass + + def messageSend(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'): + self.sendAndStoreMessage({"to": JID(to_s)}) + + def _sendMessageToStream(self, mess_data, client): + """Save the information to check later to whom messages have been sent. + + @param mess_data: message data dictionnary + @param client: profile's client + """ + client.xmlstream.send(mess_data['xml']) + return mess_data + + def _storeMessage(self, mess_data, client): + """Save the information to check later if entries have been added to the history. + + @param mess_data: message data dictionnary + @param client: profile's client + """ + self.stored_messages.append(mess_data["to"]) + return mess_data + + def sendMessageToBridge(self, mess_data, client): + """Simulate the message being sent to the frontends. + + @param mess_data: message data dictionnary + @param client: profile's client + """ + return mess_data # TODO + + def getProfileName(self, profile_key): + """Get the profile name from the profile_key""" + return profile_key + + def getClient(self, profile_key): + """Convenient method to get client from profile key + @return: client or None if it doesn't exist""" + profile = self.memory.getProfileName(profile_key) + if not profile: + raise exceptions.ProfileKeyUnknown + if profile not in self.profiles: + self.profiles[profile] = FakeClient(self, profile) + return self.profiles[profile] + + def getJidNStream(self, profile_key): + """Convenient method to get jid and stream from profile key + @return: tuple (jid, xmlstream) from profile, can be None""" + return (C.PROFILE_DICT[profile_key], None) + + def isConnected(self, profile): + return True + + def getSentMessages(self, profile_index): + """Return all the sent messages (in the order they have been sent) and + empty the list. Called by tests. FakeClient instances associated to each + profile must have been previously initialized with the method + FakeSAT.getClient. + + @param profile_index: index of the profile to consider (cf. C.PROFILE) + @return: the sent messages for given profile, or None""" + try: + tmp = self.profiles[C.PROFILE[profile_index]].xmlstream.sent + self.profiles[C.PROFILE[profile_index]].xmlstream.sent = [] + return tmp + except IndexError: + return None + + def getSentMessage(self, profile_index): + """Pop and return the sent message in first position (works like a FIFO). + Called by tests. FakeClient instances associated to each profile must have + been previously initialized with the method FakeSAT.getClient. + + @param profile_index: index of the profile to consider (cf. C.PROFILE) + @return: the sent message for given profile, or None""" + try: + return self.profiles[C.PROFILE[profile_index]].xmlstream.sent.pop(0) + except IndexError: + return None + + def getSentMessageXml(self, profile_index): + """Pop and return the sent message in first position (works like a FIFO). + Called by tests. FakeClient instances associated to each profile must have + been previously initialized with the method FakeSAT.getClient. + @return: XML representation of the sent message for given profile, or None""" + entry = self.getSentMessage(profile_index) + return entry.toXml() if entry else None + + def findFeaturesSet(self, features, identity=None, jid_=None, profile=C.PROF_KEY_NONE): + """Call self.addFeature from your tests to change the return value. + + @return: a set of entities + """ + client = self.getClient(profile) + if jid_ is None: + jid_ = JID(client.jid.host) + try: + if set(features).issubset(client.features[jid_]): + return defer.succeed(set([jid_])) + except (TypeError, AttributeError, KeyError): + pass + return defer.succeed(set()) + + def addFeature(self, jid_, feature, profile_key): + """Add a feature to an entity. + + To be called from your tests when needed. + """ + client = self.getClient(profile_key) + if not hasattr(client, 'features'): + client.features = {} + if jid_ not in client.features: + client.features[jid_] = set() + client.features[jid_].add(feature) + + +class FakeBridge(object): + """Class to simulate and test bridge calls""" + + def __init__(self): + self.expected_calls = {} + + def expectCall(self, name, *check_args, **check_kwargs): + if hasattr(self, name): # queue this new call as one already exists + self.expected_calls.setdefault(name, []) + self.expected_calls[name].append((check_args, check_kwargs)) + return + + def checkCall(*args, **kwargs): + if args != check_args or kwargs != check_kwargs: + print "\n\n--------------------" + print "Args are not equals:" + print "args\n----\n%s (sent)\n%s (wanted)" % (args, check_args) + print "kwargs\n------\n%s (sent)\n%s (wanted)" % (kwargs, check_kwargs) + print "--------------------\n\n" + raise DifferentArgsException + delattr(self, name) + + if name in self.expected_calls: # register the next call + args, kwargs = self.expected_calls[name].pop(0) + if len(self.expected_calls[name]) == 0: + del self.expected_calls[name] + self.expectCall(name, *args, **kwargs) + + setattr(self, name, checkCall) + + def addMethod(self, name, int_suffix, in_sign, out_sign, method, async=False, doc=None): + pass + + def addSignal(self, name, int_suffix, signature): + pass + + def addTestCallback(self, name, method): + """This can be used to register callbacks for bridge methods AND signals. + Contrary to expectCall, this will not check if the method or signal is + called/sent with the correct arguments, it will instead run the callback + of your choice.""" + setattr(self, name, method) + + +class FakeParams(Params): + """Class to simulate and test params object. The methods of Params that could + not be run (for example those using the storage attribute must be overwritten + by a naive simulation of what they should do.""" + + def __init__(self, host, storage): + Params.__init__(self, host, storage) + self.params = {} # naive simulation of values storage + + def setParam(self, name, value, category, security_limit=-1, profile_key='@NONE@'): + profile = self.getProfileName(profile_key) + self.params.setdefault(profile, {}) + self.params[profile_key][(category, name)] = value + + def getParamA(self, name, category, attr="value", profile_key='@NONE@'): + profile = self.getProfileName(profile_key) + return self.params[profile][(category, name)] + + def getProfileName(self, profile_key, return_profile_keys=False): + if profile_key == '@DEFAULT@': + return C.PROFILE[0] + elif profile_key == '@NONE@': + raise exceptions.ProfileNotSetError + else: + return profile_key + + def loadIndParams(self, profile, cache=None): + self.params[profile] = {} + return defer.succeed(None) + + +class FakeMemory(Memory): + """Class to simulate and test memory object""" + + def __init__(self, host): + # do not call Memory.__init__, we just want to call the methods that are + # manipulating basic stuff, the others should be overwritten when needed + self.host = host + self.params = FakeParams(host, None) + self.config = tools_config.parseMainConf() + self.reinit() + + def reinit(self): + """Tests that manipulate params, entities, features should + re-initialise the memory first to not fake the result.""" + self.params.load_default_params() + self.params.params.clear() + self.params.frontends_cache = [] + self.entities_data = {} + + def getProfileName(self, profile_key, return_profile_keys=False): + return self.params.getProfileName(profile_key, return_profile_keys) + + def addToHistory(self, from_jid, to_jid, message, _type='chat', extra=None, timestamp=None, profile="@NONE@"): + pass + + def addContact(self, contact_jid, attributes, groups, profile_key='@DEFAULT@'): + pass + + def setPresenceStatus(self, contact_jid, show, priority, statuses, profile_key='@DEFAULT@'): + pass + + def addWaitingSub(self, type_, contact_jid, profile_key): + pass + + def delWaitingSub(self, contact_jid, profile_key): + pass + + def updateEntityData(self, entity_jid, key, value, silent=False, profile_key="@NONE@"): + self.entities_data.setdefault(entity_jid, {}) + self.entities_data[entity_jid][key] = value + + def getEntityData(self, entity_jid, keys, profile_key): + result = {} + for key in keys: + result[key] = self.entities_data[entity_jid][key] + return result + + +class FakeTriggerManager(object): + + def add(self, point_name, callback, priority=0): + pass + + def point(self, point_name, *args, **kwargs): + """We always return true to continue the action""" + return True + + +class FakeRosterProtocol(SatRosterProtocol): + """This class is used by FakeClient (one instance per profile)""" + + def __init__(self, host, parent): + SatRosterProtocol.__init__(self, host) + self.parent = parent + self._jids = {} + self.addItem(parent.jid.userhostJID()) + + def addItem(self, jid, *args, **kwargs): + if not args and not kwargs: + # defaults values setted for the tests only + kwargs["subscriptionTo"] = True + kwargs["subscriptionFrom"] = True + roster_item = RosterItem(jid, *args, **kwargs) + attrs = {'to': b2s(roster_item.subscriptionTo), 'from': b2s(roster_item.subscriptionFrom), 'ask': b2s(roster_item.pendingOut)} + if roster_item.name: + attrs['name'] = roster_item.name + self.host.bridge.expectCall("newContact", jid.full(), attrs, roster_item.groups, self.parent.profile) + self._jids[jid] = roster_item + self._registerItem(roster_item) + + +class FakeXmlStream(object): + """This class is used by FakeClient (one instance per profile)""" + + def __init__(self): + self.sent = [] + + def send(self, obj): + """Save the sent messages to compare them later. + + @param obj (domish.Element, str or unicode): message to send + """ + if not isinstance(obj, domish.Element): + assert(isinstance(obj, str) or isinstance(obj, unicode)) + obj = parseXml(obj) + + if obj.name == 'iq': + # IQ request expects an answer, return the request itself so + # you can check if it has been well built by your plugin. + self.iqDeferreds[obj['id']].callback(obj) + + self.sent.append(obj) + return defer.succeed(None) + + def addObserver(self, *argv): + pass + + +class FakeClient(object): + """Tests involving more than one profile need one instance of this class per profile""" + + def __init__(self, host, profile=None): + self.host = host + self.profile = profile if profile else C.PROFILE[0] + self.jid = C.PROFILE_DICT[self.profile] + self.roster = FakeRosterProtocol(host, self) + self.xmlstream = FakeXmlStream() + + def reinit(self): + self.xmlstream = FakeXmlStream() + + def send(self, obj): + return self.xmlstream.send(obj) + + +class SatTestCase(unittest.TestCase): + + def assertEqualXML(self, xml, expected, ignore_blank=False): + def equalElt(got_elt, exp_elt): + if ignore_blank: + for elt in got_elt, exp_elt: + for attr in ('text', 'tail'): + value = getattr(elt, attr) + try: + value = value.strip() or None + except AttributeError: + value = None + setattr(elt, attr, value) + if (got_elt.tag != exp_elt.tag): + print "XML are not equals (elt %s/%s):" % (got_elt, exp_elt) + print "tag: got [%s] expected: [%s]" % (got_elt.tag, exp_elt.tag) + return False + if (got_elt.attrib != exp_elt.attrib): + print "XML are not equals (elt %s/%s):" % (got_elt, exp_elt) + print "attribs: got %s expected %s" % (got_elt.attrib, exp_elt.attrib) + return False + if (got_elt.tail != exp_elt.tail or got_elt.text != exp_elt.text): + print "XML are not equals (elt %s/%s):" % (got_elt, exp_elt) + print "text: got [%s] expected: [%s]" % (got_elt.text, exp_elt.text) + print "tail: got [%s] expected: [%s]" % (got_elt.tail, exp_elt.tail) + return False + if (len(got_elt) != len(exp_elt)): + print "XML are not equals (elt %s/%s):" % (got_elt, exp_elt) + print "children len: got %d expected: %d" % (len(got_elt), len(exp_elt)) + return False + for idx, child in enumerate(got_elt): + if not equalElt(child, exp_elt[idx]): + return False + return True + + def remove_blank(xml): + lines = [line.strip() for line in re.sub(r'[ \t\r\f\v]+', ' ', xml).split('\n')] + return '\n'.join([line for line in lines if line]) + + xml_elt = etree.fromstring(remove_blank(xml) if ignore_blank else xml) + expected_elt = etree.fromstring(remove_blank(expected) if ignore_blank else expected) + + if not equalElt(xml_elt, expected_elt): + print "---" + print "XML are not equals:" + print "got:\n-\n%s\n-\n\n" % etree.tostring(xml_elt, encoding='utf-8') + print "was expecting:\n-\n%s\n-\n\n" % etree.tostring(expected_elt, encoding='utf-8') + print "---" + raise DifferentXMLException + + def assertEqualUnsortedList(self, a, b, msg): + counter_a = Counter(a) + counter_b = Counter(b) + if counter_a != counter_b: + print "---" + print "Unsorted lists are not equals:" + print "got : %s" % counter_a + print "was expecting: %s" % counter_b + if msg: + print msg + print "---" + raise DifferentListException diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/helpers_plugins.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/helpers_plugins.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,282 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>. + +""" Helpers class for plugin dependencies """ + +from twisted.internet import defer + +from wokkel.muc import Room, User +from wokkel.generic import parseXml +from wokkel.disco import DiscoItem, DiscoItems + +# temporary until the changes are integrated to Wokkel +from sat_tmp.wokkel.rsm import RSMResponse + +from constants import Const as C +from sat.plugins import plugin_xep_0045 +from collections import OrderedDict + + +class FakeMUCClient(object): + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + self.joined_rooms = {} + + def join(self, room_jid, nick, options=None, profile_key=C.PROF_KEY_NONE): + """ + @param room_jid: the room JID + @param nick: nick to be used in the room + @param options: joining options + @param profile_key: the profile key of the user joining the room + @return: the deferred joined wokkel.muc.Room instance + """ + profile = self.host.memory.getProfileName(profile_key) + roster = {} + + # ask the other profiles to fill our roster + for i in xrange(0, len(C.PROFILE)): + other_profile = C.PROFILE[i] + if other_profile == profile: + continue + try: + other_room = self.plugin_parent.clients[other_profile].joined_rooms[room_jid] + roster.setdefault(other_room.nick, User(other_room.nick, C.PROFILE_DICT[other_profile])) + for other_nick in other_room.roster: + roster.setdefault(other_nick, other_room.roster[other_nick]) + except (AttributeError, KeyError): + pass + + # rename our nick if it already exists + while nick in roster.keys(): + if C.PROFILE_DICT[profile].userhost() == roster[nick].entity.userhost(): + break # same user with different resource --> same nickname + nick = nick + "_" + + room = Room(room_jid, nick) + room.roster = roster + self.joined_rooms[room_jid] = room + + # fill the other rosters with the new entry + for i in xrange(0, len(C.PROFILE)): + other_profile = C.PROFILE[i] + if other_profile == profile: + continue + try: + other_room = self.plugin_parent.clients[other_profile].joined_rooms[room_jid] + other_room.roster.setdefault(room.nick, User(room.nick, C.PROFILE_DICT[profile])) + except (AttributeError, KeyError): + pass + + return defer.succeed(room) + + def leave(self, roomJID, profile_key=C.PROF_KEY_NONE): + """ + @param roomJID: the room JID + @param profile_key: the profile key of the user joining the room + @return: a dummy deferred + """ + profile = self.host.memory.getProfileName(profile_key) + room = self.joined_rooms[roomJID] + # remove ourself from the other rosters + for i in xrange(0, len(C.PROFILE)): + other_profile = C.PROFILE[i] + if other_profile == profile: + continue + try: + other_room = self.plugin_parent.clients[other_profile].joined_rooms[roomJID] + del other_room.roster[room.nick] + except (AttributeError, KeyError): + pass + del self.joined_rooms[roomJID] + return defer.Deferred() + + +class FakeXEP_0045(plugin_xep_0045.XEP_0045): + + def __init__(self, host): + self.host = host + self.clients = {} + for profile in C.PROFILE: + self.clients[profile] = FakeMUCClient(self) + + def join(self, room_jid, nick, options={}, profile_key='@DEFAULT@'): + """ + @param roomJID: the room JID + @param nick: nick to be used in the room + @param options: ignore + @param profile_key: the profile of the user joining the room + @return: the deferred joined wokkel.muc.Room instance or None + """ + profile = self.host.memory.getProfileName(profile_key) + if room_jid in self.clients[profile].joined_rooms: + return defer.succeed(None) + room = self.clients[profile].join(room_jid, nick, profile_key=profile) + return room + + def joinRoom(self, muc_index, user_index): + """Called by tests + @return: the nickname of the user who joined room""" + muc_jid = C.MUC[muc_index] + nick = C.JID[user_index].user + profile = C.PROFILE[user_index] + self.join(muc_jid, nick, profile_key=profile) + return self.getNick(muc_index, user_index) + + def leave(self, room_jid, profile_key='@DEFAULT@'): + """ + @param roomJID: the room JID + @param profile_key: the profile of the user leaving the room + @return: a dummy deferred + """ + profile = self.host.memory.getProfileName(profile_key) + if room_jid not in self.clients[profile].joined_rooms: + raise plugin_xep_0045.UnknownRoom("This room has not been joined") + return self.clients[profile].leave(room_jid, profile) + + def leaveRoom(self, muc_index, user_index): + """Called by tests + @return: the nickname of the user who left the room""" + muc_jid = C.MUC[muc_index] + nick = self.getNick(muc_index, user_index) + profile = C.PROFILE[user_index] + self.leave(muc_jid, profile_key=profile) + return nick + + def getRoom(self, muc_index, user_index): + """Called by tests + @return: a wokkel.muc.Room instance""" + profile = C.PROFILE[user_index] + muc_jid = C.MUC[muc_index] + try: + return self.clients[profile].joined_rooms[muc_jid] + except (AttributeError, KeyError): + return None + + def getNick(self, muc_index, user_index): + try: + return self.getRoomNick(C.MUC[muc_index], C.PROFILE[user_index]) + except (KeyError, AttributeError): + return '' + + def getNickOfUser(self, muc_index, user_index, profile_index, secure=True): + try: + room = self.clients[C.PROFILE[profile_index]].joined_rooms[C.MUC[muc_index]] + return self.getRoomNickOfUser(room, C.JID[user_index]) + except (KeyError, AttributeError): + return None + + +class FakeXEP_0249(object): + + def __init__(self, host): + self.host = host + + def invite(self, target, room, options={}, profile_key='@DEFAULT@'): + """ + Invite a user to a room. To accept the invitation from a test, + just call FakeXEP_0045.joinRoom (no need to have a dedicated method). + @param target: jid of the user to invite + @param room: jid of the room where the user is invited + @options: attribute with extra info (reason, password) as in #XEP-0249 + @profile_key: %(doc_profile_key)s + """ + pass + + +class FakeSatPubSubClient(object): + + def __init__(self, host, parent_plugin): + self.host = host + self.parent_plugin = parent_plugin + self.__items = OrderedDict() + self.__rsm_responses = {} + + def createNode(self, service, nodeIdentifier=None, options=None, + sender=None): + return defer.succeed(None) + + def deleteNode(self, service, nodeIdentifier, sender=None): + try: + del self.__items[nodeIdentifier] + except KeyError: + pass + return defer.succeed(None) + + def subscribe(self, service, nodeIdentifier, subscriber, + options=None, sender=None): + return defer.succeed(None) + + def unsubscribe(self, service, nodeIdentifier, subscriber, + subscriptionIdentifier=None, sender=None): + return defer.succeed(None) + + def publish(self, service, nodeIdentifier, items=None, sender=None): + node = self.__items.setdefault(nodeIdentifier, []) + + def replace(item_obj): + index = 0 + for current in node: + if current['id'] == item_obj['id']: + node[index] = item_obj + return True + index += 1 + return False + + for item in items: + item_obj = parseXml(item) if isinstance(item, unicode) else item + if not replace(item_obj): + node.append(item_obj) + return defer.succeed(None) + + def items(self, service, nodeIdentifier, maxItems=None, itemIdentifiers=None, + subscriptionIdentifier=None, sender=None, ext_data=None): + try: + items = self.__items[nodeIdentifier] + except KeyError: + items = [] + if ext_data: + assert('id' in ext_data) + if 'rsm' in ext_data: + args = (0, items[0]['id'], items[-1]['id']) if items else () + self.__rsm_responses[ext_data['id']] = RSMResponse(len(items), *args) + return defer.succeed(items) + + def retractItems(self, service, nodeIdentifier, itemIdentifiers, sender=None): + node = self.__items[nodeIdentifier] + for item in [item for item in node if item['id'] in itemIdentifiers]: + node.remove(item) + return defer.succeed(None) + + def getRSMResponse(self, id): + if id not in self.__rsm_responses: + return {} + result = self.__rsm_responses[id].toDict() + del self.__rsm_responses[id] + return result + + def subscriptions(self, service, nodeIdentifier, sender=None): + return defer.succeed([]) + + def service_getDiscoItems(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE): + items = DiscoItems() + for item in self.__items.keys(): + items.append(DiscoItem(service, item)) + return defer.succeed(items) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_core_xmpp.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_core_xmpp.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,111 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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.test import helpers +from constants import Const +from twisted.trial import unittest +from sat.core import xmpp +from twisted.words.protocols.jabber.jid import JID +from wokkel.generic import parseXml +from wokkel.xmppim import RosterItem + + +class SatXMPPClientTest(unittest.TestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.client = xmpp.SatXMPPClient(self.host, Const.PROFILE[0], JID("test@example.org"), "test") + + def test_init(self): + """Check that init values are correctly initialised""" + self.assertEqual(self.client.profile, Const.PROFILE[0]) + print self.client.host + self.assertEqual(self.client.host_app, self.host) + + +class SatMessageProtocolTest(unittest.TestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.message = xmpp.SatMessageProtocol(self.host) + self.message.parent = helpers.FakeClient(self.host) + + def test_onMessage(self): + xml = """ + <message type="chat" from="sender@example.net/house" to="test@example.org/SàT" id="test_1"> + <body>test</body> + </message> + """ + stanza = parseXml(xml) + self.host.bridge.expectCall("messageNew", u"sender@example.net/house", u"test", u"chat", u"test@example.org/SàT", {}, profile=Const.PROFILE[0]) + self.message.onMessage(stanza) + + +class SatRosterProtocolTest(unittest.TestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.roster = xmpp.SatRosterProtocol(self.host) + self.roster.parent = helpers.FakeClient(self.host) + + def test__registerItem(self): + roster_item = RosterItem(Const.JID[0]) + roster_item.name = u"Test Man" + roster_item.subscriptionTo = True + roster_item.subscriptionFrom = True + roster_item.ask = False + roster_item.groups = set([u"Test Group 1", u"Test Group 2", u"Test Group 3"]) + self.host.bridge.expectCall("newContact", Const.JID_STR[0], {'to': 'True', 'from': 'True', 'ask': 'False', 'name': u'Test Man'}, set([u"Test Group 1", u"Test Group 2", u"Test Group 3"]), Const.PROFILE[0]) + self.roster._registerItem(roster_item) + + +class SatPresenceProtocolTest(unittest.TestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.presence = xmpp.SatPresenceProtocol(self.host) + self.presence.parent = helpers.FakeClient(self.host) + + def test_availableReceived(self): + self.host.bridge.expectCall("presenceUpdate", Const.JID_STR[0], "xa", 15, {'default': "test status", 'fr': 'statut de test'}, Const.PROFILE[0]) + self.presence.availableReceived(Const.JID[0], 'xa', {None: "test status", 'fr': 'statut de test'}, 15) + + def test_availableReceived_empty_statuses(self): + self.host.bridge.expectCall("presenceUpdate", Const.JID_STR[0], "xa", 15, {}, Const.PROFILE[0]) + self.presence.availableReceived(Const.JID[0], 'xa', None, 15) + + def test_unavailableReceived(self): + self.host.bridge.expectCall("presenceUpdate", Const.JID_STR[0], "unavailable", 0, {}, Const.PROFILE[0]) + self.presence.unavailableReceived(Const.JID[0], None) + + def test_subscribedReceived(self): + self.host.bridge.expectCall("subscribe", "subscribed", Const.JID[0].userhost(), Const.PROFILE[0]) + self.presence.subscribedReceived(Const.JID[0]) + + def test_unsubscribedReceived(self): + self.host.bridge.expectCall("subscribe", "unsubscribed", Const.JID[0].userhost(), Const.PROFILE[0]) + self.presence.unsubscribedReceived(Const.JID[0]) + + def test_subscribeReceived(self): + self.host.bridge.expectCall("subscribe", "subscribe", Const.JID[0].userhost(), Const.PROFILE[0]) + self.presence.subscribeReceived(Const.JID[0]) + + def test_unsubscribeReceived(self): + self.host.bridge.expectCall("subscribe", "unsubscribe", Const.JID[0].userhost(), Const.PROFILE[0]) + self.presence.unsubscribeReceived(Const.JID[0]) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_helpers_plugins.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_helpers_plugins.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,117 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>. + +""" Test the helper classes to see if they behave well""" + +from sat.test import helpers +from sat.test import helpers_plugins + + +class FakeXEP_0045Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = helpers_plugins.FakeXEP_0045(self.host) + + def test_joinRoom(self): + self.plugin.joinRoom(0, 0) + self.assertEqual('test', self.plugin.getNick(0, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 0)) + self.assertEqual('', self.plugin.getNick(0, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 1)) + self.assertEqual('', self.plugin.getNick(0, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 2)) + self.assertEqual('', self.plugin.getNick(0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 3)) + self.plugin.joinRoom(0, 1) + self.assertEqual('test', self.plugin.getNick(0, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 0)) + self.assertEqual('sender', self.plugin.getNick(0, 1)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 1)) + self.assertEqual('', self.plugin.getNick(0, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 2)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 2)) + self.assertEqual('', self.plugin.getNick(0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 3)) + self.plugin.joinRoom(0, 2) + self.assertEqual('test', self.plugin.getNick(0, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 0)) + self.assertEqual('sender', self.plugin.getNick(0, 1)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 1)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 1)) # Const.JID[2] is in the roster for Const.PROFILE[1] + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 1)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 1)) + self.assertEqual('sender', self.plugin.getNick(0, 2)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 2)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 2)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 2)) # Const.JID[1] is in the roster for Const.PROFILE[2] + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 2)) + self.assertEqual('', self.plugin.getNick(0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 1, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 2, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 3)) + self.plugin.joinRoom(0, 3) + self.assertEqual('test', self.plugin.getNick(0, 0)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 0, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 0)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 0)) + self.assertEqual('sender_', self.plugin.getNickOfUser(0, 3, 0)) + self.assertEqual('sender', self.plugin.getNick(0, 1)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 1)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 1)) # Const.JID[2] is in the roster for Const.PROFILE[1] + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 1)) + self.assertEqual('sender_', self.plugin.getNickOfUser(0, 3, 1)) + self.assertEqual('sender', self.plugin.getNick(0, 2)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 2)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 2)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 2)) # Const.JID[1] is in the roster for Const.PROFILE[2] + self.assertEqual('sender_', self.plugin.getNickOfUser(0, 3, 2)) + self.assertEqual('sender_', self.plugin.getNick(0, 3)) + self.assertEqual('test', self.plugin.getNickOfUser(0, 0, 3)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 1, 3)) + self.assertEqual('sender', self.plugin.getNickOfUser(0, 2, 3)) + self.assertEqual(None, self.plugin.getNickOfUser(0, 3, 3)) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_memory.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_memory.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,287 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 _ +from sat.test import helpers +from twisted.trial import unittest +import traceback +from constants import Const +from xml.dom import minidom + + +class MemoryTest(unittest.TestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + + def _getParamXML(self, param="1", security_level=None): + """Generate XML for testing parameters + + @param param (str): a subset of "123" + @param security_level: security level of the parameters + @return (str) + """ + def getParam(name): + return """ + <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" %(security)s/> + """ % {'param_name': name, + 'param_label': _(name), + 'security': '' if security_level is None else ('security="%d"' % security_level) + } + params = '' + if "1" in param: + params += getParam(Const.ENABLE_UNIBOX_PARAM) + if "2" in param: + params += getParam(Const.PARAM_IN_QUOTES) + if "3" in param: + params += getParam("Dummy param") + return """ + <params> + <individual> + <category name="%(category_name)s" label="%(category_label)s"> + %(params)s + </category> + </individual> + </params> + """ % { + 'category_name': Const.COMPOSITION_KEY, + 'category_label': _(Const.COMPOSITION_KEY), + 'params': params + } + + def _paramExists(self, param="1", src=None): + """ + + @param param (str): a character in "12" + @param src (DOM element): the top-level element to look in + @return: True is the param exists + """ + if param == "1": + name = Const.ENABLE_UNIBOX_PARAM + else: + name = Const.PARAM_IN_QUOTES + category = Const.COMPOSITION_KEY + if src is None: + src = self.host.memory.params.dom.documentElement + for type_node in src.childNodes: + # when src comes self.host.memory.params.dom, we have here + # some "individual" or "general" elements, when it comes + # from Memory.getParams we have here a "params" elements + if type_node.nodeName not in ("individual", "general", "params"): + continue + for cat_node in type_node.childNodes: + if cat_node.nodeName != "category" or cat_node.getAttribute("name") != category: + continue + for param in cat_node.childNodes: + if param.nodeName == "param" and param.getAttribute("name") == name: + return True + return False + + def assertParam_generic(self, param="1", src=None, exists=True, deferred=False): + """ + @param param (str): a character in "12" + @param src (DOM element): the top-level element to look in + @param exists (boolean): True to assert the param exists, False to assert it doesn't + @param deferred (boolean): True if this method is called from a Deferred callback + """ + msg = "Expected parameter not found!\n" if exists else "Unexpected parameter found!\n" + if deferred: + # in this stack we can see the line where the error came from, + # if limit=5, 6 is not enough you can increase the value + msg += "\n".join(traceback.format_stack(limit=5 if exists else 6)) + assertion = self._paramExists(param, src) + getattr(self, "assert%s" % exists)(assertion, msg) + + def assertParamExists(self, param="1", src=None): + self.assertParam_generic(param, src, True) + + def assertParamNotExists(self, param="1", src=None): + self.assertParam_generic(param, src, False) + + def assertParamExists_async(self, src, param="1"): + """@param src: a deferred result from Memory.getParams""" + self.assertParam_generic(param, minidom.parseString(src.encode("utf-8")), True, True) + + def assertParamNotExists_async(self, src, param="1"): + """@param src: a deferred result from Memory.getParams""" + self.assertParam_generic(param, minidom.parseString(src.encode("utf-8")), False, True) + + def _getParams(self, security_limit, app='', profile_key='@NONE@'): + """Get the parameters accessible with the given security limit and application name. + + @param security_limit (int): the security limit + @param app (str): empty string or "libervia" + @param profile_key + """ + if profile_key == '@NONE@': + profile_key = '@DEFAULT@' + return self.host.memory.params.getParams(security_limit, app, profile_key) + + def test_updateParams(self): + self.host.memory.reinit() + # check if the update works + self.host.memory.updateParams(self._getParamXML()) + self.assertParamExists() + previous = self.host.memory.params.dom.cloneNode(True) + # now check if it is really updated and not duplicated + self.host.memory.updateParams(self._getParamXML()) + self.assertEqual(previous.toxml().encode("utf-8"), self.host.memory.params.dom.toxml().encode("utf-8")) + + self.host.memory.reinit() + # check successive updates (without intersection) + self.host.memory.updateParams(self._getParamXML('1')) + self.assertParamExists("1") + self.assertParamNotExists("2") + self.host.memory.updateParams(self._getParamXML('2')) + self.assertParamExists("1") + self.assertParamExists("2") + + previous = self.host.memory.params.dom.cloneNode(True) # save for later + + self.host.memory.reinit() + # check successive updates (with intersection) + self.host.memory.updateParams(self._getParamXML('1')) + self.assertParamExists("1") + self.assertParamNotExists("2") + self.host.memory.updateParams(self._getParamXML('12')) + self.assertParamExists("1") + self.assertParamExists("2") + + # successive updates with or without intersection should have the same result + self.assertEqual(previous.toxml().encode("utf-8"), self.host.memory.params.dom.toxml().encode("utf-8")) + + self.host.memory.reinit() + # one update with two params in a new category + self.host.memory.updateParams(self._getParamXML('12')) + self.assertParamExists("1") + self.assertParamExists("2") + + def test_getParams(self): + # tests with no security level on the parameter (most secure) + params = self._getParamXML() + self.host.memory.reinit() + self.host.memory.updateParams(params) + self._getParams(Const.NO_SECURITY_LIMIT).addCallback(self.assertParamExists_async) + self._getParams(0).addCallback(self.assertParamNotExists_async) + self._getParams(1).addCallback(self.assertParamNotExists_async) + # tests with security level 0 on the parameter (not secure) + params = self._getParamXML(security_level=0) + self.host.memory.reinit() + self.host.memory.updateParams(params) + self._getParams(Const.NO_SECURITY_LIMIT).addCallback(self.assertParamExists_async) + self._getParams(0).addCallback(self.assertParamExists_async) + self._getParams(1).addCallback(self.assertParamExists_async) + # tests with security level 1 on the parameter (more secure) + params = self._getParamXML(security_level=1) + self.host.memory.reinit() + self.host.memory.updateParams(params) + self._getParams(Const.NO_SECURITY_LIMIT).addCallback(self.assertParamExists_async) + self._getParams(0).addCallback(self.assertParamNotExists_async) + return self._getParams(1).addCallback(self.assertParamExists_async) + + def test_paramsRegisterApp(self): + + def register(xml, security_limit, app): + """ + @param xml: XML definition of the parameters to be added + @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure + @param app: name of the frontend registering the parameters + """ + helpers.muteLogging() + self.host.memory.paramsRegisterApp(xml, security_limit, app) + helpers.unmuteLogging() + + # tests with no security level on the parameter (most secure) + params = self._getParamXML() + self.host.memory.reinit() + register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME) + self.assertParamExists() + self.host.memory.reinit() + register(params, 0, Const.APP_NAME) + self.assertParamNotExists() + self.host.memory.reinit() + register(params, 1, Const.APP_NAME) + self.assertParamNotExists() + + # tests with security level 0 on the parameter (not secure) + params = self._getParamXML(security_level=0) + self.host.memory.reinit() + register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME) + self.assertParamExists() + self.host.memory.reinit() + register(params, 0, Const.APP_NAME) + self.assertParamExists() + self.host.memory.reinit() + register(params, 1, Const.APP_NAME) + self.assertParamExists() + + # tests with security level 1 on the parameter (more secure) + params = self._getParamXML(security_level=1) + self.host.memory.reinit() + register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME) + self.assertParamExists() + self.host.memory.reinit() + register(params, 0, Const.APP_NAME) + self.assertParamNotExists() + self.host.memory.reinit() + register(params, 1, Const.APP_NAME) + self.assertParamExists() + + # tests with security level 1 and several parameters being registered + params = self._getParamXML("12", security_level=1) + self.host.memory.reinit() + register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME) + self.assertParamExists() + self.assertParamExists("2") + self.host.memory.reinit() + register(params, 0, Const.APP_NAME) + self.assertParamNotExists() + self.assertParamNotExists("2") + self.host.memory.reinit() + register(params, 1, Const.APP_NAME) + self.assertParamExists() + self.assertParamExists("2") + + # tests with several parameters being registered in an existing category + self.host.memory.reinit() + self.host.memory.updateParams(self._getParamXML("3")) + register(self._getParamXML("12"), Const.NO_SECURITY_LIMIT, Const.APP_NAME) + self.assertParamExists() + self.assertParamExists("2") + self.host.memory.reinit() + + def test_paramsRegisterApp_getParams(self): + # test retrieving the parameter for a specific frontend + self.host.memory.reinit() + params = self._getParamXML(security_level=1) + self.host.memory.paramsRegisterApp(params, 1, Const.APP_NAME) + self._getParams(1, '').addCallback(self.assertParamExists_async) + self._getParams(1, Const.APP_NAME).addCallback(self.assertParamExists_async) + self._getParams(1, 'another_dummy_frontend').addCallback(self.assertParamNotExists_async) + + # the same with several parameters registered at the same time + self.host.memory.reinit() + params = self._getParamXML('12', security_level=0) + self.host.memory.paramsRegisterApp(params, 5, Const.APP_NAME) + self._getParams(5, '').addCallback(self.assertParamExists_async) + self._getParams(5, '').addCallback(self.assertParamExists_async, "2") + self._getParams(5, Const.APP_NAME).addCallback(self.assertParamExists_async) + self._getParams(5, Const.APP_NAME).addCallback(self.assertParamExists_async, "2") + self._getParams(5, 'another_dummy_frontend').addCallback(self.assertParamNotExists_async) + return self._getParams(5, 'another_dummy_frontend').addCallback(self.assertParamNotExists_async, "2") diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_memory_crypto.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_memory_crypto.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,72 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>. + + +""" Tests for the plugin radiocol """ + +from sat.test import helpers +from sat.memory.crypto import BlockCipher, PasswordHasher +import random +import string +from twisted.internet import defer + + +def getRandomUnicode(len): + """Return a random unicode string""" + return u''.join(random.choice(string.letters + u"éáúóâêûôßüöä") for i in xrange(len)) + + +class CryptoTest(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + + def test_encrypt_decrypt(self): + d_list = [] + + def test(key, message): + d = BlockCipher.encrypt(key, message) + d.addCallback(lambda ciphertext: BlockCipher.decrypt(key, ciphertext)) + d.addCallback(lambda decrypted: self.assertEqual(message, decrypted)) + d_list.append(d) + + for key_len in (0, 2, 8, 10, 16, 24, 30, 32, 40): + key = getRandomUnicode(key_len) + for message_len in (0, 2, 16, 24, 32, 100): + message = getRandomUnicode(message_len) + test(key, message) + return defer.DeferredList(d_list) + + def test_hash_verify(self): + d_list = [] + for password in (0, 2, 8, 10, 16, 24, 30, 32, 40): + d = PasswordHasher.hash(password) + + def cb(hashed): + d1 = PasswordHasher.verify(password, hashed) + d1.addCallback(lambda result: self.assertTrue(result)) + d_list.append(d1) + attempt = getRandomUnicode(10) + d2 = PasswordHasher.verify(attempt, hashed) + d2.addCallback(lambda result: self.assertFalse(result)) + d_list.append(d2) + + d.addCallback(cb) + return defer.DeferredList(d_list) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_misc_groupblog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_misc_groupblog.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,420 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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/>. + +""" Plugin groupblogs """ + +from constants import Const as C +from sat.test import helpers, helpers_plugins +from sat.plugins import plugin_misc_groupblog +from sat.plugins import plugin_xep_0060 +from sat.plugins import plugin_xep_0277 +from sat.plugins import plugin_xep_0163 +from sat.plugins import plugin_misc_text_syntaxes +from twisted.internet import defer +from twisted.words.protocols.jabber import jid + + +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' + +DO_NOT_COUNT_COMMENTS = -1 + +SERVICE = u'pubsub.example.com' +PUBLISHER = u'test@example.org' +OTHER_PUBLISHER = u'other@xmpp.net' +NODE_ID = u'urn:xmpp:groupblog:{publisher}'.format(publisher=PUBLISHER) +OTHER_NODE_ID = u'urn:xmpp:groupblog:{publisher}'.format(publisher=OTHER_PUBLISHER) +ITEM_ID_1 = u'c745a688-9b02-11e3-a1a3-c0143dd4fe51' +COMMENT_ID_1 = u'd745a688-9b02-11e3-a1a3-c0143dd4fe52' +COMMENT_ID_2 = u'e745a688-9b02-11e3-a1a3-c0143dd4fe53' + + +def COMMENTS_NODE_ID(publisher=PUBLISHER): + return u'urn:xmpp:comments:_{id}__urn:xmpp:groupblog:{publisher}'.format(id=ITEM_ID_1, publisher=publisher) + + +def COMMENTS_NODE_URL(publisher=PUBLISHER): + return u'xmpp:{service}?node={node}'.format(service=SERVICE, id=ITEM_ID_1, + node=COMMENTS_NODE_ID(publisher).replace(':', '%3A').replace('@', '%40')) + + +def ITEM(publisher=PUBLISHER): + return u""" + <item id='{id}' xmlns='{ns}'> + <entry> + <title type='text'>The Uses of This World + {id} + 2003-12-12T17:47:23Z + 2003-12-12T17:47:23Z + + + {publisher} + + + + """.format(ns=NS_PUBSUB, id=ITEM_ID_1, publisher=publisher, comments_node_url=COMMENTS_NODE_URL(publisher)) + + +def COMMENT(id_=COMMENT_ID_1): + return u""" + + + The Uses of This World + {id} + 2003-12-12T17:47:23Z + 2003-12-12T17:47:23Z + + {publisher} + + + + """.format(ns=NS_PUBSUB, id=id_, publisher=PUBLISHER) + + +def ITEM_DATA(id_=ITEM_ID_1, count=0): + res = {'id': ITEM_ID_1, + 'type': 'main_item', + 'content': 'The Uses of This World', + 'author': PUBLISHER, + 'updated': '1071251243.0', + 'published': '1071251243.0', + 'service': SERVICE, + 'comments': COMMENTS_NODE_URL_1, + 'comments_service': SERVICE, + 'comments_node': COMMENTS_NODE_ID_1} + if count != DO_NOT_COUNT_COMMENTS: + res.update({'comments_count': unicode(count)}) + return res + + +def COMMENT_DATA(id_=COMMENT_ID_1): + return {'id': id_, + 'type': 'comment', + 'content': 'The Uses of This World', + 'author': PUBLISHER, + 'updated': '1071251243.0', + 'published': '1071251243.0', + 'service': SERVICE, + 'node': COMMENTS_NODE_ID_1, + 'verified_publisher': 'false'} + + +COMMENTS_NODE_ID_1 = COMMENTS_NODE_ID() +COMMENTS_NODE_ID_2 = COMMENTS_NODE_ID(OTHER_PUBLISHER) +COMMENTS_NODE_URL_1 = COMMENTS_NODE_URL() +COMMENTS_NODE_URL_2 = COMMENTS_NODE_URL(OTHER_PUBLISHER) +ITEM_1 = ITEM() +ITEM_2 = ITEM(OTHER_PUBLISHER) +COMMENT_1 = COMMENT(COMMENT_ID_1) +COMMENT_2 = COMMENT(COMMENT_ID_2) + + +def ITEM_DATA_1(count=0): + return ITEM_DATA(count=count) + +COMMENT_DATA_1 = COMMENT_DATA() +COMMENT_DATA_2 = COMMENT_DATA(COMMENT_ID_2) + + +class XEP_groupblogTest(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.host.plugins['XEP-0060'] = plugin_xep_0060.XEP_0060(self.host) + self.host.plugins['XEP-0163'] = plugin_xep_0163.XEP_0163(self.host) + reload(plugin_misc_text_syntaxes) # reload the plugin to avoid conflict error + self.host.plugins['TEXT-SYNTAXES'] = plugin_misc_text_syntaxes.TextSyntaxes(self.host) + self.host.plugins['XEP-0277'] = plugin_xep_0277.XEP_0277(self.host) + self.plugin = plugin_misc_groupblog.GroupBlog(self.host) + self.plugin._initialise = self._initialise + self.__initialised = False + self._initialise(C.PROFILE[0]) + + def _initialise(self, profile_key): + profile = profile_key + client = self.host.getClient(profile) + if not self.__initialised: + client.item_access_pubsub = jid.JID(SERVICE) + xep_0060 = self.host.plugins['XEP-0060'] + client.pubsub_client = helpers_plugins.FakeSatPubSubClient(self.host, xep_0060) + client.pubsub_client.parent = client + self.psclient = client.pubsub_client + helpers.FakeSAT.getDiscoItems = self.psclient.service_getDiscoItems + self.__initialised = True + return defer.succeed((profile, client)) + + def _addItem(self, profile, item, parent_node=None): + client = self.host.getClient(profile) + client.pubsub_client._addItem(item, parent_node) + + def test_sendGroupBlog(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.items(SERVICE, NODE_ID) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + d.addCallback(lambda dummy: self.plugin.sendGroupBlog('PUBLIC', [], 'test', {}, C.PROFILE[0])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + return d.addCallback(lambda items: self.assertEqual(len(items), 1)) + + def test_deleteGroupBlog(self): + pub_data = (SERVICE, NODE_ID, ITEM_ID_1) + self.host.bridge.expectCall('personalEvent', C.JID_STR[0], + "MICROBLOG_DELETE", + {'type': 'main_item', 'id': ITEM_ID_1}, + C.PROFILE[0]) + + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.deleteGroupBlog(pub_data, COMMENTS_NODE_URL_1, profile_key=C.PROFILE[0])) + return d.addCallback(self.assertEqual, None) + + def test_updateGroupBlog(self): + pub_data = (SERVICE, NODE_ID, ITEM_ID_1) + new_text = u"silfu23RFWUP)IWNOEIOEFÖ" + + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.updateGroupBlog(pub_data, COMMENTS_NODE_URL_1, new_text, {}, profile_key=C.PROFILE[0])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + return d.addCallback(lambda items: self.assertEqual(''.join(items[0].entry.title.children), new_text)) + + def test_sendGroupBlogComment(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.items(SERVICE, NODE_ID) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + d.addCallback(lambda dummy: self.plugin.sendGroupBlogComment(COMMENTS_NODE_URL_1, 'test', {}, profile_key=C.PROFILE[0])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + return d.addCallback(lambda items: self.assertEqual(len(items), 1)) + + def test_getGroupBlogs(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getGroupBlogs(PUBLISHER, profile_key=C.PROFILE[0])) + result = ([ITEM_DATA_1()], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsNoCount(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getGroupBlogs(PUBLISHER, count_comments=False, profile_key=C.PROFILE[0])) + result = ([ITEM_DATA_1(DO_NOT_COUNT_COMMENTS)], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsWithIDs(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getGroupBlogs(PUBLISHER, [ITEM_ID_1], profile_key=C.PROFILE[0])) + result = ([ITEM_DATA_1()], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsWithRSM(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getGroupBlogs(PUBLISHER, rsm_data={'max_': 1}, profile_key=C.PROFILE[0])) + result = ([ITEM_DATA_1()], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsWithComments(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])) + d.addCallback(lambda dummy: self.plugin.getGroupBlogsWithComments(PUBLISHER, [], profile_key=C.PROFILE[0])) + result = ([(ITEM_DATA_1(1), ([COMMENT_DATA_1], + {'count': '1', 'index': '0', 'first': COMMENT_ID_1, 'last': COMMENT_ID_1}))], + {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsWithComments2(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.plugin.getGroupBlogsWithComments(PUBLISHER, [], profile_key=C.PROFILE[0])) + result = ([(ITEM_DATA_1(2), ([COMMENT_DATA_1, COMMENT_DATA_2], + {'count': '2', 'index': '0', 'first': COMMENT_ID_1, 'last': COMMENT_ID_2}))], + {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1}) + + return d.addCallback(self.assertEqual, result) + + def test_getGroupBlogsAtom(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getGroupBlogsAtom(PUBLISHER, {'max_': 1}, profile_key=C.PROFILE[0])) + + def cb(atom): + self.assertIsInstance(atom, unicode) + self.assertTrue(atom.startswith('')) + + return d.addCallback(cb) + + def test_getMassiveGroupBlogs(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.plugin.getMassiveGroupBlogs('JID', [jid.JID(PUBLISHER)], {'max_': 1}, profile_key=C.PROFILE[0])) + result = {PUBLISHER: ([ITEM_DATA_1()], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1})} + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + return res + + d.addCallback(clean) + d.addCallback(self.assertEqual, result) + + def test_getMassiveGroupBlogsWithComments(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.plugin.getMassiveGroupBlogs('JID', [jid.JID(PUBLISHER)], {'max_': 1}, profile_key=C.PROFILE[0])) + result = {PUBLISHER: ([ITEM_DATA_1(2)], {'count': '1', 'index': '0', 'first': ITEM_ID_1, 'last': ITEM_ID_1})} + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + return res + + d.addCallback(clean) + d.addCallback(self.assertEqual, result) + + def test_getGroupBlogComments(self): + self._initialise(C.PROFILE[0]) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])) + d.addCallback(lambda dummy: self.plugin.getGroupBlogComments(SERVICE, COMMENTS_NODE_ID_1, {'max_': 1}, profile_key=C.PROFILE[0])) + result = ([COMMENT_DATA_1], {'count': '1', 'index': '0', 'first': COMMENT_ID_1, 'last': COMMENT_ID_1}) + return d.addCallback(self.assertEqual, result) + + def test_subscribeGroupBlog(self): + self._initialise(C.PROFILE[0]) + d = self.plugin.subscribeGroupBlog(PUBLISHER, profile_key=C.PROFILE[0]) + return d.addCallback(self.assertEqual, None) + + def test_massiveSubscribeGroupBlogs(self): + self._initialise(C.PROFILE[0]) + d = self.plugin.massiveSubscribeGroupBlogs('JID', [jid.JID(PUBLISHER)], profile_key=C.PROFILE[0]) + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@subscriptions@' + SERVICE] + return res + + d.addCallback(clean) + return d.addCallback(self.assertEqual, None) + + def test_deleteAllGroupBlogs(self): + """Delete our main node and associated comments node""" + self._initialise(C.PROFILE[0]) + self.host.profiles[C.PROFILE[0]].roster.addItem(jid.JID(OTHER_PUBLISHER)) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + return res + + d.addCallback(lambda dummy: self.plugin.deleteAllGroupBlogs(C.PROFILE[0])) + d.addCallback(clean) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + return d + + def test_deleteAllGroupBlogsComments(self): + """Delete the comments we posted on other node's""" + self._initialise(C.PROFILE[0]) + self.host.profiles[C.PROFILE[0]].roster.addItem(jid.JID(OTHER_PUBLISHER)) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + return res + + d.addCallback(lambda dummy: self.plugin.deleteAllGroupBlogsComments(C.PROFILE[0])) + d.addCallback(clean) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + return d + + def test_deleteAllGroupBlogsAndComments(self): + self._initialise(C.PROFILE[0]) + self.host.profiles[C.PROFILE[0]].roster.addItem(jid.JID(OTHER_PUBLISHER)) + d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1]) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])) + d.addCallback(lambda dummy: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2])) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 2)) + + def clean(res): + del self.host.plugins['XEP-0060'].node_cache[C.PROFILE[0] + '@found@' + SERVICE] + return res + + d.addCallback(lambda dummy: self.plugin.deleteAllGroupBlogsAndComments(C.PROFILE[0])) + d.addCallback(clean) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + + d.addCallback(lambda dummy: self.psclient.items(SERVICE, OTHER_NODE_ID)) + d.addCallback(lambda items: self.assertEqual(len(items), 1)) + d.addCallback(lambda dummy: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2)) + d.addCallback(lambda items: self.assertEqual(len(items), 0)) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_misc_radiocol.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_misc_radiocol.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,407 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009, 2010, 2011, 2012, 2013 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013 Adrien Cossa (souliane@mailoo.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 . + +""" Tests for the plugin radiocol """ + +from sat.core import exceptions +from sat.test import helpers, helpers_plugins +from sat.plugins import plugin_misc_radiocol as plugin +from sat.plugins import plugin_misc_room_game as plugin_room_game +from constants import Const + +from twisted.words.protocols.jabber.jid import JID +from twisted.words.xish import domish +from twisted.internet import reactor +from twisted.internet import defer +from twisted.python.failure import Failure +from twisted.trial.unittest import SkipTest + +try: + from mutagen.oggvorbis import OggVorbis + from mutagen.mp3 import MP3 + from mutagen.easyid3 import EasyID3 + from mutagen.id3 import ID3NoHeaderError +except ImportError: + raise exceptions.MissingModule(u"Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen") + +import uuid +import os +import copy +import shutil + + +ROOM_JID = JID(Const.MUC_STR[0]) +PROFILE = Const.PROFILE[0] +REFEREE_FULL = JID(ROOM_JID.userhost() + '/' + Const.JID[0].user) +PLAYERS_INDICES = [0, 1, 3] # referee included +OTHER_PROFILES = [Const.PROFILE[1], Const.PROFILE[3]] +OTHER_PLAYERS = [Const.JID[1], Const.JID[3]] + + +class RadiocolTest(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + + def reinit(self): + self.host.reinit() + self.host.plugins['ROOM-GAME'] = plugin_room_game.RoomGame(self.host) + self.plugin = plugin.Radiocol(self.host) # must be init after ROOM-GAME + self.plugin.testing = True + self.plugin_0045 = self.host.plugins['XEP-0045'] = helpers_plugins.FakeXEP_0045(self.host) + self.plugin_0249 = self.host.plugins['XEP-0249'] = helpers_plugins.FakeXEP_0249(self.host) + for profile in Const.PROFILE: + self.host.getClient(profile) # init self.host.profiles[profile] + self.songs = [] + self.playlist = [] + self.sound_dir = self.host.memory.getConfig('', 'media_dir') + '/test/sound/' + try: + for filename in os.listdir(self.sound_dir): + if filename.endswith('.ogg') or filename.endswith('.mp3'): + self.songs.append(filename) + except OSError: + raise SkipTest('The sound samples in sat_media/test/sound were not found') + + def _buildPlayers(self, players=[]): + """@return: the "started" content built with the given players""" + content = "<%s xmlns='%s'>%s" % (to_jid.full(), type_, plugin.RADIOC_TAG, plugin.NC_RADIOCOL, content, plugin.RADIOC_TAG) + + def _rejectSongCb(self, profile_index): + """Check if the message "song_rejected" has been sent by the referee + and process the command with the profile of the uploader + @param profile_index: uploader's profile""" + sent = self.host.getSentMessage(0) + content = "" + self.assertEqualXML(sent.toXml(), self._expectedMessage(JID(ROOM_JID.userhost() + '/' + self.plugin_0045.getNick(0, profile_index), 'normal', content))) + self._roomGameCmd(sent, ['radiocolSongRejected', ROOM_JID.full(), 'Too many songs in queue']) + + def _noUploadCb(self): + """Check if the message "no_upload" has been sent by the referee + and process the command with the profiles of each room users""" + sent = self.host.getSentMessage(0) + content = "" + self.assertEqualXML(sent.toXml(), self._expectedMessage(ROOM_JID, 'groupchat', content)) + self._roomGameCmd(sent, ['radiocolNoUpload', ROOM_JID.full()]) + + def _uploadOkCb(self): + """Check if the message "upload_ok" has been sent by the referee + and process the command with the profiles of each room users""" + sent = self.host.getSentMessage(0) + content = "" + self.assertEqualXML(sent.toXml(), self._expectedMessage(ROOM_JID, 'groupchat', content)) + self._roomGameCmd(sent, ['radiocolUploadOk', ROOM_JID.full()]) + + def _preloadCb(self, attrs, profile_index): + """Check if the message "preload" has been sent by the referee + and process the command with the profiles of each room users + @param attrs: information dict about the song + @param profile_index: profile index of the uploader + """ + sent = self.host.getSentMessage(0) + attrs['sender'] = self.plugin_0045.getNick(0, profile_index) + radiocol_elt = domish.generateElementsNamed(sent.elements(), 'radiocol').next() + preload_elt = domish.generateElementsNamed(radiocol_elt.elements(), 'preload').next() + attrs['timestamp'] = preload_elt['timestamp'] # we could not guess it... + content = "" % " ".join(["%s='%s'" % (attr, attrs[attr]) for attr in attrs]) + if sent.hasAttribute('from'): + del sent['from'] + self.assertEqualXML(sent.toXml(), self._expectedMessage(ROOM_JID, 'groupchat', content)) + self._roomGameCmd(sent, ['radiocolPreload', ROOM_JID.full(), attrs['timestamp'], attrs['filename'], attrs['title'], attrs['artist'], attrs['album'], attrs['sender']]) + + def _playNextSongCb(self): + """Check if the message "play" has been sent by the referee + and process the command with the profiles of each room users""" + sent = self.host.getSentMessage(0) + filename = self.playlist.pop(0) + content = "" % filename + self.assertEqualXML(sent.toXml(), self._expectedMessage(ROOM_JID, 'groupchat', content)) + self._roomGameCmd(sent, ['radiocolPlay', ROOM_JID.full(), filename]) + + game_data = self.plugin.games[ROOM_JID] + if len(game_data['queue']) == plugin.QUEUE_LIMIT - 1: + self._uploadOkCb() + + def _addSongCb(self, d, filepath, profile_index): + """Check if the message "song_added" has been sent by the uploader + and process the command with the profile of the referee + @param d: deferred value or failure got from self.plugin.radiocolSongAdded + @param filepath: full path to the sound file + @param profile_index: the profile index of the uploader + """ + if isinstance(d, Failure): + self.fail("OGG or MP3 song could not be added!") + + game_data = self.plugin.games[ROOM_JID] + + # this is copied from the plugin + if filepath.lower().endswith('.mp3'): + actual_song = MP3(filepath) + try: + song = EasyID3(filepath) + + class Info(object): + def __init__(self, length): + self.length = length + song.info = Info(actual_song.info.length) + except ID3NoHeaderError: + song = actual_song + else: + song = OggVorbis(filepath) + + attrs = {'filename': os.path.basename(filepath), + 'title': song.get("title", ["Unknown"])[0], + 'artist': song.get("artist", ["Unknown"])[0], + 'album': song.get("album", ["Unknown"])[0], + 'length': str(song.info.length) + } + self.assertEqual(game_data['to_delete'][attrs['filename']], filepath) + + content = "" % " ".join(["%s='%s'" % (attr, attrs[attr]) for attr in attrs]) + sent = self.host.getSentMessage(profile_index) + self.assertEqualXML(sent.toXml(), self._expectedMessage(REFEREE_FULL, 'normal', content)) + + reject_song = len(game_data['queue']) >= plugin.QUEUE_LIMIT + no_upload = len(game_data['queue']) + 1 >= plugin.QUEUE_LIMIT + play_next = not game_data['playing'] and len(game_data['queue']) + 1 == plugin.QUEUE_TO_START + + self._roomGameCmd(sent, profile_index) # queue unchanged or +1 + if reject_song: + self._rejectSongCb(profile_index) + return + if no_upload: + self._noUploadCb() + self._preloadCb(attrs, profile_index) + self.playlist.append(attrs['filename']) + if play_next: + self._playNextSongCb() # queue -1 + + def _roomGameCmd(self, sent, from_index=0, call=[]): + """Process a command. It is also possible to call this method as + _roomGameCmd(sent, call) instead of _roomGameCmd(sent, from_index, call). + If from index is a list, it is assumed that it is containing the value + for call and from_index will take its default value. + @param sent: the sent message that we need to process + @param from_index: index of the message sender + @param call: list containing the name of the expected bridge call + followed by its arguments, or empty list if no call is expected + """ + if isinstance(from_index, list): + call = from_index + from_index = 0 + + sent['from'] = ROOM_JID.full() + '/' + self.plugin_0045.getNick(0, from_index) + recipient = JID(sent['to']).resource + + # The message could have been sent to a room user (room_jid + '/' + nick), + # but when it is received, the 'to' attribute of the message has been + # changed to the recipient own JID. We need to simulate that here. + if recipient: + room = self.plugin_0045.getRoom(0, 0) + sent['to'] = Const.JID_STR[0] if recipient == room.nick else room.roster[recipient].entity.full() + + for index in xrange(0, len(Const.PROFILE)): + nick = self.plugin_0045.getNick(0, index) + if nick: + if not recipient or nick == recipient: + if call and (self.plugin.isPlayer(ROOM_JID, nick) or call[0] == 'radiocolStarted'): + args = copy.deepcopy(call) + args.append(Const.PROFILE[index]) + self.host.bridge.expectCall(*args) + self.plugin.room_game_cmd(sent, Const.PROFILE[index]) + + def _syncCb(self, sync_data, profile_index): + """Synchronize one player when he joins a running game. + @param sync_data: result from self.plugin.getSyncData + @param profile_index: index of the profile to be synchronized + """ + for nick in sync_data: + expected = self._expectedMessage(JID(ROOM_JID.userhost() + '/' + nick), 'normal', sync_data[nick]) + sent = self.host.getSentMessage(0) + self.assertEqualXML(sent.toXml(), expected) + for elt in sync_data[nick]: + if elt.name == 'preload': + self.host.bridge.expectCall('radiocolPreload', ROOM_JID.full(), elt['timestamp'], elt['filename'], elt['title'], elt['artist'], elt['album'], elt['sender'], Const.PROFILE[profile_index]) + elif elt.name == 'play': + self.host.bridge.expectCall('radiocolPlay', ROOM_JID.full(), elt['filename'], Const.PROFILE[profile_index]) + elif elt.name == 'no_upload': + self.host.bridge.expectCall('radiocolNoUpload', ROOM_JID.full(), Const.PROFILE[profile_index]) + sync_data[nick] + self._roomGameCmd(sent, []) + + def _joinRoom(self, room, nicks, player_index, sync=True): + """Make a player join a room and update the list of nicks + @param room: wokkel.muc.Room instance from the referee perspective + @param nicks: list of the players which will be updated + @param player_index: profile index of the new player + @param sync: set to True to synchronize data + """ + user_nick = self.plugin_0045.joinRoom(0, player_index) + self.plugin.userJoinedTrigger(room, room.roster[user_nick], PROFILE) + if player_index not in PLAYERS_INDICES: + # this user is actually not a player + self.assertFalse(self.plugin.isPlayer(ROOM_JID, user_nick)) + to_jid, type_ = (JID(ROOM_JID.userhost() + '/' + user_nick), 'normal') + else: + # this user is a player + self.assertTrue(self.plugin.isPlayer(ROOM_JID, user_nick)) + nicks.append(user_nick) + to_jid, type_ = (ROOM_JID, 'groupchat') + + # Check that the message "players" has been sent by the referee + expected = self._expectedMessage(to_jid, type_, self._buildPlayers(nicks)) + sent = self.host.getSentMessage(0) + self.assertEqualXML(sent.toXml(), expected) + + # Process the command with the profiles of each room users + self._roomGameCmd(sent, ['radiocolStarted', ROOM_JID.full(), REFEREE_FULL.full(), nicks, [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT]]) + + if sync: + self._syncCb(self.plugin._getSyncData(ROOM_JID, [user_nick]), player_index) + + def _leaveRoom(self, room, nicks, player_index): + """Make a player leave a room and update the list of nicks + @param room: wokkel.muc.Room instance from the referee perspective + @param nicks: list of the players which will be updated + @param player_index: profile index of the new player + """ + user_nick = self.plugin_0045.getNick(0, player_index) + user = room.roster[user_nick] + self.plugin_0045.leaveRoom(0, player_index) + self.plugin.userLeftTrigger(room, user, PROFILE) + nicks.remove(user_nick) + + def _uploadSong(self, song_index, profile_index): + """Upload the song of index song_index (modulo self.songs size) from the profile of index profile_index. + + @param song_index: index of the song or None to test with non existing file + @param profile_index: index of the uploader's profile + """ + if song_index is None: + dst_filepath = unicode(uuid.uuid1()) + expect_io_error = True + else: + song_index = song_index % len(self.songs) + src_filename = self.songs[song_index] + dst_filepath = '/tmp/%s%s' % (uuid.uuid1(), os.path.splitext(src_filename)[1]) + shutil.copy(self.sound_dir + src_filename, dst_filepath) + expect_io_error = False + + try: + d = self.plugin.radiocolSongAdded(REFEREE_FULL, dst_filepath, Const.PROFILE[profile_index]) + except IOError: + self.assertTrue(expect_io_error) + return + + self.assertFalse(expect_io_error) + cb = lambda defer: self._addSongCb(defer, dst_filepath, profile_index) + + def eb(failure): + if not isinstance(failure, Failure): + self.fail("Adding a song which is not OGG nor MP3 should fail!") + self.assertEqual(failure.value.__class__, exceptions.DataError) + + if src_filename.endswith('.ogg') or src_filename.endswith('.mp3'): + d.addCallbacks(cb, cb) + else: + d.addCallbacks(eb, eb) + + def test_init(self): + self.reinit() + self.assertEqual(self.plugin.invite_mode, self.plugin.FROM_PLAYERS) + self.assertEqual(self.plugin.wait_mode, self.plugin.FOR_NONE) + self.assertEqual(self.plugin.join_mode, self.plugin.INVITED) + self.assertEqual(self.plugin.ready_mode, self.plugin.FORCE) + + def test_game(self): + self.reinit() + + # create game + self.plugin.prepareRoom(OTHER_PLAYERS, ROOM_JID, PROFILE) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + room = self.plugin_0045.getRoom(0, 0) + nicks = [self.plugin_0045.getNick(0, 0)] + + sent = self.host.getSentMessage(0) + self.assertEqualXML(sent.toXml(), self._expectedMessage(ROOM_JID, 'groupchat', self._buildPlayers(nicks))) + self._roomGameCmd(sent, ['radiocolStarted', ROOM_JID.full(), REFEREE_FULL.full(), nicks, [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT]]) + + self._joinRoom(room, nicks, 1) # player joins + self._joinRoom(room, nicks, 4) # user not playing joins + + song_index = 0 + self._uploadSong(song_index, 0) # ogg or mp3 file should exist in sat_media/test/song + self._uploadSong(None, 0) # non existing file + + # another songs are added by Const.JID[1] until the radio starts + 1 to fill the queue + # when the first song starts + 1 to be rejected because the queue is full + for song_index in xrange(1, plugin.QUEUE_TO_START + 1): + self._uploadSong(song_index, 1) + + self.plugin.playNext(Const.MUC[0], PROFILE) # simulate the end of the first song + self._playNextSongCb() + self._uploadSong(song_index, 1) # now the song is accepted and the queue is full again + + self._joinRoom(room, nicks, 3) # new player joins + + self.plugin.playNext(Const.MUC[0], PROFILE) # the second song finishes + self._playNextSongCb() + self._uploadSong(0, 3) # the player who recently joined re-upload the first file + + self._leaveRoom(room, nicks, 1) # one player leaves + self._joinRoom(room, nicks, 1) # and join again + + self.plugin.playNext(Const.MUC[0], PROFILE) # empty the queue + self._playNextSongCb() + self.plugin.playNext(Const.MUC[0], PROFILE) + self._playNextSongCb() + + for filename in self.playlist: + self.plugin.deleteFile('/tmp/' + filename) + + return defer.succeed(None) + + def tearDown(self, *args, **kwargs): + """Clean the reactor""" + helpers.SatTestCase.tearDown(self, *args, **kwargs) + for delayed_call in reactor.getDelayedCalls(): + delayed_call.cancel() diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_misc_room_game.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_misc_room_game.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,486 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Tests for the plugin room game (base class for MUC games) """ + +from sat.core.i18n import _ +from constants import Const +from sat.test import helpers, helpers_plugins +from sat.plugins import plugin_misc_room_game as plugin +from twisted.words.protocols.jabber.jid import JID +from wokkel.muc import User + +from logging import WARNING + +# Data used for test initialization +NAMESERVICE = 'http://www.goffi.org/protocol/dummy' +TAG = 'dummy' +PLUGIN_INFO = { + "name": "Dummy plugin", + "import_name": "DUMMY", + "type": "MISC", + "protocols": [], + "dependencies": [], + "main": "Dummy", + "handler": "no", # handler MUST be "no" (dynamic inheritance) + "description": _("""Dummy plugin to test room game""") +} + +ROOM_JID = JID(Const.MUC_STR[0]) +PROFILE = Const.PROFILE[0] +OTHER_PROFILE = Const.PROFILE[1] + + +class RoomGameTest(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + + def reinit(self, game_init={}, player_init={}): + self.host.reinit() + self.plugin = plugin.RoomGame(self.host) + self.plugin._init_(self.host, PLUGIN_INFO, (NAMESERVICE, TAG), game_init, player_init) + self.plugin_0045 = self.host.plugins['XEP-0045'] = helpers_plugins.FakeXEP_0045(self.host) + self.plugin_0249 = self.host.plugins['XEP-0249'] = helpers_plugins.FakeXEP_0249(self.host) + for profile in Const.PROFILE: + self.host.getClient(profile) # init self.host.profiles[profile] + + def initGame(self, muc_index, user_index): + self.plugin_0045.joinRoom(user_index, muc_index) + self.plugin._initGame(JID(Const.MUC_STR[muc_index]), Const.JID[user_index].user) + + def _expectedMessage(self, to, type_, tag, players=[]): + content = "<%s" % tag + if not players: + content += "/>" + else: + content += ">" + for i in xrange(0, len(players)): + content += "%s" % (i, players[i]) + content += "" % tag + return "<%s xmlns='%s'>%s" % (to.full(), type_, TAG, NAMESERVICE, content) + + def test_createOrInvite_solo(self): + self.reinit() + self.plugin_0045.joinRoom(0, 0) + self.plugin._createOrInvite(self.plugin_0045.getRoom(0, 0), [], Const.PROFILE[0]) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + + def test_createOrInvite_multi_not_waiting(self): + self.reinit() + self.plugin_0045.joinRoom(0, 0) + other_players = [Const.JID[1], Const.JID[2]] + self.plugin._createOrInvite(self.plugin_0045.getRoom(0, 0), other_players, Const.PROFILE[0]) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + + def test_createOrInvite_multi_waiting(self): + self.reinit(player_init={'score': 0}) + self.plugin_0045.joinRoom(0, 0) + other_players = [Const.JID[1], Const.JID[2]] + self.plugin._createOrInvite(self.plugin_0045.getRoom(0, 0), other_players, Const.PROFILE[0]) + self.assertTrue(self.plugin._gameExists(ROOM_JID, False)) + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) + + def test_initGame(self): + self.reinit() + self.initGame(0, 0) + self.assertTrue(self.plugin.isReferee(ROOM_JID, Const.JID[0].user)) + self.assertEqual([], self.plugin.games[ROOM_JID]['players']) + + def test_checkJoinAuth(self): + self.reinit() + check = lambda value: getattr(self, "assert%s" % value)(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[0], Const.JID[0].user)) + check(False) + # to test the "invited" mode, the referee must be different than the user to test + self.initGame(0, 1) + self.plugin.join_mode = self.plugin.ALL + check(True) + self.plugin.join_mode = self.plugin.INVITED + check(False) + self.plugin.invitations[ROOM_JID] = [(None, [Const.JID[0].userhostJID()])] + check(True) + self.plugin.join_mode = self.plugin.NONE + check(False) + self.plugin.games[ROOM_JID]['players'].append(Const.JID[0].user) + check(True) + + def test_updatePlayers(self): + self.reinit() + self.initGame(0, 0) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], []) + self.plugin._updatePlayers(ROOM_JID, [], True, Const.PROFILE[0]) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], []) + self.plugin._updatePlayers(ROOM_JID, ["user1"], True, Const.PROFILE[0]) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], ["user1"]) + self.plugin._updatePlayers(ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0]) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], ["user1", "user2", "user3"]) + self.plugin._updatePlayers(ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0]) # should not be stored twice + self.assertEqual(self.plugin.games[ROOM_JID]['players'], ["user1", "user2", "user3"]) + + def test_synchronizeRoom(self): + self.reinit() + self.initGame(0, 0) + self.plugin._synchronizeRoom(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0]) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "players", [])) + self.plugin.games[ROOM_JID]['players'].append("test1") + self.plugin._synchronizeRoom(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0]) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "players", ["test1"])) + self.plugin.games[ROOM_JID]['started'] = True + self.plugin.games[ROOM_JID]['players'].append("test2") + self.plugin._synchronizeRoom(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0]) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "started", ["test1", "test2"])) + self.plugin.games[ROOM_JID]['players'].append("test3") + self.plugin.games[ROOM_JID]['players'].append("test4") + user1 = JID(ROOM_JID.userhost() + "/" + Const.JID[0].user) + user2 = JID(ROOM_JID.userhost() + "/" + Const.JID[1].user) + self.plugin._synchronizeRoom(ROOM_JID, [user1, user2], Const.PROFILE[0]) + self.assertEqualXML(self.host.getSentMessageXml(0), self._expectedMessage(user1, "normal", "started", ["test1", "test2", "test3", "test4"])) + self.assertEqualXML(self.host.getSentMessageXml(0), self._expectedMessage(user2, "normal", "started", ["test1", "test2", "test3", "test4"])) + + def test_invitePlayers(self): + self.reinit() + self.initGame(0, 0) + self.plugin_0045.joinRoom(0, 1) + self.assertEqual(self.plugin.invitations[ROOM_JID], []) + room = self.plugin_0045.getRoom(0, 0) + nicks = self.plugin._invitePlayers(room, [Const.JID[1], Const.JID[2]], Const.JID[0].user, Const.PROFILE[0]) + self.assertEqual(self.plugin.invitations[ROOM_JID][0][1], [Const.JID[1].userhostJID(), Const.JID[2].userhostJID()]) + # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost + self.assertEqual(nicks, [Const.JID[1].user, Const.JID[2].user]) + + nicks = self.plugin._invitePlayers(room, [Const.JID[1], Const.JID[3]], Const.JID[0].user, Const.PROFILE[0]) + self.assertEqual(self.plugin.invitations[ROOM_JID][1][1], [Const.JID[1].userhostJID(), Const.JID[3].userhostJID()]) + # this time Const.JID[1] and Const.JID[3] have the same user but the host differs + self.assertEqual(nicks, [Const.JID[1].user]) + + def test_checkInviteAuth(self): + + def check(value, index): + nick = self.plugin_0045.getNick(0, index) + getattr(self, "assert%s" % value)(self.plugin._checkInviteAuth(ROOM_JID, nick)) + + self.reinit() + + for mode in [self.plugin.FROM_ALL, self.plugin.FROM_NONE, self.plugin.FROM_REFEREE, self.plugin.FROM_PLAYERS]: + self.plugin.invite_mode = mode + check(True, 0) + + self.initGame(0, 0) + self.plugin.invite_mode = self.plugin.FROM_ALL + check(True, 0) + check(True, 1) + self.plugin.invite_mode = self.plugin.FROM_NONE + check(True, 0) # game initialized but not started yet, referee can invite + check(False, 1) + self.plugin.invite_mode = self.plugin.FROM_REFEREE + check(True, 0) + check(False, 1) + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.games[ROOM_JID]['players'].append(user_nick) + self.plugin.invite_mode = self.plugin.FROM_PLAYERS + check(True, 0) + check(True, 1) + check(False, 2) + + def test_isReferee(self): + self.reinit() + self.initGame(0, 0) + self.assertTrue(self.plugin.isReferee(ROOM_JID, self.plugin_0045.getNick(0, 0))) + self.assertFalse(self.plugin.isReferee(ROOM_JID, self.plugin_0045.getNick(0, 1))) + + def test_isPlayer(self): + self.reinit() + self.initGame(0, 0) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, self.plugin_0045.getNick(0, 0))) + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.games[ROOM_JID]['players'].append(user_nick) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, user_nick)) + self.assertFalse(self.plugin.isPlayer(ROOM_JID, self.plugin_0045.getNick(0, 2))) + + def test_checkWaitAuth(self): + + def check(value, other_players, confirmed, rest): + room = self.plugin_0045.getRoom(0, 0) + self.assertEqual((value, confirmed, rest), self.plugin._checkWaitAuth(room, other_players)) + + self.reinit() + self.initGame(0, 0) + other_players = [Const.JID[1], Const.JID[3]] + self.plugin.wait_mode = self.plugin.FOR_NONE + check(True, [], [], []) + check(True, [Const.JID[0]], [], [Const.JID[0]]) # getRoomNickOfUser checks for the other users only + check(True, other_players, [], other_players) + self.plugin.wait_mode = self.plugin.FOR_ALL + check(True, [], [], []) + check(False, [Const.JID[0]], [], [Const.JID[0]]) + check(False, other_players, [], other_players) + self.plugin_0045.joinRoom(0, 1) + check(False, other_players, [], other_players) + self.plugin_0045.joinRoom(0, 4) + check(False, other_players, [self.plugin_0045.getNickOfUser(0, 1, 0)], [Const.JID[3]]) + self.plugin_0045.joinRoom(0, 3) + check(True, other_players, [self.plugin_0045.getNickOfUser(0, 1, 0), + self.plugin_0045.getNickOfUser(0, 3, 0)], []) + + other_players = [Const.JID[1], Const.JID[3], Const.JID[2]] + # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost + check(True, other_players, [self.plugin_0045.getNickOfUser(0, 1, 0), + self.plugin_0045.getNickOfUser(0, 3, 0), + self.plugin_0045.getNickOfUser(0, 2, 0)], []) + + def test_prepareRoom_trivial(self): + self.reinit() + other_players = [] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + self.assertTrue(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[0], Const.JID[0].user)) + self.assertTrue(self.plugin._checkInviteAuth(ROOM_JID, Const.JID[0].user)) + self.assertEqual((True, [], []), self.plugin._checkWaitAuth(ROOM_JID, [])) + self.assertTrue(self.plugin.isReferee(ROOM_JID, Const.JID[0].user)) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, Const.JID[0].user)) + self.assertEqual((False, True), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + + def test_prepareRoom_invite(self): + self.reinit() + other_players = [Const.JID[1], Const.JID[2]] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + room = self.plugin_0045.getRoom(0, 0) + + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + self.assertTrue(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[1], Const.JID[1].user)) + self.assertFalse(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[3], Const.JID[3].user)) + self.assertFalse(self.plugin._checkInviteAuth(ROOM_JID, Const.JID[1].user)) + self.assertEqual((True, [], other_players), self.plugin._checkWaitAuth(room, other_players)) + + player2_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.userJoinedTrigger(room, room.roster[player2_nick], PROFILE) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, player2_nick)) + self.assertTrue(self.plugin._checkInviteAuth(ROOM_JID, player2_nick)) + self.assertFalse(self.plugin.isReferee(ROOM_JID, player2_nick)) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, player2_nick)) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, self.plugin_0045.getNickOfUser(0, 2, 0))) + self.assertFalse(self.plugin.isPlayer(ROOM_JID, "xxx")) + self.assertEqual((False, False), self.plugin._checkCreateGameAndInit(ROOM_JID, Const.PROFILE[1])) + + def test_prepareRoom_score1(self): + self.reinit(player_init={'score': 0}) + other_players = [Const.JID[1], Const.JID[2]] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + room = self.plugin_0045.getRoom(0, 0) + + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) + self.assertTrue(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[1], Const.JID[1].user)) + self.assertFalse(self.plugin._checkJoinAuth(ROOM_JID, Const.JID[3], Const.JID[3].user)) + self.assertFalse(self.plugin._checkInviteAuth(ROOM_JID, Const.JID[1].user)) + self.assertEqual((False, [], other_players), self.plugin._checkWaitAuth(room, other_players)) + + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.userJoinedTrigger(room, room.roster[user_nick], PROFILE) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, user_nick)) + self.assertFalse(self.plugin._checkInviteAuth(ROOM_JID, user_nick)) + self.assertFalse(self.plugin.isReferee(ROOM_JID, user_nick)) + self.assertTrue(self.plugin.isPlayer(ROOM_JID, user_nick)) + # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost + self.assertTrue(self.plugin.isPlayer(ROOM_JID, self.plugin_0045.getNickOfUser(0, 2, 0))) + # the following assertion is True because Const.JID[1] nick in the room is equal to Const.JID[3].user + self.assertTrue(self.plugin.isPlayer(ROOM_JID, Const.JID[3].user)) + # but Const.JID[3] is actually not in the room + self.assertEqual(self.plugin_0045.getNickOfUser(0, 3, 0), None) + self.assertEqual((True, False), self.plugin._checkCreateGameAndInit(ROOM_JID, Const.PROFILE[0])) + + def test_prepareRoom_score2(self): + self.reinit(player_init={'score': 0}) + other_players = [Const.JID[1], Const.JID[4]] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + room = self.plugin_0045.getRoom(0, 0) + + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.userJoinedTrigger(room, room.roster[user_nick], PROFILE) + self.assertEqual((True, False), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + user_nick = self.plugin_0045.joinRoom(0, 4) + self.plugin.userJoinedTrigger(room, room.roster[user_nick], PROFILE) + self.assertEqual((False, True), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + + def test_userJoinedTrigger(self): + self.reinit(player_init={"xxx": "xyz"}) + other_players = [Const.JID[1], Const.JID[3]] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + nicks = [self.plugin_0045.getNick(0, 0)] + + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "players", nicks)) + self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 1) + + # wrong profile + user_nick = self.plugin_0045.joinRoom(0, 1) + room = self.plugin_0045.getRoom(0, 1) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[1]), OTHER_PROFILE) + self.assertEqual(self.host.getSentMessage(0), None) # no new message has been sent + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) # game not started + + # referee profile, user is allowed, wait for one more + room = self.plugin_0045.getRoom(0, 0) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[1]), PROFILE) + nicks.append(user_nick) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "players", nicks)) + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) # game not started + + # referee profile, user is not allowed + user_nick = self.plugin_0045.joinRoom(0, 4) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[4]), PROFILE) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(JID(ROOM_JID.userhost() + '/' + user_nick), "normal", "players", nicks)) + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) # game not started + + # referee profile, user is allowed, everybody here + user_nick = self.plugin_0045.joinRoom(0, 3) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[3]), PROFILE) + nicks.append(user_nick) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "started", nicks)) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) # game started + self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0) + + # wait for none + self.reinit() + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + self.assertNotEqual(self.host.getSentMessage(0), None) # init messages + room = self.plugin_0045.getRoom(0, 0) + nicks = [self.plugin_0045.getNick(0, 0)] + user_nick = self.plugin_0045.joinRoom(0, 3) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[3]), PROFILE) + nicks.append(user_nick) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "started", nicks)) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + + def test_userLeftTrigger(self): + self.reinit(player_init={"xxx": "xyz"}) + other_players = [Const.JID[1], Const.JID[3], Const.JID[4]] + self.plugin.prepareRoom(other_players, ROOM_JID, PROFILE) + room = self.plugin_0045.getRoom(0, 0) + nicks = [self.plugin_0045.getNick(0, 0)] + self.assertEqual(self.plugin.invitations[ROOM_JID][0][1], [Const.JID[1].userhostJID(), Const.JID[3].userhostJID(), Const.JID[4].userhostJID()]) + + # one user joins + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[1]), PROFILE) + nicks.append(user_nick) + + # the user leaves + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + room = self.plugin_0045.getRoom(0, 1) + # to not call self.plugin_0045.leaveRoom(0, 1) here, we are testing the trigger with a wrong profile + self.plugin.userLeftTrigger(room, User(user_nick, Const.JID[1]), Const.PROFILE[1]) # not the referee + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + room = self.plugin_0045.getRoom(0, 0) + user_nick = self.plugin_0045.leaveRoom(0, 1) + self.plugin.userLeftTrigger(room, User(user_nick, Const.JID[1]), PROFILE) # referee + nicks.pop() + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + + # all the users join + user_nick = self.plugin_0045.joinRoom(0, 1) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[1]), PROFILE) + nicks.append(user_nick) + user_nick = self.plugin_0045.joinRoom(0, 3) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[3]), PROFILE) + nicks.append(user_nick) + user_nick = self.plugin_0045.joinRoom(0, 4) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[4]), PROFILE) + nicks.append(user_nick) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0) + + # one user leaves + user_nick = self.plugin_0045.leaveRoom(0, 4) + self.plugin.userLeftTrigger(room, User(user_nick, Const.JID[4]), PROFILE) + nicks.pop() + self.assertEqual(self.plugin.invitations[ROOM_JID][0][1], [Const.JID[4].userhostJID()]) + + # another leaves + user_nick = self.plugin_0045.leaveRoom(0, 3) + self.plugin.userLeftTrigger(room, User(user_nick, Const.JID[3]), PROFILE) + nicks.pop() + self.assertEqual(self.plugin.invitations[ROOM_JID][0][1], [Const.JID[4].userhostJID(), Const.JID[3].userhostJID()]) + + # they can join again + user_nick = self.plugin_0045.joinRoom(0, 3) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[3]), PROFILE) + nicks.append(user_nick) + user_nick = self.plugin_0045.joinRoom(0, 4) + self.plugin.userJoinedTrigger(room, User(user_nick, Const.JID[4]), PROFILE) + nicks.append(user_nick) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0) + + def test__checkCreateGameAndInit(self): + self.reinit() + helpers.muteLogging() + self.assertEqual((False, False), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + helpers.unmuteLogging() + + nick = self.plugin_0045.joinRoom(0, 0) + self.assertEqual((True, False), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + self.assertTrue(self.plugin._gameExists(ROOM_JID, False)) + self.assertFalse(self.plugin._gameExists(ROOM_JID, True)) + self.assertTrue(self.plugin.isReferee(ROOM_JID, nick)) + + helpers.muteLogging() + self.assertEqual((False, False), self.plugin._checkCreateGameAndInit(ROOM_JID, OTHER_PROFILE)) + helpers.unmuteLogging() + + self.plugin_0045.joinRoom(0, 1) + self.assertEqual((False, False), self.plugin._checkCreateGameAndInit(ROOM_JID, OTHER_PROFILE)) + + self.plugin.createGame(ROOM_JID, [Const.JID[1]], PROFILE) + self.assertEqual((False, True), self.plugin._checkCreateGameAndInit(ROOM_JID, PROFILE)) + self.assertEqual((False, False), self.plugin._checkCreateGameAndInit(ROOM_JID, OTHER_PROFILE)) + + def test_createGame(self): + + self.reinit(player_init={"xxx": "xyz"}) + nicks = [] + for i in [0, 1, 3, 4]: + nicks.append(self.plugin_0045.joinRoom(0, i)) + + # game not exists + self.plugin.createGame(ROOM_JID, nicks, PROFILE) + self.assertTrue(self.plugin._gameExists(ROOM_JID, True)) + self.assertEqual(self.plugin.games[ROOM_JID]['players'], nicks) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "started", nicks)) + for nick in nicks: + self.assertEqual('init', self.plugin.games[ROOM_JID]['status'][nick]) + self.assertEqual(self.plugin.player_init, self.plugin.games[ROOM_JID]['players_data'][nick]) + self.plugin.games[ROOM_JID]['players_data'][nick]["xxx"] = nick + for nick in nicks: + # checks that a copy of self.player_init has been done and not a reference + self.assertEqual(nick, self.plugin.games[ROOM_JID]['players_data'][nick]['xxx']) + + # game exists, current profile is referee + self.reinit(player_init={"xxx": "xyz"}) + self.initGame(0, 0) + self.plugin.games[ROOM_JID]['started'] = True + self.plugin.createGame(ROOM_JID, nicks, PROFILE) + self.assertEqual(self.host.getSentMessageXml(0), self._expectedMessage(ROOM_JID, "groupchat", "started", nicks)) + + # game exists, current profile is not referee + self.reinit(player_init={"xxx": "xyz"}) + self.initGame(0, 0) + self.plugin.games[ROOM_JID]['started'] = True + self.plugin_0045.joinRoom(0, 1) + self.plugin.createGame(ROOM_JID, nicks, OTHER_PROFILE) + self.assertEqual(self.host.getSentMessage(0), None) # no sync message has been sent by other_profile diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_misc_text_syntaxes.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_misc_text_syntaxes.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,111 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" Plugin text syntaxes tests """ + +from sat.test import helpers +from sat.plugins import plugin_misc_text_syntaxes +from twisted.trial.unittest import SkipTest +import re + + +class SanitisationTest(helpers.SatTestCase): + + EVIL_HTML1 = """ + + + + + + + + + a link + another link +

a paragraph

+
secret EVIL!
+ of EVIL! + +
+ Password: +
+ annoying EVIL! + spam spam SPAM! + + + """ # example from lxml: /usr/share/doc/python-lxml-doc/html/lxmlhtml.html#cleaning-up-html + + EVIL_HTML2 = """

test retest
toto

""" + + def setUp(self): + self.host = helpers.FakeSAT() + reload(plugin_misc_text_syntaxes) # reload the plugin to avoid conflict error + self.text_syntaxes = plugin_misc_text_syntaxes.TextSyntaxes(self.host) + + def test_xhtml_sanitise(self): + expected = u"""
+ + + a link + another link +

a paragraph

+
secret EVIL!
+ of EVIL! + Password: + annoying EVIL! + spam spam SPAM! + + +
""" + + d = self.text_syntaxes.cleanXHTML(self.EVIL_HTML1) + d.addCallback(self.assertEqualXML, expected, ignore_blank=True) + return d + + def test_styles_sanitise(self): + expected = u"""

test retest
toto

""" + + d = self.text_syntaxes.cleanXHTML(self.EVIL_HTML2) + d.addCallback(self.assertEqualXML, expected) + return d + + def test_html2text(self): + """Check that html2text is not inserting \n in the middle of that link. + By default lines are truncated after the 79th characters.""" + source = "\"sat\"/" + expected = "![sat](http://sat.goffi.org/static/images/screenshots/libervia/libervia_discussions.png)" + try: + d = self.text_syntaxes.convert(source, self.text_syntaxes.SYNTAX_XHTML, self.text_syntaxes.SYNTAX_MARKDOWN) + except plugin_misc_text_syntaxes.UnknownSyntax: + raise SkipTest("Markdown syntax is not available.") + d.addCallback(self.assertEqual, expected) + return d + + def test_removeXHTMLMarkups(self): + expected = u""" a link another link a paragraph secret EVIL! of EVIL! Password: annoying EVIL! spam spam SPAM! """ + result = self.text_syntaxes._removeMarkups(self.EVIL_HTML1) + self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip()) + + expected = u"""test retest toto""" + result = self.text_syntaxes._removeMarkups(self.EVIL_HTML2) + self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip()) + diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0033.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0033.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,185 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin extended addressing stanzas """ + +from constants import Const +from sat.test import helpers +from sat.plugins import plugin_xep_0033 as plugin +from sat.core.exceptions import CancelError +from twisted.internet import defer +from wokkel.generic import parseXml +from twisted.words.protocols.jabber.jid import JID + +PROFILE_INDEX = 0 +PROFILE = Const.PROFILE[PROFILE_INDEX] +JID_STR_FROM = Const.JID_STR[1] +JID_STR_TO = Const.PROFILE_DICT[PROFILE].host +JID_STR_X_TO = Const.JID_STR[0] +JID_STR_X_CC = Const.JID_STR[1] +JID_STR_X_BCC = Const.JID_STR[2] + +ADDRS = ('to', JID_STR_X_TO, 'cc', JID_STR_X_CC, 'bcc', JID_STR_X_BCC) + + +class XEP_0033Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = plugin.XEP_0033(self.host) + + def test_messageReceived(self): + self.host.memory.reinit() + xml = u""" + + test + +
+
+
+ + + """ % (JID_STR_FROM, JID_STR_TO, JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC) + stanza = parseXml(xml.encode("utf-8")) + treatments = defer.Deferred() + self.plugin.messageReceivedTrigger(self.host.getClient(PROFILE), stanza, treatments) + data = {'extra': {}} + + def cb(data): + expected = ('to', JID_STR_X_TO, 'cc', JID_STR_X_CC, 'bcc', JID_STR_X_BCC) + msg = 'Expected: %s\nGot: %s' % (expected, data['extra']['addresses']) + self.assertEqual(data['extra']['addresses'], '%s:%s\n%s:%s\n%s:%s\n' % expected, msg) + + treatments.addCallback(cb) + return treatments.callback(data) + + def _get_mess_data(self): + mess_data = {"to": JID(JID_STR_TO), + "type": "chat", + "message": "content", + "extra": {} + } + mess_data["extra"]["address"] = '%s:%s\n%s:%s\n%s:%s\n' % ADDRS + original_stanza = u""" + + content + + """ % (JID_STR_FROM, JID_STR_TO) + mess_data['xml'] = parseXml(original_stanza.encode("utf-8")) + return mess_data + + def _assertAddresses(self, mess_data): + """The mess_data that we got here has been modified by self.plugin.messageSendTrigger, + check that the addresses element has been added to the stanza.""" + expected = self._get_mess_data()['xml'] + addresses_extra = """ + +
+
+
+ """ % ADDRS + addresses_element = parseXml(addresses_extra.encode('utf-8')) + expected.addChild(addresses_element) + self.assertEqualXML(mess_data['xml'].toXml().encode("utf-8"), expected.toXml().encode("utf-8")) + + def _checkSentAndStored(self): + """Check that all the recipients got their messages and that the history has been filled. + /!\ see the comments in XEP_0033.sendAndStoreMessage""" + sent = [] + stored = [] + d_list = [] + + def cb(entities, to_jid): + if host in entities: + if host not in sent: # send the message to the entity offering the feature + sent.append(host) + stored.append(host) + stored.append(to_jid) # store in history for each recipient + else: # feature not supported, use normal behavior + sent.append(to_jid) + stored.append(to_jid) + helpers.unmuteLogging() + + for to_s in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC): + to_jid = JID(to_s) + host = JID(to_jid.host) + helpers.muteLogging() + d = self.host.findFeaturesSet([plugin.NS_ADDRESS], jid_=host, profile=PROFILE) + d.addCallback(cb, to_jid) + d_list.append(d) + + def cb_list(dummy): + msg = "/!\ see the comments in XEP_0033.sendAndStoreMessage" + sent_recipients = [JID(elt['to']) for elt in self.host.getSentMessages(PROFILE_INDEX)] + self.assertEqualUnsortedList(sent_recipients, sent, msg) + self.assertEqualUnsortedList(self.host.stored_messages, stored, msg) + + return defer.DeferredList(d_list).addCallback(cb_list) + + def _trigger(self, data): + """Execute self.plugin.messageSendTrigger with a different logging + level to not pollute the output, then check that the plugin did its + job. It should abort sending the message or add the extended + addressing information to the stanza. + @param data: the data to be processed by self.plugin.messageSendTrigger + """ + pre_treatments = defer.Deferred() + post_treatments = defer.Deferred() + helpers.muteLogging() + self.plugin.messageSendTrigger(self.host.getClient[PROFILE], data, pre_treatments, post_treatments) + post_treatments.callback(data) + helpers.unmuteLogging() + post_treatments.addCallbacks(self._assertAddresses, lambda failure: failure.trap(CancelError)) + return post_treatments + + def test_messageSendTriggerFeatureNotSupported(self): + # feature is not supported, abort the message + self.host.memory.reinit() + data = self._get_mess_data() + return self._trigger(data) + + def test_messageSendTriggerFeatureSupported(self): + # feature is supported by the main target server + self.host.reinit() + self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE) + data = self._get_mess_data() + d = self._trigger(data) + return d.addCallback(lambda dummy: self._checkSentAndStored()) + + def test_messageSendTriggerFeatureFullySupported(self): + # feature is supported by all target servers + self.host.reinit() + self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE) + for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC): + self.host.addFeature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE) + data = self._get_mess_data() + d = self._trigger(data) + return d.addCallback(lambda dummy: self._checkSentAndStored()) + + def test_messageSendTriggerFixWrongEntity(self): + # check that a wrong recipient entity is fixed by the backend + self.host.reinit() + self.host.addFeature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE) + for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC): + self.host.addFeature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE) + data = self._get_mess_data() + data["to"] = JID(JID_STR_X_TO) + d = self._trigger(data) + return d.addCallback(lambda dummy: self._checkSentAndStored()) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0085.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0085.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,85 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin chat states notification tests """ + +from constants import Const +from sat.test import helpers +from sat.core.constants import Const as C +from sat.plugins import plugin_xep_0085 as plugin +from copy import deepcopy +from twisted.internet import defer +from wokkel.generic import parseXml + + +class XEP_0085Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = plugin.XEP_0085(self.host) + self.host.memory.setParam(plugin.PARAM_NAME, True, plugin.PARAM_KEY, C.NO_SECURITY_LIMIT, Const.PROFILE[0]) + + def test_messageReceived(self): + for state in plugin.CHAT_STATES: + xml = u""" + + %s + <%s xmlns='%s'/> + + """ % (Const.JID_STR[1], + Const.JID_STR[0], + "test" if state == "active" else "", + state, plugin.NS_CHAT_STATES) + stanza = parseXml(xml.encode("utf-8")) + self.host.bridge.expectCall("chatStateReceived", Const.JID_STR[1], state, Const.PROFILE[0]) + self.plugin.messageReceivedTrigger(self.host.getClient(Const.PROFILE[0]), stanza, None) + + def test_messageSendTrigger(self): + def cb(data): + xml = data['xml'].toXml().encode("utf-8") + self.assertEqualXML(xml, expected.toXml().encode("utf-8")) + + d_list = [] + + for state in plugin.CHAT_STATES: + mess_data = {"to": Const.JID[0], + "type": "chat", + "message": "content", + "extra": {} if state == "active" else {"chat_state": state}} + stanza = u""" + + %s + + """ % (Const.JID_STR[1], Const.JID_STR[0], + ("%s" % mess_data['message']) if state == "active" else "") + mess_data['xml'] = parseXml(stanza.encode("utf-8")) + expected = deepcopy(mess_data['xml']) + expected.addElement(state, plugin.NS_CHAT_STATES) + post_treatments = defer.Deferred() + self.plugin.messageSendTrigger(self.host.getClient(Const.PROFILE[0]), mess_data, None, post_treatments) + + post_treatments.addCallback(cb) + post_treatments.callback(mess_data) + d_list.append(post_treatments) + + def cb_list(dummy): # cancel the timer to not block the process + self.plugin.map[Const.PROFILE[0]][Const.JID[0]].timer.cancel() + + return defer.DeferredList(d_list).addCallback(cb_list) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0203.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0203.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,65 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin XEP-0203 """ + +from sat.test import helpers +from sat.plugins.plugin_xep_0203 import XEP_0203 +from twisted.words.xish import domish +from twisted.words.protocols.jabber.jid import JID +from dateutil.tz import tzutc +import datetime + +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' + + +class XEP_0203Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = XEP_0203(self.host) + + def test_delay(self): + delay_xml = """ + + Offline Storage + + """ + message_xml = """ + + text + %s + + """ % delay_xml + + parent = domish.Element((None, 'message')) + parent['from'] = 'romeo@montague.net/orchard' + parent['to'] = 'juliet@capulet.com' + parent['type'] = 'chat' + parent.addElement('body', None, 'text') + stamp = datetime.datetime(2002, 9, 10, 23, 8, 25, tzinfo=tzutc()) + elt = self.plugin.delay(stamp, JID('capulet.com'), 'Offline Storage', parent) + self.assertEqualXML(elt.toXml(), delay_xml, True) + self.assertEqualXML(parent.toXml(), message_xml, True) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0277.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0277.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,110 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" Plugin XEP-0277 tests """ + +from sat.test import helpers +from sat.plugins import plugin_xep_0277 +from sat.plugins import plugin_xep_0060 +from sat.plugins import plugin_misc_text_syntaxes +from sat.tools.xml_tools import ElementParser +from wokkel.pubsub import NS_PUBSUB + + +class XEP_0277Test(helpers.SatTestCase): + + PUBSUB_ENTRY_1 = u""" + + + <span>titre</span> + c745a688-9b02-11e3-a1a3-c0143dd4fe51 + 2014-02-21T16:16:39+02:00 + 2014-02-21T16:16:38+02:00 + <p>contenu</p>texte sans balise<p>autre contenu</p> +

contenu

texte sans balise

autre contenu

+ + test1@souliane.org + +
+
+ """ % plugin_xep_0277.NS_ATOM + + PUBSUB_ENTRY_2 = u""" + + + <div>titre</div> + <div xmlns="http://www.w3.org/1999/xhtml"><div style="background-image: url('xxx');">titre</div></div> + c745a688-9b02-11e3-a1a3-c0143dd4fe51 + 2014-02-21T16:16:39+02:00 + 2014-02-21T16:16:38+02:00 + <div><p>contenu</p>texte dans balise<p>autre contenu</p></div> +

contenu

texte dans balise

autre contenu

+ + test1@souliane.org + test1 + +
+
+ """ % plugin_xep_0277.NS_ATOM + + def setUp(self): + self.host = helpers.FakeSAT() + + class XEP_0163(object): + def __init__(self, host): + pass + + def addPEPEvent(self, *args): + pass + self.host.plugins["XEP-0060"] = plugin_xep_0060.XEP_0060(self.host) + self.host.plugins["XEP-0163"] = XEP_0163(self.host) + reload(plugin_misc_text_syntaxes) # reload the plugin to avoid conflict error + self.host.plugins["TEXT-SYNTAXES"] = plugin_misc_text_syntaxes.TextSyntaxes(self.host) + self.plugin = plugin_xep_0277.XEP_0277(self.host) + + def test_item2mbdata_1(self): + expected = {u'id': u'c745a688-9b02-11e3-a1a3-c0143dd4fe51', + u'atom_id': u'c745a688-9b02-11e3-a1a3-c0143dd4fe51', + u'title': u'titre', + u'updated': u'1392992199.0', + u'published': u'1392992198.0', + u'content': u'

contenu

texte sans balise

autre contenu

', + u'content_xhtml': u'

contenu

texte sans balise

autre contenu

', + u'author': u'test1@souliane.org' + } + item_elt = ElementParser()(self.PUBSUB_ENTRY_1, namespace=NS_PUBSUB).elements().next() + d = self.plugin.item2mbdata(item_elt) + d.addCallback(self.assertEqual, expected) + return d + + def test_item2mbdata_2(self): + expected = {u'id': u'c745a688-9b02-11e3-a1a3-c0143dd4fe51', + u'atom_id': u'c745a688-9b02-11e3-a1a3-c0143dd4fe51', + u'title': u'
titre
', + u'title_xhtml': u'
titre
', + u'updated': u'1392992199.0', + u'published': u'1392992198.0', + u'content': u'

contenu

texte dans balise

autre contenu

', + u'content_xhtml': u'

contenu

texte dans balise

autre contenu

', + u'author': u'test1@souliane.org' + } + item_elt = ElementParser()(self.PUBSUB_ENTRY_2, namespace=NS_PUBSUB).elements().next() + d = self.plugin.item2mbdata(item_elt) + d.addCallback(self.assertEqual, expected) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0297.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0297.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,78 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin XEP-0297 """ + +from constants import Const as C +from sat.test import helpers +from sat.plugins.plugin_xep_0203 import XEP_0203 +from sat.plugins.plugin_xep_0297 import XEP_0297 +from twisted.words.protocols.jabber.jid import JID +from dateutil.tz import tzutc +import datetime +from wokkel.generic import parseXml + + +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' + + +class XEP_0297Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = XEP_0297(self.host) + self.host.plugins['XEP-0203'] = XEP_0203(self.host) + + def test_delay(self): + stanza = parseXml(""" + + Yet I should kill thee with much cherishing. + + + + + """.encode('utf-8')) + output = """ + + A most courteous exposition! + + + + Yet I should kill thee with much cherishing. + + + + + + + """ + stamp = datetime.datetime(2010, 7, 10, 23, 8, 25, tzinfo=tzutc()) + d = self.plugin.forward(stanza, JID('mercutio@verona.lit'), stamp, + body='A most courteous exposition!', + profile_key=C.PROFILE[0]) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), output, True)) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0313.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0313.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,249 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin XEP-0313 """ + +from constants import Const as C +from sat.test import helpers +from sat.plugins.plugin_xep_0313 import XEP_0313 +from twisted.words.protocols.jabber.jid import JID +from twisted.words.xish import domish +from wokkel.data_form import Field +from dateutil.tz import tzutc +import datetime + +# TODO: change this when RSM and MAM are in wokkel +from sat_tmp.wokkel.rsm import RSMRequest +from sat_tmp.wokkel.mam import buildForm, MAMRequest + +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' +SERVICE = 'sat-pubsub.tazar.int' +SERVICE_JID = JID(SERVICE) + + +class XEP_0313Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = XEP_0313(self.host) + self.client = self.host.getClient(C.PROFILE[0]) + mam_client = self.plugin.getHandler(C.PROFILE[0]) + mam_client.makeConnection(self.host.getClient(C.PROFILE[0]).xmlstream) + + def test_queryArchive(self): + xml = """ + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + d = self.plugin.queryArchive(self.client, MAMRequest(), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchivePubsub(self): + xml = """ + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + d = self.plugin.queryArchive(self.client, MAMRequest(node="fdp/submitted/capulet.lit/sonnets"), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveWith(self): + xml = """ + + + + + urn:xmpp:mam:1 + + + juliet@capulet.lit + + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + form = buildForm(with_jid=JID('juliet@capulet.lit')) + d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveStartEnd(self): + xml = """ + + + + + urn:xmpp:mam:1 + + + 2010-06-07T00:00:00Z + + + 2010-07-07T13:23:54Z + + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + start = datetime.datetime(2010, 6, 7, 0, 0, 0, tzinfo=tzutc()) + end = datetime.datetime(2010, 7, 7, 13, 23, 54, tzinfo=tzutc()) + form = buildForm(start=start, end=end) + d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveStart(self): + xml = """ + + + + + urn:xmpp:mam:1 + + + 2010-08-07T00:00:00Z + + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc()) + form = buildForm(start=start) + d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveRSM(self): + xml = """ + + + + + urn:xmpp:mam:1 + + + 2010-08-07T00:00:00Z + + + + 10 + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc()) + form = buildForm(start=start) + rsm = RSMRequest(max_=10) + d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveRSMPaging(self): + xml = """ + + + + urn:xmpp:mam:1 + 2010-08-07T00:00:00Z + + + 10 + 09af3-cc343-b409f + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc()) + form = buildForm(start=start) + rsm = RSMRequest(max_=10, after=u'09af3-cc343-b409f') + d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryFields(self): + xml = """ + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + d = self.plugin.queryFields(self.client, SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryArchiveFields(self): + xml = """ + + + + + urn:xmpp:mam:1 + + + Where arth thou, my Juliet? + + + {http://jabber.org/protocol/mood}mood/lonely + + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + extra_fields = [Field('text-single', 'urn:example:xmpp:free-text-search', 'Where arth thou, my Juliet?'), + Field('text-single', 'urn:example:xmpp:stanza-content', '{http://jabber.org/protocol/mood}mood/lonely') + ] + form = buildForm(extra_fields=extra_fields) + d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_queryPrefs(self): + xml = """ + + + + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + d = self.plugin.getPrefs(self.client, SERVICE_JID) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d + + def test_setPrefs(self): + xml = """ + + + + romeo@montague.lit + + + montague@montague.lit + + + + """ % (("H_%d" % domish.Element._idCounter), SERVICE) + always = [JID('romeo@montague.lit')] + never = [JID('montague@montague.lit')] + d = self.plugin.setPrefs(self.client, SERVICE_JID, always=always, never=never) + d.addCallback(lambda dummy: self.assertEqualXML(self.host.getSentMessageXml(0), xml, True)) + return d diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/test/test_plugin_xep_0334.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/test/test_plugin_xep_0334.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,102 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Plugin XEP-0334 """ + +from constants import Const as C +from sat.test import helpers +from sat.plugins.plugin_xep_0334 import XEP_0334 +from twisted.internet import defer +from wokkel.generic import parseXml +from sat.core import exceptions + +HINTS = ('no-permanent-storage', 'no-storage', 'no-copy') + + +class XEP_0334Test(helpers.SatTestCase): + + def setUp(self): + self.host = helpers.FakeSAT() + self.plugin = XEP_0334(self.host) + + def test_messageSendTrigger(self): + template_xml = """ + + text + %s + + """ + original_xml = template_xml % '' + + d_list = [] + + def cb(data, expected_xml): + result_xml = data['xml'].toXml().encode("utf-8") + self.assertEqualXML(result_xml, expected_xml, True) + + for key in (HINTS + ('', 'dummy_hint')): + mess_data = {'xml': parseXml(original_xml.encode("utf-8")), + 'extra': {key: True} + } + treatments = defer.Deferred() + self.plugin.messageSendTrigger(self.host.getClient(C.PROFILE[0]), mess_data, defer.Deferred(), treatments) + if treatments.callbacks: # the trigger added a callback + expected_xml = template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key) + treatments.addCallback(cb, expected_xml) + treatments.callback(mess_data) + d_list.append(treatments) + + return defer.DeferredList(d_list) + + def test_messageReceivedTrigger(self): + template_xml = """ + + text + %s + + """ + + def cb(dummy): + raise Exception("Errback should not be ran instead of callback!") + + def eb(failure): + failure.trap(exceptions.SkipHistory) + + d_list = [] + + for key in (HINTS + ('dummy_hint',)): + message = parseXml(template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key)) + post_treat = defer.Deferred() + self.plugin.messageReceivedTrigger(self.host.getClient(C.PROFILE[0]), message, post_treat) + if post_treat.callbacks: + assert(key in ('no-permanent-storage', 'no-storage')) + post_treat.addCallbacks(cb, eb) + post_treat.callback(None) + d_list.append(post_treat) + else: + assert(key not in ('no-permanent-storage', 'no-storage')) + + return defer.DeferredList(d_list) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/__init__.py diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/ansi.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/ansi.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,54 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +import sys + +class ANSI(object): + + ## ANSI escape sequences ## + RESET = '\033[0m' + NORMAL_WEIGHT = '\033[22m' + FG_BLACK, FG_RED, FG_GREEN, FG_YELLOW, FG_BLUE, FG_MAGENTA, FG_CYAN, FG_WHITE = ('\033[3%dm' % nb for nb in xrange(8)) + BOLD = '\033[1m' + BLINK = '\033[5m' + BLINK_OFF = '\033[25m' + + @classmethod + def color(cls, *args): + """output text using ANSI codes + + this method simply merge arguments, and add RESET if is not the last arguments + """ + # XXX: we expect to have at least one argument + if args[-1] != cls.RESET: + args = list(args) + args.append(cls.RESET) + return u''.join(args) + + +try: + tty = sys.stdout.isatty() +except (AttributeError, TypeError): # FIXME: TypeError is here for Pyjamas, need to be removed + tty = False +if not tty: + # we don't want ANSI escape codes if we are not outputing to a tty! + for attr in dir(ANSI): + if isinstance(getattr(ANSI, attr), basestring): + setattr(ANSI, attr, u'') +del tty diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/data_format.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/data_format.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,120 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" tools common to backend and frontends """ +# FIXME: json may be more appropriate than manual serialising like done here + +from sat.core import exceptions + +def dict2iter(name, dict_, pop=False): + """iterate into a list serialised in a dict + + name is the name of the key. + Serialisation is done with [name] [name#1] [name#2] and so on + e.g.: if name is 'group', keys are group, group#1, group#2, ... + iteration stop at first missing increment + Empty values are possible + @param name(unicode): name of the key + @param dict_(dict): dictionary with the serialised list + @param pop(bool): if True, remove the value from dict + @return iter: iterate through the deserialised list + """ + if pop: + get=lambda d,k: d.pop(k) + else: + get=lambda d,k: d[k] + + try: + yield get(dict_,name) + except KeyError: + return + else: + idx = 1 + while True: + try: + yield get(dict_,u'{}#{}'.format(name, idx)) + except KeyError: + return + else: + idx += 1 + +def dict2iterdict(name, dict_, extra_keys, pop=False): + """like dict2iter but yield dictionaries + + params are like in [dict2iter], extra_keys is used for extra dict keys. + e.g. dict2iterdict(comments, mb_data, ('node', 'service')) will yield dicts like: + {u'comments': u'value1', u'node': u'value2', u'service': u'value3'} + """ + # FIXME: this format seem overcomplicated, it may be more appropriate to use json here + if pop: + get=lambda d,k: d.pop(k) + else: + get=lambda d,k: d[k] + for idx, main_value in enumerate(dict2iter(name, dict_, pop=pop)): + ret = {name: main_value} + for k in extra_keys: + ret[k] = get(dict_, u'{}{}_{}'.format(name, (u'#' + unicode(idx)) if idx else u'', k)) + yield ret + +def iter2dict(name, iter_, dict_=None, check_conflict=True): + """Fill a dict with values from an iterable + + name is used to serialise iter_, in the same way as in [dict2iter] + Build from the tags a dict using the microblog data format. + + @param name(unicode): key to use for serialisation + e.g. "group" to have keys "group", "group#1", "group#2", ... + @param iter_(iterable): values to store + @param dict_(None, dict): dictionary to fill, or None to create one + @param check_conflict(bool): if True, raise an exception in case of existing key + @return (dict): filled dict, or newly created one + @raise exceptions.ConflictError: a needed key already exists + """ + if dict_ is None: + dict_ = {} + for idx, value in enumerate(iter_): + if idx == 0: + key = name + else: + key = u'{}#{}'.format(name, idx) + if check_conflict and key in dict_: + raise exceptions.ConflictError + dict_[key] = value + return dict + +def getSubDict(name, dict_, sep=u'_'): + """get a sub dictionary from a serialised dictionary + + look for keys starting with name, and create a dict with it + eg.: if "key" is looked for, {'html': 1, 'key_toto': 2, 'key_titi': 3} will return: + {None: 1, toto: 2, titi: 3} + @param name(unicode): name of the key + @param dict_(dict): dictionary with the serialised list + @param sep(unicode): separator used between name and subkey + @return iter: iterate through the deserialised items + """ + for k,v in dict_.iteritems(): + if k.startswith(name): + if k == name: + yield None, v + else: + if k[len(name)] != sep: + continue + else: + yield k[len(name)+1:], v diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/data_objects.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/data_objects.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,356 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +"""Objects handling bridge data, with jinja2 safe markup handling""" + +from sat.tools.common import data_format +try: + from jinja2 import Markup as safe +except ImportError: + safe = unicode + +from sat.tools.common import uri as xmpp_uri +import urllib + +q = lambda value: urllib.quote(value.encode('utf-8'), safe='@') + + +class BlogItem(object): + + def __init__(self, mb_data, parent): + self.mb_data = mb_data + self.parent = parent + self._tags = None + self._groups = None + self._comments = None + self._comments_items_list = None + + @property + def id(self): + return self.mb_data.get(u'id') + + @property + def atom_id(self): + return self.mb_data.get(u'atom_id') + + @property + def uri(self): + node = self.parent.node + service = self.parent.service + return xmpp_uri.buildXMPPUri(u'pubsub', + subtype=u'microblog', + path=service, + node=node, + item=self.id) + + @property + def published(self): + return self.mb_data.get(u'published') + + @property + def updated(self): + return self.mb_data.get(u'updated') + + @property + def language(self): + return self.mb_data.get(u'language') + + @property + def author(self): + return self.mb_data.get(u'author') + + @property + def author_jid(self): + return self.mb_data.get(u'author_jid') + + @property + def author_jid_verified(self): + return self.mb_data.get(u'author_jid_verified') + + @property + def author_email(self): + return self.mb_data.get(u'author_email') + + @property + def tags(self): + if self._tags is None: + self._tags = list(data_format.dict2iter('tag', self.mb_data)) + return self._tags + + @property + def groups(self): + if self._groups is None: + self._groups = list(data_format.dict2iter('group', self.mb_data)) + return self._groups + + @property + def title(self): + return self.mb_data.get(u'title') + + @property + def title_xhtml(self): + try: + return safe(self.mb_data[u'title_xhtml']) + except KeyError: + return None + + @property + def content(self): + return self.mb_data.get(u'content') + + @property + def content_xhtml(self): + try: + return safe(self.mb_data[u'content_xhtml']) + except KeyError: + return None + + @property + def comments(self): + if self._comments is None: + self._comments = data_format.dict2iterdict(u'comments', self.mb_data, (u'node', u'service')) + return self._comments + + @property + def comments_service(self): + return self.mb_data.get(u'comments_service') + + @property + def comments_node(self): + return self.mb_data.get(u'comments_node') + + @property + def comments_items_list(self): + return [] if self._comments_items_list is None else self._comments_items_list + + def appendCommentsItems(self, items): + """append comments items to self.comments_items""" + if self._comments_items_list is None: + self._comments_items_list = [] + self._comments_items_list.append(items) + + +class BlogItems(object): + + def __init__(self, mb_data): + self.items = [BlogItem(i, self) for i in mb_data[0]] + self.metadata = mb_data[1] + + @property + def service(self): + return self.metadata[u'service'] + + @property + def node(self): + return self.metadata[u'node'] + + @property + def uri(self): + return self.metadata[u'uri'] + + def __len__(self): + return self.items.__len__() + + def __missing__(self, key): + return self.items.__missing__(key) + + def __getitem__(self, key): + return self.items.__getitem__(key) + + def __iter__(self): + return self.items.__iter__() + + def __reversed__(self): + return self.items.__reversed__() + + def __contains__(self, item): + return self.items.__contains__(item) + + +class Message(object): + + def __init__(self, msg_data): + self._uid = msg_data[0] + self._timestamp = msg_data[1] + self._from_jid = msg_data[2] + self._to_jid = msg_data[3] + self._message_data = msg_data[4] + self._subject_data = msg_data[5] + self._type = msg_data[6] + self._extra = msg_data[7] + self._html = dict(data_format.getSubDict('xhtml', self._extra)) + + @property + def id(self): + return self._uid + + @property + def timestamp(self): + return self._timestamp + + @property + def from_(self): + return self._from_jid + + @property + def text(self): + try: + return self._message_data[''] + except KeyError: + return next(self._message_data.itervalues()) + + @property + def subject(self): + try: + return self._subject_data[''] + except KeyError: + return next(self._subject_data.itervalues()) + + @property + def type(self): + return self._type + + @property + def thread(self): + return self._extra.get('thread') + + @property + def thread_parent(self): + return self._extra.get('thread_parent') + + @property + def received(self): + return self._extra.get('received_timestamp', self._timestamp) + + @property + def delay_sender(self): + return self._extra.get('delay_sender') + + @property + def info_type(self): + return self._extra.get('info_type') + + @property + def html(self): + if not self._html: + return None + try: + return safe(self._html['']) + except KeyError: + return safe(next(self._html.itervalues())) + + +class Messages(object): + + def __init__(self, msgs_data): + self.messages = [Message(m) for m in msgs_data] + + def __len__(self): + return self.messages.__len__() + + def __missing__(self, key): + return self.messages.__missing__(key) + + def __getitem__(self, key): + return self.messages.__getitem__(key) + + def __iter__(self): + return self.messages.__iter__() + + def __reversed__(self): + return self.messages.__reversed__() + + def __contains__(self, item): + return self.messages.__contains__(item) + +class Room(object): + + def __init__(self, jid, name=None, url=None): + self.jid = jid + self.name = name or jid + if url is not None: + self.url = url + + +class Identity(object): + + def __init__(self, jid_str, data=None): + self.jid_str = jid_str + self.data = data if data is not None else {} + + def __getitem__(self, key): + return self.data[key] + + def __getattr__(self, key): + try: + return self.data[key] + except KeyError: + raise AttributeError(key) + + +class Identities(object): + + def __init__(self): + self.identities = {} + + def __getitem__(self, jid_str): + try: + return self.identities[jid_str] + except KeyError: + return None + + def __setitem__(self, jid_str, data): + self.identities[jid_str] = Identity(jid_str, data) + + def __contains__(self, jid_str): + return jid_str in self.identities + + +class ObjectQuoter(object): + """object wrapper which quote attribues (to be used in templates)""" + + def __init__(self, obj): + self.obj = obj + + def __unicode__(self): + return q(self.obj.__unicode__()) + + def __str__(self): + return self.__unicode__() + + def __getattr__(self, name): + return q(self.obj.__getattr__(name)) + + def __getitem__(self, key): + return q(self.obj.__getitem__(key)) + + +class OnClick(object): + """Class to handle clickable elements targets""" + + def __init__(self, url=None): + self.url = url + + def formatUrl(self, *args, **kwargs): + """format URL using Python formatting + + values will be quoted before being used + """ + return self.url.format(*[q(a) for a in args], + **{k: ObjectQuoter(v) for k,v in kwargs.iteritems()}) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/dynamic_import.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/dynamic_import.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,39 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP +# 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 . + +""" tools dynamic import """ + +from importlib import import_module + + +def bridge(name, module_path='sat.bridge'): + """Import bridge module + + @param module_path(str): path of the module to import + @param name(str): name of the bridge to import (e.g.: dbus) + @return (module, None): imported module or None if nothing is found + """ + try: + bridge_module = import_module(module_path + '.' + name) + except ImportError: + try: + bridge_module = import_module(module_path + '.' + name + '_bridge') + except ImportError: + bridge_module = None + return bridge_module diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/regex.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/regex.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,72 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# Salut à Toi: an XMPP client +# 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 . + +""" regex tools common to backend and frontends """ + +import re +path_escape = {'%': '%25', '/': '%2F', '\\': '%5c'} +path_escape_rev = {re.escape(v):k for k, v in path_escape.iteritems()} +path_escape = {re.escape(k):v for k, v in path_escape.iteritems()} +# thanks to Martijn Pieters (https://stackoverflow.com/a/14693789) +RE_ANSI_REMOVE = re.compile(r'\x1b[^m]*m') + + +def reJoin(exps): + """Join (OR) various regexes""" + return re.compile('|'.join(exps)) + + +def reSubDict(pattern, repl_dict, string): + """Replace key, value found in dict according to pattern + + @param pattern(basestr): pattern using keys found in repl_dict + @repl_dict(dict): keys found in this dict will be replaced by + corresponding values + @param string(basestr): string to use for the replacement + """ + return pattern.sub(lambda m: repl_dict[re.escape(m.group(0))], string) + +path_escape_re = reJoin(path_escape.keys()) +path_escape_rev_re = reJoin(path_escape_rev.keys()) + + +def pathEscape(string): + """Escape string so it can be use in a file path + + @param string(basestr): string to escape + @return (str, unicode): escaped string, usable in a file path + """ + return reSubDict(path_escape_re, path_escape, string) + + +def pathUnescape(string): + """Unescape string from value found in file path + + @param string(basestr): string found in file path + @return (str, unicode): unescaped string + """ + return reSubDict(path_escape_rev_re, path_escape_rev, string) + +def ansiRemove(string): + """Remove ANSI escape codes from string + + @param string(basestr): string to filter + @return (str, unicode): string without ANSI escape codes + """ + return RE_ANSI_REMOVE.sub('', string) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/template.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/template.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,653 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" template generation """ + +from sat.core.constants import Const as C +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.log import getLogger +log = getLogger(__name__) +import os.path +from xml.sax.saxutils import quoteattr +import datetime +import time +import re +from babel import support +from babel import Locale +from babel.core import UnknownLocaleError +from babel import dates +import pygments +from pygments import lexers +from pygments import formatters +try: + import sat_templates +except ImportError: + raise exceptions.MissingModule(u'sat_templates module is not available, please install it or check your path to use template engine') +else: + sat_templates # to avoid pyflakes warning + +try: + import jinja2 +except: + raise exceptions.MissingModule(u'Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2') + +from jinja2 import Markup as safe +from jinja2 import is_undefined +from lxml import etree + +HTML_EXT = ('html', 'xhtml') +DEFAULT_LOCALE = u'en_GB' +RE_ATTR_ESCAPE = re.compile(r'[^a-z_-]') +# TODO: handle external path (an additional search path for templates should be settable by user +# TODO: handle absolute URL (should be used for trusted use cases) only (e.g. jp) for security reason + + +class TemplateLoader(jinja2.FileSystemLoader): + + def __init__(self): + searchpath = os.path.dirname(sat_templates.__file__) + super(TemplateLoader, self).__init__(searchpath, followlinks=True) + + def parse_template(self, template): + """parse template path and return theme and relative URL + + @param template_path(unicode): path to template with parenthesis syntax + @return (tuple[(unicode,None),unicode]): theme and template_path + theme can be None if relative path is used + relative path is the path from search path with theme specified + e.g. default/blog/articles.html + """ + if template.startswith(u'('): + try: + theme_end = template.index(u')') + except IndexError: + raise ValueError(u"incorrect theme in template") + theme = template[1:theme_end] + template = template[theme_end+1:] + if not template or template.startswith(u'/'): + raise ValueError(u"incorrect path after template name") + template = os.path.join(theme, template) + elif template.startswith(u'/'): + # absolute path means no template + theme = None + raise NotImplementedError(u'absolute path is not implemented yet') + else: + theme = C.TEMPLATE_THEME_DEFAULT + template = os.path.join(theme, template) + return theme, template + + def get_default_template(self, theme, template_path): + """return default template path + + @param theme(unicode): theme used + @param template_path(unicode): path to the not found template + @return (unicode, None): default path or None if there is not + """ + ext = os.path.splitext(template_path)[1][1:] + path_elems = template_path.split(u'/') + if ext in HTML_EXT: + if path_elems[1] == u'error': + # if an inexisting error page is requested, we return base page + default_path = os.path.join(theme, u'error/base.html') + return default_path + if theme != C.TEMPLATE_THEME_DEFAULT: + # if template doesn't exists for this theme, we try with default + return os.path.join(C.TEMPLATE_THEME_DEFAULT, path_elems[1:]) + + def get_source(self, environment, template): + """relative path to template dir, with special theme handling + + if the path is just relative, "default" theme is used. + The theme can be specified in parenthesis just before the path + e.g.: (some_theme)path/to/template.html + """ + theme, template_path = self.parse_template(template) + try: + return super(TemplateLoader, self).get_source(environment, template_path) + except jinja2.exceptions.TemplateNotFound as e: + # in some special cases, a defaut template is returned if nothing is found + if theme is not None: + default_path = self.get_default_template(theme, template_path) + if default_path is not None: + return super(TemplateLoader, self).get_source(environment, default_path) + # if no default template is found, we re-raise the error + raise e + + +class Indexer(object): + """Index global to a page""" + + def __init__(self): + self._indexes = {} + + def next(self, value): + if value not in self._indexes: + self._indexes[value] = 0 + return 0 + self._indexes[value] += 1 + return self._indexes[value] + + def current(self, value): + return self._indexes.get(value) + + +class ScriptsHandler(object): + + def __init__(self, renderer, template_path, template_root_dir, root_path): + self.renderer = renderer + self.template_root_dir = template_root_dir + self.root_path = root_path + self.scripts = [] # we don't use a set because order may be important + dummy, self.theme, self.is_default_theme = renderer.getThemeData(template_path) + + def include(self, library_name, attribute='defer'): + """Mark that a script need to be imported. + + Must be used before base.html is extended, as ' + for library, attribute in self.scripts: + path = self.renderer.getStaticPath(library, self.template_root_dir, self.theme, self.is_default_theme, '.js') + if path is None: + log.warning(_(u"Can't find {}.js javascript library").format(library)) + continue + path = os.path.join(self.root_path, path) + scripts.append(tpl.format( + src = quoteattr(path), + attribute = attribute, + )) + return safe(u'\n'.join(scripts)) + + +class Renderer(object): + + def __init__(self, host): + self.host = host + self.base_dir = os.path.dirname(sat_templates.__file__) # FIXME: should be modified if we handle use extra dirs + self.env = jinja2.Environment( + loader=TemplateLoader(), + autoescape=jinja2.select_autoescape(['html', 'xhtml', 'xml']), + trim_blocks=True, + lstrip_blocks=True, + extensions=['jinja2.ext.i18n'], + ) + self._locale_str = DEFAULT_LOCALE + self._locale = Locale.parse(self._locale_str) + self.installTranslations() + # we want to have access to SàT constants in templates + self.env.globals[u'C'] = C + # custom filters + self.env.filters['next_gidx'] = self._next_gidx + self.env.filters['cur_gidx'] = self._cur_gidx + self.env.filters['date_fmt'] = self._date_fmt + self.env.filters['xmlui_class'] = self._xmlui_class + self.env.filters['attr_escape'] = self.attr_escape + self.env.filters['item_filter'] = self._item_filter + self.env.filters['adv_format'] = self._adv_format + self.env.filters['dict_ext'] = self._dict_ext + self.env.filters['highlight'] = self.highlight + # custom tests + self.env.tests['in_the_past'] = self._in_the_past + self.icons_path = os.path.join(host.media_dir, u'fonts/fontello/svg') + + def installTranslations(self): + i18n_dir = os.path.join(self.base_dir, 'i18n') + self.translations = {} + for lang_dir in os.listdir(i18n_dir): + lang_path = os.path.join(i18n_dir, lang_dir) + if not os.path.isdir(lang_path): + continue + po_path = os.path.join(lang_path, 'LC_MESSAGES/sat.mo') + try: + with open(po_path, 'rb') as f: + self.translations[Locale.parse(lang_dir)] = support.Translations(f, 'sat') + except EnvironmentError: + log.error(_(u"Can't find template translation at {path}").format(path = po_path)) + except UnknownLocaleError as e: + log.error(_(u"Invalid locale name: {msg}").format(msg=e)) + else: + log.info(_(u'loaded {lang} templates translations').format(lang=lang_dir)) + self.env.install_null_translations(True) + + def setLocale(self, locale_str): + """set current locale + + change current translation locale and self self._locale and self._locale_str + """ + if locale_str == self._locale_str: + return + if locale_str == 'en': + # we default to GB English when it's not specified + # one of the main reason is to avoid the nonsense U.S. short date format + locale_str = 'en_GB' + try: + locale = Locale.parse(locale_str) + except ValueError as e: + log.warning(_(u"invalid locale value: {msg}").format(msg=e)) + locale_str = self._locale_str = DEFAULT_LOCALE + locale = Locale.parse(locale_str) + + locale_str = unicode(locale) + if locale_str != DEFAULT_LOCALE: + try: + translations = self.translations[locale] + except KeyError: + log.warning(_(u"Can't find locale {locale}".format(locale=locale))) + locale_str = DEFAULT_LOCALE + locale = Locale.parse(self._locale_str) + else: + self.env.install_gettext_translations(translations, True) + log.debug(_(u'Switched to {lang}').format(lang=locale.english_name)) + + if locale_str == DEFAULT_LOCALE: + self.env.install_null_translations(True) + + self._locale = locale + self._locale_str = locale_str + + def getThemeAndRoot(self, template): + """retrieve theme and root dir of a given tempalte + + @param template(unicode): template to parse + @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir + """ + theme, dummy = self.env.loader.parse_template(template) + return theme, os.path.join(self.base_dir, theme) + + def getStaticPath(self, name, template_root_dir, theme, is_default, ext='.css'): + """retrieve path of a static file if it exists with current theme or default + + File will be looked at [theme]/static/[name][ext], and then default + if not found. + @param name(unicode): name of the file to look for + @param template_root_dir(unicode): absolute path to template root used + @param theme(unicode): name of the template theme used + @param is_default(bool): True if theme is the default theme + @return (unicode, None): relative path if found, else None + """ + file_ = None + path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + ext) + if os.path.exists(os.path.join(template_root_dir, path)): + file_ = path + elif not is_default: + path = os.path.join(C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + ext) + if os.path.exists(os.path.join(template_root_dir, path)): + file_.append(path) + return file_ + + def getThemeData(self, template_path): + """return template data got from template_path + + @return tuple(unicode, unicode, bool): + path_elems: elements of the path + theme: theme of the page + is_default: True if the theme is the default theme + """ + path_elems = [os.path.splitext(p)[0] for p in template_path.split(u'/')] + theme = path_elems.pop(0) + is_default = theme == C.TEMPLATE_THEME_DEFAULT + return (path_elems, theme, is_default) + + def getCSSFiles(self, template_path, template_root_dir): + """retrieve CSS files to use according to theme and template path + + for each element of the path, a .css file is looked for in /static, and returned if it exists. + previous element are kept by replacing '/' with '_', and styles.css is always returned. + For instance, if template_path is some_theme/blog/articles.html: + some_theme/static/styles.css is returned if it exists else default/static/styles.css + some_theme/static/blog.css is returned if it exists else default/static/blog.css (if it exists too) + some_theme/static/blog_articles.css is returned if it exists else default/static/blog_articles.css (if it exists too) + @param template_path(unicode): relative path to template file (e.g. some_theme/blog/articles.html) + @param template_root_dir(unicode): absolute path of the theme root dir used + @return list[unicode]: relative path to CSS files to use + """ + # TODO: some caching would be nice + css_files = [] + path_elems, theme, is_default = self.getThemeData(template_path) + for css in (u'fonts', u'styles'): + css_path = self.getStaticPath(css, template_root_dir, theme, is_default) + if css_path is not None: + css_files.append(css_path) + + for idx, path in enumerate(path_elems): + css_path = self.getStaticPath(u'_'.join(path_elems[:idx+1]), template_root_dir, theme, is_default) + if css_path is not None: + css_files.append(css_path) + + return css_files + + + ## custom filters ## + + @jinja2.contextfilter + def _next_gidx(self, ctx, value): + """Use next current global index as suffix""" + next_ = ctx['gidx'].next(value) + return value if next_ == 0 else u"{}_{}".format(value, next_) + + @jinja2.contextfilter + def _cur_gidx(self, ctx, value): + """Use current current global index as suffix""" + current = ctx['gidx'].current(value) + return value if not current else u"{}_{}".format(value, current) + + def _date_fmt(self, timestamp, fmt='short', date_only=False, auto_limit=None, auto_old_fmt=None): + try: + return self.date_fmt(timestamp, fmt, date_only, auto_limit, auto_old_fmt) + except Exception as e: + log.warning(_(u"Can't parse date: {msg}").format(msg=e)) + return timestamp + + def date_fmt(self, timestamp, fmt='short', date_only=False, auto_limit=7, auto_old_fmt='short', auto_new_fmt='relative'): + """format date according to locale + + @param timestamp(basestring, int): unix time + @param fmt(str): one of: + - short: e.g. u'31/12/17' + - medium: e.g. u'Apr 1, 2007' + - long: e.g. u'April 1, 2007' + - full: e.g. u'Sunday, April 1, 2007' + - relative: format in relative time + e.g.: 3 hours + note that this format is not precise + - iso: ISO 8601 format + e.g.: u'2007-04-01T19:53:23Z' + - auto: use auto_old_fmt if date is older than auto_limit + else use auto_new_fmt + - auto_day: shorcut to set auto format with change on day + old format will be short, and new format will be time only + or a free value which is passed to babel.dates.format_datetime + @param date_only(bool): if True, only display date (not datetime) + @param auto_limit (int): limit in days before using auto_old_fmt + use 0 to have a limit at last midnight (day change) + @param auto_old_fmt(unicode): format to use when date is older than limit + @param auto_new_fmt(unicode): format to use when date is equal to or more recent + than limit + + """ + if is_undefined(fmt): + fmt = u'short' + + if (auto_limit is not None or auto_old_fmt is not None) and fmt != 'auto': + raise ValueError(u'auto argument can only be used with auto fmt') + if fmt == 'auto_day': + fmt, auto_limit, auto_old_fmt, auto_new_fmt = 'auto', 0, 'short', 'HH:mm' + if fmt == 'auto': + if auto_limit == 0: + today = time.mktime(datetime.date.today().timetuple()) + if int(timestamp) < today: + fmt = auto_old_fmt + else: + fmt = auto_new_fmt + else: + days_delta = (time.time() - int(timestamp)) / 3600 + if days_delta > (auto_limit or 7): + fmt = auto_old_fmt + else: + fmt = auto_new_fmt + + if fmt == 'relative': + delta = int(timestamp) - time.time() + return dates.format_timedelta(delta, granularity="minute", add_direction=True, locale=self._locale_str) + elif fmt in ('short', 'long'): + formatter = dates.format_date if date_only else dates.format_datetime + return formatter(int(timestamp), format=fmt, locale=self._locale_str) + elif fmt == 'iso': + if date_only: + fmt = 'yyyy-MM-dd' + else: + fmt = "yyyy-MM-ddTHH:mm:ss'Z'" + return dates.format_datetime(int(timestamp), format=fmt) + else: + return dates.format_datetime(int(timestamp), format=fmt, locale=self._locale_str) + + def attr_escape(self, text): + """escape a text to a value usable as an attribute + + remove spaces, and put in lower case + """ + return RE_ATTR_ESCAPE.sub(u'_', text.strip().lower())[:50] + + def _xmlui_class(self, xmlui_item, fields): + """return classes computed from XMLUI fields name + + will return a string with a series of escaped {name}_{value} separated by spaces. + @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use + @param fields(iterable(unicode)): names of the widgets to use + @return (unicode, None): computer string to use as class attribute value + None if no field was specified + """ + classes = [] + for name in fields: + escaped_name = self.attr_escape(name) + try: + for value in xmlui_item.widgets[name].values: + classes.append(escaped_name + '_' + self.attr_escape(value)) + except KeyError: + log.debug(_(u"ignoring field \"{name}\": it doesn't exists").format(name=name)) + continue + return u' '.join(classes) or None + + @jinja2.contextfilter + def _item_filter(self, ctx, item, filters): + """return item's value, filtered if suitable + + @param item(object): item to filter + value must have name and value attributes, + mostly used for XMLUI items + @param filters(dict[unicode, (callable, dict, None)]): map of name => filter + if filter is None, return the value unchanged + if filter is a callable, apply it + if filter is a dict, it can have following keys: + - filters: iterable of filters to apply + - filters_args: kwargs of filters in the same order as filters (use empty dict if needed) + - template: template to format where {value} is the filtered value + """ + value = item.value + filter_ = filters.get(item.name, None) + if filter_ is None: + return value + elif isinstance(filter_, dict): + filters_args = filter_.get(u'filters_args') + for idx, f_name in enumerate(filter_.get(u'filters', [])): + kwargs = filters_args[idx] if filters_args is not None else {} + filter_func = self.env.filters[f_name] + try: + eval_context_filter = filter_func.evalcontextfilter + except AttributeError: + eval_context_filter = False + + if eval_context_filter: + value = filter_func(ctx.eval_ctx, value, **kwargs) + else: + value = filter_func(value, **kwargs) + template = filter_.get(u'template') + if template: + # format will return a string, so we need to check first + # if the value is safe or not, and re-mark it after formatting + is_safe = isinstance(value, safe) + value = template.format(value=value) + if is_safe: + value = safe(value) + return value + + def _adv_format(self, value, template, **kwargs): + """Advancer formatter + + like format() method, but take care or special values like None + @param value(unicode): value to format + @param template(None, unicode): template to use with format() method. + It will be formatted using value=value and **kwargs + None to return value unchanged + @return (unicode): formatted value + """ + if template is None: + return value + # jinja use string when no special char is used, so we have to convert to unicode + return unicode(template).format(value=value, **kwargs) + + def _dict_ext(self, source_dict, extra_dict, key=None): + """extend source_dict with extra dict and return the result + + @param source_dict(dict): dictionary to extend + @param extra_dict(dict, None): dictionary to use to extend first one + None to return source_dict unmodified + @param key(unicode, None): if specified extra_dict[key] will be used + if it doesn't exists, a copy of unmodified source_dict is returned + @return (dict): resulting dictionary + """ + if extra_dict is None: + return source_dict + if key is not None: + extra_dict = extra_dict.get(key, {}) + ret = source_dict.copy() + ret.update(extra_dict) + return ret + + def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None): + """Do syntax highlighting on code + + under the hood, pygments is used, check its documentation for options possible values + @param code(unicode): code or markup to highlight + @param lexer_name(unicode, None): name of the lexer to use + None to autodetect it + @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter + @return (unicode): HTML markup with highlight classes + """ + if lexer_opts is None: + lexer_opts = {} + if html_fmt_opts is None: + html_fmt_opts = {} + if lexer_name is None: + lexer = lexers.guess_lexer(code, **lexer_opts) + else: + lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts) + formatter = formatters.HtmlFormatter(**html_fmt_opts) + return safe(pygments.highlight(code, lexer, formatter)) + + ## custom tests ## + + def _in_the_past(self, timestamp): + """check if a date is in the past + + @param timestamp(unicode, int): unix time + @return (bool): True if date is in the past + """ + return time.time() > int(timestamp) + + ## template methods ## + + def _icon_defs(self, *names): + """Define svg icons which will be used in the template, and use their name as id""" + svg_elt = etree.Element('svg', nsmap={None: 'http://www.w3.org/2000/svg'}, + width='0', height='0', style='display: block' + ) + defs_elt = etree.SubElement(svg_elt, 'defs') + for name in names: + path = os.path.join(self.icons_path, name + u'.svg') + icon_svg_elt = etree.parse(path).getroot() + # we use icon name as id, so we can retrieve them easily + icon_svg_elt.set('id', name) + if not icon_svg_elt.tag == '{http://www.w3.org/2000/svg}svg': + raise exceptions.DataError(u'invalid SVG element') + defs_elt.append(icon_svg_elt) + return safe(etree.tostring(svg_elt, encoding='unicode')) + + def _icon_use(self, name, cls=''): + return safe(u""" + + + """.format( + name=name, + cls=(' ' + cls) if cls else '')) + + def render(self, template, theme=None, locale=DEFAULT_LOCALE, root_path=u'', media_path=u'', css_files=None, css_inline=False, **kwargs): + """render a template +. + @param template(unicode): template to render (e.g. blog/articles.html) + @param theme(unicode): template theme + @param root_path(unicode): prefix of the path/URL to use for template root + must end with a u'/' + @param media_path(unicode): prefix of the SàT media path/URL to use for template root + must end with a u'/' + @param css_files(list[unicode],None): CSS files to used + CSS files must be in static dir of the template + use None for automatic selection of CSS files based on template category + None is recommended. General static/style.css and theme file name will be used. + @param css_inline(bool): if True, CSS will be embedded in the HTML page + @param **kwargs: variable to transmit to the template + """ + if not template: + raise ValueError(u"template can't be empty") + if theme is not None: + # use want to set a theme, we add it to the template path + if template[0] == u'(': + raise ValueError(u"you can't specify theme in template path and in argument at the same time") + elif template[0] == u'/': + raise ValueError(u"you can't specify theme with absolute paths") + template= u'(' + theme + u')' + template + else: + theme, dummy = self.env.loader.parse_template(template) + + template_source = self.env.get_template(template) + template_root_dir = os.path.normpath(self.base_dir) # FIXME: should be modified if we handle use extra dirs + # XXX: template_path may have a different theme as first element than theme if a default page is used + template_path = template_source.filename[len(template_root_dir)+1:] + + if css_files is None: + css_files = self.getCSSFiles(template_path, template_root_dir) + + kwargs['icon_defs'] = self._icon_defs + kwargs['icon'] = self._icon_use + + if css_inline: + css_contents = [] + for css_file in css_files: + css_file_path = os.path.join(template_root_dir, css_file) + with open(css_file_path) as f: + css_contents.append(f.read()) + if css_contents: + kwargs['css_content'] = '\n'.join(css_contents) + + scripts_handler = ScriptsHandler(self, template_path, template_root_dir, root_path) + self.setLocale(locale) + # XXX: theme used in template arguments is the requested theme, which may differ from actual theme + # if the template doesn't exist in the requested theme. + return template_source.render(theme=theme, root_path=root_path, media_path=media_path, + css_files=css_files, locale=self._locale, + gidx=Indexer(), script=scripts_handler, + **kwargs) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/template_xmlui.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/template_xmlui.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,204 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" template XMLUI parsing + +XMLUI classes from this modules can then be iterated to create the template +""" + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat_frontends.tools import xmlui + + +## Widgets ## + +class Widget(object): + category = u'widget' + type = None + enabled = True + read_only = True + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + + @property + def name(self): + return self._xmlui_name + + +class ValueWidget(Widget): + + def __init__(self, xmlui_parent, value): + super(ValueWidget, self).__init__(xmlui_parent) + self.value = value + + @property + def values(self): + return [self.value] + + @property + def labels(self): + # helper property, there is not label for ValueWidget + # but using labels make rendering more easy (one single method to call) + # values are actually returned + return [self.value] + + +class InputWidget(ValueWidget): + + def __init__(self, xmlui_parent, value, read_only=False): + super(InputWidget, self).__init__(xmlui_parent, value) + self.read_only = read_only + + +class OptionsWidget(Widget): + + def __init__(self, xmlui_parent, options, selected, style): + super(OptionsWidget, self).__init__(xmlui_parent) + self.options = options + self.selected = selected + self.style = style + + @property + def values(self): + for value, label in self.items: + yield value + + @property + def labels(self): + """return only labels from self.items""" + for value, label in self.items: + yield label + + @property + def items(self): + """return suitable items, according to style""" + no_select = self.no_select + for value,label in self.options: + if no_select or value in self.selected: + yield value,label + + @property + def inline(self): + return u'inline' in self.style + + @property + def no_select(self): + return u'noselect' in self.style + + +class EmptyWidget(xmlui.EmptyWidget, Widget): + + def __init__(self, _xmlui_parent): + Widget.__init__(self) + + +class TextWidget(xmlui.TextWidget, ValueWidget): + type = u"text" + + +class LabelWidget(xmlui.LabelWidget, ValueWidget): + type = u"label" + + @property + def for_name(self): + try: + return self._xmlui_for_name + except AttributeError: + return None + + +class StringWidget(xmlui.StringWidget, InputWidget): + type = u"string" + + +class JidInputWidget(xmlui.JidInputWidget, StringWidget): + type = u"jid" + + +class TextBoxWidget(xmlui.TextWidget, InputWidget): + type = u"textbox" + + +class ListWidget(xmlui.ListWidget, OptionsWidget): + type = u"list" + + +## Containers ## + +class Container(object): + category = u'container' + type = None + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + self.children = [] + + def __iter__(self): + return iter(self.children) + + def _xmluiAppend(self, widget): + self.children.append(widget) + + def _xmluiRemove(self, widget): + self.children.remove(widget) + + +class VerticalContainer(xmlui.VerticalContainer, Container): + type = u'vertical' + + +class PairsContainer(xmlui.PairsContainer, Container): + type = u'pairs' + + +class LabelContainer(xmlui.PairsContainer, Container): + type = u'label' + + +## Factory ## + +class WidgetFactory(object): + + def __getattr__(self, attr): + if attr.startswith("create"): + cls = globals()[attr[6:]] + return cls + +## Core ## + + +class XMLUIPanel(xmlui.XMLUIPanel): + widget_factory = WidgetFactory() + + def show(self, *args, **kwargs): + raise NotImplementedError + + +class XMLUIDialog(xmlui.XMLUIDialog): + dialog_factory = WidgetFactory() + + def __init__(*args, **kwargs): + raise NotImplementedError + + +xmlui.registerClass(xmlui.CLASS_PANEL, XMLUIPanel) +xmlui.registerClass(xmlui.CLASS_DIALOG, XMLUIDialog) +create = xmlui.create diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/common/uri.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/common/uri.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,95 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" XMPP uri parsing tools """ + +import urlparse +import urllib + +# FIXME: basic implementation, need to follow RFC 5122 + +def parseXMPPUri(uri): + """Parse an XMPP uri and return a dict with various information + + @param uri(unicode): uri to parse + @return dict(unicode, unicode): data depending of the URI where key can be: + type: one of ("pubsub", TODO) + type is always present + sub_type: can be: + - microblog + only used for pubsub for now + path: XMPP path (jid of the service or entity) + node: node used + id: id of the element (item for pubsub) + @raise ValueError: the scheme is not xmpp + """ + uri_split = urlparse.urlsplit(uri.encode('utf-8')) + if uri_split.scheme != 'xmpp': + raise ValueError(u'this is not a XMPP URI') + + # XXX: we don't use jid.JID for path as it can be used both in backend and frontend + # which may use different JID classes + data = {u'path': urllib.unquote(uri_split.path).decode('utf-8')} + + query_end = uri_split.query.find(';') + query_type = uri_split.query[:query_end] + if query_end == -1 or '=' in query_type: + raise ValueError('no query type, invalid XMPP URI') + + pairs = urlparse.parse_qs(uri_split.geturl()) + for k, v in pairs.items(): + if len(v) != 1: + raise NotImplementedError(u"multiple values not managed") + if k in ('path', 'type', 'sub_type'): + raise NotImplementedError(u"reserved key used in URI, this is not supported") + data[k.decode('utf-8')] = urllib.unquote(v[0]).decode('utf-8') + + if query_type: + data[u'type'] = query_type.decode('utf-8') + elif u'node' in data: + data[u'type'] = u'pubsub' + else: + data[u'type'] = '' + + if u'node' in data: + if data[u'node'].startswith(u'urn:xmpp:microblog:'): + data[u'sub_type'] = 'microblog' + + return data + +def addPairs(uri, pairs): + for k,v in pairs.iteritems(): + uri.append(u';' + urllib.quote_plus(k.encode('utf-8')) + u'=' + urllib.quote_plus(v.encode('utf-8'))) + +def buildXMPPUri(type_, **kwargs): + uri = [u'xmpp:'] + subtype = kwargs.pop('subtype', None) + path = kwargs.pop('path') + uri.append(urllib.quote_plus(path.encode('utf-8')).replace(u'%40', '@')) + + if type_ == u'pubsub': + if subtype == 'microblog' and not kwargs.get('node'): + kwargs[u'node'] = 'urn:xmpp:microblog:0' + if kwargs: + uri.append(u'?') + addPairs(uri, kwargs) + else: + raise NotImplementedError(u'{type_} URI are not handled yet'.format(type_=type_)) + + return u''.join(uri) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/config.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/config.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,119 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 . + +""" Configuration related useful methods """ + +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.constants import Const as C +from sat.core.i18n import _ +from sat.core import exceptions + +from ConfigParser import SafeConfigParser, DEFAULTSECT, NoOptionError, NoSectionError +from xdg import BaseDirectory +import os +import csv +import json + + +def fixConfigOption(section, option, value, silent=True): + """Force a configuration option value, writing it in the first found user + config file, eventually creating a new user config file if none is found. + + @param section (str): the config section + @param option (str): the config option + @param value (str): the new value + @param silent (boolean): toggle logging output (must be True when called from sat.sh) + """ + config = SafeConfigParser() + target_file = None + for file_ in C.CONFIG_FILES[::-1]: + # we will eventually update the existing file with the highest priority, if it's a user personal file... + if not silent: + log.debug(_(u"Testing file %s") % file_) + if os.path.isfile(file_): + if file_.startswith(os.path.expanduser('~')): + config.read([file_]) + target_file = file_ + break + if not target_file: + # ... otherwise we create a new config file for that user + target_file = BaseDirectory.save_config_path('sat') + '/sat.conf' + if section and section.upper() != DEFAULTSECT and not config.has_section(section): + config.add_section(section) + config.set(section, option, value) + with open(target_file, 'wb') as configfile: + config.write(configfile) # for the next time that user launches sat + if not silent: + if option in ('passphrase',): # list here the options storing a password + value = '******' + log.warning(_(u"Config auto-update: %(option)s set to %(value)s in the file %(config_file)s") % + {'option': option, 'value': value, 'config_file': target_file}) + +def parseMainConf(): + """look for main .ini configuration file, and parse it""" + config = SafeConfigParser(defaults=C.DEFAULT_CONFIG) + try: + config.read(C.CONFIG_FILES) + except: + log.error(_("Can't read main config !")) + return config + +def getConfig(config, section, name, default=None): + """Get a configuration option + + @param config (SafeConfigParser): the configuration instance + @param section (str): section of the config file (None or '' for DEFAULT) + @param name (str): name of the option + @param default: value to use if not found, or Exception to raise an exception + @return (unicode, list, dict): parsed value + @raise: NoOptionError if option is not present and default is Exception + NoSectionError if section doesn't exists and default is Exception + exceptions.ParsingError error while parsing value + """ + if not section: + section = DEFAULTSECT + + try: + value = config.get(section, name).decode('utf-8') + except (NoOptionError, NoSectionError) as e: + if default is Exception: + raise e + return default + + if name.endswith('_path') or name.endswith('_dir'): + value = os.path.expanduser(value) + # thx to Brian (http://stackoverflow.com/questions/186857/splitting-a-semicolon-separated-string-to-a-dictionary-in-python/186873#186873) + elif name.endswith('_list'): + value = csv.reader([value], delimiter=',', quotechar='"', skipinitialspace=True).next() + elif name.endswith('_dict'): + try: + value = json.loads(value) + except ValueError as e: + raise exceptions.ParsingError(u"Error while parsing data: {}".format(e)) + if not isinstance(value, dict): + raise exceptions.ParsingError(u"{name} value is not a dict: {value}".format(name=name, value=value)) + elif name.endswith('_json'): + try: + value = json.loads(value) + except ValueError as e: + raise exceptions.ParsingError(u"Error while parsing data: {}".format(e)) + return value diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/email.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/email.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,70 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a jabber client +# 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 . + +"""email sending facilities""" + +from __future__ import absolute_import +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.mail import smtp +from email.mime.text import MIMEText + + + +def sendEmail(host, to_emails, subject=u'', body=u'', from_email=None): + """send an email using SàT configuration + + @param to_emails(list[unicode], unicode): list of recipients + if unicode, it will be split to get emails + @param subject(unicode): subject of the message + @param body(unicode): body of the message + @param from_email(unicode): address of the sender + @return (D): same as smtp.sendmail + """ + if isinstance(to_emails, basestring): + to_emails = to_emails.split() + email_host = host.memory.getConfig(None, u'email_server') or u'localhost' + email_from = host.memory.getConfig(None, u'email_from') + if email_from is None: + # we suppose that email domain and XMPP domain are identical + domain = host.memory.getConfig(None, u'xmpp_domain', u'example.net') + email_from = u'no_reply@' + domain + email_sender_domain = host.memory.getConfig(None, u'email_sender_domain') + email_port = int(host.memory.getConfig(None, u'email_port', 25)) + email_username = host.memory.getConfig(None, u'email_username') + email_password = host.memory.getConfig(None, u'email_password') + email_auth = C.bool(host.memory.getConfig(None, 'email_auth', False)) + email_starttls = C.bool(host.memory.getConfig(None, 'email_starttls', False)) + + msg = MIMEText(body, 'plain', 'UTF-8') + msg[u'Subject'] = subject + msg[u'From'] = email_from + msg[u'To'] = u", ".join(to_emails) + + return smtp.sendmail(email_host.encode("utf-8"), + email_from.encode("utf-8"), + [email.encode("utf-8") for email in to_emails], + msg.as_string(), + senderDomainName = email_sender_domain.encode("utf-8") if email_sender_domain else None, + port = email_port, + username = email_username.encode("utf-8") if email_username else None, + password = email_password.encode("utf-8") if email_password else None, + requireAuthentication = email_auth, + requireTransportSecurity = email_starttls) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/sat_defer.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/sat_defer.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,242 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SàT: a XMPP client +# 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 . + +"""tools related to deferred""" + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from twisted.internet import defer +from twisted.internet import error as internet_error +from twisted.internet import reactor +from twisted.python import failure +from sat.core.constants import Const as C +from sat.memory import memory + +KEY_DEFERREDS = 'deferreds' +KEY_NEXT = 'next_defer' + + +class DelayedDeferred(object): + """A Deferred-like which is launched after a delay""" + + def __init__(self, delay, result): + """ + @param delay(float): delay before launching the callback, in seconds + @param result: result used with the callback + """ + self._deferred = defer.Deferred() + self._timer = reactor.callLater(delay, self._deferred.callback, result) + + def cancel(self): + try: + self._timer.cancel() + except internet_error.AlreadyCalled: + pass + self._deferred.cancel() + + def addCallbacks(self, *args, **kwargs): + self._deferred.addCallbacks(*args,**kwargs) + + def addCallback(self, *args, **kwargs): + self._deferred.addCallback(*args,**kwargs) + + def addErrback(self, *args, **kwargs): + self._deferred.addErrback(*args,**kwargs) + + def addBoth(self, *args, **kwargs): + self._deferred.addBoth(*args,**kwargs) + + def chainDeferred(self, *args, **kwargs): + self._deferred.chainDeferred(*args,**kwargs) + + def pause(self): + self._deferred.pause() + + def unpause(self): + self._deferred.unpause() + + +class RTDeferredSessions(memory.Sessions): + """Real Time Deferred Sessions""" + + + def __init__(self, timeout=120): + """Manage list of Deferreds in real-time, allowing to get intermediate results + + @param timeout (int): nb of seconds before deferreds cancellation + """ + super(RTDeferredSessions, self).__init__(timeout=timeout, resettable_timeout=False) + + def newSession(self, deferreds, profile): + """Launch a new session with a list of deferreds + + @param deferreds(list[defer.Deferred]): list of deferred to call + @param profile: %(doc_profile)s + @param return (tupe[str, defer.Deferred]): tuple with session id and a deferred wich fire *WITHOUT RESULT* when all results are received + """ + data = {KEY_NEXT: defer.Deferred()} + session_id, session_data = super(RTDeferredSessions, self).newSession(data, profile=profile) + if isinstance(deferreds, dict): + session_data[KEY_DEFERREDS] = deferreds.values() + iterator = deferreds.iteritems() + else: + session_data[KEY_DEFERREDS] = deferreds + iterator = enumerate(deferreds) + + for idx, d in iterator: + d._RTDeferred_index = idx + d._RTDeferred_return = None + d.addCallback(self._callback, d, session_id, profile) + d.addErrback(self._errback, d, session_id, profile) + return session_id + + def _purgeSession(self, session_id, reason=u"timeout", no_warning=False, got_result=False): + """Purge the session + + @param session_id(str): id of the session to purge + @param reason (unicode): human readable reason why the session is purged + @param no_warning(bool): if True, no warning will be put in logs + @param got_result(bool): True if the session is purged after normal ending (i.e.: all the results have been gotten). + reason and no_warning are ignored if got_result is True. + @raise KeyError: session doesn't exists (anymore ?) + """ + if not got_result: + try: + timer, session_data, profile = self._sessions[session_id] + except ValueError: + raise exceptions.InternalError(u'was expecting timer, session_data and profile; is profile set ?') + + # next_defer must be called before deferreds, + # else its callback will be called by _gotResult + next_defer = session_data[KEY_NEXT] + if not next_defer.called: + next_defer.errback(failure.Failure(defer.CancelledError(reason))) + + deferreds = session_data[KEY_DEFERREDS] + for d in deferreds: + d.cancel() + + if not no_warning: + log.warning(u"RTDeferredList cancelled: {} (profile {})".format(reason, profile)) + + super(RTDeferredSessions, self)._purgeSession(session_id) + + def _gotResult(self, session_id, profile): + """Method called after each callback or errback + + manage the next_defer deferred + """ + session_data = self.profileGet(session_id, profile) + defer_next = session_data[KEY_NEXT] + if not defer_next.called: + defer_next.callback(None) + + def _callback(self, result, deferred, session_id, profile): + deferred._RTDeferred_return = (True, result) + self._gotResult(session_id, profile) + + def _errback(self, failure, deferred, session_id, profile): + deferred._RTDeferred_return = (False, failure) + self._gotResult(session_id, profile) + + def cancel(self, session_id, reason=u"timeout", no_log=False): + """Stop this RTDeferredList + + Cancel all remaining deferred, and call self.final_defer.errback + @param reason (unicode): reason of the cancellation + @param no_log(bool): if True, don't log the cancellation + """ + self._purgeSession(session_id, reason=reason, no_warning=no_log) + + def getResults(self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE): + """Get current results of a real-time deferred session + + result already gotten are deleted + @param session_id(str): session id + @param on_success: can be: + - None: add success normaly to results + - callable: replace result by the return value of on_success(result) (may be deferred) + @param on_error: can be: + - None: add error normaly to results + - C.IGNORE: don't put errors in results + - callable: replace failure by the return value of on_error(failure) (may be deferred) + @param profile=%(doc_profile)s + @param result(tuple): tuple(remaining, results) where: + - remaining[int] is the number of remaining deferred + (deferreds from which we don't have result yet) + - results is a dict where: + - key is the index of the deferred if deferred is a list, or its key if it's a dict + - value = (success, result) where: + - success is True if the deferred was successful + - result is the result in case of success, else the failure + If remaining == 0, the session is ended + @raise KeyError: the session is already finished or doesn't exists at all + """ + if profile == C.PROF_KEY_NONE: + raise exceptions.ProfileNotSetError + session_data = self.profileGet(session_id, profile) + + @defer.inlineCallbacks + def next_cb(dummy): + # we got one or several results + results = {} + filtered_data = [] # used to keep deferreds without results + deferreds = session_data[KEY_DEFERREDS] + + for d in deferreds: + if d._RTDeferred_return: # we don't use d.called as called is True before the full callbacks chain has been called + # we have a result + idx = d._RTDeferred_index + success, result = d._RTDeferred_return + if success: + if on_success is not None: + if callable(on_success): + result = yield on_success(result) + else: + raise exceptions.InternalError('Unknown value of on_success: {}'.format(on_success)) + + else: + if on_error is not None: + if on_error == C.IGNORE: + continue + elif callable(on_error): + result = yield on_error(result) + else: + raise exceptions.InternalError('Unknown value of on_error: {}'.format(on_error)) + results[idx] = (success, result) + else: + filtered_data.append(d) + + # we change the deferred with the filtered list + # in other terms, we don't want anymore deferred from which we have got the result + session_data[KEY_DEFERREDS] = filtered_data + + if filtered_data: + # we create a new next_defer only if we are still waiting for results + session_data[KEY_NEXT] = defer.Deferred() + else: + # no more data to get, the result have been gotten, + # we can cleanly finish the session + self._purgeSession(session_id, got_result=True) + + defer.returnValue((len(filtered_data), results)) + + # we wait for a result + return session_data[KEY_NEXT].addCallback(next_cb) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/stream.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/stream.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,200 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" interfaces """ + +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.log import getLogger +from twisted.protocols import basic +from twisted.internet import interfaces +from zope import interface +import uuid +import os + +log = getLogger(__name__) + + +class IStreamProducer(interface.Interface): + + def startStream(consumer): + """start producing the stream + + @return (D): deferred fired when stream is finished + """ + + +class SatFile(object): + """A file-like object to have high level files manipulation""" + # TODO: manage "with" statement + + def __init__(self, host, client, path, mode='rb', uid=None, size=None, data_cb=None, auto_end_signals=True): + """ + @param host: %(doc_host)s + @param path(str): path of the file to get + @param mode(str): same as for built-in "open" function + @param uid(unicode, None): unique id identifing this progressing element + This uid will be used with self.host.progressGet + will be automaticaly generated if None + @param size(None, int): size of the file (when known in advance) + @param data_cb(None, callable): method to call on each data read/write + mainly useful to do things like calculating hash + @param auto_end_signals(bool): if True, progressFinished and progressError signals are automatically sent + if False, you'll have to call self.progressFinished and self.progressError yourself + progressStarted signal is always sent automatically + """ + self.host = host + self.profile = client.profile + self.uid = uid or unicode(uuid.uuid4()) + self._file = open(path, mode) + self.size = size + self.data_cb = data_cb + self.auto_end_signals = auto_end_signals + metadata = self.getProgressMetadata() + self.host.registerProgressCb(self.uid, self.getProgress, metadata, profile=client.profile) + self.host.bridge.progressStarted(self.uid, metadata, client.profile) + + def checkSize(self): + """Check that current size correspond to given size + + must be used when the transfer is supposed to be finished + @return (bool): True if the position is the same as given size + @raise exceptions.NotFound: size has not be specified + """ + position = self._file.tell() + if self.size is None: + raise exceptions.NotFound + return position == self.size + + def close(self, progress_metadata=None, error=None): + """Close the current file + + @param progress_metadata(None, dict): metadata to send with _onProgressFinished message + @param error(None, unicode): set to an error message if progress was not successful + mutually exclusive with progress_metadata + error can happen even if error is None, if current size differ from given size + """ + if self._file.closed: + return # avoid double close (which is allowed) error + if error is None: + try: + size_ok = self.checkSize() + except exceptions.NotFound: + size_ok = True + if not size_ok: + error = u'declared and actual size mismatch' + log.warning(error) + progress_metadata = None + + self._file.close() + + if self.auto_end_signals: + if error is None: + self.progressFinished(progress_metadata) + else: + assert progress_metadata is None + self.progressError(error) + + self.host.removeProgressCb(self.uid, self.profile) + + def progressFinished(self, metadata=None): + if metadata is None: + metadata = {} + self.host.bridge.progressFinished(self.uid, metadata, self.profile) + + def progressError(self, error): + self.host.bridge.progressError(self.uid, error, self.profile) + + def flush(self): + self._file.flush() + + def write(self, buf): + self._file.write(buf) + if self.data_cb is not None: + return self.data_cb(buf) + + def read(self, size=-1): + read = self._file.read(size) + if self.data_cb is not None and read: + self.data_cb(read) + return read + + def seek(self, offset, whence=os.SEEK_SET): + self._file.seek(offset, whence) + + def tell(self): + return self._file.tell() + + def mode(self): + return self._file.mode() + + def getProgressMetadata(self): + """Return progression metadata as given to progressStarted + + @return (dict): metadata (check bridge for documentation) + """ + metadata = {'type': C.META_TYPE_FILE} + + mode = self._file.mode + if '+' in mode: + pass # we have no direction in read/write modes + elif mode in ('r', 'rb'): + metadata['direction'] = 'out' + elif mode in ('w', 'wb'): + metadata['direction'] = 'in' + elif 'U' in mode: + metadata['direction'] = 'out' + else: + raise exceptions.InternalError + + metadata['name'] = self._file.name + + return metadata + + def getProgress(self, progress_id, profile): + ret = {'position': self._file.tell()} + if self.size: + ret['size'] = self.size + return ret + + +@interface.implementer(IStreamProducer) +@interface.implementer(interfaces.IConsumer) +class FileStreamObject(basic.FileSender): + + def __init__(self, host, client, path, **kwargs): + """ + + A SatFile will be created and put in self.file_obj + @param path(unicode): path to the file + @param **kwargs: kw arguments to pass to SatFile + """ + self.file_obj = SatFile(host, client, path, **kwargs) + + def registerProducer(self, producer, streaming): + pass + + def startStream(self, consumer): + return self.beginFileTransfer(self.file_obj, consumer) + + def write(self, data): + self.file_obj.write(data) + + def close(self, *args, **kwargs): + self.file_obj.close(*args, **kwargs) diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/trigger.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/trigger.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,119 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +"""Misc usefull classes""" + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) + + +class TriggerException(Exception): + pass + + +class SkipOtherTriggers(Exception): + """ Exception to raise if normal behaviour must be followed instead of following triggers list """ + pass + + +class TriggerManager(object): + """This class manage triggers: code which interact to change the behaviour of SàT""" + + try: # FIXME: to be removed when a better solution is found + MIN_PRIORITY = float('-inf') + MAX_PRIORITY = float('+inf') + except: # XXX: Pyjamas will bug if you specify ValueError here + # Pyjamas uses the JS Float class + MIN_PRIORITY = Number.NEGATIVE_INFINITY + MAX_PRIORITY = Number.POSITIVE_INFINITY + + def __init__(self): + self.__triggers = {} + + def add(self, point_name, callback, priority=0): + """Add a trigger to a point + + @param point_name: name of the point when the trigger should be run + @param callback: method to call at the trigger point + @param priority: callback will be called in priority order, biggest + first + """ + if point_name not in self.__triggers: + self.__triggers[point_name] = [] + if priority != 0 and priority in [trigger_tuple[0] for trigger_tuple in self.__triggers[point_name]]: + if priority in (self.MIN_PRIORITY, self.MAX_PRIORITY): + log.warning(_(u"There is already a bound priority [%s]") % point_name) + else: + log.debug(_(u"There is already a trigger with the same priority [%s]") % point_name) + self.__triggers[point_name].append((priority, callback)) + self.__triggers[point_name].sort(key=lambda trigger_tuple: + trigger_tuple[0], reverse=True) + + def remove(self, point_name, callback): + """Remove a trigger from a point + + @param point_name: name of the point when the trigger should be run + @param callback: method to remove, must exists in the trigger point + """ + for trigger_tuple in self.__triggers[point_name]: + if trigger_tuple[1] == callback: + self.__triggers[point_name].remove(trigger_tuple) + return + raise TriggerException("Trying to remove an unexisting trigger") + + def point(self, point_name, *args, **kwargs): + """This put a trigger point + + All the triggers for that point will be run + @param point_name: name of the trigger point + @return: True if the action must be continued, False else + """ + if point_name not in self.__triggers: + return True + + for priority, trigger in self.__triggers[point_name]: + try: + if not trigger(*args, **kwargs): + return False + except SkipOtherTriggers: + break + return True + + def returnPoint(self, point_name, *args, **kwargs): + """Like point but trigger must return (continue, return_value) + + All triggers for that point must return a tuple with 2 values: + - continue, same as for point, if False action must be finished + - return_value: value to return ONLY IF CONTINUE IS FALSE + @param point_name: name of the trigger point + @return: True if the action must be continued, False else + """ + + if point_name not in self.__triggers: + return True + + for priority, trigger in self.__triggers[point_name]: + try: + cont, ret_value = trigger(*args, **kwargs) + if not cont: + return False, ret_value + except SkipOtherTriggers: + break + return True, None diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/utils.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/utils.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,224 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +""" various useful methods """ + +import unicodedata +import os.path +from sat.core.log import getLogger +log = getLogger(__name__) +import datetime +from dateutil import parser as dateutil_parser +import calendar +import time +import sys +import random +import inspect +import textwrap +import functools + + +def clean_ustr(ustr): + """Clean unicode string + + remove special characters from unicode string + """ + def valid_chars(unicode_source): + for char in unicode_source: + if unicodedata.category(char) == 'Cc' and char!='\n': + continue + yield char + return ''.join(valid_chars(ustr)) + +def partial(func, *fixed_args, **fixed_kwargs): + # FIXME: temporary hack to workaround the fact that inspect.getargspec is not working with functools.partial + # making partial unusable with current D-bus module (in addMethod). + # Should not be needed anywore once moved to Python 3 + + ori_args = inspect.getargspec(func).args + func = functools.partial(func, *fixed_args, **fixed_kwargs) + if ori_args[0] == 'self': + del ori_args[0] + ori_args = ori_args[len(fixed_args):] + for kw in fixed_kwargs: + ori_args.remove(kw) + + exec(textwrap.dedent('''\ + def method({args}): + return func({kw_args}) + ''').format( + args = ', '.join(ori_args), + kw_args = ', '.join([a+'='+a for a in ori_args])) + , locals()) + + return method + +def xmpp_date(timestamp=None, with_time=True): + """Return date according to XEP-0082 specification + + to avoid reveling the timezone, we always return UTC dates + the string returned by this method is valid with RFC 3339 + @param timestamp(None, float): posix timestamp. If None current time will be used + @param with_time(bool): if True include the time + @return(unicode): XEP-0082 formatted date and time + """ + template_date = u"%Y-%m-%d" + template_time = u"%H:%M:%SZ" + template = u"{}T{}".format(template_date, template_time) if with_time else template_date + return datetime.datetime.utcfromtimestamp(time.time() if timestamp is None else timestamp).strftime(template) + +def date_parse(value): + """Parse a date and return corresponding unix timestamp + + @param value(unicode): date to parse, in any format supported by dateutil.parser + @return (int): timestamp + """ + return calendar.timegm(dateutil_parser.parse(unicode(value)).utctimetuple()) + +def generatePassword(vocabulary=None, size=20): + """Generate a password with random characters. + + @param vocabulary(iterable): characters to use to create password + @param size(int): number of characters in the password to generate + @return (unicode): generated password + """ + random.seed() + if vocabulary is None: + vocabulary = [chr(i) for i in range(0x30,0x3A) + range(0x41,0x5B) + range (0x61,0x7B)] + return u''.join([random.choice(vocabulary) for i in range(15)]) + +def getRepositoryData(module, as_string=True, is_path=False, save_dir_path=None): + """Retrieve info on current mecurial repository + + Data is gotten by using the following methods, in order: + - using "hg" executable + - looking for a ".hg_data" file in the root of the module + this file must contain the data dictionnary serialized with pickle + - looking for a .hg/dirstate in parent directory of module (or in module/.hg if + is_path is True), and parse dirstate file to get revision + @param module(unicode): module to look for (e.g. sat, libervia) + module can be a path if is_path is True (see below) + @param as_string(bool): if True return a string, else return a dictionary + @param is_path(bool): if True "module" is not handled as a module name, but as an + absolute path to the parent of a ".hg" directory + @param save_path(str, None): if not None, the value will be saved to given path as a pickled dict + /!\\ the .hg_data file in the given directory will be overwritten + @return (unicode, dictionary): retrieved info in a nice string, + or a dictionary with retrieved data (key is not present if data is not found), + key can be: + - node: full revision number (40 bits) + - branch: branch name + - date: ISO 8601 format date + - tag: latest tag used in hierarchie + """ + if sys.platform == "android": + # FIXME: workaround to avoid trouble on android, need to be fixed properly + return u"Cagou android build" + from distutils.spawn import find_executable + import subprocess + KEYS=("node", "node_short", "branch", "date", "tag") + ori_cwd = os.getcwd() + + if is_path: + repos_root = module + else: + repos_root = os.path.dirname(module.__file__) + + hg_path = find_executable('hg') + + if hg_path is not None: + os.chdir(repos_root) + try: + hg_data_raw = subprocess.check_output(["hg","log", "-r", "-1", "--template","{node}\n{node|short}\n{branch}\n{date|isodate}\n{latesttag}"]) + except subprocess.CalledProcessError: + hg_data = {} + else: + hg_data = dict(zip(KEYS, hg_data_raw.split('\n'))) + try: + hg_data['modified'] = '+' in subprocess.check_output(["hg","id","-i"]) + except subprocess.CalledProcessError: + pass + else: + hg_data = {} + + if not hg_data: + # .hg_data pickle method + log.debug(u"Mercurial not available or working, trying other methods") + if save_dir_path is None: + log.debug(u"trying .hg_data method") + + try: + with open(os.path.join(repos_root, '.hg_data')) as f: + import cPickle as pickle + hg_data = pickle.load(f) + except IOError as e: + log.debug(u"Can't access .hg_data file: {}".format(e)) + except pickle.UnpicklingError: + log.warning(u"Error while reading {}, can't get repos data".format(f.name)) + + if not hg_data: + # .hg/dirstate method + log.debug(u"trying dirstate method") + if is_path: + os.chdir(repos_root) + else: + os.chdir(os.path.relpath('..', repos_root)) + try: + with open('.hg/dirstate') as hg_dirstate: + hg_data['node'] = hg_dirstate.read(20).encode('hex') + hg_data['node_short'] = hg_data['node'][:12] + except IOError: + log.warning(u"Can't access repository data") + + # we restore original working dir + os.chdir(ori_cwd) + + # data saving + if save_dir_path is not None and hg_data: + if not os.path.isdir(save_dir_path): + log.warning(u"Given path is not a directory, can't save data") + else: + import cPickle as pickle + dest_path = os.path.join(save_dir_path, ".hg_data") + try: + with open(dest_path, 'w') as f: + pickle.dump(hg_data, f, 2) + except IOError as e: + log.warning(u"Can't save file to {path}: {reason}".format( + path=dest_path, reason=e)) + else: + log.debug(u"repository data saved to {}".format(dest_path)) + + if as_string: + if not hg_data: + return u'repository data unknown' + strings = [u'rev', hg_data['node_short']] + try: + if hg_data['modified']: + strings.append(u"[M]") + except KeyError: + pass + try: + strings.extend([u'({branch} {date})'.format(**hg_data)]) + except KeyError: + pass + + return u' '.join(strings) + else: + return hg_data diff -r bd30dc3ffe5a -r 26edcf3a30eb sat/tools/xml_tools.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/xml_tools.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1521 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT: a jabber client +# 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 . + +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) + +from xml.dom import minidom, NotFoundErr +from wokkel import data_form +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from sat.core import exceptions +from collections import OrderedDict +from copy import deepcopy +import htmlentitydefs +import re + +"""This library help manage XML used in SàT (parameters, registration, etc)""" + +SAT_FORM_PREFIX = "SAT_FORM_" +SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names +html_entity_re = re.compile(r'&([a-zA-Z]+?);') +XML_ENTITIES = ('quot', 'amp', 'apos', 'lt', 'gt') + +# TODO: move XMLUI stuff in a separate module +# TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation + +# Helper functions + +def _dataFormField2XMLUIData(field, read_only=False): + """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field. + + The attribute field can be modified (if it's fixed and it has no value). + @param field (data_form.Field): a field with attributes "value", "fieldType", "label" and "var" + @param read_only (bool): if True and it makes sense, create a read only input widget + @return: a tuple (widget_type, widget_args, widget_kwargs) + """ + widget_args = [field.value] + widget_kwargs = {} + if field.fieldType == 'fixed' or field.fieldType is None: + widget_type = 'text' + if field.value is None: + if field.label is None: + log.warning(_("Fixed field has neither value nor label, ignoring it")) + field.value = "" + else: + field.value = field.label + field.label = None + widget_args[0] = field.value + elif field.fieldType == 'text-single': + widget_type = "string" + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'jid-single': + widget_type = "jid_input" + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'text-multi': + widget_type = "textbox" + widget_args[0] = u'\n'.join(field.values) + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'text-private': + widget_type = "password" + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'boolean': + widget_type = "bool" + if widget_args[0] is None: + widget_args[0] = 'false' + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'integer': + widget_type = "integer" + widget_kwargs['read_only'] = read_only + elif field.fieldType == 'list-single': + widget_type = "list" + widget_kwargs["options"] = [(option.value, option.label or option.value) for option in field.options] + widget_kwargs["selected"] = widget_args + widget_args = [] + else: + log.error(u"FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType) + widget_type = "string" + widget_kwargs['read_only'] = read_only + + if field.var: + widget_kwargs["name"] = field.var + + return widget_type, widget_args, widget_kwargs + + +def dataForm2Widgets(form_ui, form, read_only=False, prepend=None, filters=None): + """Complete an existing XMLUI with widget converted from XEP-0004 data forms. + + @param form_ui (XMLUI): XMLUI instance + @param form (data_form.Form): Wokkel's implementation of data form + @param read_only (bool): if True and it makes sense, create a read only input widget + @param prepend(iterable, None): widgets to prepend to main LabelContainer + if not None, must be an iterable of *args for addWidget. Those widgets will + be added first to the container. + @param filters(dict, None): if not None, a dictionary of callable: + key is the name of the widget to filter + the value is a callable, it will get form's XMLUI, widget's type, args and kwargs + and must return widget's type, args and kwargs (which can be modified) + This is especially useful to modify well known fields + @return: the completed XMLUI instance + """ + if filters is None: + filters = {} + if form.instructions: + form_ui.addText('\n'.join(form.instructions), 'instructions') + + form_ui.changeContainer("label") + + if prepend is not None: + for widget_args in prepend: + form_ui.addWidget(*widget_args) + + for field in form.fieldList: + widget_type, widget_args, widget_kwargs = _dataFormField2XMLUIData(field, read_only) + try: + widget_filter = filters[widget_kwargs['name']] + except KeyError: + pass + else: + widget_type, widget_args, widget_kwargs = widget_filter(form_ui, widget_type, widget_args, widget_kwargs) + label = field.label or field.var + if label: + form_ui.addLabel(label) + else: + form_ui.addEmpty() + + form_ui.addWidget(widget_type, *widget_args, **widget_kwargs) + + return form_ui + + +def dataForm2XMLUI(form, submit_id, session_id=None, read_only=False): + """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI. + + @param form (data_form.Form): a Form instance + @param submit_id (unicode): callback id to call when submitting form + @param session_id (unicode): session id to return with the data + @param read_only (bool): if True and it makes sense, create a read only input widget + @return: XMLUI instance + """ + form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id) + return dataForm2Widgets(form_ui, form, read_only=read_only) + + +def dataFormEltResult2XMLUIData(form_xml): + """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation). + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param form_xml (domish.Element): element of the data form + @return: a couple (headers, result_list): + - headers (dict{unicode: unicode}): form headers (field labels and types) + - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) + """ + headers = OrderedDict() + try: + reported_elt = form_xml.elements('jabber:x:data', 'reported').next() + except StopIteration: + raise exceptions.DataError("Couldn't find expected tag in %s" % form_xml.toXml()) + + for elt in reported_elt.elements(): + if elt.name != "field": + raise exceptions.DataError("Unexpected tag") + name = elt["var"] + label = elt.attributes.get('label', '') + type_ = elt.attributes.get('type') + headers[name] = (label, type_) + + if not headers: + raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)") + + xmlui_data = [] + item_elts = form_xml.elements('jabber:x:data', 'item') + + for item_elt in item_elts: + for elt in item_elt.elements(): + if elt.name != 'field': + log.warning(u"Unexpected tag (%s)" % elt.name) + continue + field = data_form.Field.fromElement(elt) + + xmlui_data.append(_dataFormField2XMLUIData(field)) + + return headers, xmlui_data + + +def XMLUIData2AdvancedList(xmlui, headers, xmlui_data): + """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added + @param headers (dict{unicode: unicode}): form headers (field labels and types) + @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) + @return: the completed XMLUI instance + """ + adv_list = AdvancedListContainer(xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container) + xmlui.changeContainer(adv_list) + + for widget_type, widget_args, widget_kwargs in xmlui_data: + xmlui.addWidget(widget_type, *widget_args, **widget_kwargs) + + return xmlui + + +def dataFormResult2AdvancedList(xmlui, form_xml): + """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added + @param form_xml (domish.Element): element of the data form + @return: the completed XMLUI instance + """ + headers, xmlui_data = dataFormEltResult2XMLUIData(form_xml) + XMLUIData2AdvancedList(xmlui, headers, xmlui_data) + + +def dataFormEltResult2XMLUI(form_elt, session_id=None): + """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI. + + The raw data form is used because Wokkel doesn't manage result items parsing yet. + @param form_elt (domish.Element): element of the data form + @param session_id (unicode): session id to return with the data + @return: XMLUI instance + """ + xml_ui = XMLUI("window", "vertical", session_id=session_id) + try: + dataFormResult2AdvancedList(xml_ui, form_elt) + except exceptions.DataError: + parsed_form = data_form.Form.fromElement(form_elt) + dataForm2Widgets(xml_ui, parsed_form, read_only=True) + return xml_ui + +def dataFormResult2XMLUI(result_form, base_form, session_id=None, prepend=None, filters=None): + """Convert data form result to SàT XMLUI. + + @param result_form (data_form.Form): result form to convert + @param base_form (data_form.Form): initial form (i.e. of form type "form") + this one is necessary to reconstruct options when needed (e.g. list elements) + @param session_id (unicode): session id to return with the data + @param prepend: same as for [dataForm2Widgets] + @param filters: same as for [dataForm2Widgets] + @return: XMLUI instance + """ + form = deepcopy(result_form) + for name, field in form.fields.iteritems(): + try: + base_field = base_form.fields[name] + except KeyError: + continue + field.options = base_field.options[:] + xml_ui = XMLUI("window", "vertical", session_id=session_id) + dataForm2Widgets(xml_ui, form, read_only=True, prepend=prepend, filters=filters) + return xml_ui + + +def _cleanValue(value): + """Workaround method to avoid DBus types with D-Bus bridge. + + @param value: value to clean + @return: value in a non DBus type (only clean string yet) + """ + # XXX: must be removed when DBus types will no cause problems anymore + # FIXME: should be cleaned inside D-Bus bridge itself + if isinstance(value, basestring): + return unicode(value) + return value + + +def XMLUIResult2DataFormResult(xmlui_data): + """ Extract form data from a XMLUI return. + + @param xmlui_data (dict): data returned by frontends for XMLUI form + @return: dict of data usable by Wokkel's data form + """ + return {key[len(SAT_FORM_PREFIX):]: _cleanValue(value) for key, value in xmlui_data.iteritems() if key.startswith(SAT_FORM_PREFIX)} + + +def formEscape(name): + """Return escaped name for forms. + + @param name (unicode): form name + @return: unicode + """ + return u"%s%s" % (SAT_FORM_PREFIX, name) + + +def XMLUIResultToElt(xmlui_data): + """Construct result domish.Element from XMLUI result. + + @param xmlui_data (dict): data returned by frontends for XMLUI form + @return: domish.Element + """ + form = data_form.Form('submit') + form.makeFields(XMLUIResult2DataFormResult(xmlui_data)) + return form.toElement() + + +def tupleList2dataForm(values): + """Convert a list of tuples (name, value) to a wokkel submit data form. + + @param values (list): list of tuples + @return: data_form.Form + """ + form = data_form.Form('submit') + for value in values: + field = data_form.Field(var=value[0], value=value[1]) + form.addField(field) + + return form + + +def paramsXML2XMLUI(xml): + """Convert the XML for parameter to a SàT XML User Interface. + + @param xml (unicode) + @return: XMLUI + """ + # TODO: refactor params and use Twisted directly to parse XML + params_doc = minidom.parseString(xml.encode('utf-8')) + top = params_doc.documentElement + if top.nodeName != 'params': + raise exceptions.DataError(_('INTERNAL ERROR: parameters xml not valid')) + + param_ui = XMLUI("param", "tabs") + tabs_cont = param_ui.current_container + + for category in top.getElementsByTagName("category"): + category_name = category.getAttribute('name') + label = category.getAttribute('label') + if not category_name: + raise exceptions.DataError(_('INTERNAL ERROR: params categories must have a name')) + tabs_cont.addTab(category_name, label=label, container=LabelContainer) + for param in category.getElementsByTagName("param"): + widget_kwargs = {} + + param_name = param.getAttribute('name') + param_label = param.getAttribute('label') + type_ = param.getAttribute('type') + if not param_name and type_ != 'text': + raise exceptions.DataError(_('INTERNAL ERROR: params must have a name')) + + value = param.getAttribute('value') or None + callback_id = param.getAttribute('callback_id') or None + + if type_ == 'list': + options, selected = _paramsGetListOptions(param) + widget_kwargs['options'] = options + widget_kwargs['selected'] = selected + widget_kwargs['styles'] = ['extensible'] + elif type_ == 'jids_list': + widget_kwargs['jids'] = _paramsGetListJids(param) + + if type_ in ("button", "text"): + param_ui.addEmpty() + value = param_label + else: + param_ui.addLabel(param_label or param_name) + + if value: + widget_kwargs["value"] = value + + if callback_id: + widget_kwargs['callback_id'] = callback_id + others = ["%s%s%s" % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute('name')) + for other in category.getElementsByTagName('param') + if other.getAttribute('type') != 'button'] + widget_kwargs['fields_back'] = others + + widget_kwargs['name'] = "%s%s%s" % (category_name, SAT_PARAM_SEPARATOR, param_name) + + param_ui.addWidget(type_, **widget_kwargs) + + return param_ui.toXml() + + +def _paramsGetListOptions(param): + """Retrieve the options for list element. + + The