# session.py -- Session, SessionCallbacks classes
#
# Copyright (C) 2003 Manish Jethani (manish_jethani AT yahoo.com)
#
# 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 2 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, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

import select
import md5

from string import split, join
from binascii import hexlify, unhexlify
from time import time

from protocol import States, Lists, PrivacyModes
from error import Error, HttpError
from friend import Group, Friend, FriendList
from net import Connection, HttpProxyConnection
from command import Command, Msg, Png, Qry
from codec import url_codec

import protocol
import chat

class _Session:  # common base for Session and Chat
    def __init__(self, callbacks):
        self.callbacks = callbacks
        self.transaction_id = 0
        self.http_proxy = None
        self.conn = None
        self.send_queue = []

    def _connect(self, server):
        conn = None
        if self.http_proxy:
            conn = HttpProxyConnection(server, self.http_proxy)
        else:
            conn = Connection(server)
        conn.establish()
        return conn

    def _increment_transaction_id(self):
        self.transaction_id = self.transaction_id + 1
        return self.transaction_id

    def _send_cmd(self, cmd, conn):
        conn.send_data_line(str(cmd))
        self._increment_transaction_id()

    def _receive_cmd(self, conn):
        buf = conn.receive_data_line()
        if buf == None:  # connection closed
            raise Error(1, 'Connection closed.')
        cmd = Command()
        cmd.parse(buf)
        return cmd

    def _sync_command(self, cmd, conn):
        # synchronous command (receive response immediately)
        self._send_cmd(cmd, conn)
        return self._receive_cmd(conn)

    def _async_command(self, cmd):
        self.send_queue.append(cmd)
        self._increment_transaction_id()

class SessionCallbacks:  # callback interface
    """Callback interface for MSN instant messaging session

    To receive notification on various protocol events, the client must
    implement some or all of the methods in this callback interface.
    """

    def ping(self):
        """Ping received from server"""

    def state_changed(self, state):
        """User's presence state has changed

        Keyword arguments:
            state -- any of the msnp.States members
        """

    def friend_online(self, state, passport_id, display_name):
        """Friend is online

        Keyword arguments:
            state -- any of the msnp.States members
            passport_id -- string representing friend's passport ID
            display_name -- friend's display name
        """

    def friend_offline(self, passport_id):
        """Friend is offline

        Keyword arguments:
            passport_id -- string representing friend's passport ID
        """

    def friend_list_updated(self, friend_list):
        """Friend list has been updated

        Keyword arguments:
            friend_list -- same as msnp.Session.friend_list
        """

    def logged_out(self):
        """User has been logged out"""

    def group_added(self, id, name):
        """Group has been added

        Keyword arguments:
            id -- group ID
            name -- name of group
        """

    def group_removed(self, id):
        """Group has been removed

        Keyword arguments:
            id -- group ID
        """

    def group_renamed(self, id, name):
        """Group has been renamed

        Keyword arguments:
            id -- group ID
            name -- new name of group
        """

    def friend_added(self, list_, passport_id, display_name, group_id = -1):
        """Friend has been added

        If list_ is msnp.Lists.REVERSE, it means that the user has been added
        to someone's list.  In that case, the passport_id and display_name
        parameters contain information about that someone.

        Keyword arguments:
            list_ -- type of list (allow, block, etc.)
            passport_id -- string representing friend's passport ID
            display_name -- friend's display name
            group_id -- group ID of group to which friend has been added
        """

    def friend_removed(self, list_, passport_id, group_id = -1):
        """Friend has been removed

        Keyword arguments:
            list_ -- type of list (allow, block, etc.)
            passport_id -- string representing friend's passport ID
            group_id -- group ID of group from which friend has been removed
        """

    def display_name_changed(self, display_name):
        """Display name changed

        Keyword arguments:
            display_name -- user's new display name
        """

    def display_name_received(self, passport_id, display_name):
        """Display name received

        Keyword arguments:
            passport_id -- string representing friend's passport ID
            display_name -- friend's display name
        """

    def chat_started(self, chat):
        """Chat started

        Keyword arguments:
            chat -- Chat instance representing new chat started
        """

