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