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