Josh VanderLinden avatar Josh VanderLinden committed 01581d6

Working on refactoring the app quite a bit, along with a shift from the config file to a sqlite backend. I'm wrestling with threads now... yay.

Comments (0)

Files changed (14)

-syntax: glob
-*.pyc
-*.log
-build
-dist
-simplejson
-xmpp
-twitter.py
-scripts/Output
-twim.wpr
+syntax: glob
+*.pyc
+*.log
+build
+dist
+simplejson
+xmpp
+twitter.py
+scripts/Output
+twim.wpr
+nbproject
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import cgi
+import logging
+import re
+import simplejson
+import sys
+import time
+import twitter
+import ui
+import unicodedata
+import urllib
+import urllib2
+import xmpp
+from datetime import datetime, timedelta
+from db import Cache, Config
+from threading import Event, Thread
+
+log = logging.getLogger('twim.core')
+
+AT_REPLY_RE = re.compile('@(\w+)')
+HASH_TAG_RE = re.compile('([^&]?)#([\w\-]+)([^;]?)')
+HREF_RE = re.compile('(http://([\w+?\.\w+]+)([a-z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*))', re.I)
+HREF_TEMPLATE = '<strong><a href="http://twitter.com/%(user)s/status/%(id)i">%(user)s</a></strong>'
+
+TWEET = str('<message><body>%(body)s</body><html xmlns="http://jabber.org/'
+    'protocol/xhtml-im"><body xmlns="http://www.w3.org/1999/xhtml">%(html)s'
+    '</body></html></message>')
+
+# Used to make our text nicer to play with
+clean = lambda s: unicodedata.normalize('NFKD', unicode(s)).encode('ascii', 'xmlcharrefreplace')
+TRANSLATIONS = {
+    0x201c: u'"',
+    0x201d: u'"',
+    0x2018: u"'",
+    0x2019: u"'",
+}
+
+class Worker(Thread):
+    def __init__(self, twim):
+        Thread.__init__(self)
+        self.twim = twim
+
+class TwimApp(object):
+    """
+    The main program
+    """
+
+    _client = None
+    _commands = {}
+    _history = []
+
+    # these are here to make it a tiny bit easier to deal with threading issues
+    config = property(lambda t: Config())
+    cache = property(lambda t: Cache())
+
+    def __init__(self, with_gui=True):
+        log.info('Starting app on %s' % sys.platform)
+        self.interval = timedelta(seconds=self.config.update_interval)
+
+        # start the GUI if necessary
+        if with_gui:
+            log.info('Starting GUI')
+            ui.main(self)
+        else:
+            self.BeginBeingUseful()
+
+    def BeginBeingUseful(self):
+        """
+        Things to do after initial startup
+        """
+        log.info('Performing post-init startup')
+        self.InitCommands()
+
+        try:
+            # handle messages sent by the user
+            self.client.RegisterHandler('message', self.OnMessage)
+
+            # tell people we're online
+            self.client.sendInitPresence()
+            self.presence = xmpp.Presence(priority=5, show='chat')
+            self.client.send(self.presence)
+        except Exception, ex:
+            sys.exit(ex)
+
+        # create some threads so we don't have an unresponsive program
+        log.info('starting threads')
+        self.checker = Event()
+        self.poster = Event()
+
+        self.check_timer = Thread(target=self.OnTimer, name='TweetChecker',
+                                  args=(self.checker, 1.0, self.CheckForUpdates))
+
+        self.post_timer = Thread(target=self.OnTimer, name='TweetPoster',
+                                 args=(self.poster, 1.0, self.CheckForPosts))
+        self.check_timer.start()
+        self.post_timer.start()
+
+    def InitCommands(self):
+        """
+        Initializes the commands that are available to the user
+        """
+        self._commands = {
+            'help': self.OnHelpCommand,
+            'filter': self.OnFilterCommand,
+            'unfilter': self.OnUnfilterCommand,
+            'follow': self.OnFollowCommand,
+            'unfollow': self.OnUnfollowCommand,
+            'whois': self.OnWhoIsCommand,
+            'undo': self.OnUndoCommand,
+            'rt': self.OnRetweetCommand,
+            'retweet': self.OnRetweetCommand,
+            'reply': self.OnReplyCommand,
+            'search': self.OnSearchCommand,
+            'favorite': self.OnFavoriteCommand,
+            'unfavorite': self.OnUnfavoriteCommand,
+            'schedule': self.OnScheduleCommand,
+            'remind': self.OnRemindCommand,
+            'trends': self.OnTrendsCommand,
+            'message': self.OnDirectMessageCommand,
+            'dm': self.OnDirectMessageCommand,
+            'limits': self.OnRateLimitsCommand
+        }
+
+        # enable the "as" command if we have multiple users setup
+        if len(self.config.users) > 1:
+            self._commands['as'] = self.OnAsCommand
+
+    def GetClient(self):
+        """
+        Gets the client and connects to Jabber if we're not already connected
+        """
+        config = self.config
+        try:
+            if not self._client:
+                # try to connect to Jabber
+                self.jid = xmpp.protocol.JID(config.login_as_user)
+                self._client = xmpp.Client(self.jid.getDomain(), debug=[])
+
+            if not self._client.isConnected():
+                self.conn = self._client.connect()
+
+                if self.conn:
+                    self.auth = self._client.auth(self.jid.getNode(),
+                                                  config.login_as_pass,
+                                                  resource=self.jid.getResource())
+                    if not self.auth:
+                        raise Exception('Failed to log into Jabber')
+                else:
+                    raise Exception('Failed to connect')
+        except Exception, ex:
+            log.error(ex)
+            time.sleep(5)
+            return self.GetClient()
+
+        return self._client
+    client = property(GetClient)
+
+    def OnExit(self, evt=None):
+        """
+        Shut everything down
+        """
+        log.info('Terminating the application')
+        self.checker.set()
+        self.poster.set()
+        self.client.disconnect()
+        sys.exit(0)
+
+    def OnHelpCommand(self, user, body, remaining):
+        """
+        Describes the commands a user can use and how to use them.
+        """
+        args = re.search('^([^ $]+)', remaining)
+        if args:
+            cmd = args.group(1)
+            cmd = self.FindCommand(cmd)
+            if cmd in self._commands.keys():
+                func = self._commands[cmd]
+                self.TellUser('%s usage:\n%s' % (cmd, func.__doc__))
+            else:
+                self.TellUser('Unknown command "%s"' % cmd)
+        else:
+            # display a list of possible commands
+            keys = self._commands.keys()
+            keys.sort()
+            cmd_list = ', '.join(keys)
+            self.TellUser('Possible commands: %s' % cmd_list)
+
+    def OnAsCommand(self, user, body, remaining):
+        """
+        Allows you to perform actions as a different user when you have multiple
+        Twitter accounts configured.  For example, if you configured "twitter_a"
+        and "twitter_b" properly, and "twitter_a" was your default user, all
+        updates you send to Twitter will be posted as "twitter_a" unless you do
+        something like `./as twitter_b I am not @twitter_a!`  In such a case,
+        "twitter_b" would post an update that says, "I am not @twitter_a!"
+        """
+        args = re.search('^([^ ]+) ', remaining)
+        if args:
+            desired = args.group(1)
+            user = self.users.get(desired, user)
+            body = re.sub('^%s ' % desired, '', remaining)
+        else:
+            self.TellUser('Incomplete command!')
+
+        return (user, body)
+
+    def OnFilterCommand(self, user, body, remaining):
+        """
+        The filter command allows you to filter out tweets that contain Twitter
+        hashtags that you don't care about (like #spymaster).  To see a list of
+        currently filtered tags, type `./filter` (with no arguments).  To filter
+        a tag that is not already filtered, type `./filter thetag`  For example,
+        if I wanted to filter all tweets dealing with #io2009, I would do
+        `./filter io2009` (no hash)
+        """
+        config = self.config
+
+        if len(remaining.strip()) == 0:
+            filtered = '\n'.join(['#' + t.tag for t in config.get_filtered_tags()])
+            self.TellUser('Currently filtered tags:\n' + filtered)
+        else:
+            # add a new tag to the filter list
+            args = re.search('^([^ $]+)', remaining)
+            if args:
+                tag = args.group(1)
+                try:
+                    config.add_filtered_tag(tag)
+                    self.TellUser('#%s is now filtered. Type "./filter" to see '
+                    'a list of filtered tags or "./unfilter [tag]" to remove a '
+                    'tag that is currently filtered.' % tag)
+                except:
+                    self.TellUser('#%s is already filtered!' % tag)
+
+    def OnUnfilterCommand(self, user, body, remaining):
+        """
+        The unfilter command allows you to remove Twitter hashtags from the list
+        of tags that you don't care about.  For example, if #io2009 was in my
+        list of hashtags to filter, I could remove it by typing
+        `./unfilter io2009`
+        """
+        config = self.config
+
+        # remove a new tag from the filter list
+        args = re.search('^([^ $]+)', remaining)
+        if args:
+            tag = args.group(1)
+            try:
+                config.remove_filtered_tag(tag)
+                self.TellUser('#%s has been removed!' % tag)
+            except:
+                self.TellUser('#%s is not filtered' % tag)
+        else:
+            self.TellUser('Incomplete command!')
+
+    def OnFollowCommand(self, user, body, remaining):
+        """
+        The follow command allows you to follow users on Twitter.  For example,
+        if I wanted to follow @tonyhawk, I would type `./follow tonyhawk`
+        """
+        # follow a new friend
+        args = re.search('^([^ $]+)', remaining)
+        if args:
+            username = args.group(1)
+            try:
+                user.api.CreateFriendship(username)
+                self.TellUser('You are now following %s' % username)
+            except urllib2.HTTPError:
+                self.TellUser('You are already following %s!' % username)
+        else:
+            self.TellUser('Incomplete command!')
+
+    def OnUnfollowCommand(self, user, body, remaining):
+        """
+        The unfollow command allows you to unfollow users on Twitter.  For
+        example, if I was following @lancearmstrong and decided I didn't want to
+        read about his life anymore, I would type `./unfollow lancearmstrong`
+        """
+        # unfollow someone
+        args = re.search('^([^ $]+)', remaining)
+        if args:
+            username = args.group(1)
+            try:
+                user.api.DestroyFriendship(username)
+                self.TellUser('You are no longer following %s' % username)
+            except urllib2.HTTPError:
+                self.TellUser('You are not following %s!' % username)
+        else:
+            self.TellUser('Incomplete command!')
+
+    def OnWhoIsCommand(self, user, body, remaining):
+        """
+        The whois command will display various bits of information about a
+        Twitter user.  Example: ./whois codekoala
+        """
+        args = re.search('^([^ $]+)', remaining)
+        if args:
+            username = args.group(1)
+            try:
+                info = user.api.GetUser(username)
+
+                init = ('location', 'description', 'url', 'utc_offset',
+                        'time_zone', 'name')
+                info_dict = dict((k, 'not specified') for k in init)
+                info_dict['profile'] = 'http://twitter.com/%s' % username
+                info_dict['status_text'] = info.status.text
+
+                info_dict.update(info.AsDict())
+
+                # clean up any unicode
+                for key, val in info_dict.items():
+                    info_dict[key] = cgi.escape(clean(val))
+
+                log.debug(info_dict)
+                self.TellUser('WhoIs: %(screen_name)s\n'
+                    'Name: %(name)s\n'
+                    'Profile: %(profile)s\n'
+                    'Location: %(location)s\n'
+                    'Description: %(description)s\n'
+                    'Homepage: %(url)s\n'
+                    'Status Updates: %(statuses_count)s\n'
+                    'Followers: %(followers_count)s\n'
+                    'Friends: %(friends_count)s\n'
+                    'Protected: %(protected)s\n'
+                    'UTC Offset: %(utc_offset)s\n'
+                    'Timezone: %(time_zone)s\n'
+                    'Status: %(status_text)s\n' % info_dict)
+            except urllib2.HTTPError:
+                self.TellUser('Invalid Twitter user: %s!' % username)
+        else:
+            self.TellUser('Incomplete command!')
+
+    def OnUndoCommand(self, user, body, remaining):
+        """
+        The undo command allows you to undo certain actions that you have done
+        using Twim.  If you accidentally post a update to Twitter, you can just
+        type ./undo and it will be removed (assuming you want to remove the
+        last tweet you posted with Twim).
+        """
+        if len(self._history):
+            last_tweet = self._history.pop()
+            try:
+                self.TellUser('Attempting to remove: %s' % last_tweet.text)
+                user.api.DestroyStatus(last_tweet.id)
+                self.TellUser('The tweet has been removed.')
+            except:
+                self.TellUser('Something went wrong when I tried to remove'
+                    ' the tweet.  Please try again.')
+
+                # add the tweet back to the stack
+                self._history.append(last_tweet)
+        else:
+            self.TellUser('There is nothing left for you to undo right now.')
+
+    def OnSearchCommand(self, user, body, remaining, callback=None):
+        """
+        Allows you to quickly search for something on Twitter.
+
+        Usage:
+            ./search #python          <-- search for #python
+            ./search|p=2 #python      <-- page 2 of the results
+            ./search|pp=15 #python    <-- show 15 results (defaults to 5)
+            ./search|p=5,pp=8 #python <-- show page 5 with 8 results per page
+            ./search from:google      <-- show latest tweets from @google
+            ./search Maui -from:ev    <-- tweets that contain "Maui" which were
+                                          not posted by @ev
+        """
+        params = dict(p=1, pp=5)
+        args = re.search('^\|([^ ]+)', remaining)
+        if args:
+            options = args.group(1)
+            log.info('Got options: ' + options)
+            for opt in options.split(','):
+                k,v = opt.split('=')
+                # be picky about the options we accept
+                if k in params.keys():
+                    params[k] = v
+            remaining = remaining[remaining.find(' '):].strip()
+
+        if len(remaining):
+            query = {
+                'q': remaining,
+                'page': params['p'],
+                'rpp': params['pp']
+            }
+            url = 'http://search.twitter.com/search.json'
+
+            log.info('[url=%s, params=%s and %s]' % (url, query, params))
+            try:
+                json = user.api._FetchUrl(url, parameters=query)
+                data = simplejson.loads(json)
+                results = data.get('results', [])
+                tweets = []
+                for result in results:
+                    tweet = twitter.Status.NewFromJsonDict(result)
+                    tweet.user = user.api.GetUser(result['from_user'])
+                    tweets.append(tweet)
+
+                # this is used for the OnIDsCommand
+                if callback:
+                    callback(tweets)
+
+                log.debug(tweets)
+
+                if len(tweets):
+                    self.SendTweets(user, tweets, do_not_update=True)
+                else:
+                    self.TellUser('No results for "%s"' % remaining)
+            except Exception, ex:
+                log.error(ex)
+                self.TellUser(
+                    'Error searching for "%s"! Please try again.' % remaining
+                )
+        else:
+            self.TellUser('Incomplete command!')
+
+    def OnScheduleCommand(self, user, body, remaining, is_reminder=False):
+        """
+        Allows you to schedule a tweet for posting at a later time.  You can
+        use several patterns to specify when a tweet should appear.  These
+        patterns are as follows:
+
+        YYYY-MM-DD HH:MM
+        MM/DD HH:MM
+        HH:MM
+
+        You also have the option of specifing a certain amount of time from
+        the time you schedule a tweet.  For example, you could schedule a
+        tweet to be posted in 5 seconds, or 2 minutes, or 1 hour, or 6 days.
+        These options cannot yet be combined into something like "5 minutes
+        and 15 seconds."
+
+        Examples:
+
+        ./schedule 2009-6-1 5:13 This should be posted at 5:13 AM on June 1st 2009
+        ./schedule 6/1 14:23 This should be posted at 2:23 PM on June 1st (cur year)
+        ./schedule 18:30 This should be posted at 6:30 PM
+        ./schedule 15minutes This should be posted 15 minutes from now
+        """
+        config = self.config
+
+        params = (
+            ('year', 'month', 'day', 'hour', 'minute', 'tweet'),
+            ('month', 'day', 'hour', 'minute', 'tweet'),
+            ('hour', 'minute', 'tweet'),
+            ('delta', 'unit', 'tweet'),
+        )
+
+        patterns = (
+            # 2009-6-1 5:13 This should be posted at 5:13 AM on June 1st 2009
+            '^(\d{4})[^:](\d{1,2})[^:](\d{1,2}) (\d{1,2}):(\d{2}) (.*)$',
+            # 6/1 14:23 This should be posted at 2:23 PM on June 1st (cur year)
+            '^(\d{1,2})[^:](\d{1,2}) (\d{1,2}):(\d{2}) (.*)$',
+            # 18:30 This should be posted at 6:30 PM
+            '^(\d{1,2}):(\d{2}) (.*)$',
+            # 15minutes This should be posted 15 minutes from now
+            '^(\d+)(seconds?|minutes?|hours?|days?) (.*)$',
+        )
+
+        matched = False
+        for i, pattern in enumerate(patterns):
+            match = re.match(pattern, remaining)
+            if match:
+                matched = True
+                args = dict(zip(params[i], match.groups()))
+                log.debug('Schedule command args: %s' % args)
+
+                tweet = args.pop('tweet')
+                now = datetime.now()
+
+                if 'delta' in args.keys():
+                    unit = args['unit']
+                    value = int(args['delta'])
+                    delta = timedelta(**{unit: value})
+                    schedule_at = now + delta
+                else:
+                    for key, val in args.items():
+                        args[key] = int(val)
+
+                    required = ('year', 'month', 'day')
+                    for arg in required:
+                        if arg not in args.keys():
+                            args[arg] = getattr(now, arg)
+
+                    schedule_at = datetime(**args)
+
+                log.info('Scheduled to say "%s" at %s' % (tweet, schedule_at))
+                config.add_scheduled_tweet.append(schedule_at, user.username,
+                                                  tweet, is_reminder)
+
+                action = is_reminder and 'remind you of' or 'tweet'
+                self.TellUser('I am now scheduled to %s "%s" at %s' % (
+                    action,
+                    tweet,
+                    schedule_at.strftime('%H:%M on %b %d, %Y')
+                ))
+
+        if not matched:
+            if len(remaining):
+                self.TellUser(
+                    "Sorry, but I don't understand your scheduling format!")
+            else:
+                schedule = [st for st in config.get_scheduled_tweets()]
+                self.TellUser('Scheduled Tweets:\n' + '\n'.join(schedule))
+
+    def OnRemindCommand(self, user, body, remaining):
+        """
+        The reminder command allows you to send yourself reminders at a certain
+        time on a certain day, using the same syntax as the schedule command.
+        """
+        return self.OnScheduleCommand(user, body, remaining, is_reminder=True)
+
+    def OnTrendsCommand(self, user, body, remaining):
+        """
+        Retrieves the trends that are currently most popular on Twitter
+        """
+
+        try:
+            url = 'http://search.twitter.com/trends/current.json'
+
+            json = user.api._FetchUrl(url)
+            data = simplejson.loads(json)
+            log.debug(data)
+            results = data.get('trends', {' ': []})
+            info = results.popitem()[1]
+
+            trends = []
+            for trend in info:
+                log.debug(trend)
+                query, name = trend.values()
+                if query == name:
+                    trends.append(query)
+                else:
+                    trends.append('%s (search for %s)' % (name, query))
+
+            if len(trends):
+                self.TellUser('Current Trends:\n%s' % '\n'.join(trends))
+            else:
+                self.TellUser('No trends were retrieved.')
+        except Exception, ex:
+            log.error(ex)
+            self.TellUser(
+                'Something went wrong when I tried to get the current trends.')
+
+    def OnRetweetCommand(self, user, body, remaining):
+        """
+        The retweet command allows you to retweet a particular tweet.  For
+        example, if you wanted to retweet something that @ev said about Maui,
+        first you must find the tweet's ID, and then you use it as so:
+
+        [ 105 ] ev: In Maui. Wowee.
+        ./retweet 105
+
+        The retweet command automatically prepends your tweet with "RT @[from]"
+        where "[from]" is replaced by the person whose tweet you're retweeting.
+        """
+        cache = self.cache
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = cache.with_id(tid)
+            if tweet:
+                text = 'RT @%s %s' % (tweet.user.screen_name, tweet.text)
+                self.PostUpdate(user, text)
+            else:
+                self.TellUser('Invalid tweet!  Please try again.')
+        else:
+            self.TellUser('Please specify an ID!')
+
+    def OnReplyCommand(self, user, body, remaining):
+        """
+        The reply command allows you to reply to a particular tweet.  You must
+        first run the ./ids command to get an ID for the tweet you wish to
+        reply to.  For example, if you wanted to reply to something that @ev
+        said about Maui, you would do this:
+
+        ./ids from:ev Maui
+        ev: [ 0 ] In Maui. Wowee.
+        ./reply 0
+
+        The reply command automatically adds the @[from], where "[from]" is
+        replaced by the username of the user to whom you are replying.
+        """
+        cache = self.cache
+
+        args = re.search('^(\d+) (.*)$', remaining)
+        if args:
+            tid = args.group(1)
+            reply = args.group(2)
+            tweet = cache.with_id(tid)
+            if tweet:
+                text = '@%s %s' % (tweet.user.screen_name, reply)
+                self.PostUpdate(user, text, in_reply_to_status_id=tweet.id)
+            else:
+                self.TellUser('Invalid tweet!  Please try again.')
+        else:
+            self.TellUser('Please specify an ID!')
+
+    def OnFavoriteCommand(self, user, body, remaining):
+        """
+        The favorite command allows you to mark a particular tweet as one of
+        your favorite.  You must first run the ./ids command to get an ID for
+        the tweet you wish to favorite.  For example, if you wanted to mark
+        something that @ev said about Maui, you would do:
+
+        ./ids from:ev Maui
+        ev: [ 0 ] In Maui. Wowee.
+        ./favorite 0
+        """
+        cache = self.cache
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = cache.with_id(tid)
+            if tweet:
+                try:
+                    user.api.CreateFavorite(tweet)
+                    self.TellUser(
+                        'You have marked "%s" from @%s as a favorite.' % (
+                            tweet.text,
+                            tweet.user.screen_name
+                        ))
+                except Exception, ex:
+                    log.error(ex)
+                    self.TellUser('That is already one of your favorites!')
+            else:
+                self.TellUser('Invalid tweet!  Please try again.')
+        else:
+            self.TellUser('Please specify an ID!')
+
+    def OnUnfavoriteCommand(self, user, body, remaining):
+        """
+        The unfavorite command allows you to remove a particular tweet as one
+        of your favorite.  You must first run the ./ids command to get an ID
+        for the tweet you wish to unfavorite.  For example, if you wanted to
+        unfavorite something that @ev said about Maui, you would do:
+
+        ./ids from:ev Maui
+        ev: [ 0 ] In Maui. Wowee.
+        ./unfavorite 0
+        """
+        cache = self.cache
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = cache.with_id(tid)
+            if tweet:
+                try:
+                    user.api.DestroyFavorite(tweet)
+                    self.TellUser('The tweet is no longer one of your favorite.')
+                except Exception, ex:
+                    log.error(ex)
+                    self.TellUser('That is not one of your favorites!')
+            else:
+                self.TellUser('Invalid tweet!  Please try again.')
+        else:
+            self.TellUser('Please specify an ID!')
+
+    def OnDirectMessageCommand(self, user, body, remaining):
+        """
+        This command allows you to send a particular Twitter user a direct
+        message.  Example:
+
+        ./dm codekoala word to your mother
+        ./message ev thanks for Twitter
+        """
+
+        args = re.search('^([^ ]+) (.*)$', remaining)
+        if args:
+            username = args.group(1)
+            message = args.group(2)
+            try:
+                log.info('Sending DM to %s (%s)' % (username, message))
+                user.api.PostDirectMessage(username, message)
+                self.TellUser('The message has been sent!')
+            except Exception, ex:
+                log.error(ex)
+                self.TellUser("I couldn't send the message.  Try again?")
+        else:
+            self.TellUser('Incomplete command!'),
+
+    def OnRateLimitsCommand(self, user, body, remaining):
+        """
+        The limits command allows you to check how many API calls you have left
+        """
+        try:
+            url = 'http://twitter.com/account/rate_limit_status.json'
+
+            json = user.api._FetchUrl(url)
+            data = simplejson.loads(json)
+            log.debug(data)
+
+            self.TellUser('Current Rate Limits for %s\n%s' % (
+                user.username,
+                '\n'.join(['%s: %s' % (k.replace('_', ' ').title(), v) for k,v in data.items()])
+            ))
+        except Exception, ex:
+            log.error(ex)
+            self.TellUser(
+                'Something went wrong when I tried to get your limits.')
+
+    def FindCommand(self, text):
+        """
+        Attempts to find a Twim command based on the specified text.  The text
+        may be a full command or the prefix that returns a unique command.
+        """
+        command = None
+        if text in self._commands.keys():
+            command = text
+        else:
+            matches = []
+            for cmd in self._commands.keys():
+                if cmd.startswith(text):
+                    matches.append(cmd)
+
+            if len(matches) == 1:
+                command = matches[0]
+            elif len(matches) > 1:
+                possible = ', '.join(matches)
+                self.TellUser(
+                    'Invalid command.  Possible matches include: ' + possible)
+
+        return command
+
+    def OnMessage(self, con, evt):
+        """
+        Handles messages sent by the user.  This will post updates to Twitter
+        directly from your IM client.
+        """
+        config = self.config
+        pattern = '^\.\/'
+        sender = evt.getFrom().getStripped()
+        body = evt.getBody()
+
+        if not body: return
+        log.info('Received message from %s: %s' % (sender, body))
+
+        # don't accept messages from anyone but the user we sent tweets to
+        if sender != config.send_to_user: return
+
+        # make sure this is a valid message
+        sub_types = ('subscribe', 'subscribed')
+        valid_types = sub_types + ('message', 'chat', None)
+        if evt.getType() not in valid_types: return
+
+        # this should accept a buddy request from the user to whom we send
+        # tweets
+        if evt.getType() in sub_types:
+            for typ in sub_types:
+                self.client.send(xmpp.Presence(to=config.send_to_user, typ=typ))
+            return
+
+        as_user = config.GetDefaultUser()
+        body = body.strip()
+        if body.startswith('./'):
+            # find the commands provided by the user
+            match = True
+
+            # loop to allow chain commands, just as: ./as user ./reply 0 asdf
+            while match:
+                action = None
+                proceed = False
+
+                match = re.search('%s([^ $]+)' % pattern, body)
+                if match:
+                    cmd = match.group(1)
+                    log.info('%s command received' % cmd.upper())
+                    remaining = re.sub('%s%s ?' % (pattern, cmd),
+                                       '',
+                                       body).strip()
+                    command = self.FindCommand(cmd)
+                    action = self._commands.get(command, None)
+
+                    if action:
+                        log.info('calling command method %s' % action)
+                        proceed = action(as_user, body, remaining)
+
+                        # this is so the ./as command doesn't keep looping :)
+                        if proceed and isinstance(proceed, tuple):
+                            as_user, body = proceed
+                            proceed = True
+                    else:
+                        if command:
+                            self.TellUser('Command "%s" is not yet implemented' % body)
+                        else:
+                            self.TellUser('Unrecognized command "%s"' % body)
+                        return
+                else:
+                    log.info('Received unrecognized command: %s' % body)
+                    self.TellUser('Unrecognized command "%s"' % body)
+                    return
+
+                log.debug('Message Body: %s :: %s :: %s' % (body, action, proceed))
+                if action and not proceed:
+                    return
+
+                body = body.strip()
+
+        self.PostUpdate(as_user, body)
+
+    def PostUpdate(self, user, body, in_reply_to_status_id=None):
+        """
+        Sends an update to Twitter
+        """
+
+        # find any long URLs and turn them into 2ze.us URLs
+        to_shorten = []
+        data = {}
+        user_info = '. I replaced the following URLs to save space:\n'
+        urls = HREF_RE.findall(body)
+        log.debug('Found URLs: %s' % urls)
+        for url in urls:
+            if len(url[0]) >= 25:
+                log.debug('Shortening: %s' % url[0])
+                to_shorten.append(url[0])
+
+        if len(to_shorten):
+            # send the URLs to 2ze.us
+            args = '&'.join(urllib.urlencode({'url': url}) for url in to_shorten)
+            url = 'http://2ze.us/generate/?' + args
+            log.debug(url)
+            json = urllib2.urlopen(url).read()
+            data = simplejson.loads(json)
+            log.debug('2ze.us output: %s' % data)
+
+            for url, info in data.get('urls', {}).items():
+                body = body.replace(url, info['shortcut'])
+                user_info += '%s => %s (%s smaller)\n' % (url,
+                                                          info['shortcut'],
+                                                          info['compression'])
+
+        posted = user.api.PostUpdates(body,
+                                      in_reply_to_status_id=in_reply_to_status_id)
+        if posted:
+            log.info('Posted message as %s' % user.username)
+            self._history.extend(posted)
+            text = 'Successfully posted %i message%s as %s' % (
+                len(posted),
+                len(posted) != 1 and 's' or '',
+                user.username
+            )
+            if len(data):
+                text += user_info
+
+            self.TellUser(text)
+
+    def OnTimer(self, event, interval, action):
+        """
+        For the love of threads!
+        """
+        config = self.config
+        while not event.isSet():
+            try:
+                event.wait(interval)
+
+                # run through any scheduled tweets to see if it's time to post
+                for tweet in config.get_due_tweets():
+                    log.info('Time to tweet "%s"' % tweet.text)
+                    self.PostUpdate(tweet.user, tweet.text)
+                    config.remove_scheduled_tweet(tweet)
+
+                action()
+            except Exception, ex:
+                log.error(ex)
+                self.TellUser('We are experiencing problems... please be patient.')
+
+    def CheckForUpdates(self):
+        """
+        Checks each Twitter user's junk for updates to their timeline.  If
+        updates are found, they will be sent to the Jabber user specified in
+        the configuration file
+        """
+        try:
+            now = datetime.now()
+            for user in self.config.users:
+                last_update = user.last_update or now - self.interval
+                next_run = last_update + self.interval
+                if next_run <= now:
+                    self.GetUpdatesFor(user)
+        except urllib2.HTTPError, ex:
+            log.error(ex)
+            self._client = None
+            self.GetClient()
+            time.sleep(5)
+
+    def CheckForPosts(self):
+        """
+        Handles messages sent through Jabber... this is running in its own
+        thread
+        """
+        print 'Checking for posts'
+        self.client.Process(1)
+
+    def GetUpdatesFor(self, user):
+        """
+        Connects to Twitter to find updates for a particular user.  If updates
+        are found, they're IM'ed out.
+        """
+        config = self.config
+
+        # get some updates
+        last_id = user.last_tweet_id or config.last_tweet_id
+        try:
+            updates = user.api.GetFriendsTimeline(since_id=last_id)
+        except Exception, ex:
+            # sometimes we get an HTTPError
+            log.error(ex)
+            updates = []
+
+        if updates and len(updates):
+            updates.reverse()
+            self.SendTweets(user, updates)
+
+        user.last_update = datetime.now()
+
+    def SendTweets(self, user, tweets, do_not_update=False):
+        """
+        Sends a collection of tweets to the user's IM
+        """
+        config = self.config
+        cache = self.cache
+        prefix = len(config.users) > 1 and '(%s) ' % user.username or ''
+
+        for tweet in tweets:
+            tweet = cache.get(tweet)
+            name = tweet.user and tweet.user.screen_name or ''
+
+            # make sure the tweet doesn't have any filtered tags in it
+            good_tags = True
+            for tag in config.get_filtered_tags():
+                if '#' + tag.tag.lower() in tweet.text.lower():
+                    good_tags = False
+                    break
+            if not good_tags: continue
+
+            text = self.HTMLizeTweet(name, tweet)
+            log.debug('HTML Text: ' + text)
+
+            pre = '[ %i ] %s' % (tweet.counter, prefix)
+            params = {
+                'body': pre + name + ': ' + tweet.text,
+                'html': pre + text
+            }
+            output = clean(TWEET % params)
+            output = unicode(output).translate(TRANSLATIONS)
+
+            log.debug(output)
+
+            try:
+                node = xmpp.simplexml.BadXML2Node(output)
+                kw = {'node': node}
+            except:
+                # sometimes we get invalid XML--from HTML entities
+                kw = {'body': params['body']}
+
+            # send the update
+            message = xmpp.Message(to=config.send_to_user, **kw)
+            self.client.send(message)
+
+            # sleep a bit so we don't get flagged as a spammer
+            time.sleep(1)
+
+            # keep track of the last update
+            if not do_not_update:
+                config.last_tweet_id = user.last_tweet_id = int(tweet.id)
+
+    def HTMLizeTweet(self, name, tweet):
+        """
+        Makes a few enhancements to the text of a tweet so it can render as
+        HTML
+        """
+        if len(name):
+            uhref = HREF_TEMPLATE % {'user': name, 'id': tweet.id}
+        else:
+            uhref = ''
+
+        text = clean(tweet.text.replace('& ', '&amp; '))
+        text = AT_REPLY_RE.sub(
+            r'@<a href="http://twitter.com/\1">\1</a>',
+            text)
+        text = HASH_TAG_RE.sub(
+            r'\1<em><a href="http://twitter.com/#search?q=%23\2">#\2</a></em>\3',
+            text)
+
+        return uhref + ': ' + text
+
+    def TellUser(self, message):
+        """
+        Sends a message back to the user
+        """
+        msg = xmpp.Message(to=self.config.send_to_user,
+                           body='==> ' + message)
+        self.client.send(msg)
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+This is where all of the configuration and cache storage takes place.  Two
+separate sqlite databases are used here: a persistent one for configuration,
+and a volatile one for caching.  Maintaining a separate database for cache
+makes it easier to use commands like retweeting and replying.
+"""
+
+import logging
+import os
+import sqlalchemy as sql
+import sqlite3
+import twitter
+from datetime import datetime
+from functools import wraps
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy import pool
+
+log = logging.getLogger('twim.db')
+
+HOME_DIR = os.path.expanduser('~')
+CONFIG = os.path.join(HOME_DIR, '.twimdb')
+CACHE = os.path.join(HOME_DIR, '.twimcache')
+
+ConfigBase = declarative_base()
+CacheBase = declarative_base()
+
+ConfigSession = scoped_session(sessionmaker())
+CacheSession = scoped_session(sessionmaker())
+
+class TwimUser(ConfigBase):
+    """
+    Represents a Twitter user in Twim.
+    """
+    __tablename__ = 'twim_user'
+    _api = None
+
+    username = sql.Column(sql.String, primary_key=True)
+    password = sql.Column(sql.String)
+    last_tweet_id = sql.Column(sql.Integer)
+    last_update = sql.Column(sql.DateTime)
+
+    def __init__(self, username, password, last_tweet=None, last_update=None):
+        self.username = username
+        self.password = password
+        self.last_tweet = last_tweet
+        self.last_update = last_update
+
+    def __unicode__(self):
+        return self.username
+
+    def _get_api(self):
+        """
+        Returns the Twitter API for this user
+        """
+        if not self._api:
+            import twim
+            # connect to Twitter for this user
+            self._api = twitter.Api(username=self.username,
+                                    password=self.password)
+            self._api.SetSource(twim.APP_TITLE)
+            self._api.SetXTwitterHeaders(twim.APP_TITLE,
+                                         twim.__url__,
+                                         twim.__version__)
+        return self._api
+    api = property(_get_api)
+
+class Setting(ConfigBase):
+    """
+    Represents a configuration setting for Twim
+    """
+    __tablename__ = 'setting'
+
+    key = sql.Column(sql.String, primary_key=True)
+    value = sql.Column(sql.String)
+    default = sql.Column(sql.String)
+    data_type = sql.Column(sql.String)
+
+    def __init__(self, key, default='', data_type='str'):
+        self.key = key
+        self.default = default
+        self.data_type = data_type
+
+    def __unicode__(self):
+        self.key
+
+class ScheduledTweet(ConfigBase):
+    """
+    Represents a tweet that is scheduled to be sent at some point in the future
+    """
+    __tablename__ = 'scheduled_tweet'
+
+    id = sql.Column(sql.Integer, primary_key=True)
+    as_user = sql.Column(sql.String, sql.ForeignKey('twim_user.username'))
+    text = sql.Column(sql.String)
+    send_at = sql.Column(sql.DateTime)
+    is_reminder = sql.Column(sql.Boolean)
+
+    def __init__(self, user, text, send_at, is_reminder=False):
+        self.as_user = user
+        self.text = text
+        self.send_at = send_at
+        self.is_reminder = is_reminder
+
+    def __unicode__(self):
+        return u'On %s: %s' % (self.send_at.strftime('%Y-%m-%d (%H:%M)'),
+                               self.text)
+
+class FilteredTag(ConfigBase):
+    """
+    Represents a hashtag that the user would like to filter from the updates
+    sent to them
+    """
+    __tablename__ = 'filtered_tag'
+
+    tag = sql.Column(sql.String, primary_key=True)
+
+    def __init__(self, tag):
+        self.tag = tag
+
+    def __unicode__(self):
+        return self.tag
+
+class User(CacheBase):
+    """
+    Represents a Twitter user from python-twitter
+    """
+    __tablename__ = 'user'
+
+    id = sql.Column(sql.Integer, primary_key=True)
+    name = sql.Column(sql.String)
+    screen_name = sql.Column(sql.String)
+
+    def __init__(self, id, name, screen_name=None):
+        self.id = id
+        self.name = name
+        self.screen_name = screen_name or name
+
+    def __unicode__(self):
+        return self.screen_name
+
+class Status(CacheBase):
+    """
+    Represents a Status object from python-twitter
+    """
+    __tablename__ = 'status'
+
+    counter = sql.Column(sql.Integer, primary_key=True)
+    id = sql.Column(sql.Integer, unique=True)
+    user = sql.Column(sql.Integer, sql.ForeignKey('user.id'))
+    text = sql.Column(sql.String)
+    source = sql.Column(sql.String)
+    in_reply_to_status_id = sql.Column(sql.Integer)
+    in_reply_to_user_id = sql.Column(sql.Integer)
+    in_reply_to_screen_name = sql.Column(sql.String)
+    favorited = sql.Column(sql.String)
+#    created_at = sql.Column(sql.DateTime)
+
+    def __init__(self, **kwargs):
+        """
+        Initialize the tweet's information
+        """
+        for key, val in kwargs.items():
+            if hasattr(self, key):
+                if key == 'user':
+                    # handle the foreign key
+                    u = User(val['id'],
+                             val['name'],
+                             val['screen_name'])
+                    self.user = u
+                else:
+                    setattr(self, key, val)
+
+class Config(object):
+    defaults = {
+        'update_interval': (90, 'int'),
+        'last_tweet_id': (0, 'int'),
+        'login_as_user': ('', 'str'),
+        'login_as_pass': ('', 'str'),
+        'send_to_user': ('', 'str'),
+    }
+    #_session = None
+
+    def __init__(self):
+        self.engine = sql.create_engine('sqlite:///' + CONFIG, echo=False)
+
+        ConfigSession.configure(bind=self.engine)
+        self.metadata = ConfigBase.metadata
+
+        # ensure that the appropriate tables have been created
+        self.metadata.create_all(self.engine)
+        self.session = ConfigSession()
+
+        self.init_defaults()
+
+#    def _get_session(self):
+#        if not self._session or self._session.connection().closed:
+#            self._session = ConfigSession()
+#        return self._session
+#    session = property(_get_session)
+#
+#    def close(self):
+#        if self._session:
+#            self._session.close()
+
+    def _get_users(self):
+        """
+        Retrieves the information for the Twitter accounts that the user wishes
+        to check
+        """
+        results = self.session.query(TwimUser).all()
+#        self.close()
+        return results
+
+    def _set_users(self, collection):
+        """
+        Makes sure that a particular list of users is in our database
+        """
+        still_valid = [u.username for u in collection]
+        to_remove = []
+        for user in self.users:
+            if user.username not in still_valid:
+                to_remove.append(user)
+
+        for user in to_remove:
+            self.session.delete(user)
+
+        for user in collection:
+            self.session.add(user)
+        self.session.commit()
+#        self.close()
+
+    users = property(_get_users, _set_users)
+
+    def get_filtered_tags(self):
+        """
+        Retrieves any tags that should be filtered
+        """
+        results = self.session.query(FilteredTag).all()
+#        self.close()
+        return results
+
+    def add_filtered_tag(self, tag):
+        """
+        Adds a new tag to filter to the database
+        """
+        self.session.add(FilteredTag(tag))
+        self.session.commit()
+#        self.close()
+
+    def remove_filtered_tag(self, tag):
+        """
+        Removes a filtered tag from the database.  Any exceptions must be
+        handled wherever this method is called
+        """
+        obj = self.session.query(FilteredTag).filter(FilteredTag.tag==tag).one()
+        self.session.delete(obj)
+#        self.close()
+
+    def get_due_tweets(self):
+        """
+        Retrieves any scheduled tweets that are past due
+        """
+        return self.session.query(ScheduledTweet).\
+            filter(ScheduledTweet.send_at<=datetime.now()).all()
+#        self.close()
+
+    def get_scheduled_tweets(self):
+        """
+        Retrieves all scheduled tweets
+        """
+        results = self.session.query(ScheduledTweet).all()
+#        self.close()
+        return results
+
+    def add_scheduled_tweet(self, user, text, send_at, is_reminder=False):
+        """
+        Adds a scheduled tweet to the database
+        """
+        tweet = ScheduledTweet(user, text, send_at, is_reminder)
+        self.session.add(tweet)
+        self.session.commit()
+#        self.close()
+
+    def remove_scheduled_tweet(self, tweet):
+        """
+        Removes a scheduled tweet from the database
+        """
+        self.session.delete(tweet)
+#        self.close()
+
+    def init_defaults(self):
+        """
+        Initializes any default configuration settings we may have
+        """
+        for key, value in Config.defaults.items():
+            res = self.session.query(Setting).filter(Setting.key==key).first()
+            if not res:
+                default, data_type = value
+                self.session.add(Setting(key, default, data_type))
+        self.session.commit()
+#        self.close()
+
+    def __getattr__(self, key):
+        """
+        Attempts to make it easier to access configuration settings
+        """
+        if key in Config.defaults.keys():
+            res = self.session.query(Setting).filter(Setting.key==key).first()
+            default = Config.defaults[key][0]
+
+            if res: value = res.value
+            if value == None: value = default
+
+            log.debug('Getting setting "%s": %s' % (key, value))
+        else:
+            value = getattr(super(Config, self), key, None)
+#        self.close()
+
+        return value
+
+    def __setattr__(self, key, value):
+        """
+        Attempts to make it easier to save configuration settings
+        """
+#        log.debug('Attempting to set %s to %s' % (key, value))
+        if key in Config.defaults.keys():
+            res = self.session.query(Setting).filter(Setting.key==key).one()
+            setattr(res, 'value', value)
+#            self.close()
+        else:
+            super(Config, self).__setattr__(key, value)
+
+class Cache(object):
+    def __init__(self):
+        self.engine = sql.create_engine('sqlite:///' + CACHE, echo=False)
+        
+        # create a session
+        CacheSession.configure(bind=self.engine)
+        self.metadata = CacheBase.metadata
+
+        # ensure that the appropriate tables have been created (and wiped)
+        self.metadata.drop_all(self.engine)
+        self.metadata.create_all(self.engine)
+        self.session = CacheSession()
+
+    def get(self, tweet):
+        """
+        Tries to find a cached version of the tweet
+        """
+        res = self.session.query(Status).filter(Status.id==tweet.id).first()
+        if not res:
+            res = Status(**tweet.AsDict())
+            self.session.add(res)
+            self.session.commit()
+        return res
+
+    def with_id(self, id):
+        """
+        Finds a cached tweet with the specified ID or None if there's no match
+        """
+        log.info('Retrieving ID %s from cache' % id)
+        res = self.session.query(Status).filter(Status.counter==int(id)).first()
+        return res
+
+# wrap the User and Status constructors so we can cache them seamlessly
+def cache_user(func):
+    @wraps(func)
+    def wrapped(*args, **kwargs):
+        obj = func(*args, **kwargs)
+        if obj:
+            user = User(obj.id, obj.name, obj.screen_name)
+            cache.session.add(user)
+            cache.session.commit()
+        return obj
+    return wrapped
+
+def cache_status(func):
+    @wraps(func)
+    def wrapped(*args, **kwargs):
+        obj = func(*args, **kwargs)
+        if obj:
+            status = Status(**obj.AsDict())
+            cache.session.add(status)
+            cache.session.commit()
+            obj = status
+        return obj
+    return wrapped
+
+twitter.User.__init__ = cache_user(twitter.User.__init__)
+twitter.Status.__init__ = cache_status(twitter.Status.__init__)

Binary file added.

Added
New image
Added
New image
Added
New image
Added
New image
 setup.py - script for building Twim
 
 Windows Usage:
-    % python setup.py py2exe
-
-MacOS X Usage:
+    % python setup.py py2exe
+
+MacOS X Usage:
     % python setup.py py2app
 
 """
     )),
     ('resources', (
         'twim.ico',
+        'twim.icns',
+        'twim_16x16.png',
+        'twim_32x32.png',
+        'twim_48x48.png',
+        'twim_128x128.png'
     )),
 ]
 
