view sat.tac @ 42:874de3020e1c

Initial VCard (XEP-0054) support + misc fixes - new xep-0054 plugin, avatar are cached, new getProfile bridge method - gateways plugin (XEP-0100): new __private__ key in resulting data, used to keep target jid
author Goffi <goffi@goffi.org>
date Mon, 21 Dec 2009 13:22:11 +1100
parents d24629c631fc
children daa1f01a5332
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
SAT: a jabber client
Copyright (C) 2009  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 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 General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""

CONST = {
    'client_name' : u'SàT (Salut à toi)',
    'client_version' : u'0.0.1D',   #Please add 'D' at the end for dev versions
    'local_dir' : '~/.sat'
}

from twisted.application import internet, service
from twisted.internet import glib2reactor, protocol, task
glib2reactor.install()

from twisted.words.protocols.jabber import jid, xmlstream
from twisted.words.protocols.jabber import error as jab_error
from twisted.words.xish import domish

from twisted.internet import reactor
import pdb

from wokkel import client, disco, xmppim, generic, compat

from sat_bridge.DBus import DBusBridge
import logging
from logging import debug, info, error

import signal, sys
import os.path

from tools.memory import Memory
from tools.xml_tools import XMLTools 
from glob import glob

try:
    from twisted.words.protocols.xmlstream import XMPPHandler
except ImportError:
    from wokkel.subprotocols import XMPPHandler


### logging configuration FIXME: put this elsewhere ###
logging.basicConfig(level=logging.DEBUG,
                    format='%(message)s')
###


sat_id = 0

def sat_next_id():
    global sat_id
    sat_id+=1
    return "sat_id_"+str(sat_id)

class SatXMPPClient(client.XMPPClient):
    
    def __init__(self, jid, password, host=None, port=5222):
        client.XMPPClient.__init__(self, jid, password, host, port)
        self.factory.clientConnectionLost = self.connectionLost
        self.__connected=False

    def _authd(self, xmlstream):
        print "SatXMPPClient"
        client.XMPPClient._authd(self, xmlstream)
        self.__connected=True
        print "********** CONNECTED **********"
        self.streamInitialized()

    def streamInitialized(self):
        """Called after _authd"""
        self.keep_alife = task.LoopingCall(self.xmlstream.send, " ")  #Needed to avoid disconnection (specially with openfire)
        self.keep_alife.start(180)

    def isConnected(self):
        return self.__connected
    
    def connectionLost(self, connector, unused_reason):
        self.__connected=False
        print "********** DISCONNECTED **********"
        try:
            self.keep_alife.stop()
        except AttributeError:
            debug("No keep_alife")


class SatMessageProtocol(xmppim.MessageProtocol):
    
    def __init__(self, host):
        xmppim.MessageProtocol.__init__(self)
        self.host = host

    def onMessage(self, message):
      debug (u"got_message from: %s", message["from"])
      for e in message.elements():
        if e.name == "body":
          self.host.bridge.newMessage(message["from"], e.children[0])
          self.host.memory.addToHistory(self.host.me, jid.JID(message["from"]), self.host.me, "chat", e.children[0])
          break
    
class SatRosterProtocol(xmppim.RosterClientProtocol):

    def __init__(self, host):
        xmppim.RosterClientProtocol.__init__(self)
        self.host = host
    
    def rosterCb(self, roster):
        for jid, item in roster.iteritems():
            info ("new contact in roster list: %s", jid)
            #FIXME: fill attributes
            self.host.memory.addContact(jid, {}, item.groups)
            self.host.bridge.newContact(jid, {}, item.groups)

    def requestRoster(self):
        """ ask the server for Roster list """
        debug("requestRoster")
        self.getRoster().addCallback(self.rosterCb)

    def removeItem(self, to):
        """Remove a contact from roster list"""
        to_jid=jid.JID(to)
        xmppim.RosterClientProtocol.removeItem(self, to_jid)
        #TODO: check IQ result
    
    def addItem(self, to):
        """Add a contact to roster list"""
        to_jid=jid.JID(to)
        xmppim.RosterClientProtocol.addItem(self, to_jid)
        #TODO: check IQ result

