#!/usr/bin/env python


"""AIM bot that allows users to set alarms.

Author:  Kent Hu <kenthu@kenthu.net>

Originally written May 12, 2002.
Using Jamie Turner's Py-TOC module (http://www.jamwt.com/Py-TOC/)

To do:  handle anonymous warnings (block the only person you've responded to so far)
        implement recoverable alarms (so can still alarm even if crashed or logged off
        handle if can't log in (use alarmbots 3-9?)
        what if user changes screen name formatting while alarmbot is running?  (look at TocTalk.normalize())
        implement administration (remove block, add block, set delay, show delay)

"""


# To run AlarmBot, you'll need to set the following four variables.
login = ''  # Screen name from which you want to run AlarmBot.
password = ''
logFileName = ''  # AlarmBot will output messages to both stdout and this log.
blocklistFilename = ''  # AlarmBot stores its list of blocked users here.


import misc
import os
import thread
import threading
import time
import toc


maxLogFileLength = 2**15


errMessages = {'901':'%s not currently available',
               '902':'Warning of %s not currently available',
               '903':'A message has been dropped, you are exceeding the server speed limit',
               '950':'Chat in %s is unavailable.',
               '960':'You are sending message too fast to %s',
               '961':'You missed an im from %s because it was too big.',
               '962':'You missed an im from %s because it was sent too fast.',
               '970':'Failure',
               '971':'Too many matches',
               '972':'Need more qualifiers',
               '973':'Dir service temporarily unavailable',
               '974':'Email lookup restricted',
               '975':'Keyword Ignored',
               '976':'No Keywords',
               '977':'Language not supported',
               '978':'Country not supported',
               '979':'Failure unknown %s'}


