├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── __init__.py └── bot.py ├── requirements.txt ├── setup.py └── toastbot.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | logs 4 | env 5 | examples/toasty.py 6 | dist 7 | MANIFEST 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Daniel Lindsley. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of toastbot nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Toastbot 2 | ======== 3 | 4 | A clean, extensible IRC bot using Python, irckit, gevent & requests. 5 | 6 | **Author:** Daniel Lindsley
7 | **License:** BSD
8 | **Version:** 0.4.2 9 | 10 | 11 | Requirements 12 | ------------ 13 | 14 | * Python 2.6+ 15 | * gevent 16 | * irckit 17 | * requests 18 | 19 | 20 | Usage 21 | ----- 22 | 23 | Create your own ``bot.py`` file & drop in: 24 | 25 | import toastbot 26 | 27 | bot = toastbot.ToastBot('myircbot', '#myircchannel') 28 | bot.setup() 29 | 30 | Then run it with ``python bot.py``. 31 | 32 | 33 | Configuration 34 | ------------- 35 | 36 | The ``Toastbot`` object requires ``nick`` & ``channel`` arguments & can take a 37 | variety of non-required options. 38 | 39 | ### Required arguments 40 | 41 | * ``nick`` - The nickname of the bot, as a string. 42 | * ``channel`` - The channel the bot should connect to, as a string. 43 | 44 | ### Options 45 | 46 | * ``server`` - The server the bot should connect to (default: ``irc.freenode.net``). 47 | * ``username`` -The username the bot should identify as (default: ``nick``); 48 | * ``realname`` - The human readable name the bot should provide (default: 'ToastBot'). 49 | * ``debug`` - Controls if the IRC connection should dump debug messages (default: ``false``). 50 | * ``log_dir`` - Controls what directory the logs should go in (default: ``$INSTALL_DIRECTORY/logs``). 51 | * ``variants`` - Used to override ways to address the bot. Should be strings (default: ``[self.nick+': ', self.nick+', ', self.nick+'- ', self.nick+' - ']``). 52 | 53 | 54 | Available "handlers" 55 | -------------------- 56 | 57 | Handlers are how the bot can perform actions based on an incoming message. They 58 | are simple methods hanging off the bot object. The built-in list consists of: 59 | 60 | * ``help`` - Provides a description of what I respond to. 61 | * ``dance`` - Get down and funky. 62 | * ``woodies`` - Best quote on the internet.. 63 | * ``wiki`` - Search Wikipedia for a topic. 64 | * ``metar`` - Fetch a NOAA METAR by station code. 65 | * ``twitter`` - Search Twitter for a topic. 66 | * ``fatpita`` - Get a random fatpita image. For the lulz. 67 | * ``corgibomb`` - CORGI BOMB 68 | 69 | 70 | Extending the bot 71 | ----------------- 72 | 73 | Adding on further handlers is relatively simple. At its most basic, it's simply 74 | adding on a new method decorated with ``toastbot.handler``. For example, logging 75 | how many times a user has said something in the channel might look like: 76 | 77 | import toastbot 78 | 79 | class MyBot(toastbot.ToastBot): 80 | talkers = {} 81 | 82 | def __init__(self, *args, **kwargs): 83 | super(MyBot, self).__init__(*args, **kwargs) 84 | self.enabled_commands += [ 85 | self.how_chatty, 86 | ] 87 | 88 | def how_chatty(self, nick, text): 89 | """Logs how often a user has said something.""" 90 | if nick in self.talkers: 91 | self.talkers[nick] += 1 92 | else: 93 | self.talkers[nick] = 1 94 | 95 | print self.talkers.items() 96 | 97 | 98 | bot = MyBot('myircbot', '#myircchannel') 99 | bot.setup() 100 | 101 | Note that this command does not require addressing the bot at all. If you want 102 | a command that the bot responds to, you might write something like: 103 | 104 | import toastbot 105 | 106 | class StoolPigeon(toastbot.ToastBot): 107 | # Assume the previous example, but adding... 108 | def __init__(self, *args, **kwargs): 109 | super(StoolPigeon, self).__init__(*args, **kwargs) 110 | self.enabled_commands += [ 111 | self.stool_pigeon, 112 | ] 113 | 114 | def stool_pigeon(self, nick, text): 115 | """Rat out the talkers.""" 116 | text = self.is_direct_command('stool_pigeon', text) 117 | 118 | if not text: 119 | raise NotHandled() 120 | 121 | return str(self.talkers) 122 | 123 | bot = StoolPigeon('myircbot', '#myircchannel') 124 | bot.setup() 125 | 126 | This checks to see if the bot is being directly addressed then returns a 127 | string-ified version of the ``talker`` stats. The included handlers demonstrate 128 | even more complex behavior, such as how to do network fetches or asynchronous 129 | responses. 130 | 131 | To disable handlers: 132 | 133 | import toastbot 134 | 135 | class MyBot(toastbot.ToastBot): 136 | talkers = {} 137 | 138 | def __init__(self, *args, **kwargs): 139 | super(MyBot, self).__init__(*args, **kwargs) 140 | self.enabled_commands = [func for func in self.enabled_commands if func.__name__ != 'twitter'] 141 | 142 | bot = MyBot('myircbot', '#myircchannel') 143 | bot.setup() 144 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toastdriven/toastbot/8926f6dd70ee15de48ebb79274bc4ce0c812beee/examples/__init__.py -------------------------------------------------------------------------------- /examples/bot.py: -------------------------------------------------------------------------------- 1 | import toastbot 2 | 3 | bot = toastbot.ToastBot('test_bot', '#botwars') 4 | bot.setup() 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gevent 2 | irckit 3 | requests 4 | pyquery 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from distutils.core import setup 4 | import os 5 | 6 | README = os.path.join(os.path.dirname(__file__), 'README.md') 7 | 8 | setup( 9 | name='toastbot', 10 | version='0.4.3', 11 | description='A clean, extensible IRC bot using irckit.', 12 | long_description=open(README, 'r').read(), 13 | author='Daniel Lindsley', 14 | author_email='daniel@toastdriven.com', 15 | py_modules = ['toastbot'], 16 | classifiers=[ 17 | 'Development Status :: 4 - Beta', 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: BSD License', 20 | 'Operating System :: OS Independent', 21 | 'Programming Language :: Python', 22 | 'Topic :: Utilities' 23 | ], 24 | url = 'http://github.com/toastdriven/toastbot' 25 | ) 26 | -------------------------------------------------------------------------------- /toastbot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import irc 3 | 4 | import codecs 5 | import datetime 6 | import os 7 | import re 8 | import socket 9 | import subprocess 10 | import urllib 11 | 12 | import pyquery 13 | import requests 14 | 15 | try: 16 | import simplejson as json 17 | except ImportError: 18 | import json 19 | 20 | 21 | __author__ = 'Daniel Lindsley' 22 | __version__ = (0, 4, 3) 23 | __license__ = 'BSD' 24 | 25 | 26 | class NotHandled(Exception): 27 | pass 28 | 29 | 30 | class ToastBot(object): 31 | server = 'irc.freenode.net' 32 | port = 6667 33 | username = None 34 | realname = 'ToastBot' 35 | debug = False 36 | log_dir = os.path.join(os.path.dirname(__file__), 'logs') 37 | variant_endings = [ 38 | ': ', 39 | ', ', 40 | '- ', 41 | ' - ', 42 | ] 43 | 44 | def __init__(self, nick, channel, **kwargs): 45 | self.nick = nick 46 | self.channel = channel 47 | self.client = None 48 | 49 | if 'server' in kwargs: 50 | self.server = kwargs['server'] 51 | 52 | if 'port' in kwargs: 53 | self.port = kwargs['port'] 54 | 55 | if 'username' in kwargs: 56 | self.username = kwargs['username'] 57 | 58 | if 'realname' in kwargs: 59 | self.realname = kwargs['realname'] 60 | 61 | if 'debug' in kwargs: 62 | self.debug = kwargs['debug'] 63 | 64 | if 'log_dir' in kwargs: 65 | self.log_dir = kwargs['log_dir'] 66 | 67 | self.variants = [self.nick + variant for variant in self.variant_endings] 68 | self.enabled_commands = [ 69 | self.help, 70 | self.dance, 71 | self.woodies, 72 | self.wiki, 73 | self.metar, 74 | self.twitter, 75 | self.fatpita, 76 | self.corgibomb, 77 | ] 78 | 79 | def run(self): 80 | patterns = [ 81 | (self.client.ping_re, self.client.handle_ping), 82 | (self.client.part_re, self.handle_part), 83 | (self.client.join_re, self.handle_join), 84 | (self.client.chanmsg_re, self.handle_channel_message), 85 | (self.client.privmsg_re, self.handle_private_message), 86 | ] 87 | self.client.logger.debug('entering receive loop') 88 | 89 | while 1: 90 | try: 91 | data = self.client._sock_file.readline() 92 | except socket.error: 93 | data = None 94 | 95 | if not data: 96 | self.client.logger.info('server closed connection') 97 | self.client.close() 98 | return True 99 | 100 | data = data.rstrip() 101 | 102 | for pattern, callback in patterns: 103 | match = pattern.match(data) 104 | if match: 105 | callback(**match.groupdict()) 106 | 107 | def setup(self): 108 | self.ensure_log_directory() 109 | self.client = irc.IRCConnection(self.server, self.port, self.nick, verbosity=2) 110 | self.client.connect() 111 | self.client.join(self.channel) 112 | self.run() 113 | 114 | def ensure_log_directory(self): 115 | if not os.path.exists(self.log_dir): 116 | os.makedirs(self.log_dir) 117 | 118 | if self.debug: 119 | self.log('Ensured the log directory exists.') 120 | 121 | def log(self, message): 122 | now = datetime.datetime.now() 123 | log_filename = "%4d%2d%2d.log" % (now.year, now.month, now.day) 124 | log_filepath = os.path.join(self.log_dir, log_filename) 125 | 126 | with codecs.open(log_filepath, 'a', encoding='utf-8') as log: 127 | log.write(u'[%4d-%02d-%02d %02d:%02d:%02d] %s\n' % ( 128 | now.year, 129 | now.month, 130 | now.day, 131 | now.hour, 132 | now.minute, 133 | now.second, 134 | message 135 | )) 136 | 137 | def say(self, response): 138 | if not isinstance(response, (list, tuple)): 139 | response = [response] 140 | 141 | for resp in response: 142 | self.log(u"%s: %s" % (self.nick, resp)) 143 | self.client.respond(resp.encode('utf-8'), channel=self.channel) 144 | 145 | def handle_join(self, nick, channel): 146 | self.log(u"%s joined %s." % (nick, channel)) 147 | 148 | def handle_part(self, nick, channel): 149 | self.log(u"%s left %s." % (nick, channel)) 150 | 151 | def clean_message(self, text): 152 | if not isinstance(text, unicode): 153 | text = text.decode('utf-8', 'ignore') 154 | 155 | clean_text = text.strip() 156 | clean_text = clean_text.replace('\u0001', '') 157 | return clean_text 158 | 159 | def said_to_me(self, text): 160 | for variant in self.variants: 161 | if text.startswith(variant): 162 | return ['direct', text.replace(variant, '', 1)] 163 | elif variant in text: 164 | return ['indirect', text] 165 | 166 | return ['nomention', text] 167 | 168 | def is_direct_command(self, name, text): 169 | address, text = self.said_to_me(text) 170 | 171 | if address != 'direct': 172 | return None 173 | 174 | if text.lower() != name: 175 | return None 176 | 177 | return text 178 | 179 | def handle_channel_message(self, nick, channel, message): 180 | nick = self.clean_message(nick) 181 | cleaned_text = self.clean_message(message) 182 | 183 | if cleaned_text.startswith('ACTION'): 184 | self.log(u"* %s %s" % (nick, cleaned_text.replace('ACTION', '', 1))) 185 | else: 186 | self.log(u"%s: %s" % (nick, cleaned_text)) 187 | 188 | for command in self.enabled_commands: 189 | try: 190 | response = command(nick, cleaned_text) 191 | 192 | if response is True: 193 | # It's doing it's own output. 194 | return 195 | 196 | self.say(response) 197 | except NotHandled: 198 | # Nope, not that one. Try the next command. 199 | continue 200 | 201 | def handle_private_message(self, nick, message): 202 | cleaned_text = self.clean_message(message) 203 | self.log(u"PM <- %s: %s" % (nick, cleaned_text)) 204 | response = "Sorry, I don't respond to PMs yet." 205 | self.log(u"PM -> %s: %s" % (nick, response)) 206 | self.client.respond(response, nick=nick) 207 | 208 | # Available commands 209 | def help(self, nick, text): 210 | """Provides a description of what I respond to.""" 211 | text = self.is_direct_command('help', text) 212 | 213 | if not text: 214 | raise NotHandled() 215 | 216 | commands = [ 217 | u'%s: Valid commands - ' % nick, 218 | ] 219 | 220 | for command in self.enabled_commands: 221 | commands.append(" - %s = %s" % (command.__name__, command.__doc__ or 'No documentation.')) 222 | 223 | return commands 224 | 225 | def dance(self, nick, text): 226 | """Get down and funky.""" 227 | text = self.is_direct_command('dance', text) 228 | 229 | if not text: 230 | raise NotHandled() 231 | 232 | sweet_moves = [ 233 | "_O_", 234 | "\\O_", 235 | "_O/", 236 | "\\O/", 237 | ] 238 | return sweet_moves 239 | 240 | def woodies(self, nick, text): 241 | """Best quote on the internet.""" 242 | if not 'woodies' in text: 243 | raise NotHandled() 244 | 245 | return 'U GUYZ R THE BEST AND GIVE ME A BILLION WOODIES A DAY! [https://code.djangoproject.com/ticket/7712#comment:2]' 246 | 247 | def wiki(self, nick, text): 248 | """Search Wikipedia for a topic.""" 249 | address, text = self.said_to_me(text) 250 | 251 | if address != 'direct': 252 | raise NotHandled() 253 | 254 | if not text.startswith('wiki'): 255 | raise NotHandled() 256 | 257 | search_terms = text.replace('wiki ', '').encode('utf-8', 'ignore') 258 | resp = requests.get('http://en.wikipedia.org/w/index.php?search=%s' % urllib.quote_plus(search_terms), headers={'User-Agent': 'Mozilla/4.0 (toastbot)'}) 259 | 260 | if resp.status_code in (404, 500): 261 | self.log("Failed to load wiki entry for '%s'." % search_terms) 262 | return True 263 | 264 | return u"%s: %s" % (nick, resp.url) 265 | 266 | def metar(self, nick, text): 267 | """Fetch a NOAA METAR by station code.""" 268 | address, text = self.said_to_me(text) 269 | 270 | if address != 'direct': 271 | raise NotHandled() 272 | 273 | if not text.startswith('metar'): 274 | raise NotHandled() 275 | 276 | station = text.replace('metar ', '') 277 | url = "ftp://tgftp.nws.noaa.gov/data/observations/metar/stations/%s.TXT" % station.upper() 278 | proc = subprocess.Popen('curl %s' % url, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 279 | stdout, stderr = proc.communicate() 280 | 281 | if proc.returncode != 0: 282 | self.log("Failed to load metar entry for '%s'." % station); 283 | return u"%s: Sorry, couldn't find that station." % nick 284 | 285 | return u"%s: %s" % (nick, stdout.replace('\n', ' ').replace('\r', '')) 286 | 287 | def twitter(self, nick, text): 288 | """Search Twitter for a topic.""" 289 | address, text = self.said_to_me(text) 290 | 291 | if address != 'direct': 292 | raise NotHandled() 293 | 294 | if not text.startswith('twitter'): 295 | raise NotHandled() 296 | 297 | search_terms = text.replace('twitter ', '').encode('utf-8', 'ignore') 298 | resp = requests.get('http://search.twitter.com/search.json?rpp=5&result_type=recent&q=%s' % urllib.quote_plus(search_terms), headers={'User-Agent': 'Mozilla/4.0 (toastbot)'}) 299 | 300 | if resp.status_code != 200: 301 | self.log("Failed to load wiki entry for '%s'." % search_terms) 302 | self.say(u"%s: Sorry, Twitter isn't responding." % nick) 303 | return True 304 | 305 | try: 306 | resp_data = json.loads(resp.content) 307 | results = [ 308 | u'%s: Top 5 results - ' % nick, 309 | ] 310 | 311 | for tweet in resp_data.get('results', []): 312 | results.append(u" - @%s: %s" % (tweet['from_user'], tweet['text'])) 313 | 314 | return results 315 | except: 316 | self.log("FAIL WHALE for '%s'." % search_terms) 317 | self.say(u"%s: Twitter fail whale'd." % nick) 318 | return True 319 | 320 | def fatpita(self, nick, text): 321 | """Get a random fatpita image. For the lulz.""" 322 | text = self.is_direct_command('fatpita', text) 323 | 324 | if not text: 325 | raise NotHandled() 326 | 327 | resp = requests.get('http://fatpita.net/', headers={'User-Agent': 'Mozilla/4.0 (toastbot)'}) 328 | 329 | if resp.status_code in (404, 500): 330 | self.log("Failed to load random fatpita image.") 331 | return True 332 | 333 | return u"%s: %s" % (nick, resp.url) 334 | 335 | def corgibomb(self, nick, text): 336 | """CORGI BOMB!""" 337 | text = self.is_direct_command('corgibomb', text) 338 | 339 | if not text: 340 | raise NotHandled() 341 | 342 | resp = requests.get('http://www.tumblr.com/tagged/corgi', headers={'User-Agent': 'Mozilla/4.0 (toastbot)'}) 343 | 344 | if resp.status_code in (404, 500): 345 | self.log("Failed to load corgibomb image.") 346 | return True 347 | 348 | doc = pyquery.PyQuery(resp.content) 349 | corgi_js = doc('.image_thumbnail:first').attr('onclick') 350 | 351 | # Because Tumblr LOL. 352 | tumblr_rage = re.search(r"this\.src=\'(?P.*?)\'", corgi_js) 353 | 354 | if tumblr_rage: 355 | corgi_pic = tumblr_rage.groupdict()['pic'] 356 | return u"%s: %s" % (nick, corgi_pic) 357 | else: 358 | return u"%s: Sorry, Tumblr is being crappy. No pic for you." % nick 359 | --------------------------------------------------------------------------------