]> jfr.im git - z_archive/twitter.git/blame - twitter/ircbot.py
bandaid unicode/str.encode-related crash bug
[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>
6db986bd 22modes: <modes to set (i.e. +ix)>
23autocmd: <command to send upon connect>
5b7080ef 24
25[twitter]
52792ab4 26oauth_token_file: <oauth_token_filename>
5b7080ef 27
b313a2b4 28
5b7080ef 29 If no config file is given "twitterbot.ini" will be used by default.
77a8613a
M
30
31 The channel argument can accept multiple channels separated by commas.
52792ab4
MV
32
33 The default token file is ~/.twitterbot_oauth.
34
0bcc8bb9
MV
35 The default prefix type is 'cats'. You can also use 'none'.
36
6db986bd 37 autocmd and modes may be omitted.
38
5b7080ef 39"""
d8ac8b72 40
c2907f1e
MV
41from __future__ import print_function
42
be68219c 43BOT_VERSION = "TwitterBot 1.9.1 (http://mike.verdone.ca/twitter)"
52792ab4
MV
44
45CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ"
46CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE"
d8ac8b72 47
48IRC_BOLD = chr(0x02)
49IRC_ITALIC = chr(0x16)
50IRC_UNDERLINE = chr(0x1f)
51IRC_REGULAR = chr(0x0f)
52
7d7c8094 53import sys
772fbdd1 54import time
36805447 55from datetime import datetime, timedelta
5b85a4f1 56from email.utils import parsedate
c2907f1e
MV
57try:
58 from configparser import ConfigParser
59except ImportError:
60 from ConfigParser import ConfigParser
7d7c8094 61from heapq import heappop, heappush
62import traceback
52792ab4 63import os
9d1c2940 64import os.path
7d7c8094 65
f7e63802
MV
66from .api import Twitter, TwitterError
67from .oauth import OAuth, read_token_file
68from .oauth_dance import oauth_dance
69from .util import htmlentitydecode
772fbdd1 70
0bcc8bb9
MV
71PREFIXES = dict(
72 cats=dict(
73 new_tweet="=^_^= ",
74 error="=O_o= ",
75 inform="=o_o= "
85681608 76 ),
0bcc8bb9
MV
77 none=dict(
78 new_tweet=""
85681608 79 ),
0bcc8bb9
MV
80 )
81ACTIVE_PREFIXES=dict()
82
83def get_prefix(prefix_typ=None):
84 return ACTIVE_PREFIXES.get(prefix_typ, ACTIVE_PREFIXES.get('new_tweet', ''))
85
86
772fbdd1 87try:
88 import irclib
c2907f1e 89except ImportError:
7d7c8094 90 raise ImportError(
91 "This module requires python irclib available from "
3d18c203 92 + "https://github.com/sixohsix/python-irclib/zipball/python-irclib3-0.4.8")
7d7c8094 93
89330227 94OAUTH_FILE = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.twitterbot_oauth'
52792ab4 95
7d7c8094 96def debug(msg):
97 # uncomment this for debug text stuff
c2907f1e 98 # print(msg, file=sys.stdout)
7d7c8094 99 pass
772fbdd1 100
101class SchedTask(object):
102 def __init__(self, task, delta):
103 self.task = task
104 self.delta = delta
7d7c8094 105 self.next = time.time()
106
107 def __repr__(self):
108 return "<SchedTask %s next:%i delta:%i>" %(
f7e63802 109 self.task.__name__, self.__next__, self.delta)
d828f28d 110
33c60d21
MV
111 def __lt__(self, other):
112 return self.next < other.next
d828f28d 113
7d7c8094 114 def __call__(self):
115 return self.task()
116
772fbdd1 117class Scheduler(object):
118 def __init__(self, tasks):
7d7c8094 119 self.task_heap = []
120 for task in tasks:
121 heappush(self.task_heap, task)
d828f28d 122
772fbdd1 123 def next_task(self):
124 now = time.time()
7d7c8094 125 task = heappop(self.task_heap)
33c60d21 126 wait = task.next - now
65ec2606 127 task.next = now + task.delta
128 heappush(self.task_heap, task)
772fbdd1 129 if (wait > 0):
130 time.sleep(wait)
7d7c8094 131 task()
85681608 132 #debug("tasks: " + str(self.task_heap))
d828f28d 133
772fbdd1 134 def run_forever(self):
65ec2606 135 while True:
136 self.next_task()
137
d828f28d 138
772fbdd1 139class TwitterBot(object):
7d7c8094 140 def __init__(self, configFilename):
141 self.configFilename = configFilename
142 self.config = load_config(self.configFilename)
52792ab4 143
85681608
MV
144 global ACTIVE_PREFIXES
145 ACTIVE_PREFIXES = PREFIXES[self.config.get('irc', 'prefixes')]
146
52792ab4
MV
147 oauth_file = self.config.get('twitter', 'oauth_token_file')
148 if not os.path.exists(oauth_file):
149 oauth_dance("IRC Bot", CONSUMER_KEY, CONSUMER_SECRET, oauth_file)
150 oauth_token, oauth_secret = read_token_file(oauth_file)
151
152 self.twitter = Twitter(
153 auth=OAuth(
154 oauth_token, oauth_secret, CONSUMER_KEY, CONSUMER_SECRET),
d828f28d 155 domain='api.twitter.com')
52792ab4 156
772fbdd1 157 self.irc = irclib.IRC()
7d7c8094 158 self.irc.add_global_handler('privmsg', self.handle_privmsg)
d8ac8b72 159 self.irc.add_global_handler('ctcp', self.handle_ctcp)
7ab101b7 160 self.irc.add_global_handler('umode', self.handle_umode)
7d7c8094 161 self.ircServer = self.irc.server()
52792ab4 162
772fbdd1 163 self.sched = Scheduler(
7d7c8094 164 (SchedTask(self.process_events, 1),
d8ac8b72 165 SchedTask(self.check_statuses, 120)))
bd6ce073 166 self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple()
772fbdd1 167
168 def check_statuses(self):
7d7c8094 169 debug("In check_statuses")
170 try:
e4bec6bd 171 updates = reversed(self.twitter.statuses.home_timeline())
f7e63802
MV
172 except Exception as e:
173 print("Exception while querying twitter:", file=sys.stderr)
7d7c8094 174 traceback.print_exc(file=sys.stderr)
175 return
d828f28d 176
6da3627e 177 nextLastUpdate = self.lastUpdate
7d7c8094 178 for update in updates:
f5d5b4ba 179 try:
180 # This part raises lots of exceptions which kill the bot
181 # (Unicode errors, etc.)
182 # Ignore any exceptions, as a band-aid.
183 crt = parsedate(update['created_at'])
184 if (crt > nextLastUpdate):
185 text = (htmlentitydecode(
186 update['text'].replace('\n', ' '))
187 .encode('utf8', 'replace'))
188
189 # Skip updates beginning with @
190 # TODO This would be better if we only ignored messages
191 # to people who are not on our following list.
192 if not text.startswith(b"@"):
193 msg = "%s %s%s%s %s" %(
194 get_prefix(),
195 IRC_BOLD, update['user']['screen_name'],
196 IRC_BOLD, text.decode('utf8'))
197 self.privmsg_channels(msg)
198
199 nextLastUpdate = crt
200 except Exception as e:
201 print("Exception while sending updates:", file=sys.stderr)
202 traceback.print_exc(file=sys.stderr)
203 pass # don't return as this one is likely to keep happening
204
5b85a4f1 205 crt = parsedate(update['created_at'])
85681608 206 if (crt > nextLastUpdate):
8ad2cf0b 207 text = (htmlentitydecode(
208 update['text'].replace('\n', ' '))
2712c06b 209 .encode('utf8', 'replace'))
7f6c5ae3 210
211 # Skip updates beginning with @
212 # TODO This would be better if we only ignored messages
213 # to people who are not on our following list.
250db77f 214 if not text.startswith(b"@"):
2712c06b
MV
215 msg = "%s %s%s%s %s" %(
216 get_prefix(),
217 IRC_BOLD, update['user']['screen_name'],
218 IRC_BOLD, text.decode('utf8'))
219 self.privmsg_channels(msg)
d828f28d 220
6da3627e 221 nextLastUpdate = crt
36805447 222
6da3627e 223 self.lastUpdate = nextLastUpdate
d828f28d 224
7d7c8094 225 def process_events(self):
7d7c8094 226 self.irc.process_once()
d828f28d 227
7d7c8094 228 def handle_privmsg(self, conn, evt):
229 debug('got privmsg')
230 args = evt.arguments()[0].split(' ')
231 try:
232 if (not args):
233 return
234 if (args[0] == 'follow' and args[1:]):
235 self.follow(conn, evt, args[1])
236 elif (args[0] == 'unfollow' and args[1:]):
237 self.unfollow(conn, evt, args[1])
238 else:
239 conn.privmsg(
d828f28d 240 evt.source().split('!')[0],
0bcc8bb9 241 "%sHi! I'm Twitterbot! you can (follow "
bd6ce073
MV
242 "<twitter_name>) to make me follow a user or "
243 "(unfollow <twitter_name>) to make me stop." %
0bcc8bb9 244 get_prefix())
7d7c8094 245 except Exception:
246 traceback.print_exc(file=sys.stderr)
d828f28d 247
d8ac8b72 248 def handle_ctcp(self, conn, evt):
249 args = evt.arguments()
250 source = evt.source().split('!')[0]
251 if (args):
252 if args[0] == 'VERSION':
253 conn.ctcp_reply(source, "VERSION " + BOT_VERSION)
254 elif args[0] == 'PING':
255 conn.ctcp_reply(source, "PING")
256 elif args[0] == 'CLIENTINFO':
257 conn.ctcp_reply(source, "CLIENTINFO PING VERSION CLIENTINFO")
7d7c8094 258
7ab101b7
MV
259 def handle_umode(self, conn, evt):
260 """
261 QuakeNet ignores all your commands until after the MOTD. This
262 handler defers joining until after it sees a magic line. It
263 also tries to join right after connect, but this will just
264 make it join again which should be safe.
265 """
266 args = evt.arguments()
267 if (args and args[0] == '+i'):
6db986bd 268 try:
269 self.ircServer.send_raw(self.config.get('irc', 'autocmd'))
270 except:
271 pass # ignore errors, it's probably just not defined
272 try:
273 self.ircServer.send_raw("MODE %s %s" % (self.ircServer.get_nickname(), self.config.get('irc', 'modes')))
274 except:
275 pass # ignore errors again
7ab101b7
MV
276 channels = self.config.get('irc', 'channel').split(',')
277 for channel in channels:
278 self.ircServer.join(channel)
279
c1b9acea
PP
280 def privmsg_channels(self, msg):
281 return_response=True
282 channels=self.config.get('irc','channel').split(',')
2712c06b 283 return self.ircServer.privmsg_many(channels, msg.encode('utf8'))
d828f28d 284
7d7c8094 285 def follow(self, conn, evt, name):
286 userNick = evt.source().split('!')[0]
287 friends = [x['name'] for x in self.twitter.statuses.friends()]
288 debug("Current friends: %s" %(friends))
289 if (name in friends):
290 conn.privmsg(
291 userNick,
0bcc8bb9 292 "%sI'm already following %s." %(get_prefix('error'), name))
7d7c8094 293 else:
c93672d4 294 try:
84e22677 295 self.twitter.friendships.create(screen_name=name)
c93672d4 296 except TwitterError:
297 conn.privmsg(
298 userNick,
0bcc8bb9
MV
299 "%sI can't follow that user. Are you sure the name is correct?" %(
300 get_prefix('error')
301 ))
c93672d4 302 return
7d7c8094 303 conn.privmsg(
304 userNick,
0bcc8bb9 305 "%sOkay! I'm now following %s." %(get_prefix('followed'), name))
c1b9acea 306 self.privmsg_channels(
0bcc8bb9
MV
307 "%s%s has asked me to start following %s" %(
308 get_prefix('inform'), userNick, name))
d828f28d 309
7d7c8094 310 def unfollow(self, conn, evt, name):
311 userNick = evt.source().split('!')[0]
312 friends = [x['name'] for x in self.twitter.statuses.friends()]
313 debug("Current friends: %s" %(friends))
314 if (name not in friends):
315 conn.privmsg(
316 userNick,
0bcc8bb9 317 "%sI'm not following %s." %(get_prefix('error'), name))
7d7c8094 318 else:
84e22677 319 self.twitter.friendships.destroy(screen_name=name)
7d7c8094 320 conn.privmsg(
321 userNick,
0bcc8bb9
MV
322 "%sOkay! I've stopped following %s." %(
323 get_prefix('stop_follow'), name))
c1b9acea 324 self.privmsg_channels(
0bcc8bb9
MV
325 "%s%s has asked me to stop following %s" %(
326 get_prefix('inform'), userNick, name))
d828f28d 327
1015276a 328 def _irc_connect(self):
7d7c8094 329 self.ircServer.connect(
d828f28d 330 self.config.get('irc', 'server'),
7d7c8094 331 self.config.getint('irc', 'port'),
332 self.config.get('irc', 'nick'))
c1b9acea 333 channels=self.config.get('irc', 'channel').split(',')
6db986bd 334 try:
335 self.ircServer.send_raw(self.config.get('irc', 'autocmd'))
336 except Exception as e:
337 pass # ignore errors, it's probably just not defined
338 try:
339 self.ircServer.send_raw("MODE %s %s" % (self.ircServer.get_nickname(), self.config.get('irc', 'modes')))
340 except Exception as e:
341 pass # ignore errors again
c1b9acea
PP
342 for channel in channels:
343 self.ircServer.join(channel)
65ec2606 344
1015276a
MV
345 def run(self):
346 self._irc_connect()
347
65ec2606 348 while True:
349 try:
350 self.sched.run_forever()
351 except KeyboardInterrupt:
352 break
353 except TwitterError:
0bcc8bb9
MV
354 # twitter.com is probably down because it
355 # sucks. ignore the fault and keep going
65ec2606 356 pass
1015276a
MV
357 except irclib.ServerNotConnectedError:
358 # Try and reconnect to IRC.
359 self._irc_connect()
360
7d7c8094 361
362def load_config(filename):
ca242389
MV
363 # Note: Python ConfigParser module has the worst interface in the
364 # world. Mega gross.
33c60d21 365 cp = ConfigParser()
ca242389
MV
366 cp.add_section('irc')
367 cp.set('irc', 'port', '6667')
368 cp.set('irc', 'nick', 'twitterbot')
0bcc8bb9 369 cp.set('irc', 'prefixes', 'cats')
ca242389
MV
370 cp.add_section('twitter')
371 cp.set('twitter', 'oauth_token_file', OAUTH_FILE)
0bcc8bb9 372
7d7c8094 373 cp.read((filename,))
d828f28d 374
9d1c2940 375 # attempt to read these properties-- they are required
52792ab4 376 cp.get('twitter', 'oauth_token_file'),
625bc8f0
MV
377 cp.get('irc', 'server')
378 cp.getint('irc', 'port')
379 cp.get('irc', 'nick')
380 cp.get('irc', 'channel')
9d1c2940 381
7d7c8094 382 return cp
772fbdd1 383
44bcaa4f
MV
384# So there was a joke here about the twitter business model
385# but I got rid of it. Not because I want this codebase to
386# be "professional" in any way, but because someone forked
387# this and deleted the comment because they couldn't take
388# a joke. Hi guy!
9d1c2940 389#
44bcaa4f
MV
390# Fact: The number one use of Google Code is to look for that
391# comment in the Linux kernel that goes "FUCK me gently with
392# a chainsaw." Pretty sure Linus himself wrote it.
9d1c2940 393
7d7c8094 394def main():
395 configFilename = "twitterbot.ini"
396 if (sys.argv[1:]):
397 configFilename = sys.argv[1]
d828f28d 398
5b7080ef 399 try:
9d1c2940
MV
400 if not os.path.exists(configFilename):
401 raise Exception()
5b7080ef 402 load_config(configFilename)
f7e63802
MV
403 except Exception as e:
404 print("Error while loading ini file %s" %(
405 configFilename), file=sys.stderr)
406 print(e, file=sys.stderr)
407 print(__doc__, file=sys.stderr)
5b7080ef 408 sys.exit(1)
9d1c2940 409
7d7c8094 410 bot = TwitterBot(configFilename)
411 return bot.run()