]> jfr.im git - z_archive/twitter.git/blame - twitter/ircbot.py
Add a umode handler to ircbot to work with quakenet. see: http://www.reddit.com/r...
[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
c2907f1e
MV
37from __future__ import print_function
38
2712c06b 39BOT_VERSION = "TwitterBot 1.6.1 (http://mike.verdone.ca/twitter)"
52792ab4
MV
40
41CONSUMER_KEY = "XryIxN3J2ACaJs50EizfLQ"
42CONSUMER_SECRET = "j7IuDCNjftVY8DBauRdqXs4jDl5Fgk1IJRag8iE"
d8ac8b72 43
44IRC_BOLD = chr(0x02)
45IRC_ITALIC = chr(0x16)
46IRC_UNDERLINE = chr(0x1f)
47IRC_REGULAR = chr(0x0f)
48
7d7c8094 49import sys
772fbdd1 50import time
36805447 51from datetime import datetime, timedelta
5b85a4f1 52from email.utils import parsedate
c2907f1e
MV
53try:
54 from configparser import ConfigParser
55except ImportError:
56 from ConfigParser import ConfigParser
7d7c8094 57from heapq import heappop, heappush
58import traceback
52792ab4 59import os
9d1c2940 60import os.path
7d7c8094 61
f7e63802
MV
62from .api import Twitter, TwitterError
63from .oauth import OAuth, read_token_file
64from .oauth_dance import oauth_dance
65from .util import htmlentitydecode
772fbdd1 66
0bcc8bb9
MV
67PREFIXES = dict(
68 cats=dict(
69 new_tweet="=^_^= ",
70 error="=O_o= ",
71 inform="=o_o= "
85681608 72 ),
0bcc8bb9
MV
73 none=dict(
74 new_tweet=""
85681608 75 ),
0bcc8bb9
MV
76 )
77ACTIVE_PREFIXES=dict()
78
79def get_prefix(prefix_typ=None):
80 return ACTIVE_PREFIXES.get(prefix_typ, ACTIVE_PREFIXES.get('new_tweet', ''))
81
82
772fbdd1 83try:
84 import irclib
c2907f1e 85except ImportError:
7d7c8094 86 raise ImportError(
87 "This module requires python irclib available from "
3d18c203 88 + "https://github.com/sixohsix/python-irclib/zipball/python-irclib3-0.4.8")
7d7c8094 89
52792ab4
MV
90OAUTH_FILE = os.environ.get('HOME', '') + os.sep + '.twitterbot_oauth'
91
7d7c8094 92def debug(msg):
93 # uncomment this for debug text stuff
c2907f1e 94 # print(msg, file=sys.stdout)
7d7c8094 95 pass
772fbdd1 96
97class SchedTask(object):
98 def __init__(self, task, delta):
99 self.task = task
100 self.delta = delta
7d7c8094 101 self.next = time.time()
102
103 def __repr__(self):
104 return "<SchedTask %s next:%i delta:%i>" %(
f7e63802 105 self.task.__name__, self.__next__, self.delta)
d828f28d 106
33c60d21
MV
107 def __lt__(self, other):
108 return self.next < other.next
d828f28d 109
7d7c8094 110 def __call__(self):
111 return self.task()
112
772fbdd1 113class Scheduler(object):
114 def __init__(self, tasks):
7d7c8094 115 self.task_heap = []
116 for task in tasks:
117 heappush(self.task_heap, task)
d828f28d 118
772fbdd1 119 def next_task(self):
120 now = time.time()
7d7c8094 121 task = heappop(self.task_heap)
33c60d21 122 wait = task.next - now
65ec2606 123 task.next = now + task.delta
124 heappush(self.task_heap, task)
772fbdd1 125 if (wait > 0):
126 time.sleep(wait)
7d7c8094 127 task()
85681608 128 #debug("tasks: " + str(self.task_heap))
d828f28d 129
772fbdd1 130 def run_forever(self):
65ec2606 131 while True:
132 self.next_task()
133
d828f28d 134
772fbdd1 135class TwitterBot(object):
7d7c8094 136 def __init__(self, configFilename):
137 self.configFilename = configFilename
138 self.config = load_config(self.configFilename)
52792ab4 139
85681608
MV
140 global ACTIVE_PREFIXES
141 ACTIVE_PREFIXES = PREFIXES[self.config.get('irc', 'prefixes')]
142
52792ab4
MV
143 oauth_file = self.config.get('twitter', 'oauth_token_file')
144 if not os.path.exists(oauth_file):
145 oauth_dance("IRC Bot", CONSUMER_KEY, CONSUMER_SECRET, oauth_file)
146 oauth_token, oauth_secret = read_token_file(oauth_file)
147
148 self.twitter = Twitter(
149 auth=OAuth(
150 oauth_token, oauth_secret, CONSUMER_KEY, CONSUMER_SECRET),
d828f28d
MV
151 api_version='1',
152 domain='api.twitter.com')
52792ab4 153
772fbdd1 154 self.irc = irclib.IRC()
7d7c8094 155 self.irc.add_global_handler('privmsg', self.handle_privmsg)
d8ac8b72 156 self.irc.add_global_handler('ctcp', self.handle_ctcp)
7ab101b7 157 self.irc.add_global_handler('umode', self.handle_umode)
7d7c8094 158 self.ircServer = self.irc.server()
52792ab4 159
772fbdd1 160 self.sched = Scheduler(
7d7c8094 161 (SchedTask(self.process_events, 1),
d8ac8b72 162 SchedTask(self.check_statuses, 120)))
bd6ce073 163 self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple()
772fbdd1 164
165 def check_statuses(self):
7d7c8094 166 debug("In check_statuses")
167 try:
36805447 168 updates = reversed(self.twitter.statuses.friends_timeline())
f7e63802
MV
169 except Exception as e:
170 print("Exception while querying twitter:", file=sys.stderr)
7d7c8094 171 traceback.print_exc(file=sys.stderr)
172 return
d828f28d 173
6da3627e 174 nextLastUpdate = self.lastUpdate
7d7c8094 175 for update in updates:
5b85a4f1 176 crt = parsedate(update['created_at'])
85681608 177 if (crt > nextLastUpdate):
8ad2cf0b 178 text = (htmlentitydecode(
179 update['text'].replace('\n', ' '))
2712c06b 180 .encode('utf8', 'replace'))
7f6c5ae3 181
182 # Skip updates beginning with @
183 # TODO This would be better if we only ignored messages
184 # to people who are not on our following list.
250db77f 185 if not text.startswith(b"@"):
2712c06b
MV
186 msg = "%s %s%s%s %s" %(
187 get_prefix(),
188 IRC_BOLD, update['user']['screen_name'],
189 IRC_BOLD, text.decode('utf8'))
190 self.privmsg_channels(msg)
d828f28d 191
6da3627e 192 nextLastUpdate = crt
36805447 193
6da3627e 194 self.lastUpdate = nextLastUpdate
d828f28d 195
7d7c8094 196 def process_events(self):
7d7c8094 197 self.irc.process_once()
d828f28d 198
7d7c8094 199 def handle_privmsg(self, conn, evt):
200 debug('got privmsg')
201 args = evt.arguments()[0].split(' ')
202 try:
203 if (not args):
204 return
205 if (args[0] == 'follow' and args[1:]):
206 self.follow(conn, evt, args[1])
207 elif (args[0] == 'unfollow' and args[1:]):
208 self.unfollow(conn, evt, args[1])
209 else:
210 conn.privmsg(
d828f28d 211 evt.source().split('!')[0],
0bcc8bb9 212 "%sHi! I'm Twitterbot! you can (follow "
bd6ce073
MV
213 "<twitter_name>) to make me follow a user or "
214 "(unfollow <twitter_name>) to make me stop." %
0bcc8bb9 215 get_prefix())
7d7c8094 216 except Exception:
217 traceback.print_exc(file=sys.stderr)
d828f28d 218
d8ac8b72 219 def handle_ctcp(self, conn, evt):
220 args = evt.arguments()
221 source = evt.source().split('!')[0]
222 if (args):
223 if args[0] == 'VERSION':
224 conn.ctcp_reply(source, "VERSION " + BOT_VERSION)
225 elif args[0] == 'PING':
226 conn.ctcp_reply(source, "PING")
227 elif args[0] == 'CLIENTINFO':
228 conn.ctcp_reply(source, "CLIENTINFO PING VERSION CLIENTINFO")
7d7c8094 229
7ab101b7
MV
230 def handle_umode(self, conn, evt):
231 """
232 QuakeNet ignores all your commands until after the MOTD. This
233 handler defers joining until after it sees a magic line. It
234 also tries to join right after connect, but this will just
235 make it join again which should be safe.
236 """
237 args = evt.arguments()
238 if (args and args[0] == '+i'):
239 channels = self.config.get('irc', 'channel').split(',')
240 for channel in channels:
241 self.ircServer.join(channel)
242
c1b9acea
PP
243 def privmsg_channels(self, msg):
244 return_response=True
245 channels=self.config.get('irc','channel').split(',')
2712c06b 246 return self.ircServer.privmsg_many(channels, msg.encode('utf8'))
d828f28d 247
7d7c8094 248 def follow(self, conn, evt, name):
249 userNick = evt.source().split('!')[0]
250 friends = [x['name'] for x in self.twitter.statuses.friends()]
251 debug("Current friends: %s" %(friends))
252 if (name in friends):
253 conn.privmsg(
254 userNick,
0bcc8bb9 255 "%sI'm already following %s." %(get_prefix('error'), name))
7d7c8094 256 else:
c93672d4 257 try:
258 self.twitter.friendships.create(id=name)
259 except TwitterError:
260 conn.privmsg(
261 userNick,
0bcc8bb9
MV
262 "%sI can't follow that user. Are you sure the name is correct?" %(
263 get_prefix('error')
264 ))
c93672d4 265 return
7d7c8094 266 conn.privmsg(
267 userNick,
0bcc8bb9 268 "%sOkay! I'm now following %s." %(get_prefix('followed'), name))
c1b9acea 269 self.privmsg_channels(
0bcc8bb9
MV
270 "%s%s has asked me to start following %s" %(
271 get_prefix('inform'), userNick, name))
d828f28d 272
7d7c8094 273 def unfollow(self, conn, evt, name):
274 userNick = evt.source().split('!')[0]
275 friends = [x['name'] for x in self.twitter.statuses.friends()]
276 debug("Current friends: %s" %(friends))
277 if (name not in friends):
278 conn.privmsg(
279 userNick,
0bcc8bb9 280 "%sI'm not following %s." %(get_prefix('error'), name))
7d7c8094 281 else:
282 self.twitter.friendships.destroy(id=name)
283 conn.privmsg(
284 userNick,
0bcc8bb9
MV
285 "%sOkay! I've stopped following %s." %(
286 get_prefix('stop_follow'), name))
c1b9acea 287 self.privmsg_channels(
0bcc8bb9
MV
288 "%s%s has asked me to stop following %s" %(
289 get_prefix('inform'), userNick, name))
d828f28d 290
1015276a 291 def _irc_connect(self):
7d7c8094 292 self.ircServer.connect(
d828f28d 293 self.config.get('irc', 'server'),
7d7c8094 294 self.config.getint('irc', 'port'),
295 self.config.get('irc', 'nick'))
c1b9acea
PP
296 channels=self.config.get('irc', 'channel').split(',')
297 for channel in channels:
298 self.ircServer.join(channel)
65ec2606 299
1015276a
MV
300 def run(self):
301 self._irc_connect()
302
65ec2606 303 while True:
304 try:
305 self.sched.run_forever()
306 except KeyboardInterrupt:
307 break
308 except TwitterError:
0bcc8bb9
MV
309 # twitter.com is probably down because it
310 # sucks. ignore the fault and keep going
65ec2606 311 pass
1015276a
MV
312 except irclib.ServerNotConnectedError:
313 # Try and reconnect to IRC.
314 self._irc_connect()
315
7d7c8094 316
317def load_config(filename):
ca242389
MV
318 # Note: Python ConfigParser module has the worst interface in the
319 # world. Mega gross.
33c60d21 320 cp = ConfigParser()
ca242389
MV
321 cp.add_section('irc')
322 cp.set('irc', 'port', '6667')
323 cp.set('irc', 'nick', 'twitterbot')
0bcc8bb9 324 cp.set('irc', 'prefixes', 'cats')
ca242389
MV
325 cp.add_section('twitter')
326 cp.set('twitter', 'oauth_token_file', OAUTH_FILE)
0bcc8bb9 327
7d7c8094 328 cp.read((filename,))
d828f28d 329
9d1c2940 330 # attempt to read these properties-- they are required
52792ab4 331 cp.get('twitter', 'oauth_token_file'),
625bc8f0
MV
332 cp.get('irc', 'server')
333 cp.getint('irc', 'port')
334 cp.get('irc', 'nick')
335 cp.get('irc', 'channel')
9d1c2940 336
7d7c8094 337 return cp
772fbdd1 338
44bcaa4f
MV
339# So there was a joke here about the twitter business model
340# but I got rid of it. Not because I want this codebase to
341# be "professional" in any way, but because someone forked
342# this and deleted the comment because they couldn't take
343# a joke. Hi guy!
9d1c2940 344#
44bcaa4f
MV
345# Fact: The number one use of Google Code is to look for that
346# comment in the Linux kernel that goes "FUCK me gently with
347# a chainsaw." Pretty sure Linus himself wrote it.
9d1c2940 348
7d7c8094 349def main():
350 configFilename = "twitterbot.ini"
351 if (sys.argv[1:]):
352 configFilename = sys.argv[1]
d828f28d 353
5b7080ef 354 try:
9d1c2940
MV
355 if not os.path.exists(configFilename):
356 raise Exception()
5b7080ef 357 load_config(configFilename)
f7e63802
MV
358 except Exception as e:
359 print("Error while loading ini file %s" %(
360 configFilename), file=sys.stderr)
361 print(e, file=sys.stderr)
362 print(__doc__, file=sys.stderr)
5b7080ef 363 sys.exit(1)
9d1c2940 364
7d7c8094 365 bot = TwitterBot(configFilename)
366 return bot.run()