├── .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 |
--------------------------------------------------------------------------------