]> jfr.im git - z_archive/twitter.git/blame - twitter/ircbot.py
Merge pull request #214 from edi-bice/master
[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
be68219c 39BOT_VERSION = "TwitterBot 1.9.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
89330227 90OAUTH_FILE = os.environ.get('HOME', os.environ.get('USERPROFILE', '')) + os.sep + '.twitterbot_oauth'
52792ab4 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 151 domain='api.twitter.com')
52792ab4 152
772fbdd1 153 self.irc = irclib.IRC()
7d7c8094 154 self.irc.add_global_handler('privmsg', self.handle_privmsg)
d8ac8b72 155 self.irc.add_global_handler('ctcp', self.handle_ctcp)
7ab101b7 156 self.irc.add_global_handler('umode', self.handle_umode)
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:
e4bec6bd 167 updates = reversed(self.twitter.statuses.home_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', ' '))
2712c06b 179 .encode('utf8', '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"@"):
2712c06b
MV
185 msg = "%s %s%s%s %s" %(
186 get_prefix(),
187 IRC_BOLD, update['user']['screen_name'],
188 IRC_BOLD, text.decode('utf8'))
189 self.privmsg_channels(msg)
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
7ab101b7
MV
229 def handle_umode(self, conn, evt):
230 """
231 QuakeNet ignores all your commands until after the MOTD. This
232 handler defers joining until after it sees a magic line. It
233 also tries to join right after connect, but this will just
234 make it join again which should be safe.
235 """
236 args = evt.arguments()
237 if (args and args[0] == '+i'):
238 channels = self.config.get('irc', 'channel').split(',')
239 for channel in channels:
240 self.ircServer.join(channel)
241
c1b9acea
PP
242 def privmsg_channels(self, msg):
243 return_response=True
244 channels=self.config.get('irc','channel').split(',')
2712c06b 245 return self.ircServer.privmsg_many(channels, msg.encode('utf8'))
d828f28d 246
7d7c8094 247 def follow(self, conn, evt, name):
248 userNick = evt.source().split('!')[0]
249 friends = [x['name'] for x in self.twitter.statuses.friends()]
250 debug("Current friends: %s" %(friends))
251 if (name in friends):
252 conn.privmsg(
253 userNick,
0bcc8bb9 254 "%sI'm already following %s." %(get_prefix('error'), name))
7d7c8094 255 else:
c93672d4 256 try:
84e22677 257 self.twitter.friendships.create(screen_name=name)
c93672d4 258 except TwitterError:
259 conn.privmsg(
260 userNick,
0bcc8bb9
MV
261 "%sI can't follow that user. Are you sure the name is correct?" %(
262 get_prefix('error')
263 ))
c93672d4 264 return
7d7c8094 265 conn.privmsg(
266 userNick,
0bcc8bb9 267 "%sOkay! I'm now following %s." %(get_prefix('followed'), name))
c1b9acea 268 self.privmsg_channels(
0bcc8bb9
MV
269 "%s%s has asked me to start following %s" %(
270 get_prefix('inform'), userNick, name))
d828f28d 271
7d7c8094 272 def unfollow(self, conn, evt, name):
273 userNick = evt.source().split('!')[0]
274 friends = [x['name'] for x in self.twitter.statuses.friends()]
275 debug("Current friends: %s" %(friends))
276 if (name not in friends):
277 conn.privmsg(
278 userNick,
0bcc8bb9 279 "%sI'm not following %s." %(get_prefix('error'), name))
7d7c8094 280 else:
84e22677 281 self.twitter.friendships.destroy(screen_name=name)
7d7c8094 282 conn.privmsg(
283 userNick,
0bcc8bb9
MV
284 "%sOkay! I've stopped following %s." %(
285 get_prefix('stop_follow'), name))
c1b9acea 286 self.privmsg_channels(
0bcc8bb9
MV
287 "%s%s has asked me to stop following %s" %(
288 get_prefix('inform'), userNick, name))
d828f28d 289
1015276a 290 def _irc_connect(self):
7d7c8094 291 self.ircServer.connect(
d828f28d 292 self.config.get('irc', 'server'),
7d7c8094 293 self.config.getint('irc', 'port'),
294 self.config.get('irc', 'nick'))
c1b9acea
PP
295 channels=self.config.get('irc', 'channel').split(',')
296 for channel in channels:
297 self.ircServer.join(channel)
65ec2606 298
1015276a
MV
299 def run(self):
300 self._irc_connect()
301
65ec2606 302 while True:
303 try:
304 self.sched.run_forever()
305 except KeyboardInterrupt:
306 break
307 except TwitterError:
0bcc8bb9
MV
308 # twitter.com is probably down because it
309 # sucks. ignore the fault and keep going
65ec2606 310 pass
1015276a
MV
311 except irclib.ServerNotConnectedError:
312 # Try and reconnect to IRC.
313 self._irc_connect()
314
7d7c8094 315
316def load_config(filename):
ca242389
MV
317 # Note: Python ConfigParser module has the worst interface in the
318 # world. Mega gross.
33c60d21 319 cp = ConfigParser()
ca242389
MV
320 cp.add_section('irc')
321 cp.set('irc', 'port', '6667')
322 cp.set('irc', 'nick', 'twitterbot')
0bcc8bb9 323 cp.set('irc', 'prefixes', 'cats')
ca242389
MV
324 cp.add_section('twitter')
325 cp.set('twitter', 'oauth_token_file', OAUTH_FILE)
0bcc8bb9 326
7d7c8094 327 cp.read((filename,))
d828f28d 328
9d1c2940 329 # attempt to read these properties-- they are required
52792ab4 330 cp.get('twitter', 'oauth_token_file'),
625bc8f0
MV
331 cp.get('irc', 'server')
332 cp.getint('irc', 'port')
333 cp.get('irc', 'nick')
334 cp.get('irc', 'channel')
9d1c2940 335
7d7c8094 336 return cp
772fbdd1 337
44bcaa4f
MV
338# So there was a joke here about the twitter business model
339# but I got rid of it. Not because I want this codebase to
340# be "professional" in any way, but because someone forked
341# this and deleted the comment because they couldn't take
342# a joke. Hi guy!
9d1c2940 343#
44bcaa4f
MV
344# Fact: The number one use of Google Code is to look for that
345# comment in the Linux kernel that goes "FUCK me gently with
346# a chainsaw." Pretty sure Linus himself wrote it.
9d1c2940 347
7d7c8094 348def main():
349 configFilename = "twitterbot.ini"
350 if (sys.argv[1:]):
351 configFilename = sys.argv[1]
d828f28d 352
5b7080ef 353 try:
9d1c2940
MV
354 if not os.path.exists(configFilename):
355 raise Exception()
5b7080ef 356 load_config(configFilename)
f7e63802
MV
357 except Exception as e:
358 print("Error while loading ini file %s" %(
359 configFilename), file=sys.stderr)
360 print(e, file=sys.stderr)
361 print(__doc__, file=sys.stderr)
5b7080ef 362 sys.exit(1)
9d1c2940 363
7d7c8094 364 bot = TwitterBot(configFilename)
365 return bot.run()