class Session(_Session):
    """MSN instant messaging session

    To get into an instant messaging session, an instance of msnp.Session must
    be created.  The session can be started by calling the login method.  After
    logging in, the process method must be called periodically to process the
    server's commands.
    """

    class __ChatRequest:
        def __init__(self, invitee):
            self.invitee = invitee

    def __init__(self, callbacks = None, dispatch_server = None):
        """Constructor for msnp.Session

        Keyword arguments:
            callbacks -- callback interface
            dispatch_server -- dispatch server host, port
        """

        if callbacks == None:
            callbacks = SessionCallbacks()
        _Session.__init__(self, callbacks)

        if dispatch_server == None:
            self.dispatch_server = ('messenger.hotmail.com', 1863)

        self.logged_in = 0
        self.passport_id = None
        self.display_name = None
        self.chat_requests = {}
        self.friend_list = FriendList()
        self.active_chats = {}

    def __get_twn_ticket(self, twn_string, username, password):
        from net import HTTPSConnection
        from urllib import urlencode
        debuglevel = 0

        # step 1: get address of login server
        con = HTTPSConnection('nexus.passport.com',
            http_proxy = self.http_proxy)
        con.set_debuglevel(debuglevel)
        con.request('GET', '/rdr/pprdr.asp')
        res = con.getresponse()
        con.close()
        if res.status != 200:
            raise HttpError(0, 'Bad response from passport nexus server.',
                res.status, res.reason)
        hdr = res.getheader('PassportURLs')
        url = {}
        for u in hdr.split(','):
            k, v = u.split('=')
            url[k] = v
        dalogin = url['DALogin'].split('/', 1)

        # step 2: get "ticket" to notification server
        while True:
            con = HTTPSConnection(dalogin[0], http_proxy = self.http_proxy)
            con.set_debuglevel(debuglevel)
            auth = 'Passport1.4 OrgVerb=GET,%s,%s,%s,%s' \
                % (urlencode({'OrgURL': 'http://messenger.msn.com'}),
                    urlencode({'sign-in': username}),
                    urlencode({'pwd': password}),
                    twn_string)
            con.request('GET', '/%s' % (dalogin[1]), '',
                {'Authorization': auth})
            res = con.getresponse()
            con.close()
            if res.status != 200:
                raise HttpError(0, 'Bad response from login server.',
                    res.status, res.reason) # XXX handle redirection?
            else:
                break
        hdr = res.getheader('Authentication-Info') or \
              res.getheader('WWW-Authenticate')
        hdr = hdr[len('Passport1.4 '):]
        auth = {}
        for u in hdr.split(','):
            k, v = u.split('=', 1)
            if v[0] == '\'' and v[-1] == '\'':
                v = v[1:-1]
            auth[k] = v
        ticket = auth['from-PP']

        return ticket
        # TODO code cleanup

    def __handshake(self, server, username, password):
        conn = self._connect(server)
        try:
            ver = Command('VER', self.transaction_id, ('MSNP8', 'CVR0'))
            resp = self._sync_command(ver, conn)
            if resp.cmd != 'VER' or resp.args[0] == '0':
                raise Error(0, 'Bad response for VER command.')

            cvr = Command('CVR', self.transaction_id,
                ('0x0409', 'win', '4.10', 'i386', 'MSNMSGR', '6.0.0602',
                'MSMSGS ', username))
            resp = self._sync_command(cvr, conn)
            if resp.cmd != 'CVR':
                raise Error(0, 'Bad response for CVR command.')

            usr = Command('USR', self.transaction_id, ('TWN', 'I', username))
            resp = self._sync_command(usr, conn)
            if resp.cmd != 'USR' and resp.cmd != 'XFR':
                raise Error(0, 'Bad response for USR command.')

            # for dispatch server, response is ver, cvr, xfr; for notification
            # server, it is ver, cvr, usr (or same as dispatch server, in some
            # cases)

            if resp.cmd == 'XFR':
                return split(resp.args[1], ':', 1)
            elif resp.cmd == 'USR':
                twn_string = resp.args[2]

                ticket = self.__get_twn_ticket(twn_string, username, password)

                usr = Command('USR', self.transaction_id, ('TWN', 'S', ticket))
                resp = self._sync_command(usr, conn)
                if resp.cmd != 'USR':
                    raise Error(int(resp.cmd), protocol.errors[resp.cmd])
                elif resp.args[0] != 'OK':
                    raise Error(0, 'Bad response for USR command.')

                self.passport_id = resp.args[1]
                self.display_name = url_codec.decode(resp.args[2])
                self.logged_in = 1
        finally:
            if not self.logged_in:
                conn.break_()
            else:
                self.conn = conn

    def process(self, chats = False):
        """Process events

        Keyword arguments:
            chats -- whether or not to call msnp.Chat.process for all active
                chat sessions

        This method must be called periodically, preferably in the client
        application's main loop.
        """
        while self.logged_in:
            fd = self.conn.socket.fileno()
            r = select.select([fd], [], [], 0)
            if len(r[0]) > 0:
                buf = self.conn.receive_data_line()
                self.__process_command_buf(buf)
            elif len(self.send_queue) > 0:
                cmd = self.send_queue.pop(0)
                cmd.send(self.conn)
            else:
                break
        if chats:
            self.__process_active_chats()

    def __process_active_chats(self):
        [chat_.process() for chat_ in self.active_chats.values()]

    def __process_command_buf(self, buf):
        cmd = buf[:3]
        if cmd == 'MSG':
            self.__process_msg(buf)
        elif cmd == 'QNG':
            self.__process_qng(buf)
        elif cmd == 'OUT':
            self.__process_out(buf)
        elif cmd == 'RNG':
            self.__process_rng(buf)
        else:
            c = Command()
            c.parse(buf)
            if c.cmd == 'CHG':
                self.__process_chg(c)
            elif c.cmd == 'ILN':
                self.__process_iln(c)
            elif c.cmd == 'NLN':
                self.__process_nln(c)
            elif c.cmd == 'FLN':
                self.__process_fln(c)
            elif c.cmd == 'CHL':
                self.__process_chl(c)
            elif c.cmd == 'LSG':
                self.__process_lsg(c)
            elif c.cmd == 'LST':
                self.__process_lst(c)
            elif c.cmd == 'SYN':
                self.__process_syn(c)
            elif c.cmd == 'XFR':
                self.__process_xfr(c)
            elif c.cmd == 'BLP':
                self.__process_blp(c)
            elif c.cmd == 'GTC':
                self.__process_gtc(c)
            elif c.cmd == 'ADG':
                self.__process_adg(c)
            elif c.cmd == 'RMG':
                self.__process_rmg(c)
            elif c.cmd == 'REG':
                self.__process_reg(c)
            elif c.cmd == 'ADD':
                self.__process_add(c)
            elif c.cmd == 'REM':
                self.__process_rem(c)
            elif c.cmd == 'REA':
                self.__process_rea(c)
            elif c.cmd == '218':
                pass
            # TODO error handling

    def __process_msg(self, buf):
        msg = Msg()
        msg.parse(buf)
        msg.receive(self.conn)
        # discard NS messages for now

    def __process_qng(self, buf):
        self.callbacks.ping()

    def __process_out(self, buf):
        self.conn.break_()
        self.conn = None
        self.logged_in = 0
        self.callbacks.logged_out()

    def __process_rng(self, buf):
        cmdline = split(buf)
        session_id = cmdline[1]
        sb = split(cmdline[2], ':')
        server = (sb[0], int(sb[1]))
        hash = cmdline[4]
        passport_id = cmdline[5]
        display_name = url_codec.decode(cmdline[6])
        try:
            chat_ = chat.Chat(self, server, hash, passport_id,
                display_name, session_id)
        except Error, e:
            if e.code == 1:  # connection closed
                return
            raise e
        self.active_chats[chat_.session_id] = chat_
        self.callbacks.chat_started(chat_)

    def __process_chg(self, command):
        self.callbacks.state_changed(command.args[0])

    def __process_iln(self, command):
        state = command.args[0]
        passport_id = command.args[1]
        display_name = url_codec.decode(command.args[2])
        friend = self.friend_list.get_friend(passport_id)
        if friend != None:
            friend.state = state
            friend.display_name = display_name
            self.__friend_list_updated()
        else:  # usu. immed. after login
            self.friend_list.temp_iln[passport_id] = state
        self.callbacks.friend_online(state, passport_id, display_name)

    def __process_nln(self, command):
        state = command.args[0]
        passport_id = command.args[1]
        display_name = url_codec.decode(command.args[2])
        friend = self.friend_list.get_friend(passport_id)
        if friend != None:
            friend.display_name = display_name
            friend.state = state
            self.__friend_list_updated()
        self.callbacks.friend_online(state, passport_id, display_name)

    def __process_fln(self, command):
        passport_id = command.args[0]
        friend = self.friend_list.get_friend(passport_id)
        if friend != None:
            friend.state = States.OFFLINE
            self.__friend_list_updated()
        self.callbacks.friend_offline(passport_id)

    def __process_chl(self, command):
        qry = Qry(self.transaction_id, command.args[0])
        self._async_command(qry)

    def __process_lsg(self, command):
        id = int(command.args[0])
        name = url_codec.decode(command.args[1])

        group = Group(id, name)
        self.friend_list.groups[id] = group

        self.__friend_list_updated()

    def __process_lst(self, command):
        from protocol import list_flags

        passport_id  = command.args[0]
        display_name = url_codec.decode(command.args[1])
        list_        = int(command.args[2])
        group_id     = []

        if list_ & list_flags[Lists.FORWARD]:
            group_id = [int(i) for i in split(command.args[3], ',')]

        groups = None
        if len(group_id):
            groups = [self.friend_list.groups[g_id] for g_id in group_id]

        friend = Friend(passport_id, display_name, groups = groups)
        for f in list_flags.keys():
            if list_ & list_flags[f]:
                self.friend_list.lists[f][passport_id] = friend

        if self.friend_list.temp_iln.has_key(passport_id):
            friend.state = self.friend_list.temp_iln[passport_id]

        self.__friend_list_updated()

    def __process_syn(self, command):
        ver = int(command.args[0])
        self.friend_list.ver = ver
        self.__friend_list_updated()

    def __process_xfr(self, command):
        sb = split(command.args[1], ':')
        server = (sb[0], int(sb[1]))
        cr = self.chat_requests[command.trn]
        invitee = cr.invitee
        chat_ = chat.Chat(self, server, command.args[3], self.passport_id,
            self.display_name, None, invitee)
        self.active_chats[chat_.session_id] = chat_
        self.callbacks.chat_started(chat_)

    def __process_blp(self, command):
        privacy_mode = command.args[0]
        self.friend_list.privacy_mode = privacy_mode
        self.__friend_list_updated()

    def __process_gtc(self, command):
        notify_on_add = command.args[0] == 'A'
        self.friend_list.notify_on_add_ = notify_on_add
        self.__friend_list_updated()

    def __process_adg(self, command):
        ver = int(command.args[0])
        name = url_codec.decode(command.args[1])
        id = int(command.args[2])
        self.friend_list.ver = ver
        self.friend_list.groups[id] = Group(id, name)
        self.__friend_list_updated()
        self.callbacks.group_added(id, name)

    def __process_rmg(self, command):
        ver = int(command.args[0])
        id = int(command.args[1])
        self.friend_list.ver = ver
        if self.friend_list.groups.has_key(id):
            del self.friend_list.groups[id]
        self.__friend_list_updated()
        self.callbacks.group_removed(id)

    def __process_reg(self, command):
        ver = int(command.args[0])
        id = int(command.args[1])
        name = url_codec.decode(command.args[2])
        self.friend_list.ver = ver
        if self.friend_list.groups.has_key(id):
            self.friend_list.groups[id].name = name
        self.__friend_list_updated()
        self.callbacks.group_renamed(id, name)

    def __process_add(self, command):
        list_ = command.args[0]
        ver = int(command.args[1])
        passport_id = command.args[2]
        display_name = url_codec.decode(command.args[3])
        group = None
        if list_ == Lists.FORWARD:
            group = self.friend_list.groups[int(command.args[4])]

        self.friend_list.ver = ver
 
        friend = self.friend_list.get_friend(passport_id, list_)
        if friend != None:
            friend.add_to_group(group)
        else:
            if group != None:
                friend = Friend(passport_id, passport_id, (group))
            else:
                friend = Friend(passport_id, passport_id)
            self.friend_list.lists[list_][passport_id] = friend

        self.__friend_list_updated()

        if group != None:
            self.callbacks.friend_added(list_, passport_id, display_name,
                group.get_id())
        else:
            self.callbacks.friend_added(list_, passport_id, display_name)

    def __process_rem(self, command):
        list_ = command.args[0]
        ver = int(command.args[1])
        passport_id = command.args[2]
        group = None
        if list_ == Lists.FORWARD:
            group = self.friend_list.groups[int(command.args[3])]

        self.friend_list.ver = ver

        friend = self.friend_list.get_friend(passport_id, list_)
        if friend != None: # this shouldn't be None, unless friend_list stale
            if group != None:
                friend.remove_from_group(group)
            if len(friend.get_groups()) == 0:
                del self.friend_list.lists[list_][passport_id]

        self.__friend_list_updated()
        if group != None:
            self.callbacks.friend_removed(list_, passport_id, group.get_id())
        else:
            self.callbacks.friend_removed(list_, passport_id)

    def __process_rea(self, command):
        ver = int(command.args[0])
        passport_id = command.args[1]
        display_name = url_codec.decode(command.args[2])

        if passport_id == self.passport_id:
            self.display_name = display_name
            self.callbacks.display_name_changed(display_name)
        else:
            self.callbacks.display_name_received(passport_id, display_name)

    def __friend_list_updated(self):
        self.friend_list.updated = time()
        self.callbacks.friend_list_updated(self.friend_list)

    def login(self, username, password, initial_state = States.ONLINE):
        """Login to MSN server

        Keyword arguments:
            username -- username
            password -- password
            initial_state -- initial state (default msnp.States.ONLINE)
        """
        if self.logged_in:
            return
        server = self.dispatch_server
        while not self.logged_in:
            server = self.__handshake(server, username, password)
        self.change_state(initial_state)

    def ping(self):
        """Ping server"""
        if not self.logged_in:
            return
        self._async_command(Png())
        self.process()

    def logout(self):
        """Logout from server"""
        if not self.logged_in:
            return
        [chat_.leave() for chat_ in self.active_chats.values()]
        self.process()
        self.conn.break_()
        self.conn = None
        self.logged_in = 0

    def change_state(self, state):
        """Change user's state

        Keyword arguments:
            state -- new state (see msnp.States)
        """
        if not self.logged_in:
            return
        chg = Command('CHG', self.transaction_id, (state,))
        self._async_command(chg)
        self.process()

    def sync_friend_list(self, ver = -1):
        """Synchronise friend list by getting new copy from server

        The friend list is updated asynchronously.
        msnp.SessionCallbacks.friend_list_updated will be called repeatedly
        after a call to this method.  The client may want to set a timer
        instead, and check for updates to the friend list using the
        msnp.FriendList.last_updated method.

        Keyword arguments:
            ver -- friend list version
        """
        if not self.logged_in:
            return
        self.friend_list.dirty = False
        if ver == -1:
            ver = self.friend_list.ver
        syn = Command('SYN', self.transaction_id, (str(ver),))
        self._async_command(syn)
        self.process()

    def request_list(self, list_ = Lists.FORWARD):
        """Request a list from the server

        Keyword arguments:
            list_ -- type of list to request (see msnp.Lists)
        """
        if not self.logged_in:
            return
        lst = Command('LST', self.transaction_id, (list_,))
        self._async_command(lst)
        self.process()

    def request_groups(self):
        """Request groups from server"""
        if not self.logged_in:
            return
        lsg = Command('LSG', self.transaction_id, ())
        self._async_command(lsg)
        self.process()

    def change_privacy_mode(self, privacy_mode):
        """Change privacy mode

        Keyword arguments:
            privacy_mode -- new privacy mode (see msnp.PrivacyModes)
        """
        if not self.logged_in:
            return
        blp = Command('BLP', self.transaction_id, (privacy_mode,))
        self._async_command(blp)
        self.process()

    def notify_on_add(self, notify):
        """Change setting for being notified on being added"""
        if not self.logged_in:
            return
        setting = 'N'
        if notify:
            setting = 'A'
        gtc = Command('GTC', self.transaction_id, (setting,))
        self._async_command(gtc)
        self.process()

    def add_group(self, name):
        """Add a group
        
        Keyword arguments:
            name -- name of new group
        """
        if not self.logged_in:
            return
        adg = Command('ADG', self.transaction_id,
            (url_codec.encode(name), '0'))
        self._async_command(adg)
        self.process()

    def remove_group(self, id):
        """Remove a group

        Keyword arguments:
            id -- group ID
        """
        if not self.logged_in:
            return
        rmg = Command('RMG', self.transaction_id, (str(id),))
        self._async_command(rmg)
        self.process()

    def rename_group(self, id, name):
        """Rename a group

        Keyword arguments:
            id -- group ID
            name -- new name of group
        """
        if not self.logged_in:
            return
        reg = Command('REG', self.transaction_id,
            (str(id), url_codec.encode(name), '0'))
        self._async_command(reg)
        self.process()

    def add_friend(self, list_, passport_id, group_id = 0):
        """Add a friend

        Keyword arguments:
            list_ -- type of list (allow, block, etc.)
            passport_id -- string representing friend's passport ID
            group_id -- group ID of group to which friend is being added
        """
        add = None
        if list_ == Lists.FORWARD:
            add = Command('ADD', self.transaction_id,
                (list_, passport_id, passport_id, str(group_id)))
        else:
            add = Command('ADD', self.transaction_id,
                (list_, passport_id, passport_id))
        self._async_command(add)
        self.process()

    def remove_friend(self, list_, passport_id, group_id = 0):
        """Remove a friend

        Keyword arguments:
            list_ -- type of list (allow, block, etc.)
            passport_id -- string representing friend's passport ID
            group_id -- group ID of group from which friend is being removed
        """
        rem = None
        if list_ == Lists.FORWARD:
            rem = Command('REM', self.transaction_id,
                (list_, passport_id, str(group_id)))
        else:
            rem = Command('REM', self.transaction_id,
                (list_, passport_id))
        self._async_command(rem)
        self.process()

    def change_display_name(self, display_name):
        """Change user's display name

        Keyword arguments:
            display_name -- user's new display name
        """
        if not self.logged_in:
            return
        rea = Command('REA', self.transaction_id,
            (self.passport_id, url_codec.encode(display_name)))
        self._async_command(rea)
        self.process()

    def request_display_name(self, passport_id):
        """Request display name of a friend

        Keyword arguments:
            passport_id -- string representing friend's passport ID
        """
        if not self.logged_in:
            return
        rea = Command('REA', self.transaction_id,
            (passport_id, url_codec.encode('MJ++')))
        self._async_command(rea)
        self.process()

    def start_chat(self, invitee):
        """Start a chat

        Keyword arguments:
            invitee -- friend invited for chat
        """
        if not self.logged_in:
            return
        xfr = Command('XFR', self.transaction_id, ('SB',))
        self._async_command(xfr)
        self.chat_requests[xfr.trn] = Session.__ChatRequest(invitee)
        self.process()

# vim: set ts=4 sw=4 et tw=79 :