-if 'py2app' in sys.argv and sys.platform == 'darwin':
-    import py2app
-    options = dict(
-        iconfile='twim.icns',
-        compressed=1,
-        optimize=2,
-        plist=dict(
-            CFBundleName = APP_TITLE,
-            CFBundleShortVersionString = APP_VERSION,
-            CFBundleGetInfoString = '%s %s' % (APP_TITLE,
-                                               APP_VERSION),
-            CFBundleExecutable = APP_TITLE,
-            CFBundleIdentifier = 'com.codekoala.%s' % APP_TITLE.lower(),
-        )
-    )
-
-    setup(app=['twim.py'],
-          name=APP_TITLE,
-          version=APP_VERSION,
-          data_files=data_files,
-          options=dict(py2app=options),
-    )
+if 'py2app' in sys.argv and sys.platform == 'darwin':
+    import py2app
+    options = dict(
+        iconfile='res/twim.icns',
+        semi_standalone=1,
+        compressed=1,
+        optimize=2,
+        plist=dict(
+            CFBundleName = APP_TITLE,
+            CFBundleShortVersionString = APP_VERSION,
+            CFBundleGetInfoString = '%s %s' % (APP_TITLE,
+                                               APP_VERSION),
+            CFBundleExecutable = APP_TITLE,
+            CFBundleIdentifier = 'com.codekoala.%s' % APP_TITLE.lower(),
+        )
+    )
+
+    setup(app=['twim.py'],
+          name=APP_TITLE,
+          version=APP_VERSION,
+          data_files=data_files,
+          options=dict(py2app=options),
+    )
 elif 'py2exe' in sys.argv and sys.platform in ['nt', 'win32']:
     import py2exe
 
         other_resources=[
             (RT_MANIFEST, 1, manifest_template % dict(prog=APP_TITLE))
         ],