class SatPresenceProtocol(xmppim.PresenceClientProtocol):

    def __init__(self, host):
        xmppim.PresenceClientProtocol.__init__(self)
        self.host = host
    
    def availableReceived(self, entity, show=None, statuses=None, priority=0):
        info ("presence update for [%s]", entity)

        ### we check if the status is not about subscription ###
        #FIXME: type is not needed anymore
        #TODO: management of differents statuses (differents languages)
        status = statuses.values()[0] if len(statuses) else ""
        self.host.memory.addPresenceStatus(entity.full(), "", show or "",
                status or "", int(priority))

        #now it's time to notify frontends
        self.host.bridge.presenceUpdate(entity.full(), "", show or "",
                status or "", int(priority))
    
    def unavailableReceived(self, entity, statuses=None):
        #TODO: management of differents statuses (differents languages)
        status = statuses.values()[0] if len(statuses) else ""
        self.host.memory.addPresenceStatus(entity.full(), "unavailable", "",
                status or "", 0)

        #now it's time to notify frontends
        self.host.bridge.presenceUpdate(entity.full(), "unavailable", "",
                status or "", 0)
        

    def subscribedReceived(self, entity):
        debug ("subscription approved for [%s]" % entity)

    def unsubscribedReceived(self, entity):
        debug ("unsubscription confirmed for [%s]" % entity)

    def subscribeReceived(self, entity):
        #FIXME: auto answer for subscribe request, must be checked !
        debug ("subscription request for [%s]" % entity)
        self.subscribed(entity)

    def unsubscribeReceived(self, entity):
        debug ("unsubscription asked for [%s]" % entity)

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):
        #pdb.set_trace()
        print "iqFallback: xml = [%s], handled=%s" % (iq.toXml(), "True" if iq.handled else "False")
        generic.FallbackHandler.iqFallback(self, iq)

class RegisteringAuthenticator(xmlstream.ConnectAuthenticator):

    def __init__(self, host, jabber_host, user_login, user_pass, answer_id):
        xmlstream.ConnectAuthenticator.__init__(self, jabber_host)
        self.host = host
        self.jabber_host = jabber_host
        self.user_login = user_login
        self.user_pass = user_pass
        self.answer_id = answer_id
        print "Registration asked for",user_login, user_pass, jabber_host
    
    def connectionMade(self):
        print "connectionMade"
        
        self.xmlstream.namespace = "jabber:client"
        self.xmlstream.sendHeader()

        iq = compat.IQ(self.xmlstream, 'set')
        iq["to"] = self.jabber_host
        query = iq.addElement(('jabber:iq:register', 'query'))
        _user = query.addElement('username')
        _user.addContent(self.user_login)
        _pass = query.addElement('password')
        _pass.addContent(self.user_pass)
        reg = iq.send(self.jabber_host).addCallbacks(self.registrationAnswer, self.registrationFailure)

    def registrationAnswer(self, answer):
        debug ("registration answer: %s" % answer.toXml())
        answer_type = "SUCCESS"
        answer_data={"message":"Registration successfull"}
        self.host.bridge.actionResult(answer_type, self.answer_id, answer_data)
        self.xmlstream.sendFooter()
        
    def registrationFailure(self, failure):
        info ("Registration failure: %s" % str(failure.value))
        answer_type = "ERROR"
        answer_data = {}
        if failure.value.condition == 'conflict':
            answer_data['reason'] = 'conflict'
            answer_data={"message":"Username already exists, please choose an other one"}
        else:
            answer_data['reason'] = 'unknown'
            answer_data={"message":"Registration failed"}
        self.host.bridge.actionResult(answer_type, self.answer_id, answer_data)
        self.xmlstream.sendFooter()
        

