Commits

Josh VanderLinden committed fc42e1c

Updated the version and renamed the app to Twim in the Windows build script. Changed the default update interval to 90 seconds in order to preserve API calls for regular users a bit better. Added the capability to do multi-step commands using the ./ids command. I changed the way the ./as command works in order to stop ridiculously infinite loops. Added a callback option to the ./search command so the ./ids command could do what it needs to. Refactored the code that is responsible for sending tweets to the user's IM. Made it possible to use a command using whatever prefix makes it unique. For example, ./h will call the help command, but ./s does nothing because it could mean ./search or ./schedule. Made it possible to shorten long URLs in messages that you post using 2ze.us. Updated the hashtag regex a bit.

Commands Added: Retweet, Reply, Favorite, Unfavorite, Direct Message, Limits

Comments (0)

Files changed (2)

scripts/inno_setup.iss

 ; Do not use the same AppId value in installers for other applications.
 ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
 AppId={{97B7036F-5D9E-400D-B945-59C0F6B5E2F3}
-AppName=Tim
-AppVerName=Tim 0.0.2.4
-VersionInfoVersion=0.0.2.4
+AppName=Twim
+AppVerName=Twim 0.0.3.1
+VersionInfoVersion=0.0.3.1
 AppPublisher=Josh VanderLinden
 AppPublisherURL=http://bitbucket.org/codekoala/twitter-im
 AppSupportURL=http://bitbucket.org/codekoala/twitter-im
 AppUpdatesURL=http://bitbucket.org/codekoala/twitter-im
-DefaultDirName={pf}\Tim
-DefaultGroupName=Tim
+DefaultDirName={pf}\Twim
+DefaultGroupName=Twim
 AllowNoIcons=yes
 LicenseFile=C:\dev\twitter-im\LICENSE
-OutputBaseFilename=Tim Setup
+OutputBaseFilename=Twim Setup
 Compression=lzma
 SolidCompression=yes
 
 Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"
 
 [Files]
-Source: "C:\dev\twitter-im\dist\tim.exe"; DestDir: "{app}"; Flags: ignoreversion
+Source: "C:\dev\twitter-im\dist\twim.exe"; DestDir: "{app}"; Flags: ignoreversion
 Source: "C:\dev\twitter-im\dist\MSVCR71.dll"; DestDir: "{app}"; Flags: ignoreversion
 Source: "C:\dev\twitter-im\dist\w9xpopen.exe"; DestDir: "{app}"; Flags: ignoreversion
 Source: "C:\dev\twitter-im\dist\library.zip"; DestDir: "{app}"; Flags: ignoreversion
 ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
 
 [Icons]
-Name: "{group}\Tim"; Filename: "{app}\tim.exe"; WorkingDir: "{app}"
-Name: "{group}\{cm:UninstallProgram,Tim}"; Filename: "{uninstallexe}"
-Name: "{commondesktop}\Tim"; Filename: "{app}\tim.exe"; WorkingDir: "{app}"; Tasks: desktopicon
-Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\Tim"; Filename: "{app}\tim.exe"; WorkingDir: "{app}"; Tasks: quicklaunchicon
+Name: "{group}\Twim"; Filename: "{app}\twim.exe"; WorkingDir: "{app}"
+Name: "{group}\{cm:UninstallProgram,Twim}"; Filename: "{uninstallexe}"
+Name: "{commondesktop}\Twim"; Filename: "{app}\twim.exe"; WorkingDir: "{app}"; Tasks: desktopicon
+Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\Twim"; Filename: "{app}\twim.exe"; WorkingDir: "{app}"; Tasks: quicklaunchicon
 
 [Run]
-Filename: "{app}\tim.exe"; Description: "{cm:LaunchProgram,Tim}"; Flags: nowait postinstall skipifsilent
+Filename: "{app}\twim.exe"; Description: "{cm:LaunchProgram,Twim}"; Flags: nowait postinstall skipifsilent
 
 [UninstallDelete]
 Type: files; Name: "{app}\*.pyc"
 """
 
 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
 __version__ = '0.0.3.1'
 
 AT_REPLY_RE = re.compile('@(\w+)')
-HASH_TAG_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\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*))', re.I)
 HREF_TEMPLATE = '<strong><a href="http://twitter.com/%s/status/%i">%s</a></strong>'
 
 HOME_DIR = os.path.expanduser('~')
 DEFAULTS = {
     'twitter': {
         'default_user': (0, int),
-        'update_interval': (60, int),
+        'update_interval': (90, int),
         'last_tweet_id': (0, int),
         'username[0]': 'twitter_id',
         'password[0]': 'twitter_pass',
     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.
     _client = None
     _commands = {}
     _history = []
+    _current_ids = {}
 
     def __init__(self, app=None, *args, **kwargs):
         self.app = app
 
         self._commands = {
             'help': self.OnHelpCommand,
+            'ids': self.OnIDsCommand,
             'filter': self.OnFilterCommand,
             'unfilter': self.OnUnfilterCommand,
             'follow': self.OnFollowCommand,
             'unfollow': self.OnUnfollowCommand,
             'whois': self.OnWhoIsCommand,
             'undo': self.OnUndoCommand,
-            'rt': None,
+            'rt': self.OnRetweetCommand,
+            'retweet': self.OnRetweetCommand,
+            'reply': self.OnReplyCommand,
             'search': self.OnSearchCommand,
-            'favorite': None,
-            'unfavorite': None,
+            'favorite': self.OnFavoriteCommand,
+            'unfavorite': self.OnUnfavoriteCommand,
             'schedule': self.OnScheduleCommand,
             'trends': self.OnTrendsCommand,
+            'message': self.OnDirectMessageCommand,
+            'dm': self.OnDirectMessageCommand,
+            'limits': self.OnRateLimitsCommand
         }
         if len(self.users) > 1:
             self._commands['as'] = self.OnAsCommand
         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__))
             cmd_list = ', '.join(keys)
             self.TellUser('Possible commands: %s' % cmd_list)
 
+    def OnIDsCommand(self, user, body, remaining):
+        """
+        Gives the user a list of IDs to work with in order to make certain 
+        commands easier to use, such as retweeting, replying, and favoriting.
+        """
+        def _prepend_ids(tweets):
+            log.debug('prepending IDs to search results')
+            self._current_ids = {}
+            for i, tweet in enumerate(tweets):
+                tweet.text = '[ %i ] %s' % (i, tweet.text)
+                self._current_ids[i] = tweet
+        
+        if len(remaining):
+            self.OnSearchCommand(user, body, remaining, callback=_prepend_ids)
+        elif len(self._current_ids):
+            self.TellUser('Current IDs:')
+            from operator import itemgetter
+            cached = sorted(self._current_ids.iteritems(), key=itemgetter(1))
+            tweets = [st for id, st in cached]
+            self.SendTweets(user, tweets, do_not_update=True)
+        else:
+            self.TellUser('No IDs have been cached.')
+
     def OnAsCommand(self, user, body, remaining):
         """
         Allows you to perform actions as a different user when you have multiple
         else:
             self.TellUser('Incomplete command!')
 
-        return True
+        return (user, body)
 
     def OnFilterCommand(self, user, body, remaining):
         """
         else:
             self.TellUser('There is nothing left for you to undo right now.')
     
-    def OnSearchCommand(self, user, body, remaining):
+    def OnSearchCommand(self, user, body, remaining, callback=None):
         """
         Allows you to quickly search for something on Twitter.
         
                     tweet = twitter.Status.NewFromJsonDict(result)
                     tweet.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.GetUpdatesFor(user, updates=tweets)
+                    self.SendTweets(user, tweets, do_not_update=True)
                 else:
                     self.TellUser('No results for "%s"' % remaining)
             except Exception, ex:
             self.TellUser(
                 'Something went wrong when I tried to get the current trends.')
 
+    @requires_id
+    def OnRetweetCommand(self, user, body, remaining):
+        """
+        The retweet command allows you to retweet a particular tweet.  You must
+        first run the ./ids command to get an ID for the tweet you wish to 
+        retweet.  For example, if you wanted to retweet something that @ev said
+        about Maui, you would do:
+        
+        ./ids from:ev Maui
+        ev: [ 0 ] In Maui. Wowee.
+        ./retweet 0
+        
+        The retweet command automatically prepends your tweet with "RT @[from]"
+        where "[from]" is replaced by the person whose tweet you're retweeting.
+        """
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = self._current_ids.get(int(tid), None)
+            if tweet:
+                no_id = self.CleanID(tweet.text)
+                text = 'RT @%s %s' % (tweet.user.screen_name, no_id)
+                self.PostUpdate(user, text)
+            else:
+                self.TellUser('Invalid tweet!  Please try again.')
+        else:
+            self.TellUser('Please specify an ID!')
+
+    @requires_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.
+        """
+
+        args = re.search('^(\d+) (.*)$', remaining)
+        if args:
+            tid = args.group(1)
+            reply = args.group(2)
+            tweet = self._current_ids.get(int(tid), None)
+            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!')
+
+    @requires_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
+        """
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = self._current_ids.get(int(tid), None)
+            if tweet:
+                try:
+                    user.api.CreateFavorite(tweet)
+                    self.TellUser(
+                        'You have marked "%s" from @%s as a favorite.' % (
+                            self.CleanID(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!')
+
+    @requires_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
+        """
+
+        args = re.search('^(\d+)', remaining)
+        if args:
+            tid = args.group(1)
+            tweet = self._current_ids.get(int(tid), None)
+            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 CleanID(self, text):
+        return re.sub('^\[[ \d]+\] ', '', text)
+
+    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
         if body.startswith('./'):
             # find the commands provided by the user
             match = True
-            action = None
-            proceed = False
-            cmds = '|'.join(self._commands.keys())
+            
+            # loop to allow chain commands, just as: ./as user ./reply 0 asdf
             while match:
-                match = re.search('%s(%s)' % (pattern, cmds), body)
+                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()
-
-                    action = self._commands.get(cmd, None)
+                    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:
-                        self.TellUser('Command "%s" is not yet implemented' % body)
+                        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
 
         
         self.PostUpdate(as_user, body)
     
-    def PostUpdate(self, user, body):
+    def PostUpdate(self, user, body, in_reply_to_status_id=None):
         """
         Sends an update to Twitter
         """
 
-        posted = user.api.PostUpdates(body)
+        # 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)
                 len(posted) != 1 and 's' or '',
                 user.username
             )
+            if len(data):
+                text += user_info
+
             self.TellUser(text)
 
     def OnTimer(self, event, interval, action):
         """
         self.client.Process(1)
 
-    def GetUpdatesFor(self, user, updates=[]):
+    def GetUpdatesFor(self, user):
         """
         Connects to Twitter to find updates for a particular user.  If updates
         are found, they're IM'ed out.
         api = user.api
 
         # get some updates
-        do_not_update = len(updates) > 0
-        if not len(updates):
-            last_id = user.last_tweet_id or config.last_tweet_id
-            try:
-                updates = api.GetFriendsTimeline(since_id=last_id)
-            except:
-                # sometimes we get an HTTPError
-                pass
+        last_id = user.last_tweet_id or config.last_tweet_id
+        try:
+            updates = api.GetFriendsTimeline(since_id=last_id)
+        except:
+            # sometimes we get an HTTPError
+            updates = []
 
         if updates and len(updates):
             updates.reverse()
-            prefix = len(self.users) > 1 and '(%s) ' % user.username or ''
-            log.info('Prefix: ' + prefix)
+            self.SendTweets(user, updates)
+        
+        user.last_update = datetime.now()
+        config.Persist()
 
-            for tweet in updates:
-                log.debug(tweet.AsDict())
+    def SendTweets(self, user, tweets, do_not_update=False):
+        """
+        Sends a collection of tweets to the user's IM
+        """
+        prefix = len(self.users) > 1 and '(%s) ' % user.username or ''
+        log.info('Prefix: ' + prefix)
 
-                if tweet.user:
-                    name = tweet.user.screen_name
-                else:
-                    name = ''
+        for tweet in tweets:
+            log.debug(tweet.AsDict())
+            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.GetFilteredTags():
-                    if '#' + 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)
+            # make sure the tweet doesn't have any filtered tags in it
+            good_tags = True
+            for tag in config.GetFilteredTags():
+                if '#' + 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)
 
-                params = {
-                    'body': prefix + name + ': ' + tweet.text,
-                    'html': prefix + text
-                }
-                output = clean(self.template % params)
-                output = unicode(output).translate(TRANSLATIONS)
+            params = {
+                'body': prefix + name + ': ' + tweet.text,
+                'html': prefix + text
+            }
+            output = clean(self.template % params)
+            output = unicode(output).translate(TRANSLATIONS)
 
-                log.debug(output)
+            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']}
+            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)
+            # 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)
-
+            # 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)
-        
-        if not do_not_update:
-            user.last_update = datetime.now()
-        
-        config.Persist()
     
     def HTMLizeTweet(self, name, tweet):
         """
             r'@<a href="http://twitter.com/\1">\1</a>', 
             text)
         text = HASH_TAG_RE.sub(
-            r'<em><a href="http://twitter.com/#search?q=%23\1">#\1</a></em>', 
+            r'\1<em><a href="http://twitter.com/#search?q=%23\2">#\2</a></em>', 
             text)
         
         return uhref + ': ' + text
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.