-        icon_resources=[(1, 'twim.ico')],
+        icon_resources=[(1, 'res/twim.ico')],
         dest_base='twim')
 
     excludes = ['pywin', 'pywin.debugger', 'pywin.debugger.dbgcon',
     )
 else:
     setup(
-        name='twitter-im',
+        name=APP_TITLE.lower(),
         version=APP_VERSION,
         url='http://bitbucket.org/codekoala/twitter-im',
         author='Josh VanderLinden',

Binary file removed.

Removed
Old image
 # -*- coding: utf-8 -*-
 
 """
-Regularly check your Twitter accounts for updates and IMs them to you.
+Twim is a simple program which regularly checks your Twitter account(s) for
+updates.  Upon finding an update, it will send it to you via Instant Message
+using any Jabber-compatible client.
 """
 
-from datetime import datetime, timedelta
-from functools import wraps
-from threading import Thread, Event
-from wx import wizard
-from wx.lib.agw.hyperlink import HyperLinkCtrl
-from wx.lib.embeddedimage import PyEmbeddedImage
-import base64
-import cgi
-import ConfigParser
 import logging
-import os
-import pickle
-import re
-import simplejson
 import sys
-import time
-import twitter
-import unicodedata
-import urllib
-import urllib2
-import wx
-import xmpp
+from core import TwimApp
 
 APP_TITLE = 'Twim'
-__homepage__ = 'http://bitbucket.org/codekoala/twitter-im'
-__version__ = '0.0.3.1'
-
-AT_REPLY_RE = re.compile('@(\w+)')
-HASH_TAG_RE = re.compile('([^&])#([\w\-]+)[^;]?')
-EMAIL_RE = re.compile('[a-z0-9._%-]+@[a-z0-9.-]+\.[a-z]{2,4}', re.I)
-HREF_RE = re.compile('(http://([\w+?\.\w+]+)([a-z0-9\~\!\@\#\$\%\^\&amp;\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*))', re.I)
-HREF_TEMPLATE = '<strong><a href="http://twitter.com/%s/status/%i">%s</a></strong>'
-
-HOME_DIR = os.path.expanduser('~')
-CONFIG_FILE = os.path.join(HOME_DIR, '.twitterimrc')
-
-# configure logging
-format = '%(levelname)s %(asctime)s %(name)s:%(funcName)s:%(lineno)d\n\t%(message)s\n'
-
-logging.basicConfig(filename='twim.log',
-                    filemode='w',
-                    level=logging.DEBUG,
-                    format=format)
-log = logging.getLogger('twim')
-
-USERNAME = 'username[%i]'
-PASSWORD = 'password[%i]'
-DEFAULTS = {
-    'twitter': {
-        'default_user': (0, int),
-        'update_interval': (90, int),
-        'last_tweet_id': (0, int),
-        'username[0]': 'twitter_id',
-        'password[0]': 'twitter_pass',
-        'filtered_tags': '',
-        'scheduled_tweets': '',
-    },
-    'jabber': {
-        'login_as_user': 'sender@jabber.org',
-        'login_as_pass': '',
-        'send_to_user': 'yourjabber@jabber.org',
-    }
-}
-OPTIONS = {}
-for section, options in DEFAULTS.items():
-    for option in options.keys():
-        OPTIONS[option] = section
-
-# Used to make our text nicer to play with
-clean = lambda s: unicodedata.normalize('NFKD',
-                                        unicode(s)).encode('ascii',
-                                                           'xmlcharrefreplace')
-TRANSLATIONS = {
-    0x201c: u'"',
-    0x201d: u'"',
-    0x2018: u"'",
-    0x2019: u"'",
-}
-
-class User(object):
-    __slots__ = ('id', 'username', 'password', 'last_tweet_id', '_api', 
-                 'last_update')
-    def __init__(self, **kwargs):
-        for kw, val in kwargs.items():
-            setattr(self, kw, val)
-        
-        for sl in self.__slots__:
-            if sl not in kwargs.keys():
-                setattr(self, sl, None)
-    
-    def _get_api(self):
-        """
-        Returns the Twitter API for this user
-        """
-        if not self._api:
-            # connect to Twitter for this user
-            self._api = twitter.Api(username=self.username, 
-                                    password=self.password)
-            self._api.SetSource(APP_TITLE)
-            self._api.SetXTwitterHeaders(APP_TITLE, __homepage__, __version__)
-        return self._api
-
-    api = property(_get_api)
-
-class IMConfig(object):
-    _users = {}
-    _scheduled_tweets = []
-    def __init__(self, configfile):
-        # read the configuration and establish default settings
-        self.configfile = configfile
-        self._parser = ConfigParser.SafeConfigParser()
-        log.debug('Reading config from %s' % configfile)
-        self._parser.read(configfile)
-        self._default_user = None
-
-        for section, options in DEFAULTS.items():
-            if not self._parser.has_section(section):
-                self._parser.add_section(section)
-
-            for option, default in options.items():
-                if not self._parser.has_option(section, option):
-                    self._parser.set(section, option,
-                                     str(self._get_default(section, option)))
-
-        # save the configuration just in case the file has not yet been created
-        self.Persist()
-
-        self.GetUserList()
-        self.GetScheduledTweets()
-
-        if self.send_to_user == self.login_as_user:
-            raise ValueError('send_to_user cannot be the same as login_as_user!')
-
-    def __getattr__(self, option):
-        """
-        Allows easier access to various configuration options
-        """
-        try:
-            assert not option.startswith('_') and not option.endswith('_')
-
-            section = OPTIONS[option]
-            try:
-                cast = DEFAULTS[section][option][1]
-            except IndexError:
-                cast = str
-
-            # determine which method we'll use to retrieve the option based
-            # on its datatype
-            func = {
-                bool: self._parser.getboolean,
-                float: self._parser.getfloat,
-                int: self._parser.getint
-            }.get(cast, self._parser.get)
-
-            # make sure we have the default value ready just in case
-            default = self._get_default(section, option)
-
-            result = func(section, option)
-            if result == None:
-                # only use the default when we get 'None' from the function
-                result = default
-
-            return result
-        except:
-            pass
-
-        return getattr(super(IMConfig, self), option, None)
-
-    def __setattr__(self, option, value):
-        """
-        Allows easier access to various configuration options
-        """
-        try:
-            assert not option.startswith('_') and not option.endswith('_')
-
-            section = OPTIONS[option]
-            result = self._parser.set(section, option, str(value))
-            #self.Persist()
-            return result
-        except:
-            pass
-
-        return super(IMConfig, self).__setattr__(option, value)
-
-    def _get_default(self, section, option):
-        """
-        Determines the default value of some setting
-        """
-        default = DEFAULTS[section].get(option, '')
-        if isinstance(default, tuple):
-            default = default[0]
-        return default
-
-    def GetUserList(self):
-        """
-        Retrieves the list of Twitter users whose timeline will be checked for
-        updates.
-        """
-        users = {}
-
-        # walk over n users in the config
-        counter = 0
-        loop = True
-        while loop:
-            try:
-                un_opt = USERNAME % counter
-                pw_opt = PASSWORD % counter
-
-                username = self._parser.get('twitter', un_opt)
-                password = self._parser.get('twitter', pw_opt)
-
-                if self._get_default('twitter', un_opt) != username:
-                    users[username] = User(
-                        id=counter,
-                        username=username,
-                        password=password
-                    )
-
-                counter += 1
-            except ConfigParser.NoOptionError:
-                # if we get here, we have no more users
-                loop = False
-
-        self._users = users
-        return self._users
-    
-    def RemoveUser(self, username):
-        """
-        Removes a user from the list of Twitter users based on their username
-        """
-        if username in self._users.keys():
-            user = self._users[username]
-            if user.id:
-                # remove the user from the config file
-                for opt in (USERNAME % user.id, PASSWORD % user.id):
-                    self._parser.remove_option('twitter', opt)
-            del self._users[username]
-
-    def SaveUserList(self):
-        """
-        Formats the user list in a fashion that should play nicely with the
-        configuration file.
-        """
-        AUTH = 'twitter'
-        unsorted = []
-        counter = 0
-
-        # update any previously existing users
-        for user in self._users.values():
-            if user.id == None:
-                log.info('%s has not been saved yet...' % user.username)
-                unsorted.append(user)
-            else:
-                un_opt = USERNAME % user.id
-                pw_opt = PASSWORD % user.id
-
-                # don't save any changes to this user unless the password has
-                # changed since the launch
-                if self._parser.get(AUTH, pw_opt) != user.password:
-                    self._parser.set(AUTH, un_opt, user.username)
-                    self._parser.set(AUTH, pw_opt, user.password)
-
-                counter += 1
-
-        # now format any new users
-        for user in unsorted:
-            un_opt = USERNAME % counter
-            pw_opt = PASSWORD % counter
-
-            self._parser.set(AUTH, un_opt, user.username)
-            self._parser.set(AUTH, pw_opt, user.password)
-            user.id = counter
-            log.info('Assigned %s ID of %i' % (user.username, user.id))
-
-            counter += 1
-
-    def GetDefaultUser(self):
-        """
-        Retrieves the default user based on the configuration file
-        """
-        if not self._default_user:
-            for user in self._users.values():
-                if user.id == self.default_user:
-                    self._default_user = user
-                    break
-
-        return self._default_user
-
-    def SetDefaultUser(self, username):
-        """
-        Sets the user who should be the one sending updates from Twim by 
-        default
-        """
-        # make sure every user has an ID
-        Persist()
-        for user in self._users.values():
-            if user.username == username:
-                self.default_user = user.id or 0
-                self._default_user = user
-                return user
-
-    def GetFilteredTags(self):
-        """
-        Retrieves a list of tags that should be filtered out when sending
-        updates to a user.
-        """
-        tags = self.filtered_tags
-        filtered = tags.split(',')
-        if '' in filtered:
-            filtered = []
-        return filtered
-
-    def SetFilteredTags(self, tags):
-        """
-        Creates a comma-separated list of tags that should be filtered
-        """
-        self.filtered_tags = ','.join(list(tags))
-
-    def AddFilteredTag(self, tag):
-        """
-        Adds another tag to filter
-        """
-        tags = self.GetFilteredTags()
-        tags.append(tag)
-        self.SetFilteredTags(tags)
-
-    def RemoveFilteredTag(self, tag):
-        """
-        Removes a tag from the list of tags to filter
-        """
-        tags = self.GetFilteredTags()
-        try:
-            tags.remove(tag)
-            self.SetFilteredTags(tags)
-        except ValueError:
-            pass
-    
-    def GetScheduledTweets(self):
-        """
-        Retrieves any scheduled tweets that we have
-        """
-        try:
-            schedule = pickle.loads(base64.b64decode(self.scheduled_tweets))
-        except:
-            schedule = []
-        self._scheduled_tweets = schedule
-    
-    def SetScheduledTweets(self):
-        """
-        Pickles the scheduled tweets
-        """
-        dump = pickle.dumps(self._scheduled_tweets)
-        self.scheduled_tweets = base64.b64encode(dump)
-
-    def Persist(self):
-        """
-        Writes changes to the config to disk.
-        """
-        self.SaveUserList()
-        if len(self._scheduled_tweets):
-            self.SetScheduledTweets()
-        out = open(self.configfile, 'wb')
-        self._parser.write(out)
-        out.close()
-
-config = IMConfig(CONFIG_FILE)
-
-# easy way to deal with sizer flags
-sf = lambda x,y: wx.SizerFlags(wx.ALIGN_CENTER_VERTICAL).Expand().Border(x,y)
-
-def makePageTitle(wizPg, title):
-    sizer = wx.BoxSizer(wx.VERTICAL)
-    wizPg.SetSizer(sizer)
-    title = wx.StaticText(wizPg, -1, title)
-    title.SetFont(wx.Font(18, wx.SWISS, wx.NORMAL, wx.BOLD))
-    sizer.Add(title, 0, wx.ALIGN_CENTRE|wx.ALL, 5)
-    sizer.Add(wx.StaticLine(wizPg, -1), 0, wx.EXPAND|wx.ALL, 5)
-    return sizer
-
-class WelcomePage(wizard.WizardPageSimple):
-    def __init__(self, parent):
-        wizard.WizardPageSimple.__init__(self, parent)
-        self.sizer = makePageTitle(self, 'Welcome to %s!' % APP_TITLE)
-        info = wx.StaticText(self, label='This wizard will help you '
-            'get started with %s.  You will be able to configure Twitter '
-            'accounts that you would like to regularly check for updates, '
-            'the Jabber user who shall receive the updates via instant '
-            'message, and the Jabber user which will be used to send the '
-            'updates.  Click the Next button below to proceed.' % APP_TITLE)
-        info.Wrap(400)
-        self.sizer.Add(info, 0)
-
-class TwitterAccountsPage(wizard.WizardPageSimple):
-    valid_users = {}
-
-    def __init__(self, parent):
-        wizard.WizardPageSimple.__init__(self, parent)
-        
-        # determine Twitter user information
-        for user in config.GetUserList().values():
-            un = user.username
-            if un != config._get_default('twitter','username[0]'):
-                self.valid_users[un] = user.password
-        usernames = self.valid_users.keys()
-        usernames.sort()
-
-        bold = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT)
-        bold.SetWeight(wx.BOLD)
-
-        # build the GUI
-        self.sizer = makePageTitle(self, 'Twitter Users')
-        info = wx.StaticText(self, label='Please configure the username and '
-            'password for each Twitter user whose updates you would like to '
-            'receive via instant message.  You may add more than one Twitter '
-            'user, but you must configure at least one.')
-        info.Wrap(400)
-        self.sizer.Add(info)
-        
-        form_container = wx.BoxSizer(wx.HORIZONTAL)
-        self.sizer.AddF(form_container, sf(wx.TOP, 10))
-        
-        # user list, delete button
-        list_sizer = wx.BoxSizer(wx.VERTICAL)
-        form_container.AddF(list_sizer, sf(wx.RIGHT, 5))
-
-        self.user_list = wx.ListBox(self, choices=usernames)
-        list_sizer.AddF(self.user_list, sf(wx.BOTTOM, 10))
-        
-        self.delete = wx.Button(self, label='Delete User')
-        self.delete.Enable(False)
-        list_sizer.AddF(self.delete, wx.SizerFlags(wx.ALIGN_CENTER_HORIZONTAL))
-        
-        # Username/Password Form
-        form = wx.FlexGridSizer(rows=3, cols=2, vgap=10, hgap=10)
-        form_container.AddF(form, sf(wx.LEFT, 5))
-        
-        cv = wx.SizerFlags(wx.ALIGN_CENTER_VERTICAL)
-        twitter_id = wx.StaticText(self, label='Twitter Username:')
-        twitter_id.SetFont(bold)
-        form.AddF(twitter_id, cv)
-        self.username = wx.TextCtrl(self)
-        form.AddF(self.username, sf(wx.ALL, 0))
-        
-        twitter_pass = wx.StaticText(self, label='Twitter Password:')
-        twitter_pass.SetFont(bold)
-        form.AddF(twitter_pass, cv)
-        self.password = wx.TextCtrl(self, style=wx.PASSWORD)
-        form.AddF(self.password, sf(wx.ALL, 0))
-
-        form.Add(wx.StaticText(self, label=''))
-        self.save = wx.Button(self, label='Save User')
-        self.save.Enable(False)
-        form.AddF(self.save, sf(wx.ALL, 0))
-        
-        # bind to some events
-        self.Bind(wx.EVT_LISTBOX, self.ToggleDeleteButton, self.user_list)
-        self.Bind(wx.EVT_LISTBOX_DCLICK, 
-                  self.EditUserInfo, self.user_list)
-        self.delete.Bind(wx.EVT_BUTTON, self.DeleteSelectedUser)
-        self.username.Bind(wx.EVT_TEXT, self.ToggleSaveButton)
-        self.password.Bind(wx.EVT_TEXT, self.ToggleSaveButton)
-        self.save.Bind(wx.EVT_BUTTON, self.CheckTwitterUser)
-    
-    def CheckTwitterUser(self, evt=None):
-        """
-        Verifies that the user can log into Twitter using the specified 
-        username and password
-        """
-        username = self.username.GetValue()
-        password = self.password.GetValue()
-        if len(username) and len(password):
-            try:
-                api = twitter.Api(username=username, password=password)
-                api.GetFollowers()
-            except urllib2.HTTPError:
-                wx.MessageBox('Failed to authenicate with Twitter!', 
-                              'Invalid Credentials')
-            else:
-                self.valid_users[username] = password
-                self.username.Clear()
-                self.password.Clear()
-                self.UpdateUserList()
-    
-    def DeleteSelectedUser(self, evt=None):
-        """
-        Removes the selected user(s) from the list of Twitter users
-        """
-        for index in self.user_list.GetSelections():
-            username = self.user_list.GetString(index)
-            
-            res = wx.MessageBox('Are you sure you want to remove %s from the '
-                'list of Twitter users?' % username, 'Confirm Delete',
-                style=wx.YES_NO|wx.NO_DEFAULT|wx.ICON_QUESTION)
-            if res == wx.YES:
-                del self.valid_users[username]
-                self.UpdateUserList()
-    
-    def EditUserInfo(self, evt=None):
-        """
-        Pulls the username and password for the selected user out so it can be 
-        updated
-        """
-        user = self.user_list.GetStringSelection()
-        self.username.SetValue(user)
-        self.password.SetValue(self.valid_users[user])
-    
-    def ToggleDeleteButton(self, evt=None):
-        """
-        Toggles the Delete User button depending on whether or not a user is 
-        selected.
-        """
-        self.delete.Enable(len(self.user_list.GetStringSelection()))
-    
-    def ToggleSaveButton(self, evt=None):
-        """
-        Toggles the Save User button based on the input provided by the user
-        """
-        username = self.username.GetValue()
-        password = self.password.GetValue()
-        self.save.Enable(len(username.strip()) and len(password.strip()))
-    
-    def UpdateUserList(self):
-        """
-        Refreshes the list of Twitter users
-        """
-        usernames = self.valid_users.keys()
-        usernames.sort()
-        self.user_list.SetItems(usernames)
-    
-    def ValidatePage(self, evt):
-        """
-        Verifies that at least one Twitter account has been successfully 
-        configured.
-        """
-        if len(self.valid_users) <= 0:
-            wx.MessageBox('Please specify at least one Twitter user!',
-                          'Incomplete Data', style=wx.ICON_ERROR)
-            evt.Veto()
-
-class RecipientPage(wizard.WizardPageSimple):
-    def __init__(self, parent):
-        wizard.WizardPageSimple.__init__(self, parent)
-        
-        bold = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT)
-        bold.SetWeight(wx.BOLD)
-        italic = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT)
-        italic.SetStyle(wx.ITALIC)
-
-        self.sizer = makePageTitle(self, 'Twitter Update Recipient')
-        info = wx.StaticText(self, label='Please specify the user who shall '
-            'receive the Twitter updates.  This user must be a valid user on '
-            'a Jabber server.  For example, Google Talk and Joost use Jabber '
-            'as their instant messenger platform.  If you have a Gmail account'
-            ', you can use youremail@gmail.com here.  If you do not have any '
-            'Jabber accounts, or you would like to create a special account '
-            'for use with %s, click the "Create a Jabber ID" link below.' % APP_TITLE)
-        info.Wrap(400)
-        self.sizer.Add(info)
-        
-        form = wx.FlexGridSizer(rows=2, cols=2, hgap=10, vgap=10)
-        self.sizer.AddF(form, sf(wx.TOP, 20))
-        
-        jabber_id = wx.StaticText(self, label="Recipient's Jabber ID:")
-        jabber_id.SetFont(bold)
-        form.Add(jabber_id)
-        self.username = wx.TextCtrl(self, value=config.send_to_user)
-        form.AddF(self.username, sf(wx.ALL, 0))
-        form.Add(wx.StaticText(self, label='Example:'))
-        example = wx.StaticText(self, label='john.doe@gmail.com')
-        example.SetFont(italic)
-        form.Add(example)
-        
-        self.register_link = HyperLinkCtrl(self, label='Create a Jabber ID',
-                                           URL='http://register.jabber.org/')
-        self.register_link.SetFont(bold)
-        self.sizer.Add(self.register_link)
-    
-    def ValidatePage(self, evt):
-        """
-        Makes sure the recipient's Jabber ID resembles an e-mail address
-        """
-        log.info('Validating recipient')
-        if not EMAIL_RE.match(self.username.GetValue()):
-            wx.MessageBox("It appears that your Jabber ID is invalid.  Please "
-                "ensure that it resembles: john.doe@gmail.com", 
-                "Invalid Jabber ID", style=wx.ICON_ERROR)
-            evt.Veto()
-
-class TransportPage(wizard.WizardPageSimple):
-    def __init__(self, parent):
-        wizard.WizardPageSimple.__init__(self, parent)
-        
-        bold = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT)
-        bold.SetWeight(wx.BOLD)
-        italic = wx.SystemSettings_GetFont(wx.SYS_DEFAULT_GUI_FONT)
-        italic.SetStyle(wx.ITALIC)
-
-        self.sizer = makePageTitle(self, '%s Courier' % APP_TITLE)
-        info = wx.StaticText(self, label='%(t)s requires that you have a '
-            'second Jabber ID that is specific to %(t)s and its purposes.  '
-            'You are not allowed to use the same Jabber ID that you specified '
-            'on the previous page.  The courier ID will be the account that '
-            'periodically sends your Twitter updates to your instant messenger'
-            '.  It will also accept commands from you.  You are encouraged to '
-            'create this new Jabber ID using the link below.  It is a quick '
-            'and painless process.' % {'t': APP_TITLE})
-        info.Wrap(400)
-        self.sizer.Add(info)
-        
-        self.sizer.AddSpacer(10)
-        
-        reminder = wx.StaticText(self, label="Don't forget to add the "
-            "courier's Jabber ID as a contact on the buddy list for the user "
-            "specified on the previous screen.  Failure to do so will likely "
-            "cause the Twitter updates to not appear in your instant "
-            "messenger.")
-        reminder.SetFont(bold)
-        reminder.Wrap(400)
-        self.sizer.Add(reminder)
-        
-        form = wx.FlexGridSizer(rows=3, cols=2, vgap=10, hgap=10)
-        self.sizer.AddF(form, sf(wx.TOP, 10))
-        
-        cv = wx.SizerFlags(wx.ALIGN_CENTER_VERTICAL)
-        jabber_id = wx.StaticText(self, label="Courier's Jabber ID:")
-        jabber_id.SetFont(bold)
-        form.AddF(jabber_id, cv)
-        self.jabber_id = wx.TextCtrl(self, value=config.login_as_user)
-        form.AddF(self.jabber_id, sf(wx.ALL, 0))
-        
-        form.Add(wx.StaticText(self, label=''))
-        example = wx.StaticText(self, label='john.doe.twitter@jabber.org')
-        example.SetFont(italic)
-        form.Add(example)
-        
-        jabber_pass = wx.StaticText(self, label="Courier's Jabber Password:")
-        jabber_pass.SetFont(bold)
-        form.AddF(jabber_pass, cv)
-        self.password = wx.TextCtrl(self, value=config.login_as_pass,
-                                    style=wx.PASSWORD)
-        form.AddF(self.password, sf(wx.ALL, 0))
-        
-        self.register_link = HyperLinkCtrl(self, 
-                                           label='Create a new Jabber ID',
-                                           URL='http://register.jabber.org/')
-        self.register_link.SetFont(bold)
-        self.sizer.Add(self.register_link)
-    
-    def ValidatePage(self, evt):
-        """
-        Makes sure that we can connect to a Jabber server using the info 
-        provided by the user.
-        """
-        log.info('Validating Twitter update sender')
-        
-        try:
-            jid = xmpp.protocol.JID(self.jabber_id.GetValue())
-            domain = jid.getDomain()
-            client = xmpp.Client(domain, debug=[])
-            conn = client.connect()
-
-            if conn:
-                auth = client.auth(jid.getNode(),
-                                   self.password.GetValue(),
-                                   resource=jid.getResource())
-                if not auth:
-                    raise Exception('Failed to log into Jabber')
-            else:
-                raise Exception('Failed to connect to %s' % domain)
-        except Exception, ex:
-            wx.MessageBox(ex, 'Courier Error', style=wx.ICON_ERROR)
-            evt.Veto()
-
-class ConfigurationWizard(wizard.Wizard):
-    """
-    A configuration wizard that is intended to remove some of the hassles
-    involved in getting Twim up and running
-    """
-    
-    def __init__(self, parent=None, *args, **kwargs):
-        title = APP_TITLE + ' Configuration Wizard'
-        wizard.Wizard.__init__(self, parent, title=title)
-        
-        # steps
-        # - configure twitter account(s)
-        # - configure Jabber user who will receive Twitter updates
-        # - configure special Jabber account
-        
-        self.page0 = WelcomePage(self)
-        self.page1 = TwitterAccountsPage(self)
-        self.page2 = RecipientPage(self)
-        self.page3 = TransportPage(self)
-
-        if config.last_tweet_id <= 0:
-            wizard.WizardPageSimple_Chain(self.page0, self.page1)
-            start_page = self.page0
-        else:
-            start_page = self.page1
-
-        wizard.WizardPageSimple_Chain(self.page1, self.page2)
-        wizard.WizardPageSimple_Chain(self.page2, self.page3)
-
-        self.Bind(wizard.EVT_WIZARD_PAGE_CHANGING, self.OnPageChange)
-        self.Bind(wizard.EVT_WIZARD_FINISHED, self.OnFinished)
-        
-        self.FitToPage(self.page1)
-        self.GetPageAreaSizer().Add(start_page)
-        if not self.RunWizard(start_page):
-            # if they cancel the wizard, see if the start page is the intro
-            if start_page == self.page0:
-                sys.exit(1)
-
-    def OnPageChange(self, evt):
-        """
-        Verifies that each page has the appropriate information before 
-        proceeding to the next step.
-        """
-        action = getattr(evt.GetPage(), 'ValidatePage', None)
-        
-        if action:
-            action(evt)
-        
-    def OnFinished(self, evt):
-        """
-        Persist all of the configuration changes to disk
-        """
-        log.info('Persisting configuration to disk')
-        
-        # save Twitter users
-        existing = dict((u.username, u.password) for u in config._users.values())
-        current_users = []
-        for username, password in self.page1.valid_users.items():
-            if username in existing.keys():
-                log.info(username + ' already exists... updating password')
-                # update the password
-                for user in config._users.values():
-                    if user.username == username:
-                        user.password = password
-                        current_users.append(user)
-            else:
-                log.info('adding %s to configuration' % username)
-                current_users.append({
-                    'username': username,
-                    'password': password
-                })
-        config._users = dict((u.username, u) for u in current_users)
-        
-        # Persist() is automatically called for each of these
-        config.send_to_user = self.page2.username.GetValue()
-        config.login_as_user = self.page3.jabber_id.GetValue()
-        config.login_as_pass = self.page3.password.GetValue()
-        config.Persist()
-
-ICON = PyEmbeddedImage(
-    'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAGIElEQVRIiY2Wa4yUVxnHf+/9n'
-    'cs7s7szu8tldhGWXbYWasGF2lQLLY1Lq5AUV7mYYI36oR/UxghR8FICNoKN0VYTa5uYQs1Cql'
-    'tbW6tGMMZUpCBtgXbZZW9lF5aZHWZ2Z2dn3vvxwyLpDepJzpeT53l+yf95zvkfSQjBjZYsbZX'
-    'qqbY2GaKlJqqkEBLFqp8fdeSBnOgZuGEyIF0PEJG2pDrrSts2ddR2fWJ588fmtzbF9FQtIOEW'
-    'JhkbGCsfO/X2a4dPFH7314J10Bbdxf8bcJv5mc2PLLf3rr2jtYW2dmicD3VpiMVnA2bKMFWAi'
-    'XHo7+Mv/+jv33VK++5J+8We9ynw3oOtiTU/6vn45e61i+ItoEHFAz8ARQUrAdEYSAo4PpRdkH'
-    'Q62xNtL6zM/r7Lumv3DQGbrdV7dy8Y2RmKgHJxGkolcGyo2qAosGgxNC8A3wO7OrsnJ7EL00i'
-    'E7MkM/mBjbM3DHwhYqX+2a8ecwV2TMzalUhlb1wkdG3JZuJIH+ypEN2BqEgoFuHwZIQRlzyNf'
-    'mGLadthe3/fDW7X1G94FiEnbar6RPr9fuBVKU9ME9Q3UPHkQeefDCMeBgX7IXp7N8H0YvQD9v'
-    'ZBKIe3dT2zPPqqeS7lio4VVHqzp3R+RtiWvAe40Lm37qDaxsDDj4rsulSBASadhyU3w0HbCUg'
-    'kuvH11LIC3zhIma+Hr34JUGmrrKFer+J5Hsepzi5pdcrs2/gUAWZW+rNwdu7SpWq3geh6BojD'
-    'x71d544EvErou0uI2xI5d2LkseC7h2Bh2JIq0fSdYCeyLY5za8nkqE3n8MMTzfDzXZrU5ukWV'
-    'viKrKclubpHzy0oVFz+E0BcQwtCBbvA8bvnNMyg3L2Ni/BLyA1sJJ4uoHbdhJmuojgxz8v71Z'
-    'F9/E1kFyXaQFYlpT9Cm5lbUyvZ8tVG3W+LhjJUPBJ4PiVQNsWQCTVEpHj/GmS9twTBNnJMnUI'
-    'QASSIYHWVibJTS8BBOfoLkovk4vk+pWMC3PWQFouFMMqXaC9S47NZPOjYXHGhdtZx1f3gJPZG'
-    '4KjaUTp3kzU33o6gqsqoiAL9YRD/9BssOP4fe0ABCgIBCXy/P3/tpqpMlNNkjqgZ1KrIUTvgh'
-    'gwLmGiZGKo2sadfm2GhoxIjGkESIpCizfVZVzNo6Ik3NSO+IjWWaqGgaIyE4vkDooJbUaDYaS'
-    'GR0QeVfxzjSsYx4QwO6piF7Hka6ntiSJTjn+5ABIUnI8TiypnKmawOe6xDIMp7jcmlkmOn8FW'
-    'wNbCQqSiSvjmu1Q3KQmGryppKWCm5/HzMjwziuS8O6+2j75a9R61IMbuhEFIsE0yVi93SSefT'
-    'nTL3yT3q3baZ88RKuBIoPjcqsYroULeT15LBc1K3RETNzqlkDU1UxEwlMw2Duxi7aDxxCrUvh'
-    'jQyRyDSxsOclmh9/gogIEUFA8o5PsfTZ50m2tRKJx4jGTSwJFgI5o+nEldyvxuXw4qPiiL6k2'
-    '4rHMQwdVQiSy1fQ+uTTyJZFcHEM//vfoS6TQamvJ9KxkmQui/fTHyMCn/iKDtqfOoARj6MqCl'
-    'FFpkGTeSW+9JlrN/lYpOXQ6/pHehvjJoqiYFgWkmEQjI0SPrKbiKaitCye7aQA9dblaG+dJfz'
-    'Fz8Bz0VJpdDOKoiikTZXT8dbXTsZaeuAdftDe+tV1v1WOvmjKgRJKKjU3L6VWU4lbFrTdBB2r'
-    '4M67wHXgT3+E4SFE71lmZIXxwUGmhofAd5gOVe9r5obOgf4n/v6u1/Tc+af+/D1W7YyZUZKxC'
-    'OHwILrjQqYZ5s6DZA3XHqM582DuPKRMM1p2HPITWBEdw4yyJ7rm2/8r/j4/ePncof0P+h07XM'
-    'X05tUl0BMWmBGwLFAVKJfBdSGZgLgFkQhGsoa5yQhVOeI+pK3+5tFz3Y9d13AAXj598Cf3BZ9'
-    'c3zNT859QBkwFNAVkaVaewANZBk0GXcEXIc/Z9cc3mp33Hjlz4LH31ruu6Utr90VuJ/e5rsZg'
-    '093tjSsXtC+qj85plAFmLufCkd6B3NFz2ePP5tTDr0pzesTftjsfWOfDvi0A+vp9jQ16kLEMO'
-    'Q2IkisKOVe54L2wI/dhuf8FvLqZTOrRE7wAAAAASUVORK5CYII=')
-
-class TwitterTray(wx.TaskBarIcon):
-    """
-    A simple tray icon to tell people that the program is running
-    """
-    def __init__(self, parent):
-        wx.TaskBarIcon.__init__(self)
-        self.main_icon = wx.IconFromBitmap(ICON.GetBitmap())
-
-        if sys.platform != 'darwin':
-            # The .icns looks better on OSX than this embedded image
-            self.SetIcon(self.main_icon, '%s v%s' % (APP_TITLE, __version__))
-
-        self.menu = None
-        self.GetMenu()
-
-        self.Bind(wx.EVT_TASKBAR_RIGHT_UP, self.OnShowMenu)
-
-    def GetMenu(self):
-        if not self.menu:
-            self.menu = wx.Menu()
-            self.menu.Append(wx.ID_SETUP, 'Configure %s' % APP_TITLE)
-            self.menu.AppendSeparator()
-            self.menu.Append(wx.ID_EXIT, 'E&xit')
-
-        return self.menu
-
-    def OnShowMenu(self, evt):
-        self.PopupMenu(self.GetMenu())
-
-# this ensures that a certain method is being called after OnIDsCommand
-def requires_id(func):
-    @wraps(func)
-    def wrapped(obj, *args, **kwargs):
-        if not len(obj._current_ids):
-            obj.TellUser(
-                'Please use the ./ids command before trying to do this!')
-            return
-        return func(obj, *args, **kwargs)
-    return wrapped
-
-class TwitterIM(object):
-    """
-    The main program class.
-    """
-
-    # the template used to send messages in HTML format
-    template = str('<message><body>%(body)s</body><html xmlns="http://jabber.o'
-                'rg/protocol/xhtml-im"><body xmlns="http://www.w3.org/1999/xht'
-                'ml">%(html)s</body></html></message>')
-    users = []
-    _client = None
-    _commands = {}
-    _history = []
-    _current_ids = {}
-
-    def __init__(self, app=None, *args, **kwargs):
-        self.app = app
-        if self.app:
-            # this app can be run with no GUI
-            self.frame = wx.Frame(parent=None, *args, **kwargs)
-            
-            # check for some configuration
-            config_exists = True
-            to_check = ('login_as_user', 'send_to_user', 
-                        'username[0]', 'password[0]')
-            for option in to_check:
-                section = OPTIONS[option]
-                default = config._get_default(section, option)
-                if default == getattr(config, option):
-                    config_exists = False