]>
Commit | Line | Data |
---|---|---|
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 | ||
23 | [twitter] | |
24 | oauth_token_file: <oauth_token_filename> | |
25 | ||
26 | ||
27 | If no config file is given "twitterbot.ini" will be used by default. | |
28 | ||
29 | The channel argument can accept multiple channels separated by commas. | |
30 | ||
31 | The default token file is ~/.twitterbot_oauth. | |
32 | ||
33 | The default prefix type is 'cats'. You can also use 'none'. | |
34 | ||
35 | """ | |
36 | ||
37 | BOT_VERSION = "TwitterBot 1.4 (http://mike.verdone.ca/twitter)" | |
38 | ||
39 | CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ" | |
40 | CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE" | |
41 | ||
42 | IRC_BOLD = chr(0x02) | |
43 | IRC_ITALIC = chr(0x16) | |
44 | IRC_UNDERLINE = chr(0x1f) | |
45 | IRC_REGULAR = chr(0x0f) | |
46 | ||
47 | import sys | |
48 | import time | |
49 | from datetime import datetime, timedelta | |
50 | from email.utils import parsedate | |
51 | from ConfigParser import SafeConfigParser | |
52 | from heapq import heappop, heappush | |
53 | import traceback | |
54 | import os | |
55 | import os.path | |
56 | ||
57 | from api import Twitter, TwitterError | |
58 | from oauth import OAuth, read_token_file | |
59 | from oauth_dance import oauth_dance | |
60 | from util import htmlentitydecode | |
61 | ||
62 | PREFIXES = dict( | |
63 | cats=dict( | |
64 | new_tweet="=^_^= ", | |
65 | error="=O_o= ", | |
66 | inform="=o_o= " | |
67 | ), | |
68 | none=dict( | |
69 | new_tweet="" | |
70 | ), | |
71 | ) | |
72 | ACTIVE_PREFIXES=dict() | |
73 | ||
74 | def get_prefix(prefix_typ=None): | |
75 | return ACTIVE_PREFIXES.get(prefix_typ, ACTIVE_PREFIXES.get('new_tweet', '')) | |
76 | ||
77 | ||
78 | try: | |
79 | import irclib | |
80 | except: | |
81 | raise ImportError( | |
82 | "This module requires python irclib available from " | |
83 | + "http://python-irclib.sourceforge.net/") | |
84 | ||
85 | OAUTH_FILE = os.environ.get('HOME', '') + os.sep + '.twitterbot_oauth' | |
86 | ||
87 | def debug(msg): | |
88 | # uncomment this for debug text stuff | |
89 | # print >> sys.stderr, msg | |
90 | pass | |
91 | ||
92 | class SchedTask(object): | |
93 | def __init__(self, task, delta): | |
94 | self.task = task | |
95 | self.delta = delta | |
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) | |
101 | ||
102 | def __cmp__(self, other): | |
103 | return cmp(self.next, other.next) | |
104 | ||
105 | def __call__(self): | |
106 | return self.task() | |
107 | ||
108 | class Scheduler(object): | |
109 | def __init__(self, tasks): | |
110 | self.task_heap = [] | |
111 | for task in tasks: | |
112 | heappush(self.task_heap, task) | |
113 | ||
114 | def next_task(self): | |
115 | now = time.time() | |
116 | task = heappop(self.task_heap) | |
117 | wait = task.next - now | |
118 | task.next = now + task.delta | |
119 | heappush(self.task_heap, task) | |
120 | if (wait > 0): | |
121 | time.sleep(wait) | |
122 | task() | |
123 | #debug("tasks: " + str(self.task_heap)) | |
124 | ||
125 | def run_forever(self): | |
126 | while True: | |
127 | self.next_task() | |
128 | ||
129 | ||
130 | class TwitterBot(object): | |
131 | def __init__(self, configFilename): | |
132 | self.configFilename = configFilename | |
133 | self.config = load_config(self.configFilename) | |
134 | ||
135 | global ACTIVE_PREFIXES | |
136 | ACTIVE_PREFIXES = PREFIXES[self.config.get('irc', 'prefixes')] | |
137 | ||
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), | |
146 | api_version='1', | |
147 | domain='api.twitter.com') | |
148 | ||
149 | self.irc = irclib.IRC() | |
150 | self.irc.add_global_handler('privmsg', self.handle_privmsg) | |
151 | self.irc.add_global_handler('ctcp', self.handle_ctcp) | |
152 | self.ircServer = self.irc.server() | |
153 | ||
154 | self.sched = Scheduler( | |
155 | (SchedTask(self.process_events, 1), | |
156 | SchedTask(self.check_statuses, 120))) | |
157 | self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple() | |
158 | ||
159 | def check_statuses(self): | |
160 | debug("In check_statuses") | |
161 | try: | |
162 | updates = reversed(self.twitter.statuses.friends_timeline()) | |
163 | except Exception, e: | |
164 | print >> sys.stderr, "Exception while querying twitter:" | |
165 | traceback.print_exc(file=sys.stderr) | |
166 | return | |
167 | ||
168 | nextLastUpdate = self.lastUpdate | |
169 | debug("self.lastUpdate is %s" % self.lastUpdate) | |
170 | for update in updates: | |
171 | crt = parsedate(update['created_at']) | |
172 | if (crt > nextLastUpdate): | |
173 | text = (htmlentitydecode( | |
174 | update['text'].replace('\n', ' ')) | |
175 | .encode('utf-8', 'replace')) | |
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("@"): | |
181 | self.privmsg_channels( | |
182 | u"%s %s%s%s %s" %( | |
183 | get_prefix(), | |
184 | IRC_BOLD, update['user']['screen_name'], | |
185 | IRC_BOLD, text.decode('utf-8'))) | |
186 | ||
187 | nextLastUpdate = crt | |
188 | ||
189 | debug("setting self.lastUpdate to %s" % nextLastUpdate) | |
190 | self.lastUpdate = nextLastUpdate | |
191 | ||
192 | def process_events(self): | |
193 | self.irc.process_once() | |
194 | ||
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( | |
207 | evt.source().split('!')[0], | |
208 | "%sHi! I'm Twitterbot! you can (follow " | |
209 | "<twitter_name>) to make me follow a user or " | |
210 | "(unfollow <twitter_name>) to make me stop." % | |
211 | get_prefix()) | |
212 | except Exception: | |
213 | traceback.print_exc(file=sys.stderr) | |
214 | ||
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") | |
225 | ||
226 | def privmsg_channel(self, msg): | |
227 | return self.ircServer.privmsg( | |
228 | self.config.get('irc', 'channel'), msg.encode('utf-8')) | |
229 | ||
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')) | |
234 | ||
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, | |
242 | "%sI'm already following %s." %(get_prefix('error'), name)) | |
243 | else: | |
244 | try: | |
245 | self.twitter.friendships.create(id=name) | |
246 | except TwitterError: | |
247 | conn.privmsg( | |
248 | userNick, | |
249 | "%sI can't follow that user. Are you sure the name is correct?" %( | |
250 | get_prefix('error') | |
251 | )) | |
252 | return | |
253 | conn.privmsg( | |
254 | userNick, | |
255 | "%sOkay! I'm now following %s." %(get_prefix('followed'), name)) | |
256 | self.privmsg_channels( | |
257 | "%s%s has asked me to start following %s" %( | |
258 | get_prefix('inform'), userNick, name)) | |
259 | ||
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, | |
267 | "%sI'm not following %s." %(get_prefix('error'), name)) | |
268 | else: | |
269 | self.twitter.friendships.destroy(id=name) | |
270 | conn.privmsg( | |
271 | userNick, | |
272 | "%sOkay! I've stopped following %s." %( | |
273 | get_prefix('stop_follow'), name)) | |
274 | self.privmsg_channels( | |
275 | "%s%s has asked me to stop following %s" %( | |
276 | get_prefix('inform'), userNick, name)) | |
277 | ||
278 | def run(self): | |
279 | self.ircServer.connect( | |
280 | self.config.get('irc', 'server'), | |
281 | self.config.getint('irc', 'port'), | |
282 | self.config.get('irc', 'nick')) | |
283 | channels=self.config.get('irc', 'channel').split(',') | |
284 | for channel in channels: | |
285 | self.ircServer.join(channel) | |
286 | ||
287 | while True: | |
288 | try: | |
289 | self.sched.run_forever() | |
290 | except KeyboardInterrupt: | |
291 | break | |
292 | except TwitterError: | |
293 | # twitter.com is probably down because it | |
294 | # sucks. ignore the fault and keep going | |
295 | pass | |
296 | ||
297 | def load_config(filename): | |
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') | |
304 | cp.set('irc', 'prefixes', 'cats') | |
305 | cp.add_section('twitter') | |
306 | cp.set('twitter', 'oauth_token_file', OAUTH_FILE) | |
307 | ||
308 | cp.read((filename,)) | |
309 | ||
310 | # attempt to read these properties-- they are required | |
311 | cp.get('twitter', 'oauth_token_file'), | |
312 | cp.get('irc', 'server') | |
313 | cp.getint('irc', 'port') | |
314 | cp.get('irc', 'nick') | |
315 | cp.get('irc', 'channel') | |
316 | ||
317 | return cp | |
318 | ||
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! | |
324 | # | |
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. | |
328 | ||
329 | def main(): | |
330 | configFilename = "twitterbot.ini" | |
331 | if (sys.argv[1:]): | |
332 | configFilename = sys.argv[1] | |
333 | ||
334 | try: | |
335 | if not os.path.exists(configFilename): | |
336 | raise Exception() | |
337 | load_config(configFilename) | |
338 | except Exception, e: | |
339 | print >> sys.stderr, "Error while loading ini file %s" %( | |
340 | configFilename) | |
341 | print >> sys.stderr, e | |
342 | print >> sys.stderr, __doc__ | |
343 | sys.exit(1) | |
344 | ||
345 | bot = TwitterBot(configFilename) | |
346 | return bot.run() |