class MyBot(toc.TocTalk):
    # Dictionary that maps (screen name, alarm ID) tuples to time of alarmD
    # (float:  seconds since epoch, in UTC).
    alarmTable = {}
    alarmTableLock = threading.Lock()

    # Dictionary that maps screen names to maximum alarm ID for that screen
    # name.
    alarmIDTable = {}
    alarmIDTableLock = threading.Lock()

    # List of screen names to ignore.
    blockList = []
    blockListFileLock = threading.Lock()

    # File handle for log file.
    logFile = None
    logFileLock = threading.Lock()

    # Lock to enforce delay between IMs.  Always sleep before releasing.
    delayLock = threading.Lock()

    # Delay between consecutive IM sends.
    delayTime = 3.0
    delayTimeLock = threading.Lock()

    """DEADLOCK PREVENTION

    alarmTableLock
        acquired/released in setAlarm()  (twice)
        acquired/released in showAlarms()
        acquired/released in cancelAlarm()
            okay because never holding any other locks when acquiring this one
    alarmIDTableLock
        acquired/released in getAlarmID(), which is called from setAlarm()
            okay because never holding any other locks when acquiring this one
    blockListFileLock
        acquired/released in on_EVILED()
            okay because never holding any other locks when acquiring this one
    logFileLock
        acquired/released in printAndLog()
            although alarmTableLock is sometimes held when printAndLog() is
            called, we're okay because printAndLog() doesn't try to acquire
            any other locks
    delayLock
        acquired/released in sendIM()
            okay because sendIM() doesn't try to acquire any other locks
    delayTimeLock
        acquired/released in ON_ERROR()
            okay because never holding any other locks when acquiring this one

    """

    def sendIM(self, sn, message):
        """Send IM, enforcing delay between IMs."""
        self.delayLock.acquire()
        self.do_SEND_IM(sn, message)
        time.sleep(self.delayTime)
        self.delayLock.release()

    def printAndLog(self, message):
        """Print message to stdout and log."""
        self.logFileLock.acquire()
        message = time.strftime('[%H:%M:%S] ') + message
        print message
        print >> self.logFile, message
        self.logFile = misc.truncateFile(self.logFile, maxLogFileLength)
        self.logFileLock.release()

    def start(self):
        """Open log file, set user info, and read blocklist."""
        # Open log file.
        self.logFile = open(logFileName, 'a')  # Using open() for pre-2.2
        self.logFile.write('\n\n')
        self.printAndLog(time.strftime('%s logging in on %%B %%d, %%Y' % self._nick))

        self.do_SET_INFO('AlarmBot written May 12, 2002 by Kent Hu (kenthu@kenthu.net) using Jamie Turner\'s <a href="http://jamwt.com/Py-TOC/">Py-TOC module</a>')

        # Read blocklist.
        try:
            f = open(blocklistFilename, 'r')
            lines = f.readlines()
            f.close()
            for i in lines:
                name = i[:-1]
                self.printAndLog('blocking %s' % name)
                self.blockList.append(name)
        except IOError, (errno, strerror):
            if errno == 2:  # File does not exist, can be created later.
                return
            else:
                self.printAndLog('ERROR:  could not read blockList file')
                raise IOError, (errno, strerror)
        self.do_ADD_DENY(blockList)
        
    def getAlarmID(self, screenName):
        """Return next unused alarm ID for screenName.

        Also update alarmIDTable.

        """
        self.alarmIDTableLock.acquire()

        # Increment max if exists, else set to 0.
        if self.alarmIDTable.has_key(screenName):
            self.alarmIDTable[screenName] += 1
        else:
            self.alarmIDTable[screenName] = 1

        alarmID = self.alarmIDTable[screenName]
        self.alarmIDTableLock.release()
        return alarmID

    def setAlarm(self, screenName, n):
        """Set alarm, sleep n minutes, then notify user.

        This function runs in a separate thread.

        """
        # Check if n is a valid #.
        try:
            n = float(n)
        except ValueError:
            self.printAndLog('ERROR:  user %s tried to set alarm for invalid number of minutes:  %s' % (screenName, n))
            self.showMenu(screenName)
            return

        # Add entry to alarmTable.
        alarmID = self.getAlarmID(screenName)
        self.alarmTableLock.acquire()
        self.alarmTable[(screenName, alarmID)] = time.time() + 60 * n
        self.alarmTableLock.release()

        # Send acknowledgment messages and sleep.
        self.printAndLog('%s has added alarm for %s minutes.' % (screenName, n))
        self.sendIM(screenName, 'Alarm set for %s minutes.' % n)
        time.sleep(60 * n)

        # Notify user, after checking if entry in alarmTable has been
        # cancelled.
        self.alarmTableLock.acquire()
        if self.alarmTable.has_key((screenName, alarmID)) and screenName not in self.blockList:
            self.printAndLog('alerting %s now.' % screenName)
            self.sendIM(screenName, 'ALERT!  Alarm set %s minutes ago going off now!' % n)
            del self.alarmTable[(screenName, alarmID)]

        self.alarmTableLock.release()

    def showAlarms(self, screenName):
        """Show alarms set by screenname."""
        self.printAndLog('showing alarms for %s' % screenName)
        
        keys = []
        self.alarmTableLock.acquire()

        # Get alarm entries from table.
        keys = [key
                for key in self.alarmTable.keys()
                if key[0] == screenName]

        # Print alarm entries.
        if len(keys) == 0:
            self.sendIM(screenName, 'You have no alarms set.')
        else:
            keys.sort()
            message = 'Showing alarms:'
            for key in keys:
                secondsLeft = int(self.alarmTable[key] - time.time())
                minutesLeft, secondsLeft = divmod(secondsLeft, 60)
                message += '\nAlarm ID: %d, Time Left: %d:%02d' % (key[1], minutesLeft, secondsLeft)
            self.sendIM(screenName, message)
        
        self.alarmTableLock.release()
        
    def showMenu(self, screenName):
        """Show menu."""
        self.printAndLog('showing menu to %s' % screenName)
        message = 'Enter one of the following commands:<br>' +\
                  '<b>set M</b>:  sets alarm to go off <b>M</b> minutes from now<br>' +\
                  '<b>show</b>:  shows all alarms set<br>' +\
                  '<b>cancel N</b>:   cancels alarm with alarm ID <b>N</b><br>' +\
                  ' <br>' +\
                  'Examples:<br>' +\
                  '<i>set 1.5</i> will have AlarmBot IM you back in 1.5 minutes.<br>' +\
                  '<i>cancel 2</i> will cancel the second alarm that you set.'
        self.sendIM(screenName, message)

    def cancelAlarm(self, screenName, n):
        """Cancel alarm."""
        # Check if n is valid integer.
        try:
            n = int(n)
        except ValueError:
            self.printAndLog('ERROR:  user %s entered invalid number when cancelling alarm:  %s' % (screenName, n))
            self.showMenu(screenName)
            return

        # Remove entry from alarmTable.
        self.alarmTableLock.acquire()
        if self.alarmTable.has_key((screenName, n)):
            self.printAndLog('%s cancelled alarm with ID %d.' % (screenName, n))
            self.sendIM(screenName, 'Alarm cancelled.')
            del self.alarmTable[(screenName, n)]
        else:
            self.printAndLog('ERROR:  %s tried to cancel alarm with ID %d, but it does not exist.' % (screenName, n))
            self.sendIM(screenName, 'Alarm with alarm ID %d does not exist.' % n)
        self.alarmTableLock.release()

    def on_IM_IN(self, data):
        """Handle incoming IM."""
        data_components = data.split(":",2)
        screenName = data_components[0]
        message = data_components[2]

        # Strip html from incoming message.
        message = self.strip_html(message)
        self.printAndLog('MESSAGE FROM %s:  %s' % (screenName, message))

        if screenName in self.blockList:
            self.printAndLog('ignoring message from blocked user %s' % screenName)
            return

        parsedMessage = message.split()
        if len(parsedMessage) == 0:  # Show menu if empty message.
            self.showMenu(screenName)            
            return

        command = parsedMessage[0].lower()

        if command == 'show':
            thread.start_new_thread(self.showAlarms, (screenName,))
        elif command == 'set':
            if len(parsedMessage) == 2:
                thread.start_new_thread(self.setAlarm, (screenName, parsedMessage[1]))
            else:
                self.showMenu(screenName)
        elif command == 'cancel':
            if len(parsedMessage) == 2:
                thread.start_new_thread(self.cancelAlarm, (screenName, parsedMessage[1]))
            else:
                self.showMenu(screenName)            
        else:
            self.showMenu(screenName)
               
    def on_EVILED(self, data):
        """Handle warning."""
        data_components = data.split(':', 1)
        percentage = data_components[0]
        screenName = data_components[1]

        if screenName:
            self.printAndLog('warned by %s to %s%%.' % (screenName, percentage))

            # Warn user back.
            self.printAndLog('warning ' + screenName + ' back.')
            self.do_EVIL(screenName)

            # Block user.
            self.printAndLog('blocking ' + screenName + '.')
            self.blockList.append(screenName)
            self.do_ADD_DENY([screenName])
            self.blockListFileLock.acquire()
            f = open(blocklistFilename, 'a')
            f.write(screenName + '\n')
            f.close()
            self.blockListFileLock.release()
        else:
            self.printAndLog('warned anonymously to %s%%.' % percentage)

    def on_ERROR(self, data):
        data_components = data.split(':', 1)
        err = data_components[0]

        if err in errMessages.keys():
            errmsg1 = errMessages[err]
        else:
            errmsg1 = 'unknown error code'

        if len(data_components) == 2:
            errmsg2 = data_components[1]
        else:
            errmsg2 = 'ARGUMENT'

        message = 'ERROR %s (%s)' % (err, errmsg1)
        if message.count('%s'):
            message = message % errmsg2
        else:
            message += ': %s' % errmsg2
        self.printAndLog(message)

        if err == '903':
            # Add some time to delay time.
            self.delayTimeLock.acquire()
            self.delayTime += 0.4
            self.delayTime = min(4.2, delayTime)
            self.delayTimeLock.release()

            # Tests show that if we keep on sending messages after receiving
            # error 903, we'll never get out of the penalty box.
            self.delayLock.acquire()
            time.sleep(40)  # Actually, should only take 38 sec, but being safe
            self.delayLock.release()

    def on_NICK(self, data):
        """Ignore NICK message."""
        pass

    def on_UPDATE_BUDDY(self, data):
        """Ignore UPDATE_BUDDY message."""
        pass

    def on_NOID(self, data):
        """Ignore NOID message."""
        pass

    def on_CONFIG(self, data):
        """Ignore CONFIG message."""
        pass


if __name__ == '__main__':
    bot = MyBot(login, password)
    bot.go()

