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