]> jfr.im git - z_archive/twitter.git/blob - twitter/ircbot.py
bandaid unicode/str.encode-related crash bug
[z_archive/twitter.git] / twitter / ircbot.py
1 """
2 twitterbot
3
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.
7
8 USAGE
9
10 twitterbot [config_file]
11
12 CONFIG_FILE
13
14 The config file is an ini-style file that must contain the following:
15
16 [irc]
17 server: <irc_server>
18 port: <irc_port>
19 nick: <irc_nickname>
20 channel: <irc_channels_to_join>
21 prefixes: <prefix_type>
22 modes: <modes to set (i.e. +ix)>
23 autocmd: <command to send upon connect>
24
25 [twitter]
26 oauth_token_file: <oauth_token_filename>
27
28
29 If no config file is given "twitterbot.ini" will be used by default.
30
31 The channel argument can accept multiple channels separated by commas.
32
33 The default token file is ~/.twitterbot_oauth.
34
35 The default prefix type is 'cats'. You can also use 'none'.
36
37 autocmd and modes may be omitted.
38
39 """
40
41 from __future__ import print_function
42
43 BOT_VERSION = "TwitterBot 1.9.1 (http://mike.verdone.ca/twitter)"
44
45 CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ"
46 CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE"
47
48 IRC_BOLD = chr(0x02)
49 IRC_ITALIC = chr(0x16)
50 IRC_UNDERLINE = chr(0x1f)
51 IRC_REGULAR = chr(0x0f)
52
53 import sys
54 import time
55 from datetime import datetime, timedelta
56 from email.utils import parsedate
57 try:
58 from configparser import ConfigParser
59 except ImportError:
60 from ConfigParser import ConfigParser
61 from heapq import heappop, heappush
62 import traceback
63 import os
64 import os.path
65
66 from .api import Twitter, TwitterError
67 from .oauth import OAuth, read_token_file
68 from .oauth_dance import oauth_dance
69 from .util import htmlentitydecode
70
71 PREFIXES = dict(
72 cats=dict(
73 new_tweet="=^_^= ",
74 error="=O_o= ",
75 inform="=o_o= "
76 ),
77 none=dict(
78 new_tweet=""
79 ),
80 )
81 ACTIVE_PREFIXES=dict()
82
83 def get_prefix(prefix_typ=None):
84 return ACTIVE_PREFIXES.get(prefix_typ, ACTIVE_PREFIXES.get('new_tweet', ''))
85
86
87 try:
88 import irclib
89 except ImportError:
90 raise ImportError(
91 "This module requires python irclib available from "
92 + "https://github.com/sixohsix/python-irclib/zipball/python-irclib3-0.4.8")
93
94 OAUTH_FILE = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.twitterbot_oauth'
95
96 def debug(msg):
97 # uncomment this for debug text stuff
98 # print(msg, file=sys.stdout)
99 pass
100
101 class SchedTask(object):
102 def __init__(self, task, delta):
103 self.task = task
104 self.delta = delta
105 self.next = time.time()
106
107 def __repr__(self):
108 return "<SchedTask %s next:%i delta:%i>" %(
109 self.task.__name__, self.__next__, self.delta)
110
111 def __lt__(self, other):
112 return self.next < other.next
113
114 def __call__(self):
115 return self.task()
116
117 class Scheduler(object):
118 def __init__(self, tasks):
119 self.task_heap = []
120 for task in tasks:
121 heappush(self.task_heap, task)
122
123 def next_task(self):
124 now = time.time()
125 task = heappop(self.task_heap)
126 wait = task.next - now
127 task.next = now + task.delta
128 heappush(self.task_heap, task)
129 if (wait > 0):
130 time.sleep(wait)
131 task()
132 #debug("tasks: " + str(self.task_heap))
133
134 def run_forever(self):
135 while True:
136 self.next_task()
137
138
139 class TwitterBot(object):
140 def __init__(self, configFilename):
141 self.configFilename = configFilename
142 self.config = load_config(self.configFilename)
143
144 global ACTIVE_PREFIXES
145 ACTIVE_PREFIXES = PREFIXES[self.config.get('irc', 'prefixes')]
146
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),
155 domain='api.twitter.com')
156
157 self.irc = irclib.IRC()
158 self.irc.add_global_handler('privmsg', self.handle_privmsg)
159 self.irc.add_global_handler('ctcp', self.handle_ctcp)
160 self.irc.add_global_handler('umode', self.handle_umode)
161 self.ircServer = self.irc.server()
162
163 self.sched = Scheduler(
164 (SchedTask(self.process_events, 1),
165 SchedTask(self.check_statuses, 120)))
166 self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple()
167
168 def check_statuses(self):
169 debug("In check_statuses")
170 try:
171 updates = reversed(self.twitter.statuses.home_timeline())
172 except Exception as e:
173 print("Exception while querying twitter:", file=sys.stderr)
174 traceback.print_exc(file=sys.stderr)
175 return
176
177 nextLastUpdate = self.lastUpdate
178 for update in updates:
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
205 crt = parsedate(update['created_at'])
206 if (crt > nextLastUpdate):
207 text = (htmlentitydecode(
208 update['text'].replace('\n', ' '))
209 .encode('utf8', 'replace'))
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.
214 if not text.startswith(b"@"):
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)
220
221 nextLastUpdate = crt
222
223 self.lastUpdate = nextLastUpdate
224
225 def process_events(self):
226 self.irc.process_once()
227
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(
240 evt.source().split('!')[0],
241 "%sHi! I'm Twitterbot! you can (follow "
242 "<twitter_name>) to make me follow a user or "
243 "(unfollow <twitter_name>) to make me stop." %
244 get_prefix())
245 except Exception:
246 traceback.print_exc(file=sys.stderr)
247
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")
258
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'):
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
276 channels = self.config.get('irc', 'channel').split(',')
277 for channel in channels:
278 self.ircServer.join(channel)
279
280 def privmsg_channels(self, msg):
281 return_response=True
282 channels=self.config.get('irc','channel').split(',')
283 return self.ircServer.privmsg_many(channels, msg.encode('utf8'))
284
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,
292 "%sI'm already following %s." %(get_prefix('error'), name))
293 else:
294 try:
295 self.twitter.friendships.create(screen_name=name)
296 except TwitterError:
297 conn.privmsg(
298 userNick,
299 "%sI can't follow that user. Are you sure the name is correct?" %(
300 get_prefix('error')
301 ))
302 return
303 conn.privmsg(
304 userNick,
305 "%sOkay! I'm now following %s." %(get_prefix('followed'), name))
306 self.privmsg_channels(
307 "%s%s has asked me to start following %s" %(
308 get_prefix('inform'), userNick, name))
309
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,
317 "%sI'm not following %s." %(get_prefix('error'), name))
318 else:
319 self.twitter.friendships.destroy(screen_name=name)
320 conn.privmsg(
321 userNick,
322 "%sOkay! I've stopped following %s." %(
323 get_prefix('stop_follow'), name))
324 self.privmsg_channels(
325 "%s%s has asked me to stop following %s" %(
326 get_prefix('inform'), userNick, name))
327
328 def _irc_connect(self):
329 self.ircServer.connect(
330 self.config.get('irc', 'server'),
331 self.config.getint('irc', 'port'),
332 self.config.get('irc', 'nick'))
333 channels=self.config.get('irc', 'channel').split(',')
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
342 for channel in channels:
343 self.ircServer.join(channel)
344
345 def run(self):
346 self._irc_connect()
347
348 while True:
349 try:
350 self.sched.run_forever()
351 except KeyboardInterrupt:
352 break
353 except TwitterError:
354 # twitter.com is probably down because it
355 # sucks. ignore the fault and keep going
356 pass
357 except irclib.ServerNotConnectedError:
358 # Try and reconnect to IRC.
359 self._irc_connect()
360
361
362 def load_config(filename):
363 # Note: Python ConfigParser module has the worst interface in the
364 # world. Mega gross.
365 cp = ConfigParser()
366 cp.add_section('irc')
367 cp.set('irc', 'port', '6667')
368 cp.set('irc', 'nick', 'twitterbot')
369 cp.set('irc', 'prefixes', 'cats')
370 cp.add_section('twitter')
371 cp.set('twitter', 'oauth_token_file', OAUTH_FILE)
372
373 cp.read((filename,))
374
375 # attempt to read these properties-- they are required
376 cp.get('twitter', 'oauth_token_file'),
377 cp.get('irc', 'server')
378 cp.getint('irc', 'port')
379 cp.get('irc', 'nick')
380 cp.get('irc', 'channel')
381
382 return cp
383
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!
389 #
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.
393
394 def main():
395 configFilename = "twitterbot.ini"
396 if (sys.argv[1:]):
397 configFilename = sys.argv[1]
398
399 try:
400 if not os.path.exists(configFilename):
401 raise Exception()
402 load_config(configFilename)
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)
408 sys.exit(1)
409
410 bot = TwitterBot(configFilename)
411 return bot.run()