]> jfr.im git - z_archive/twitter.git/blame - twitter/ircbot.py
Add a simple prompt thing to twitter cmdline.
[z_archive/twitter.git] / twitter / ircbot.py
CommitLineData
5b7080ef 1"""
2twitterbot
772fbdd1 3
5b7080ef 4 A twitter IRC bot. Twitterbot connected to an IRC server and idles in
5 a channel, polling a twitter account and broadcasting all updates to
6 friends.
d828f28d 7
5b7080ef 8USAGE
9
10 twitterbot [config_file]
11
12CONFIG_FILE
13
14 The config file is an ini-style file that must contain the following:
d828f28d 15
5b7080ef 16[irc]
17server: <irc_server>
18port: <irc_port>
19nick: <irc_nickname>
77a8613a 20channel: <irc_channels_to_join>
0bcc8bb9 21prefixes: <prefix_type>
5b7080ef 22
23[twitter]
52792ab4 24oauth_token_file: <oauth_token_filename>
5b7080ef 25
b313a2b4 26
5b7080ef 27 If no config file is given "twitterbot.ini" will be used by default.
77a8613a
M
28
29 The channel argument can accept multiple channels separated by commas.
52792ab4
MV
30
31 The default token file is ~/.twitterbot_oauth.
32
0bcc8bb9
MV
33 The default prefix type is 'cats'. You can also use 'none'.
34
5b7080ef 35"""
d8ac8b72 36
52792ab4
MV
37BOT_VERSION = "TwitterBot 1.4 (http://mike.verdone.ca/twitter)"
38
39CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ"
40CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE"
d8ac8b72 41
42IRC_BOLD = chr(0x02)
43IRC_ITALIC = chr(0x16)
44IRC_UNDERLINE = chr(0x1f)
45IRC_REGULAR = chr(0x0f)
46
7d7c8094 47import sys
772fbdd1 48import time
36805447 49from datetime import datetime, timedelta
5b85a4f1 50from email.utils import parsedate
65ec2606 51from ConfigParser import SafeConfigParser
7d7c8094 52from heapq import heappop, heappush
53import traceback
52792ab4 54import os
9d1c2940 55import os.path
7d7c8094 56
c93672d4 57from api import Twitter, TwitterError
52792ab4
MV
58from oauth import OAuth, read_token_file
59from oauth_dance import oauth_dance
8ad2cf0b 60from util import htmlentitydecode
772fbdd1 61
0bcc8bb9
MV
62PREFIXES = dict(
63 cats=dict(
64 new_tweet="=^_^= ",
65 error="=O_o= ",
66 inform="=o_o= "
85681608 67 ),
0bcc8bb9
MV
68 none=dict(
69 new_tweet=""
85681608 70 ),
0bcc8bb9
MV
71 )
72ACTIVE_PREFIXES=dict()
73
74def get_prefix(prefix_typ=None):
75 return ACTIVE_PREFIXES.get(prefix_typ, ACTIVE_PREFIXES.get('new_tweet', ''))
76
77
772fbdd1 78try:
79 import irclib
80except:
7d7c8094 81 raise ImportError(
82 "This module requires python irclib available from "
83 + "http://python-irclib.sourceforge.net/")
84
52792ab4
MV
85OAUTH_FILE = os.environ.get('HOME', '') + os.sep + '.twitterbot_oauth'
86
7d7c8094 87def debug(msg):
88 # uncomment this for debug text stuff
5b7080ef 89 # print >> sys.stderr, msg
7d7c8094 90 pass
772fbdd1 91
92class SchedTask(object):
93 def __init__(self, task, delta):
94 self.task = task
95 self.delta = delta
7d7c8094 96 self.next = time.time()
97
98 def __repr__(self):
99 return "<SchedTask %s next:%i delta:%i>" %(
100 self.task.__name__, self.next, self.delta)
d828f28d 101
7d7c8094 102 def __cmp__(self, other):
103 return cmp(self.next, other.next)
d828f28d 104
7d7c8094 105 def __call__(self):
106 return self.task()
107
772fbdd1 108class Scheduler(object):
109 def __init__(self, tasks):
7d7c8094 110 self.task_heap = []
111 for task in tasks:
112 heappush(self.task_heap, task)
d828f28d 113
772fbdd1 114 def next_task(self):
115 now = time.time()
7d7c8094 116 task = heappop(self.task_heap)
772fbdd1 117 wait = task.next - now
65ec2606 118 task.next = now + task.delta
119 heappush(self.task_heap, task)
772fbdd1 120 if (wait > 0):
121 time.sleep(wait)
7d7c8094 122 task()
85681608 123 #debug("tasks: " + str(self.task_heap))
d828f28d 124
772fbdd1 125 def run_forever(self):
65ec2606 126 while True:
127 self.next_task()
128
d828f28d 129
772fbdd1 130class TwitterBot(object):
7d7c8094 131 def __init__(self, configFilename):
132 self.configFilename = configFilename
133 self.config = load_config(self.configFilename)
52792ab4 134
85681608
MV
135 global ACTIVE_PREFIXES
136 ACTIVE_PREFIXES = PREFIXES[self.config.get('irc', 'prefixes')]
137
52792ab4
MV
138 oauth_file = self.config.get('twitter', 'oauth_token_file')
139 if not os.path.exists(oauth_file):
140 oauth_dance("IRC Bot", CONSUMER_KEY, CONSUMER_SECRET, oauth_file)
141 oauth_token, oauth_secret = read_token_file(oauth_file)
142
143 self.twitter = Twitter(
144 auth=OAuth(
145 oauth_token, oauth_secret, CONSUMER_KEY, CONSUMER_SECRET),
d828f28d
MV
146 api_version='1',
147 domain='api.twitter.com')
52792ab4 148
772fbdd1 149 self.irc = irclib.IRC()
7d7c8094 150 self.irc.add_global_handler('privmsg', self.handle_privmsg)
d8ac8b72 151 self.irc.add_global_handler('ctcp', self.handle_ctcp)
7d7c8094 152 self.ircServer = self.irc.server()
52792ab4 153
772fbdd1 154 self.sched = Scheduler(
7d7c8094 155 (SchedTask(self.process_events, 1),
d8ac8b72 156 SchedTask(self.check_statuses, 120)))
bd6ce073 157 self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple()
772fbdd1 158
159 def check_statuses(self):
7d7c8094 160 debug("In check_statuses")
161 try:
36805447 162 updates = reversed(self.twitter.statuses.friends_timeline())
7d7c8094 163 except Exception, e:
164 print >> sys.stderr, "Exception while querying twitter:"
165 traceback.print_exc(file=sys.stderr)
166 return
d828f28d 167
6da3627e 168 nextLastUpdate = self.lastUpdate
85681608 169 debug("self.lastUpdate is %s" % self.lastUpdate)
7d7c8094 170 for update in updates:
5b85a4f1 171 crt = parsedate(update['created_at'])
85681608 172 if (crt > nextLastUpdate):
8ad2cf0b 173 text = (htmlentitydecode(
174 update['text'].replace('\n', ' '))
175 .encode('utf-8', 'replace'))
7f6c5ae3 176
177 # Skip updates beginning with @
178 # TODO This would be better if we only ignored messages
179 # to people who are not on our following list.
180 if not text.startswith("@"):
c1b9acea 181 self.privmsg_channels(
85681608 182 u"%s %s%s%s %s" %(
0bcc8bb9 183 get_prefix(),
7f6c5ae3 184 IRC_BOLD, update['user']['screen_name'],
65ec2606 185 IRC_BOLD, text.decode('utf-8')))
d828f28d 186
6da3627e 187 nextLastUpdate = crt
36805447 188
85681608 189 debug("setting self.lastUpdate to %s" % nextLastUpdate)
6da3627e 190 self.lastUpdate = nextLastUpdate
d828f28d 191
7d7c8094 192 def process_events(self):
7d7c8094 193 self.irc.process_once()
d828f28d 194
7d7c8094 195 def handle_privmsg(self, conn, evt):
196 debug('got privmsg')
197 args = evt.arguments()[0].split(' ')
198 try:
199 if (not args):
200 return
201 if (args[0] == 'follow' and args[1:]):
202 self.follow(conn, evt, args[1])
203 elif (args[0] == 'unfollow' and args[1:]):
204 self.unfollow(conn, evt, args[1])
205 else:
206 conn.privmsg(
d828f28d 207 evt.source().split('!')[0],
0bcc8bb9 208 "%sHi! I'm Twitterbot! you can (follow "
bd6ce073
MV
209 "<twitter_name>) to make me follow a user or "
210 "(unfollow <twitter_name>) to make me stop." %
0bcc8bb9 211 get_prefix())
7d7c8094 212 except Exception:
213 traceback.print_exc(file=sys.stderr)
d828f28d 214
d8ac8b72 215 def handle_ctcp(self, conn, evt):
216 args = evt.arguments()
217 source = evt.source().split('!')[0]
218 if (args):
219 if args[0] == 'VERSION':
220 conn.ctcp_reply(source, "VERSION " + BOT_VERSION)
221 elif args[0] == 'PING':
222 conn.ctcp_reply(source, "PING")
223 elif args[0] == 'CLIENTINFO':
224 conn.ctcp_reply(source, "CLIENTINFO PING VERSION CLIENTINFO")
7d7c8094 225
226 def privmsg_channel(self, msg):
227 return self.ircServer.privmsg(
65ec2606 228 self.config.get('irc', 'channel'), msg.encode('utf-8'))
d828f28d 229
c1b9acea
PP
230 def privmsg_channels(self, msg):
231 return_response=True
232 channels=self.config.get('irc','channel').split(',')
233 return self.ircServer.privmsg_many(channels, msg.encode('utf-8'))
d828f28d 234
7d7c8094 235 def follow(self, conn, evt, name):
236 userNick = evt.source().split('!')[0]
237 friends = [x['name'] for x in self.twitter.statuses.friends()]
238 debug("Current friends: %s" %(friends))
239 if (name in friends):
240 conn.privmsg(
241 userNick,
0bcc8bb9 242 "%sI'm already following %s." %(get_prefix('error'), name))
7d7c8094 243 else:
c93672d4 244 try:
245 self.twitter.friendships.create(id=name)
246 except TwitterError:
247 conn.privmsg(
248 userNick,
0bcc8bb9
MV
249 "%sI can't follow that user. Are you sure the name is correct?" %(
250 get_prefix('error')
251 ))
c93672d4 252 return
7d7c8094 253 conn.privmsg(
254 userNick,
0bcc8bb9 255 "%sOkay! I'm now following %s." %(get_prefix('followed'), name))
c1b9acea 256 self.privmsg_channels(
0bcc8bb9
MV
257 "%s%s has asked me to start following %s" %(
258 get_prefix('inform'), userNick, name))
d828f28d 259
7d7c8094 260 def unfollow(self, conn, evt, name):
261 userNick = evt.source().split('!')[0]
262 friends = [x['name'] for x in self.twitter.statuses.friends()]
263 debug("Current friends: %s" %(friends))
264 if (name not in friends):
265 conn.privmsg(
266 userNick,
0bcc8bb9 267 "%sI'm not following %s." %(get_prefix('error'), name))
7d7c8094 268 else:
269 self.twitter.friendships.destroy(id=name)
270 conn.privmsg(
271 userNick,
0bcc8bb9
MV
272 "%sOkay! I've stopped following %s." %(
273 get_prefix('stop_follow'), name))
c1b9acea 274 self.privmsg_channels(
0bcc8bb9
MV
275 "%s%s has asked me to stop following %s" %(
276 get_prefix('inform'), userNick, name))
d828f28d 277
772fbdd1 278 def run(self):
7d7c8094 279 self.ircServer.connect(
d828f28d 280 self.config.get('irc', 'server'),
7d7c8094 281 self.config.getint('irc', 'port'),
282 self.config.get('irc', 'nick'))
c1b9acea
PP
283 channels=self.config.get('irc', 'channel').split(',')
284 for channel in channels:
285 self.ircServer.join(channel)
65ec2606 286
287 while True:
288 try:
289 self.sched.run_forever()
290 except KeyboardInterrupt:
291 break
292 except TwitterError:
0bcc8bb9
MV
293 # twitter.com is probably down because it
294 # sucks. ignore the fault and keep going
65ec2606 295 pass
7d7c8094 296
297def load_config(filename):
ca242389
MV
298 # Note: Python ConfigParser module has the worst interface in the
299 # world. Mega gross.
300 cp = SafeConfigParser()
301 cp.add_section('irc')
302 cp.set('irc', 'port', '6667')
303 cp.set('irc', 'nick', 'twitterbot')
0bcc8bb9 304 cp.set('irc', 'prefixes', 'cats')
ca242389
MV
305 cp.add_section('twitter')
306 cp.set('twitter', 'oauth_token_file', OAUTH_FILE)
0bcc8bb9 307
7d7c8094 308 cp.read((filename,))
d828f28d 309
9d1c2940 310 # attempt to read these properties-- they are required
52792ab4 311 cp.get('twitter', 'oauth_token_file'),
625bc8f0
MV
312 cp.get('irc', 'server')
313 cp.getint('irc', 'port')
314 cp.get('irc', 'nick')
315 cp.get('irc', 'channel')
9d1c2940 316
7d7c8094 317 return cp
772fbdd1 318
44bcaa4f
MV
319# So there was a joke here about the twitter business model
320# but I got rid of it. Not because I want this codebase to
321# be "professional" in any way, but because someone forked
322# this and deleted the comment because they couldn't take
323# a joke. Hi guy!
9d1c2940 324#
44bcaa4f
MV
325# Fact: The number one use of Google Code is to look for that
326# comment in the Linux kernel that goes "FUCK me gently with
327# a chainsaw." Pretty sure Linus himself wrote it.
9d1c2940 328
7d7c8094 329def main():
330 configFilename = "twitterbot.ini"
331 if (sys.argv[1:]):
332 configFilename = sys.argv[1]
d828f28d 333
5b7080ef 334 try:
9d1c2940
MV
335 if not os.path.exists(configFilename):
336 raise Exception()
5b7080ef 337 load_config(configFilename)
625bc8f0 338 except Exception, e:
9d1c2940 339 print >> sys.stderr, "Error while loading ini file %s" %(
5b7080ef 340 configFilename)
625bc8f0
MV
341 print >> sys.stderr, e
342 print >> sys.stderr, __doc__
5b7080ef 343 sys.exit(1)
9d1c2940 344
7d7c8094 345 bot = TwitterBot(configFilename)
346 return bot.run()