]>
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> | |
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. | |
5b7080ef | 27 | """ |
d8ac8b72 | 28 | |
9d1c2940 | 29 | BOT_VERSION = "TwitterBot 0.5.1 (http://mike.verdone.ca/twitter)" |
d8ac8b72 | 30 | |
31 | IRC_BOLD = chr(0x02) | |
32 | IRC_ITALIC = chr(0x16) | |
33 | IRC_UNDERLINE = chr(0x1f) | |
34 | IRC_REGULAR = chr(0x0f) | |
35 | ||
7d7c8094 | 36 | import sys |
772fbdd1 | 37 | import time |
38 | from dateutil.parser import parse | |
65ec2606 | 39 | from ConfigParser import SafeConfigParser |
7d7c8094 | 40 | from heapq import heappop, heappush |
41 | import traceback | |
9d1c2940 | 42 | import os.path |
7d7c8094 | 43 | |
c93672d4 | 44 | from api import Twitter, TwitterError |
8ad2cf0b | 45 | from util import htmlentitydecode |
772fbdd1 | 46 | |
47 | try: | |
48 | import irclib | |
49 | except: | |
7d7c8094 | 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 | |
5b7080ef | 56 | # print >> sys.stderr, msg |
7d7c8094 | 57 | pass |
772fbdd1 | 58 | |
59 | class SchedTask(object): | |
60 | def __init__(self, task, delta): | |
61 | self.task = task | |
62 | self.delta = delta | |
7d7c8094 | 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 | ||
772fbdd1 | 75 | class Scheduler(object): |
76 | def __init__(self, tasks): | |
7d7c8094 | 77 | self.task_heap = [] |
78 | for task in tasks: | |
79 | heappush(self.task_heap, task) | |
772fbdd1 | 80 | |
81 | def next_task(self): | |
82 | now = time.time() | |
7d7c8094 | 83 | task = heappop(self.task_heap) |
772fbdd1 | 84 | wait = task.next - now |
65ec2606 | 85 | task.next = now + task.delta |
86 | heappush(self.task_heap, task) | |
772fbdd1 | 87 | if (wait > 0): |
88 | time.sleep(wait) | |
7d7c8094 | 89 | task() |
7d7c8094 | 90 | debug("tasks: " + str(self.task_heap)) |
772fbdd1 | 91 | |
92 | def run_forever(self): | |
65ec2606 | 93 | while True: |
94 | self.next_task() | |
95 | ||
772fbdd1 | 96 | |
97 | class TwitterBot(object): | |
7d7c8094 | 98 | def __init__(self, configFilename): |
99 | self.configFilename = configFilename | |
100 | self.config = load_config(self.configFilename) | |
772fbdd1 | 101 | self.irc = irclib.IRC() |
7d7c8094 | 102 | self.irc.add_global_handler('privmsg', self.handle_privmsg) |
d8ac8b72 | 103 | self.irc.add_global_handler('ctcp', self.handle_ctcp) |
7d7c8094 | 104 | self.ircServer = self.irc.server() |
105 | self.twitter = Twitter( | |
106 | self.config.get('twitter', 'email'), | |
107 | self.config.get('twitter', 'password')) | |
772fbdd1 | 108 | self.sched = Scheduler( |
7d7c8094 | 109 | (SchedTask(self.process_events, 1), |
d8ac8b72 | 110 | SchedTask(self.check_statuses, 120))) |
7d7c8094 | 111 | self.lastUpdate = time.gmtime() |
772fbdd1 | 112 | |
113 | def check_statuses(self): | |
7d7c8094 | 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 | ||
6da3627e | 122 | nextLastUpdate = self.lastUpdate |
7d7c8094 | 123 | for update in updates: |
124 | crt = parse(update['created_at']).utctimetuple() | |
125 | if (crt > self.lastUpdate): | |
8ad2cf0b | 126 | text = (htmlentitydecode( |
127 | update['text'].replace('\n', ' ')) | |
128 | .encode('utf-8', 'replace')) | |
7f6c5ae3 | 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( | |
65ec2606 | 135 | u"=^_^= %s%s%s %s" %( |
7f6c5ae3 | 136 | IRC_BOLD, update['user']['screen_name'], |
65ec2606 | 137 | IRC_BOLD, text.decode('utf-8'))) |
7f6c5ae3 | 138 | |
6da3627e | 139 | nextLastUpdate = crt |
7d7c8094 | 140 | else: |
141 | break | |
6da3627e | 142 | self.lastUpdate = nextLastUpdate |
143 | ||
7d7c8094 | 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) | |
d8ac8b72 | 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") | |
7d7c8094 | 177 | |
178 | def privmsg_channel(self, msg): | |
179 | return self.ircServer.privmsg( | |
65ec2606 | 180 | self.config.get('irc', 'channel'), msg.encode('utf-8')) |
7d7c8094 | 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: | |
c93672d4 | 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 | |
7d7c8094 | 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 | ||
772fbdd1 | 222 | def run(self): |
7d7c8094 | 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')) | |
65ec2606 | 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 | |
7d7c8094 | 237 | |
238 | def load_config(filename): | |
239 | defaults = dict(server=dict(port=6667, nick="twitterbot")) | |
65ec2606 | 240 | cp = SafeConfigParser(defaults) |
7d7c8094 | 241 | cp.read((filename,)) |
9d1c2940 MV |
242 | |
243 | # attempt to read these properties-- they are required | |
244 | self.config.get('twitter', 'email'), | |
245 | self.config.get('twitter', 'password') | |
246 | self.config.get('irc', 'server') | |
247 | self.config.getint('irc', 'port') | |
248 | self.config.get('irc', 'nick') | |
249 | ||
7d7c8094 | 250 | return cp |
772fbdd1 | 251 | |
9d1c2940 MV |
252 | |
253 | # Howdy, hacker!! You've found the secret Twitter business model!! | |
254 | # | |
255 | # 1. provide awesome status-update service | |
256 | # 2. buy a lot of new hardware to keep it running | |
257 | # 3. ??? | |
258 | # 4. profit! | |
259 | # | |
260 | # I'm just kidding... :3 | |
261 | ||
262 | ||
7d7c8094 | 263 | def main(): |
264 | configFilename = "twitterbot.ini" | |
265 | if (sys.argv[1:]): | |
266 | configFilename = sys.argv[1] | |
9d1c2940 | 267 | |
5b7080ef | 268 | try: |
9d1c2940 MV |
269 | if not os.path.exists(configFilename): |
270 | raise Exception() | |
5b7080ef | 271 | load_config(configFilename) |
272 | except: | |
9d1c2940 | 273 | print >> sys.stderr, "Error while loading ini file %s" %( |
5b7080ef | 274 | configFilename) |
275 | print __doc__ | |
276 | sys.exit(1) | |
9d1c2940 | 277 | |
7d7c8094 | 278 | bot = TwitterBot(configFilename) |
279 | return bot.run() |