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