]> jfr.im git - z_archive/twitter.git/blame - twitter/ircbot.py
Remove agent cruft. Default to api.twitter.com and version 1.
[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
52792ab4
MV
39BOT_VERSION = "TwitterBot 1.4 (http://mike.verdone.ca/twitter)"
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)
7d7c8094 157 self.ircServer = self.irc.server()
52792ab4 158
772fbdd1 159 self.sched = Scheduler(
7d7c8094 160 (SchedTask(self.process_events, 1),
d8ac8b72 161 SchedTask(self.check_statuses, 120)))
bd6ce073 162 self.lastUpdate = (datetime.utcnow() - timedelta(minutes=10)).utctimetuple()
772fbdd1 163
164 def check_statuses(self):
7d7c8094 165 debug("In check_statuses")
166 try:
36805447 167 updates = reversed(self.twitter.statuses.friends_timeline())
f7e63802
MV
168 except Exception as e:
169 print("Exception while querying twitter:", file=sys.stderr)
7d7c8094 170 traceback.print_exc(file=sys.stderr)
171 return
d828f28d 172
6da3627e 173 nextLastUpdate = self.lastUpdate
7d7c8094 174 for update in updates:
5b85a4f1 175 crt = parsedate(update['created_at'])
85681608 176 if (crt > nextLastUpdate):
8ad2cf0b 177 text = (htmlentitydecode(
178 update['text'].replace('\n', ' '))
179 .encode('utf-8', 'replace'))
7f6c5ae3 180
181 # Skip updates beginning with @
182 # TODO This would be better if we only ignored messages
183 # to people who are not on our following list.
250db77f 184 if not text.startswith(b"@"):
c1b9acea 185 self.privmsg_channels(
1e28b4c3 186 "%s %s%s%s %s" %(
0bcc8bb9 187 get_prefix(),
7f6c5ae3 188 IRC_BOLD, update['user']['screen_name'],
65ec2606 189 IRC_BOLD, text.decode('utf-8')))
d828f28d 190
6da3627e 191 nextLastUpdate = crt
36805447 192
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 "
bd6ce073
MV
212 "<twitter_name>) to make me follow a user or "
213 "(unfollow <twitter_name>) to make me stop." %
0bcc8bb9 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(
250db77f 231 self.config.get('irc', 'channel'), msg)
d828f28d 232
c1b9acea
PP
233 def privmsg_channels(self, msg):
234 return_response=True
235 channels=self.config.get('irc','channel').split(',')
250db77f 236 return self.ircServer.privmsg_many(channels, msg)
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.
33c60d21 303 cp = ConfigParser()
ca242389
MV
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()