chiark / gitweb /
new trout from Emperor
[irc.git] / commands.py
1 # Part of Acrobat.
2 import string, cPickle, random, urllib, sys, time, re, os, twitter, subprocess, datetime, urlparse, hashlib
3 from collections import defaultdict
4 from irclib import irc_lower, nm_to_n
5 import json
6
7 try:
8     from blame_filter import bfd
9 except ImportError:
10     bfd = None
11
12 # query karma
13 def karmaq(bot, cmd, nick, conn, public, karma):
14     try:
15         item=cmd.split()[1].lower()
16     except IndexError:
17         item=None
18     if item==None:
19         bot.automsg(public,nick,"I have karma on %s items." %
20                          len(karma.keys()))
21     elif karma.has_key(item):
22         bot.automsg(public,nick,"%s has karma %s."
23                      %(item,karma[item]))
24     else:
25         bot.automsg(public,nick, "%s has no karma set." % item)
26
27 # delete karma
28 def karmadelq(bot, cmd, nick, conn, public, karma):
29     try:
30         item=cmd.split()[1].lower()
31     except IndexError:
32         conn.notice(nick, "What should I delete?")
33         return
34     if nick != bot.owner:
35         conn.notice(nick, "You are not my owner.")
36         return
37     if karma.has_key(item):
38         del karma[item]
39         conn.notice(nick, "Item %s deleted."%item)
40     else:
41         conn.notice(nick, "There is no karma stored for %s."%item)
42
43 # help - provides the URL of the help file
44 def helpq(bot, cmd, nick, conn, public):
45     bot.automsg(public,nick,
46                 "For help see http://www.chiark.greenend.org.uk/~matthewv/irc/servus.html")
47
48
49 # query bot status
50 def infoq(bot, cmd, nick, conn, public, karma):
51     bot.automsg(public,nick,
52         ("I am Acrobat %s, on %s, as nick %s.  "+
53         "My owner is %s; I have karma on %s items.") %
54         (bot.revision.split()[1], bot.channel, conn.get_nickname(),
55          bot.owner, len(karma.keys())))
56
57 class FishPond:
58     def __init__(fishpond):
59         fishpond.last=[]
60         fishpond.DoS=0
61         fishpond.quotatime=0
62
63     def note_last(fishpond, msg, cfg):
64         fishpond.last.insert(0,(msg,cfg))
65         fishpond.last = fishpond.last[0:10]
66
67 # Check on fish stocks
68 def fish_quota(pond):
69     if pond.DoS:
70         if time.time()>=pond.quotatime:
71             pond.DoS=0
72         else:
73             return
74     if (time.time()-pond.quotatime)>pond.fish_time_inc:
75         pond.cur_fish+=(((time.time()-pond.quotatime)
76                          /pond.fish_time_inc)*pond.fish_inc)
77         if pond.cur_fish>pond.max_fish:
78             pond.cur_fish=pond.max_fish
79         pond.quotatime=time.time()
80
81 # List of things the bot might be called to work round the self-trouting code
82 synonyms=["itself","the bot","themself"]
83
84 # trout someone, or flirt with them
85 def troutq(bot, cmd, nick, conn, public, cfg):
86     fishlist=cfg[0]
87     selftrout=cfg[1]
88     quietmsg=cfg[2]
89     notargetmsg=cfg[3]
90     nofishmsg=cfg[4]
91     fishpond=cfg[5]
92     selftroutchance=cfg[6]
93
94     fish_quota(fishpond)
95     if fishpond.DoS:
96         conn.notice(nick, quietmsg%fishpond.Boring_Git)
97         return
98     if fishpond.cur_fish<=0:
99         conn.notice(nick, nofishmsg)
100         return
101     target = string.join(cmd.split()[1:])
102     if len(target)==0:
103         conn.notice(nick, notargetmsg)
104         return
105     me = bot.connection.get_nickname()
106     trout_msg = random.choice(fishlist)
107     fishpond.note_last(trout_msg,cfg)
108     # The bot won't trout or flirt with itself;
109     if irc_lower(me) == irc_lower(target) or irc_lower(target) in synonyms:
110         target = nick
111     # There's a chance the game may be given away if the request was not
112     # public...
113     if not public:
114         if random.random()<=selftroutchance:
115             trout_msg=trout_msg+(selftrout%nick)
116
117     conn.action(bot.channel, trout_msg % target)
118     fishpond.cur_fish-=1
119
120 # slash a pair
121 def slashq(bot, cmd, nick, conn, public, cfg):
122     fishlist=cfg[0]
123     selfslash=cfg[1]
124     quietmsg=cfg[2]
125     notargetmsg=cfg[3]
126     nofishmsg=cfg[4]
127     fishpond=cfg[5]
128     selfslashchance=cfg[6]
129
130     fish_quota(fishpond)
131     if fishpond.DoS:
132         conn.notice(nick, quietmsg%fishpond.Boring_Git)
133         return
134     if fishpond.cur_fish<=0:
135         conn.notice(nick, nofishmsg)
136         return
137     target = string.join(cmd.split()[1:])
138     #who = cmd.split()[1:]
139     who = ' '.join(cmd.split()[1:]).split(' / ')
140     if len(who) < 2:
141         conn.notice(nick, "it takes two to tango!")
142         return
143     elif len(who) > 2:
144         conn.notice(nick, "we'll have none of that round here")
145         return
146     me = bot.connection.get_nickname()
147     slash_msg = random.choice(fishlist)
148     fishpond.note_last(slash_msg,cfg)
149     # The bot won't slash people with themselves
150     if irc_lower(who[0]) == irc_lower(who[1]):
151         conn.notice(nick, "oooooh no missus!")
152         return
153     # The bot won't slash with itself, instead slashing the requester
154     for n in [0,1]:
155         if irc_lower(me) == irc_lower(who[n]) or irc_lower(who[n]) in synonyms:
156             who[n] = nick
157     # Perhaps someone asked to slash themselves with the bot then we get
158     if irc_lower(who[0]) == irc_lower(who[1]):
159         conn.notice(nick, "you wish!")
160         return
161     # There's a chance the game may be given away if the request was not
162     # public...
163     if not public:
164         if random.random()<=selfslashchance:
165             slash_msg=slash_msg+(selfslash%nick)
166
167     conn.action(bot.channel, slash_msg % (who[0], who[1]))
168     fishpond.cur_fish-=1
169
170 #query units
171 def unitq(bot, cmd, nick, conn, public):
172     args = ' '.join(cmd.split()[1:]).split(' as ')
173     if len(args) != 2:
174         args = ' '.join(cmd.split()[1:]).split(' / ')
175         if len(args) != 2:
176             conn.notice(nick, "syntax: units arg1 as arg2")
177             return
178     if args[1]=='?':
179         sin,sout=os.popen4(["units","--verbose","--",args[0]],"r")
180     else:
181         sin,sout=os.popen4(["units","--verbose","--",args[0],args[1]],"r")
182     sin.close()
183     res=sout.readlines()
184     #popen2 doesn't clean up the child properly. Do this by hand
185     child=os.wait()
186     if os.WEXITSTATUS(child[1])==0:
187         bot.automsg(public,nick,res[0].strip())
188     else:
189         conn.notice(nick,'; '.join(map(lambda x: x.strip(),res)))
190
191 # Shut up trouting for a minute
192 def nofishq(bot, cmd, nick, conn, public, fish):
193     fish.cur_fish=0
194     fish.DoS=1
195     fish.Boring_Git=nick
196     fish.quotatime=time.time()
197     fish.quotatime+=fish.nofish_time
198     conn.notice(nick, "Fish stocks depleted, as you wish.")
199
200 # rehash bot config
201 def reloadq(bot, cmd, nick, conn, public):
202     if not public and irc_lower(nick) == irc_lower(bot.owner):
203         try:
204             reload(bot.config)
205             conn.notice(nick, "Config reloaded.")
206         except ImportError:
207             conn.notice(nick, "Config reloading failed!")
208     else:
209         bot.automsg(public,nick,
210                 "Configuration can only be reloaded by my owner, by /msg.")
211
212 # quit irc
213 def quitq(bot, cmd, nick, conn, public):
214     if irc_lower(nick) == irc_lower(bot.owner):
215         bot.die(msg = "I have been chosen!")
216     elif public:
217         conn.notice(nick, "Such aggression in public!")
218     else:
219         conn.notice(nick, "You're not my owner.")
220
221 # google for something
222 def googleq(bot, cmd, nick, conn, public):
223     cmdrest = string.join(cmd.split()[1:])
224     # "I'm Feeling Lucky" rather than try and parse the html
225     targ = ("http://www.google.com/search?q=%s&btnI=I'm+Feeling+Lucky"
226             % urllib.quote_plus(cmdrest))
227     try:
228         # get redirected and grab the resulting url for returning
229         gsearch = urllib.urlopen(targ).geturl()
230         if gsearch != targ: # we've found something
231             bot.automsg(public,nick,str(gsearch))
232         else: # we haven't found anything.
233             bot.automsg(public,nick,"No pages found.")
234     except IOError: # if the connection times out. This blocks. :(
235         bot.automsg(public,nick,"The web's broken. Waah!")
236
237 # Look up the definition of something using google
238 def defineq(bot, cmd, nick, conn, public):
239     #this doesn't work any more
240     bot.automsg(public,nick,"'define' is broken because google are bastards :(")
241     return
242     cmdrest = string.join(cmd.split()[1:])
243     targ = ("http://www.google.co.uk/search?q=define%%3A%s&ie=utf-8&oe=utf-8"
244             % urllib.quote_plus(cmdrest))
245     try:
246         # Just slurp everything into a string
247         defnpage = urllib.urlopen(targ).read()
248         # For definitions we really do have to parse the HTML, sadly.
249         # This is of course going to be a bit fragile. We first look for
250         # 'Definitions of %s on the Web' -- if this isn't present we
251         # assume we have the 'no definitions found page'.
252         # The first defn starts after the following <p> tag, but as the
253         # first <li> in a <ul type="disc" class=std>
254         # Following that we assume that each definition is all the non-markup
255         # before a <br> tag. Currently we just dump out the first definition.
256         match = re.search(r"Definitions of <b>.*?</b> on the Web.*?<li>\s*([^>]*)((<br>)|(<li>))",defnpage,re.MULTILINE)
257         if match == None:
258            bot.automsg(public,nick,"Some things defy definition.")
259         else:
260            # We assume google has truncated the definition for us so this
261            # won't flood the channel with text...
262            defn = " ".join(match.group(1).split("\n"))
263            bot.automsg(public,nick,defn)
264     except IOError: # if the connection times out. This blocks. :(
265          bot.automsg(public,nick,"The web's broken. Waah!")
266
267 # Look up a currency conversion via xe.com
268 def currencyq(bot, cmd, nick, conn, public):
269     args = ' '.join(cmd.split()[1:]).split(' as ')
270     if len(args) != 2 or len(args[0]) != 3 or len(args[1]) != 3:
271         conn.notice(nick, "syntax: currency arg1 as arg2")
272         return
273     targ = ("http://www.xe.com/ucc/convert.cgi?From=%s&To=%s" % (args[0], args[1]))
274     try:
275         currencypage = urllib.urlopen(targ).read()
276         match = re.search(r"(1&nbsp;%s&nbsp;=&nbsp;[\d\.]+&nbsp;%s)" % (args[0].upper(),args[1].upper()),currencypage,re.MULTILINE)
277         if match == None:
278             bot.automsg(public,nick,"Dear Chief Secretary, there is no money.")
279         else:
280             conversion = match.group(1);
281             conversion = conversion.replace('&nbsp;',' ');
282             bot.automsg(public,nick,conversion + " (from xe.com)")
283     except IOError: # if the connection times out. This blocks. :(
284         bot.automsg(public,nick,"The web's broken. Waah!")
285                  
286
287 ### extract the commit message and timestamp for commit 
288 def __getcommitinfo(commit):
289     cmd=["git","log","-n","1","--pretty=format:%ct|%s",commit]
290     x=subprocess.Popen(cmd,
291                        stdout=subprocess.PIPE,stderr=subprocess.PIPE)
292     out,err=x.communicate()
293
294     if len(err):
295         return(err)
296
297     ts,mes=out.split('|')
298     mes=mes.strip()
299     md5mes=hashlib.md5(mes).hexdigest()
300     if bfd and md5mes in bfd:
301         mes=bfd[md5mes]
302     when=datetime.date.fromtimestamp(float(ts))
303     return mes, when
304
305 ###Return an array of commit messages and timestamps for lines in db that match what
306 def __getcommits(db,keys,what):
307     ans=[]
308     for k in keys:
309         if what in k:
310             ret=__getcommitinfo(db[k])
311             if len(ret)==1: #error message
312                 return ["Error message from git blame: %s" % ret]
313             else:
314                 ans.append( (k,ret[0],ret[1]) )
315     return ans
316
317 ###search all three databases for what
318 def __getall(tdb,tdbk,fdb,fdbk,sdb,sdbk,what):
319     if what.strip()=="":
320         return []
321     tans=__getcommits(tdb,tdbk,what)
322     fans=__getcommits(fdb,fdbk,what)
323     sans=__getcommits(sdb,sdbk,what)
324     return tans+fans+sans
325
326 def blameq(bot,cmd,nick,conn,public,fishpond,cfgs):
327     tdb,tdbk,x = cfgs[0][7] # urgh, magic, to support magic knowledge below
328     fdb,fdbk,x = cfgs[1][7]
329     sdb,sdbk,x = cfgs[2][7]
330     clist=cmd.split()
331     if len(clist) < 2:
332         bot.automsg(public,nick,"Who or what do you want to blame?")
333         return
334     cwhat=' '.join(clist[2:])
335     kindsfile = "fish?"
336     if clist[1]=="#last":
337         try:
338             n = abs(int(clist[2]))-1
339             if n < 0: raise ValueError
340         except IndexError: n = 0
341         except ValueError:
342             bot.automsg(public,nick,"Huh?")
343             return
344         try: lmsg, lcfg = fishpond.last[n]
345         except IndexError:
346             bot.automsg(public,nick,"Nothing")
347             return
348         xdb,xdbk,kindsfile = lcfg[7]
349         ans=__getcommits(xdb,xdbk,lmsg)
350     elif clist[1]=="#trouts" or clist[1]=="#trout":
351         ans=__getcommits(tdb,tdbk,cwhat)
352     elif clist[1]=="#flirts" or clist[1]=="#flirt":
353         ans=__getcommits(fdb,fdbk,cwhat)
354     elif clist[1]=="#slashes" or clist[1]=="#slash":
355         ans=__getcommits(sdb,sdbk,cwhat)
356     else:
357         cwhat=' '.join(clist[1:])
358         ans=__getall(tdb,tdbk,fdb,fdbk,sdb,sdbk,cwhat)
359     if len(ans)==0:
360         bot.automsg(public,nick,"No match found")
361     elif len(ans)==1:
362         if len(ans[0])==1:
363             bot.automsg(public,nick,ans[0])
364         else:
365             bot.automsg(public,nick,"Modified %s %s: %s" % (kindsfile, ans[0][2].isoformat(),ans[0][1]))
366     elif len(ans)>4:
367         bot.automsg(public,nick,"I found %d matches, which is too many. Please be more specific!" % (len(ans)) )
368     else:
369         for a in ans:
370             if len(a)==1:
371                 bot.automsg(public,nick,a)
372             else:
373                 bot.automsg(public,nick,"%s '%s' modified on %s: %s" % (kindsfile, a[0],a[2].isoformat(),a[1]))
374
375 ### say to msg/channel            
376 def sayq(bot, cmd, nick, conn, public):
377     if irc_lower(nick) == irc_lower(bot.owner):
378         conn.privmsg(bot.channel, string.join(cmd.split()[1:]))
379     else:
380         if not public:
381             conn.notice(nick, "You're not my owner!")
382
383 ### action to msg/channel
384 def doq(bot, cmd, nick, conn, public):
385     sys.stderr.write(irc_lower(bot.owner))
386     sys.stderr.write(irc_lower(nick))
387     if not public:
388         if irc_lower(nick) == irc_lower(bot.owner):
389             conn.action(bot.channel, string.join(cmd.split()[1:]))
390         else:
391             conn.notice(nick, "You're not my owner!")
392
393 ###disconnect
394 def disconnq(bot, cmd, nick, conn, public):
395     if cmd == "disconnect": # hop off for 60s
396         bot.disconnect(msg="Be right back.")
397
398 ### list keys of a dictionary
399 def listkeysq(bot, cmd, nick, conn, public, dict, sort=False):
400     d=dict.keys()
401     if sort:
402         d.sort()
403     bot.automsg(public,nick,string.join(d))
404
405 ### rot13 text (yes, I could have typed out the letters....)
406 ### also "foo".encode('rot13') would have worked
407 def rot13q(bot, cmd, nick, conn, public):
408     a=''.join(map(chr,range((ord('a')),(ord('z')+1))))
409     b=a[13:]+a[:13]
410     trans=string.maketrans(a+a.upper(),b+b.upper())
411     conn.notice(nick, string.join(cmd.split()[1:]).translate(trans))
412
413 ### URL-tracking stuff
414
415 ### return a easy-to-read approximation of a time period
416 def nicetime(tempus):
417   if (tempus<120):
418     tm="%d seconds ago"%int(tempus)
419   elif (tempus<7200):
420     tm="%d minutes ago"%int(tempus/60)
421   if (tempus>7200):
422     tm="%d hours ago"%int(tempus/3600)
423   return tm
424
425 ### class to store URL data
426 class UrlLog:
427     "contains meta-data about a URL seen on-channel"
428     def __init__(self,url,nick):
429         self.nick=nick
430         self.url=url
431         self.first=time.time()
432         self.localfirst=time.localtime(self.first)
433         self.count=1
434         self.lastseen=time.time()
435         self.lastasked=time.time()
436     def recenttime(self):
437         return max(self.lastseen,self.lastasked)
438     def firstmen(self):
439         n=time.localtime(time.time())
440         s="%02d:%02d" % (self.localfirst.tm_hour,self.localfirst.tm_min)
441         if n.tm_yday != self.localfirst.tm_yday:
442             s+=time.strftime(" on %d %B", self.localfirst)
443         return s
444     def urltype(self):
445         z=min(len(urlinfos)-1, self.count-1)
446         return urlinfos[z]
447
448 #(?:) is a regexp that doesn't group        
449 urlre = re.compile(r"((?:(?:http)|(?:nsfw))s?://[^ ]+)( |$)")
450 hturlre= re.compile(r"(http)(s?://[^ ]+)( |$)")
451 #matches \bre\:?\s+ before a regexp; (?i)==case insensitive match
452 shibboleth = re.compile(r"(?i)\bre\:?\s+((?:(?:http)|(?:nsfw))s?://[^ ]+)( |$)")
453 #How long (in s) to wait since the most recent mention before commenting
454 url_repeat_time = 300
455 urlinfos = ["a new",
456             "a fascinating",
457             "an interesting",
458             "a popular"]
459
460 ### Deal with /msg bot url or ~url in channel
461 def urlq(bot, cmd, nick, conn, public,urldb):
462   if (not urlre.search(cmd)):
463     bot.automsg(False,nick,"Please use 'url' only with http, https, nsfw, or nsfws URLs")
464     return
465
466   urlstring=urlre.search(cmd).group(1)
467   url=canonical_url(urlstring)
468   if (url in urldb):
469     T = urldb[url]
470     comment="I saw that URL in scrool, first mentioned by %s at %s" % \
471                (T.nick,T.firstmen())
472     if (public):
473       comment=comment+". Furthermore it defeats the point of this command to use it other than via /msg."
474       T.count+=1
475     bot.automsg(False,nick,comment)
476     T.lastasked=time.time()
477     #URL suppressed, so mention in #urls
478     if urlstring != cmd.split()[1]: #first argument to URL was not the url
479       conn.privmsg("#urls","%s remarks: %s" % (nick," ".join(cmd.split()[1:])))
480     else:
481       conn.privmsg("#urls","(via %s) %s"%(nick," ".join(cmd.split()[1:])))
482   else:
483     if (public):
484       bot.automsg(False,nick,"That URL was unique. There is little point in using !url out loud; please use it via /msg")
485     else:
486       if urlstring != cmd.split()[1]: #first argument to URL was not the url
487         conn.privmsg(bot.channel,"%s remarks: %s" % (nick," ".join(cmd.split()[1:])))
488       else:
489         conn.privmsg(bot.channel,"(via %s) %s"%(nick," ".join(cmd.split()[1:])))
490     urldb[url]=UrlLog(url,nick)
491
492 ### Deal with URLs spotted in channel
493 def dourl(bot,conn,nick,command,urldb):
494   urlstring=urlre.search(command).group(1)
495   urlstring=canonical_url(urlstring)
496
497   if urlstring in urldb:
498     T=urldb[urlstring]
499     message="saw that URL in scrool, first mentioned by %s at %s" % \
500              (T.nick,T.firstmen())
501     if shibboleth.search(command)==None and \
502        time.time() - T.lastseen > url_repeat_time:
503         conn.action(bot.channel, message)
504     T.lastseen=time.time()
505     T.count+=1
506   else:
507     urldb[urlstring]=UrlLog(urlstring,nick)
508
509 ### Expire old urls
510 def urlexpire(urldb,expire):
511     urls=urldb.keys()
512     for u in urls:
513         if time.time() - urldb[u].recenttime() > expire:
514             del urldb[u]
515
516 # canonicalise BBC URLs (internal use only)
517 def canonical_url(urlstring):
518   if "nsfw://" in urlstring or "nsfws://" in urlstring:
519       urlstring=urlstring.replace("nsfw","http",1)
520   if (urlstring.find("news.bbc.co.uk") != -1):
521     for middle in ("/low/","/mobile/"):
522       x = urlstring.find(middle)
523       if (x != -1):
524         urlstring.replace(middle,"/hi/")
525   return urlstring
526
527 # automatically make nsfw urls for you and pass them on to url
528 def nsfwq(bot,cmd,nick,conn,public,urldb):
529   if (not hturlre.search(cmd)):
530     bot.automsg(False,nick,"Please use 'nsfw' only with http or https URLs")
531     return
532   newcmd=hturlre.sub(nsfwify,cmd)
533   urlq(bot,newcmd,nick,conn,public,urldb)
534
535 def nsfwify(match):
536     a,b,c=match.groups()
537     return 'nsfw'+b+c
538
539 #get tweet text
540 def twitterq(bot,cmd,nick,conn,public,twitapi):
541   
542   if (not urlre.search(cmd)):
543     bot.automsg(False,nick,"Please use 'twit' only with http or https URLs")
544     return
545
546   urlstring = urlre.search(cmd).group(1)
547   if (urlstring.find("twitter.com") !=-1):
548     stringsout = getTweet(urlstring,twitapi)
549     for stringout in stringsout:
550         bot.automsg(public, nick, stringout)
551   
552 def getTweet(urlstring,twitapi,inclusion=False,recurlvl=0):
553   unobfuscate_urls=True
554   expand_included_tweets=True
555   stringsout=[]
556
557   path = urlparse.urlparse(urlstring).path
558   tweetID = path.split('/')[-1]
559   try:
560     status = twitapi.GetStatus(tweetID)
561     if status == {}:
562         return "twitapi.GetStatus returned nothing :-("
563     if status.user == None and status.text == None:
564         return "Empty status object returned :("
565     if status.retweeted_status and status.retweeted_status.text:
566         status = status.retweeted_status
567     if status.user is not None:
568         tweeter_screen = status.user.screen_name #.encode('UTF-8', 'replace')
569         tweeter_name = status.user.name #.encode('UTF-8', 'replace')
570     else:
571         tweeter_screen = "[not returned]" ; tweeter_name = "[not returned]"
572         tweeter_name = tweeter_name + " RTing " + status.user.name #.encode('UTF-8', 'replace')
573     tweetText = status.full_text
574     if status.media:
575         replacements = defaultdict(list)
576
577         for medium in status.media:
578             replacements[medium.url].append(medium.media_url_https)
579
580         # The twitter-api 'conveniently' parses this for you and
581         # throws away the actual video URLs, so we have to take the
582         # JSON and reparse it :sadpanda:
583         # This is particularly annoying because we don't know
584         # for sure that status.media and the JSON 'media' entry
585         # have the same elements in the same order.  Probably they
586         # do but maybe twitter-api randomly reorganised things or
587         # filtered the list or something.  So instead we go through
588         # the JSON and handle the media urls, discarding whatever
589         # unfortunate thing we have put in replacements already.
590         parsed_tweet = json.loads(status.AsJsonString())
591         for medium in parsed_tweet.get('media', []):
592             if medium['type'] == 'video':
593                 best = { 'bitrate': -1 }
594                 for vt in medium['video_info']['variants']:
595                     if (vt.get('content_type') == 'video/mp4' and
596                         vt.get('bitrate', -1) > best['bitrate']):
597                         best = vt
598                 if 'url' in best:
599                     video_url = best['url'].split('?',1)[0]
600                     duration = medium['video_info']['duration_millis']
601                     # ^ duration_millis is a string
602                     duration = "%.1f" % (float(duration)/1000.)
603                     video_desc = "%s (%ss)" % (video_url, duration)
604                     replacements[medium['url']] = [video_desc]
605
606         for k,v in replacements.items():
607             if len(v) > 1:
608                 replacementstring = "[" +  " ; ".join(v) +"]"
609             else:
610                 replacementstring = v[0]
611             tweetText = tweetText.replace(k, replacementstring)
612
613     for url in status.urls:
614         toReplace = url.expanded_url
615
616         if unobfuscate_urls:
617             import urllib
618             rv = urlparse.urlparse(toReplace)
619             if rv.hostname in {
620                 # sourced from http://bit.do/list-of-url-shorteners.php
621                 "bit.do", "t.co", "lnkd.in", "db.tt", "qr.ae", "adf.ly",
622                 "goo.gl", "bitly.com", "cur.lv", "tinyurl.com", "ow.ly",
623                 "bit.ly", "adcrun.ch", "ity.im", "q.gs", "viralurl.com",
624                 "is.gd", "po.st", "vur.me", "bc.vc", "twitthis.com", "u.to",
625                 "j.mp", "buzurl.com", "cutt.us", "u.bb", "yourls.org",
626                 "crisco.com", "x.co", "prettylinkpro.com", "viralurl.biz",
627                 "adcraft.co", "virl.ws", "scrnch.me", "filoops.info", "vurl.bz",
628                 "vzturl.com", "lemde.fr", "qr.net", "1url.com", "tweez.me",
629                 "7vd.cn", "v.gd", "dft.ba", "aka.gr", "tr.im",
630                  # added by ASB:
631                  "trib.al", "dlvr.it"
632                                }:
633                 #expand list as needed.
634                 response = urllib.urlopen('http://urlex.org/txt/' + toReplace)
635                 resptext = response.read()
636                 if resptext.startswith('http'): # ie it looks urlish (http or https)
637                     if resptext != toReplace:
638                         toReplace = resptext
639                     # maybe make a note of the domain of the original URL to compile list of shortenable domains?
640
641         # remove tracking utm_ query parameters, for privacy and brevity
642         # code snippet from https://gist.github.com/lepture/5997883
643         rv = urlparse.urlparse(toReplace)
644         if rv.query:
645             query = re.sub(r'utm_\w+=[^&]+&?', '', rv.query)
646             if query:
647                 toReplace = '%s://%s%s?%s' % (rv.scheme, rv.hostname, rv.path, query)
648             else:
649                 toReplace = '%s://%s%s' % (rv.scheme, rv.hostname, rv.path) # leave off the final '?'
650
651         if expand_included_tweets:
652             if rv.hostname == 'twitter.com' and re.search(r'status/\d+',rv.path):
653                 if recurlvl > 2:
654                   stringsout = [ "{{ Recursion level too high }}" ] + stringsout
655                 else:
656                   quotedtweet = getTweet(toReplace, twitapi, inclusion=True, recurlvl=recurlvl+1) # inclusion parameter limits recursion.
657                   if not quotedtweet:
658                       quotedtweet = [""]
659                   quotedtweet[0] = "Q{ " + quotedtweet[0]
660                   quotedtweet[-1] += " }"
661                   stringsout = quotedtweet + stringsout
662
663         tweetText = tweetText.replace(url.url, toReplace)
664
665     tweetText = tweetText.replace("&gt;",">")
666     tweetText = tweetText.replace("&lt;","<")
667     tweetText = tweetText.replace("&amp;","&")
668     tweetText = tweetText.replace("\n"," ")
669     stringout = "tweet by %s (%s): %s" %(tweeter_screen,tweeter_name,tweetText)
670   except twitter.TwitterError:
671     terror = sys.exc_info()
672     stringout = "Twitter error: %s" % terror[1].__str__()
673   except Exception:
674     terror = sys.exc_info()
675     stringout = "Error: %s" % terror[1].__str__()
676   stringsout = [stringout] + stringsout
677   if inclusion:
678       return stringsout # don't want to double-encode it, so just pass it on for now and encode later
679
680   return map(lambda x: x.encode('UTF-8', 'replace'), stringsout)