class SAT(service.Service):
   
    def get_next_id(self):
        return sat_next_id()

    def get_const(self, name):
        """Return a constant"""
        if not CONST.has_key(name):
            error('Trying to access an undefined constant')
            raise Exception
        return CONST[name]

    def set_const(self, name, value):
        """Save a constant"""
        if CONST.has_key(name):
            error('Trying to redefine a constant')
            raise Exception  
        CONST[name] = value
    
    def __init__(self):
        #TODO: standardize callback system
        
        local_dir = os.path.expanduser(self.get_const('local_dir'))
        if not os.path.exists(local_dir):
            os.makedirs(local_dir)

        self.__waiting_conf = {}  #callback called when a confirmation is received
        self.__progress_cb_map = {}  #callback called when a progress is requested (key = progress id)
        self.__general_cb_map = {}  #callback called for general reasons (key = name) 
        self.__private_data = {}  #used for internal callbacks (key = id)
        self.plugins = {}
        
        self.memory=Memory(self)
        self.server_features=[]  #XXX: temp dic, need to be transfered into self.memory in the future

        self.bridge=DBusBridge()
        self.bridge.register("registerNewAccount", self.registerNewAccount)
        self.bridge.register("connect", self.connect)
        self.bridge.register("disconnect", self.disconnect)
        self.bridge.register("getContacts", self.memory.getContacts)
        self.bridge.register("getPresenceStatus", self.memory.getPresenceStatus)
        self.bridge.register("sendMessage", self.sendMessage)
        self.bridge.register("setParam", self.setParam)
        self.bridge.register("getParamA", self.memory.getParamA)
        self.bridge.register("getParams", self.memory.getParams)
        self.bridge.register("getParamsForCategory", self.memory.getParamsForCategory)
        self.bridge.register("getParamsCategories", self.memory.getParamsCategories)
        self.bridge.register("getHistory", self.memory.getHistory)
        self.bridge.register("setPresence", self.setPresence)
        self.bridge.register("addContact", self.addContact)
        self.bridge.register("delContact", self.delContact)
        self.bridge.register("isConnected", self.isConnected)
        self.bridge.register("launchAction", self.launchAction)
        self.bridge.register("confirmationAnswer", self.confirmationAnswer)
        self.bridge.register("getProgress", self.getProgress)

        self._import_plugins()


    def _import_plugins(self):
        """Import all plugins found in plugins directory"""
        #TODO: manage dependencies
        plug_lst = [os.path.splitext(plugin)[0] for plugin in map(os.path.basename,glob ("plugins/plugin*.py"))]
        
        for plug in plug_lst:
            plug_path = 'plugins.'+plug
            __import__(plug_path)
            mod = sys.modules[plug_path]
            plug_info = mod.PLUGIN_INFO
            info ("importing plugin: %s", plug_info['name'])
            self.plugins[plug_info['import_name']] = getattr(mod, plug_info['main'])(self)
            #TODO: test xmppclient presence and register handler parent

    def connect(self):
        """Connect to jabber server"""

        if (self.isConnected()):
            info("already connected !")
            return
        print "connecting..."
        self.me = jid.JID(self.memory.getParamA("JabberID", "Connection"))
        self.xmppclient = SatXMPPClient(self.me, self.memory.getParamA("Password", "Connection"),
            self.memory.getParamA("Server", "Connection"), 5222)
        self.xmppclient.streamInitialized = self.streamInitialized

        self.messageProt = SatMessageProtocol(self)
        self.messageProt.setHandlerParent(self.xmppclient)
        
        self.roster = SatRosterProtocol(self)
        self.roster.setHandlerParent(self.xmppclient)

        self.presence = SatPresenceProtocol(self)
        self.presence.setHandlerParent(self.xmppclient)

        self.fallBack = SatFallbackHandler(self)
        self.fallBack.setHandlerParent(self.xmppclient)

        self.versionHandler = generic.VersionHandler(self.get_const('client_name'),
                                                     self.get_const('client_version'))
        self.versionHandler.setHandlerParent(self.xmppclient)

        debug ("setting plugins parents")
        for plugin in self.plugins.iteritems():
            if isinstance(plugin[1], XMPPHandler):
                plugin[1].setHandlerParent(self.xmppclient)

        self.xmppclient.startService()
    
    def disconnect(self):
        """disconnect from jabber server"""
        if (not self.isConnected()):
            info("not connected !")
            return
        info("Disconnecting...")
        self.xmppclient.stopService()

    def startService(self):
        info("Salut à toi ô mon frère !")
        self.connect()
    
    def stopService(self):
        self.memory.save()
        info("Salut aussi à Rantanplan")

    def run(self):
        debug("running app")
        reactor.run()
    
    def stop(self):
        debug("stopping app")
        reactor.stop()
        
    def streamInitialized(self):
        """Called when xmlstream is OK"""
        SatXMPPClient.streamInitialized(self.xmppclient)
        debug ("XML stream is initialized")
        self.xmlstream = self.xmppclient.xmlstream
        self.me = self.xmppclient.jid #in case of the ressource has changed
        
        self.disco = SatDiscoProtocol(self)
        self.disco.setHandlerParent(self.xmppclient)
        self.discoHandler = disco.DiscoHandler()
        self.discoHandler.setHandlerParent(self.xmppclient)

        self.roster.requestRoster()
        
        self.presence.available()
        
        self.disco.requestInfo(jid.JID(self.memory.getParamA("Server", "Connection"))).addCallback(self.serverDisco)
 
    ## Misc methods ##
   
    def registerNewAccount(self, login, password, server, port = 5222, id = None):
        """Connect to a server and create a new account using in-band registration"""

        next_id = id or sat_next_id()  #the id is used to send server's answer
        serverRegistrer = xmlstream.XmlStreamFactory(RegisteringAuthenticator(self, server, login, password, next_id))
        connector = reactor.connectTCP(server, port, serverRegistrer)
        serverRegistrer.clientConnectionLost = lambda conn, reason: connector.disconnect()
        
        return next_id 

    def registerNewAccountCB(self, id, data):
        user = jid.parse(self.memory.getParamA("JabberID", "Connection"))[0]
        password = self.memory.getParamA("Password", "Connection")
        server = self.memory.getParamA("Server", "Connection")

        if not user or not password or not server:
            info ('No user or server given')
            #TODO: a proper error message must be sent to frontend
            self.actionResult(id, "ERROR", {'message':"No user, password or server given, can't register new account."})
            return

        confirm_id = sat_next_id()
        self.__private_data[confirm_id]=id
    
        self.askConfirmation(confirm_id, "YES/NO",
            {"message":"Are you sure to register new account [%s] to server %s ?" % (user, server)},
            self.regisConfirmCB)
        print ("===============+++++++++++ REGISTER NEW ACCOUNT++++++++++++++============")
        print "id=",id
        print "data=",data

    def regisConfirmCB(self, id, accepted, data):
        print "register Confirmation CB ! (%s)" % str(accepted)
        action_id = self.__private_data[id]
        del self.__private_data[id]
        user = jid.parse(self.memory.getParamA("JabberID", "Connection"))[0]
        password = self.memory.getParamA("Password", "Connection")
        server = self.memory.getParamA("Server", "Connection")
        if accepted:
            self.registerNewAccount(user, password, server, id=action_id)
        else:
            self.actionResult(action_id, "SUPPRESS", {})

    def submitForm(self, action, target, fields):
        """submit a form
        @param target: target jid where we are submitting
        @param fields: list of tuples (name, value)
        @return: tuple: (id, deferred)
        """
        to_jid = jid.JID(target)
        
        iq = compat.IQ(self.xmlstream, 'set')
        iq["to"] = target
        iq["from"] = self.me.full()
        query = iq.addElement(('jabber:iq:register', 'query'))
        if action=='SUBMIT':
            form = XMLTools.tupleList2dataForm(fields)
            query.addChild(form.toElement())
        elif action=='CANCEL':
            query.addElement('remove')
        else:
            error ("FIXME FIXME FIXME: Unmanaged action (%s) in submitForm" % action)
            raise NotImplementedError

        deferred = iq.send(target)
        return (iq['id'], deferred)

    ## Client management ##

    def setParam(self, name, value, category):
        """set wanted paramater and notice observers"""
        info ("setting param: %s=%s in category %s", name, value, category)
        self.memory.setParam(name, value, category)

    def failed(self,xmlstream):
        debug("failed: %s", xmlstream.getErrorMessage())
        debug("failed: %s", dir(xmlstream))

    def isConnected(self):
        try:
            if self.xmppclient.isConnected():
                return True
        except AttributeError:
            #xmppclient not available
            pass
        return False

    def launchAction(self, type, data):
        """Launch a specific action asked by client
        @param type: action type (button)
        @param data: needed data to launch the action

        @return: action id for result, or empty string in case or error
        """
        if type=="button":
            try:
                cb_name = self.memory.getParamA(data["name"], data["category"], "callback")
            except KeyError:
                error ("Incomplete data")
                return ""
            id = sat_next_id()
            self.callGeneralCB(cb_name, id, data)
            return id
        else:
            error ("Unknown action type")
            return ""


    ## jabber methods ##
    
    def sendMessage(self,to,msg,type='chat'):
        #FIXME: check validity of recipient
        debug("Sending jabber message to %s...", to)
        message = domish.Element(('jabber:client','message'))
        message["to"] = jid.JID(to).full()
        message["from"] = self.me.full()
        message["type"] = type
        message.addElement("body", "jabber:client", msg)
        self.xmlstream.send(message)
        self.memory.addToHistory(self.me, self.me, jid.JID(to), message["type"], unicode(msg))
        self.bridge.newMessage(message['from'], unicode(msg), to=message['to']) #We send back the message, so all clients are aware of it


    def setPresence(self, to="", type="", show="", status="", priority=0):
        """Send our presence information"""
        if not type in ["", "unavailable", "subscribed", "subscribe",
                        "unsubscribe", "unsubscribed", "prob", "error"]:
            error("Type error !")
            #TODO: throw an error
            return
        to_jid=jid.JID(to)
        status = None if not status else {None:status}  #FIXME: use the proper way here (change signature to use dict)
        #TODO: refactor subscription bridge API
        if type=="":
            self.presence.available(to_jid, show, status, priority)
        elif type=="subscribe":
            self.presence.subscribe(to_jid)
        elif type=="subscribed":
            self.presence.subscribed(to_jid)
        elif type=="unsubscribe":
            self.presence.unsubscribe(to_jid)
        elif type=="unsubscribed":
            self.presence.unsubscribed(to_jid)


    def addContact(self, to):
        """Add a contact in roster list"""
        to_jid=jid.JID(to)
        self.roster.addItem(to_jid.userhost())
        self.setPresence(to_jid.userhost(), "subscribe")

    def delContact(self, to):
        """Remove contact from roster list"""
        to_jid=jid.JID(to)
        self.roster.removeItem(to_jid.userhost())
        self.bridge.contactDeleted(to)


    ## callbacks ##

    def serverDisco(self, disco):
        """xep-0030 Discovery Protocol."""
        for feature in disco.features:
            debug ("Feature found: %s",feature)
            self.server_features.append(feature)
        for cat, type in disco.identities:
            debug ("Identity found: [%s/%s] %s" % (cat, type, disco.identities[(cat,type)]))

    
    ## Generic HMI ## 
    
    def actionResult(self, id, type, data):
        """Send the result of an action
        @param id: same id used with action
        @param type: result type ("PARAM", "SUCCESS", "ERROR", "FORM")
        @param data: dictionary
        """
        self.bridge.actionResult(type, id, data)

    def actionResultExt(self, id, type, data):
        """Send the result of an action, extended version
        @param id: same id used with action
        @param type: result type /!\ only "DICT_DICT" for this method
        @param data: dictionary of dictionaries
        """
        if type != "DICT_DICT":
            error("type for actionResultExt must be DICT_DICT, fixing it")
            type = "DICT_DICT"
        self.bridge.actionResultExt(type, id, data)



    def askConfirmation(self, id, type, data, cb):
        """Add a confirmation callback
        @param id: id used to get answer
        @param type: confirmation type ("YES/NO", "FILE_TRANSFERT")
        @param data: data (depend of confirmation type)
        @param cb: callback called with the answer
        """
        if self.__waiting_conf.has_key(id):
            error ("Attempt to register two callbacks for the same confirmation")
        else:
            self.__waiting_conf[id] = cb
            self.bridge.askConfirmation(type, id, data)


    def confirmationAnswer(self, id, accepted, data):
        """Called by frontends to answer confirmation requests"""
        debug ("Received confirmation answer for id [%s]: %s", id, "accepted" if accepted else "refused")
        if not self.__waiting_conf.has_key(id):
            error ("Received an unknown confirmation")
        else:
            cb = self.__waiting_conf[id]
            del self.__waiting_conf[id]
            cb(id, accepted, data)

    def registerProgressCB(self, id, CB):
        """Register a callback called when progress is requested for id"""
        self.__progress_cb_map[id] = CB

    def removeProgressCB(self, id):
        """Remove a progress callback"""
        if not self.__progress_cb_map.has_key(id):
            error ("Trying to remove an unknow progress callback")
        else:
            del self.__progress_cb_map[id]

    def getProgress(self, id):
        """Return a dict with progress information
        data['position'] : current possition
        data['size'] : end_position
        """
        data = {}
        try:
            self.__progress_cb_map[id](data)
        except KeyError:
            pass
            #debug("Requested progress for unknown id")
        return data

    def registerGeneralCB(self, name, CB):
        """Register a callback called for general reason"""
        self.__general_cb_map[name] = CB

    def removeGeneralCB(self, name):
        """Remove a general callback"""
        if not self.__general_cb_map.has_key(name):
            error ("Trying to remove an unknow general callback")
        else:
            del self.__general_cb_map[name]

    def callGeneralCB(self, name, *args, **kwargs):
        """Call general function back"""
        try:
            return self.__general_cb_map[name](*args, **kwargs)
        except KeyError:
            error("Trying to call unknown function")
            return None

application = service.Application('SàT')
service = SAT()
service.setServiceParent(application)