]>
Commit | Line | Data |
---|---|---|
5b7080ef | 1 | """ |
2 | twitterbot | |
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 | 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: | |
d828f28d | 15 | |
5b7080ef | 16 | [irc] |
17 | server: <irc_server> | |
18 | port: <irc_port> | |
19 | nick: <irc_nickname> | |
77a8613a | 20 | channel: <irc_channels_to_join> |
0bcc8bb9 | 21 | prefixes: <prefix_type> |
6db986bd | 22 | modes: <modes to set (i.e. +ix)> |
23 | autocmd: <command to send upon connect> | |
5b7080ef | 24 | |
25 | [twitter] | |
52792ab4 | 26 | oauth_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 |
41 | from __future__ import print_function |
42 | ||
be68219c | 43 | BOT_VERSION = "TwitterBot 1.9.1 (http://mike.verdone.ca/twitter)" |
52792ab4 MV |
44 | |
45 | CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ" | |
46 | CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE" | |
d8ac8b72 | 47 | |
48 | IRC_BOLD = chr(0x02) | |
49 | IRC_ITALIC = chr(0x16) | |
50 | IRC_UNDERLINE = chr(0x1f) | |
51 | IRC_REGULAR = chr(0x0f) | |
52 | ||
7d7c8094 | 53 | import sys |
772fbdd1 | 54 | import time |
36805447 | 55 | from datetime import datetime, timedelta |
5b85a4f1 | 56 | from email.utils import parsedate |
c2907f1e MV |
57 | try: |
58 | from configparser import ConfigParser | |
59 | except ImportError: | |
60 | from ConfigParser import ConfigParser | |
7d7c8094 | 61 | from heapq import heappop, heappush |
62 | import traceback | |
52792ab4 | 63 | import os |
9d1c2940 | 64 | import os.path |
7d7c8094 | 65 | |
f7e63802 MV |
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 | |
772fbdd1 | 70 | |
0bcc8bb9 MV |
71 | PREFIXES = 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 | ) |
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 | ||
772fbdd1 | 87 | try: |
88 | import irclib | |
c2907f1e | 89 | except 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 | 94 | OAUTH_FILE = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.twitterbot_oauth' |
52792ab4 | 95 | |
7d7c8094 | 96 | def debug(msg): |
97 | # uncomment this for debug text stuff | |
c2907f1e | 98 | # print(msg, file=sys.stdout) |
7d7c8094 | 99 | pass |
772fbdd1 | 100 | |
101 | class 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 | 117 | class 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 | 139 | class 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 | |
362 | def 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 | 394 | def 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() |