├── .gitignore ├── MANIFEST.in ├── README.md ├── chatbot ├── __init__.py ├── bots.py ├── chat.py ├── client.py ├── contrib │ ├── __init__.py │ ├── base.py │ ├── humor.py │ ├── random.py │ ├── simple.py │ ├── twitter.py │ └── urls.py ├── settings.py └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harrislapiroff/python-chatbot/073dc9f54f55f5904068bff2e02fd3ba27c8b145/MANIFEST.in -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Python Chatbot 2 | ============== 3 | 4 | A simple, extensible bot for your IRC channels. 5 | 6 | Installation 7 | ------------ 8 | 9 | With Pip: 10 | 11 | ```bash 12 | sudo pip install git+https://github.com/harrislapiroff/python-chatbot.git@master#egg=chatbot 13 | ``` 14 | 15 | Usage 16 | ----- 17 | 18 | To run a bot, you must write a short python script. For example `simple_bot.py`: 19 | 20 | ```python 21 | from chatbot.bots import Bot 22 | from chatbot.contrib import * 23 | 24 | bot = Bot( 25 | nickname = 'bestbot', 26 | hostname = 'chat.freenode.net', 27 | port = 6665, 28 | server_password = 'my_bots_password', 29 | channels = ('#freenode', '#python'), 30 | features = ( 31 | PyPIFeature(), 32 | WikipediaFeature(), 33 | DictionaryFeature(), 34 | DiceFeature(), 35 | ChoiceFeature(), 36 | SlapbackFeature(), 37 | ) 38 | ) 39 | 40 | bot.run() 41 | ``` 42 | 43 | Then run the script: 44 | 45 | ```bash 46 | python simple_bot.py 47 | ``` 48 | 49 | The Flexible `Match` Feature 50 | ---------------------------- 51 | 52 | **Chatbot** comes with a built in `Match` feature, which is both simple and 53 | powerful. You can build an entire bot from `Match` features alone. Here is an 54 | example of a simple bot that will slap people on command. 55 | 56 | ```python 57 | from chatbot.bots import Bot 58 | from chatbot.contrib.simple import Match 59 | from chatbot.chat import ChatResponse 60 | 61 | SLAP_OPTIONS = ( 62 | ChatResponse('slaps \g around a bit with a baseball bat', action=True), 63 | ChatResponse('slaps \g around a bit with a large trout', action=True), 64 | ChatResponse('slaps \g around a bit with a piano', action=True), 65 | ChatResponse('slaps \g around a bit with a french fry', action=True), 66 | ) 67 | 68 | bot = Bot( 69 | nickname = 'bestbot', 70 | hostname = 'chat.freenode.net', 71 | port = 6665, 72 | server_password = 'my_bots_password', 73 | channels = ('#freenode', '#python'), 74 | features = ( 75 | Match(r'slap (?P[^\s]+) (?P.+)', ChatResponse('slaps \g around a bit \g', action=True), addressing_required=True, allow_continuation=False), 76 | Match(r'slap (?P.+)', SLAP_OPTIONS, addressing_required=True, allow_continuation=False), 77 | ) 78 | ) 79 | 80 | bot.run() 81 | ``` 82 | 83 | In this case, the bot handles two possible matches. The first pattern matches sentences such as `bestbot: slap melinath with a frying pan` by responding with an action, `slaps melinath around a bit with a frying pan`. The second pattern matches commands to slap that do not specify the method of slapping (e.g., `slap melinath`), by choosing an option randomly from `SLAP_OPTIONS` (e.g., `slaps melinath around a bit with a french fry`). -------------------------------------------------------------------------------- /chatbot/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 1, 1) -------------------------------------------------------------------------------- /chatbot/bots.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from chatbot.client import IRCBotFactory 3 | from chatbot.settings import default_settings 4 | 5 | class Bot(object): 6 | def __init__(self, **settings): 7 | self.settings = default_settings.copy() 8 | self.settings.update(settings) 9 | 10 | def run(self): 11 | factory = IRCBotFactory(self.settings) 12 | reactor.connectTCP(self.settings['hostname'], self.settings['port'], factory) 13 | reactor.run() 14 | -------------------------------------------------------------------------------- /chatbot/chat.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class ChatQuery(object): 4 | ADDRESSED_RE = "%s\s*[:,\-]?\s*(.*)" 5 | 6 | def __init__(self, **kwargs): 7 | self.raw_data = kwargs 8 | self.bot = kwargs['bot'] 9 | self.nickname = kwargs['user'].split('!', 1)[0] 10 | self.private = True if kwargs['channel'] == self.bot.settings['nickname'] else False 11 | self.query = kwargs['message'] 12 | self.channel = kwargs['channel'] 13 | self.action = kwargs['action'] if 'action' in kwargs else False 14 | 15 | # check if the match is addressed 16 | addressed_match = re.match(self.ADDRESSED_RE % self.bot.settings['nickname'], self.query) 17 | if addressed_match: 18 | self.addressed = True 19 | self.query = addressed_match.group(1) 20 | else: 21 | self.addressed = False 22 | 23 | class ChatResponse(object): 24 | 25 | def __init__(self, content, **kwargs): 26 | self.content = content 27 | if 'target' in kwargs: 28 | self.target = kwargs['target'] 29 | self.action = kwargs['action'] if 'action' in kwargs else False 30 | 31 | def __str__(self): 32 | return content -------------------------------------------------------------------------------- /chatbot/client.py: -------------------------------------------------------------------------------- 1 | from twisted.words.protocols import irc 2 | from twisted.internet import protocol 3 | from chatbot.chat import ChatQuery, ChatResponse 4 | 5 | class IRCBot(irc.IRCClient): 6 | 7 | def __init__(self, settings=None, *args, **kwargs): 8 | self.settings = settings 9 | self.features = [] 10 | self.nickname = self.settings['nickname'] 11 | self.channels = self.settings['channels'] 12 | self.password = settings['server_password'] 13 | for feature in self.settings['features']: 14 | self.features.append(feature) 15 | 16 | def signedOn(self): 17 | for channel in self.channels: 18 | self.join(channel) 19 | 20 | def privmsg(self, user, channel, message, action=False): 21 | "Upon receiving a message, handle it with the bot's feature set." 22 | query = ChatQuery(user=user, channel=channel, message=message, bot=self, action=action) 23 | for feature in self.features: 24 | # If they query is unaddressed and addressing is required, move to the next feature 25 | if feature.addressing_required and not query.addressed: 26 | continue 27 | if feature.handles_query(query): 28 | default_target = query.user['raw'] if query.private else query.channel 29 | response = feature.handle_query(query) 30 | if response is not None: 31 | # if a target it attached to the response, use it 32 | target = getattr(response, 'target', default_target) 33 | # Send either an action or a message. 34 | if response.action: 35 | self.describe(target, response.content) 36 | else: 37 | self.msg(target, response.content) 38 | # if the feature disallows continuation, stop iterating over features here 39 | if not feature.allow_continuation: 40 | break 41 | 42 | def action(self, user, channel, data): 43 | self.privmsg(user, channel, data, action=True) 44 | 45 | class IRCBotFactory(protocol.ClientFactory): 46 | protocol = IRCBot 47 | 48 | def __init__(self, settings=None, *args, **kwargs): 49 | self.settings = settings 50 | 51 | def clientConnectionLost(self, connector, reason): 52 | "If disconnected, reconnect." 53 | connector.connect() 54 | 55 | def buildProtocol(self, addr): 56 | bot = self.protocol(self.settings) 57 | bot.factory = self 58 | return bot 59 | 60 | def clientConnectionFailed(self, connector, reason): 61 | print "connection failed: ", reason -------------------------------------------------------------------------------- /chatbot/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | from humor import * 2 | from random import * 3 | from urls import * 4 | from simple import * 5 | from twitter import * -------------------------------------------------------------------------------- /chatbot/contrib/base.py: -------------------------------------------------------------------------------- 1 | class Feature(object): 2 | "A base feature class that provides some sane defaults and should be subclassed." 3 | allow_continuation = False 4 | addressing_required = False 5 | 6 | def __init__(self, allow_continuation=False, addressing_required=False): 7 | self.allow_continuation = allow_continuation 8 | self.addressing_required = addressing_required 9 | 10 | def handles_query(self, query): 11 | raise NotImplementedError('`handles_query` method must be defined on %s.' % self.__class__.__name__) 12 | 13 | def handle_query(self, query): 14 | raise NotImplementedError('`handle_query` method must be defined on %s.' % self.__class__.__name__) -------------------------------------------------------------------------------- /chatbot/contrib/humor.py: -------------------------------------------------------------------------------- 1 | import re 2 | from chatbot.chat import ChatResponse 3 | from chatbot.contrib.base import Feature 4 | 5 | class SlapbackFeature(Feature): 6 | match_re = r"slaps %s(.*)" 7 | 8 | def handles_query(self, query): 9 | if re.match(self.match_re % query.bot.nickname, query.query): 10 | return True 11 | 12 | def handle_query(self, query): 13 | match = re.match(self.match_re % query.bot.nickname, query.query) 14 | return ChatResponse("slaps %s back%s" % (query.nickname, match.group(1)), action=True) -------------------------------------------------------------------------------- /chatbot/contrib/random.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import re 3 | import random 4 | from chatbot.chat import ChatResponse 5 | from chatbot.contrib.base import Feature 6 | 7 | class DiceFeature(Feature): 8 | """ 9 | Rolls dice and responds with the results. 10 | 11 | Example queries that should be matched by match_re: 12 | 13 | /me rolls 1d6 14 | Rolls one six-sided die. 15 | 16 | /me rolls 3d2+1 17 | Rolls three two-sided dice and adds 1 to each result. 18 | 19 | /me rolls 6d6 - 4 20 | Rolls six six-sided dice and subtracts 4 from each result. 21 | 22 | """ 23 | match_re = r"rolls ([0-9]+)d([0-9]+)[\s]*([\+\-]?)[\s]*([0-9]*)" 24 | 25 | def handles_query(self, query): 26 | if query.action and re.match(self.match_re, query.query): 27 | return True 28 | 29 | def handle_query(self, query): 30 | bits = re.match(self.match_re, query.query) 31 | dice_count = int(bits.group(1)) 32 | dice_sides = int(bits.group(2)) 33 | operator = bits.group(3) 34 | addend_or_subtrahend = int(bits.group(4)) if operator != "" else None 35 | 36 | results = [random.randint(1, dice_sides) for x in range(0, dice_count)] 37 | results_sum = sum(results) 38 | 39 | # add or subtract the addend_or_subtrahend if an operator is present 40 | if operator == "+": 41 | results_sum = results_sum + addend_or_subtrahend 42 | elif operator == "-": 43 | results_sum = results_sum - addend_or_subtrahend 44 | 45 | results_text = ", ".join(str(i) for i in results) 46 | response_content = "%s got %s for a total of %d" % (query.nickname, results_text, results_sum) 47 | 48 | # if the response is too long, don't display individual results 49 | if len(response_content) > query.bot.settings['message_max_length']: 50 | response_content = "%s rolled %sd%s for a total of %d" % (query.nickname, dice_count, dice_sides, results_sum) 51 | 52 | return ChatResponse(response_content) 53 | 54 | class ChoiceFeature(Feature): 55 | addressing_required = True 56 | match_re = r"(.*) or ([^?]*)\??" 57 | 58 | def handles_query(self, query): 59 | if query.addressed and re.match(self.match_re, query.query): 60 | return True 61 | 62 | def handle_query(self, query): 63 | bits = re.match(self.match_re, query.query) 64 | choice = random.randint(1,2) 65 | 66 | return ChatResponse(bits.group(choice)) -------------------------------------------------------------------------------- /chatbot/contrib/simple.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import re 3 | import random 4 | from copy import copy 5 | from chatbot.chat import ChatResponse 6 | from chatbot.contrib.base import Feature 7 | 8 | class Match(Feature): 9 | 10 | def __init__(self, match_re, response, allow_continuation=True, addressing_required=False): 11 | """ 12 | Arguments: 13 | match_re -- the regular expression to match 14 | response -- either a single ChatResponse/text string/replacement 15 | function, or a list of the same to choose randomly from. 16 | Text can include $1, et. al. for pattern 17 | match interpolation. 18 | Response text should be formatted as a replacement string 19 | to be handled by Python's re.sub() 20 | 21 | """ 22 | 23 | self.match_re = match_re 24 | self.response = response 25 | self.allow_continuation = allow_continuation 26 | self.addressing_required = addressing_required 27 | 28 | def handles_query(self, query): 29 | # If the query matches the regular expression, return True. 30 | if re.match(self.match_re, query.query): 31 | return True 32 | 33 | def handle_query(self, query): 34 | response = self.get_response(query) # pass the query for subclasses that override get_response and need the query to be available 35 | compiled_re = re.compile(self.match_re) 36 | 37 | if isinstance(response, ChatResponse): 38 | # if the response is already a ChatResponse instance, modify it in place 39 | response.content = compiled_re.sub(response.content, query.query) 40 | else: 41 | # otherwise, it's a string -- use the string and create a ChatResponse 42 | content = compiled_re.sub(response, query.query) 43 | response = ChatResponse(content) 44 | 45 | return response 46 | 47 | def get_response(self, query): 48 | """ 49 | Returns either a response object or a response string. Copies the 50 | response to avoid modifying the original instance attribute. 51 | 52 | """ 53 | 54 | if hasattr(self.response, '__iter__'): 55 | # if the response variable is an iterable, select one item randomly 56 | response = random.choice(self.response) 57 | else: 58 | # otherwise, assume it's a single string/ChatResponse 59 | response = self.response 60 | return copy(response) -------------------------------------------------------------------------------- /chatbot/contrib/twitter.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib2 3 | import json 4 | from chatbot.chat import ChatResponse 5 | from chatbot.contrib.base import Feature 6 | 7 | class TweetReader(Feature): 8 | match_re = r'http[s]?://twitter.com/(?P[^/]+)/status/(?P[0-9]+)/?' 9 | api_url_pattern = 'http://api.twitter.com/1/statuses/show.json?id=%s&include_entities=true' 10 | 11 | def handles_query(self, query): 12 | if re.search(self.match_re, query.query): 13 | return True 14 | 15 | def handle_query(self, query): 16 | search_results = re.findall(self.match_re, query.query) 17 | response_content = "" 18 | for result in search_results: 19 | user_name = result[0] 20 | tweet_id = result[1] 21 | try: 22 | page = urllib2.urlopen(self.api_url_pattern % tweet_id) 23 | except urllib2.URLError: 24 | response_content = response_content + "Error accessing tweet from %s." % user_name + "\n" 25 | continue 26 | data = json.load(page) 27 | response_content = "%s (%s): %s" % (data['user']['name'], data['user']['screen_name'], data['text']) + "\n" 28 | return ChatResponse(str(response_content)) -------------------------------------------------------------------------------- /chatbot/contrib/urls.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib2 3 | from chatbot.chat import ChatResponse 4 | from chatbot.contrib.base import Feature 5 | 6 | class BaseURLFeature(Feature): 7 | allow_continuation = True 8 | 9 | def __init__(self): 10 | if not hasattr(self, 'request_re'): 11 | raise NotImplementedError('`request_re` must be defined on %s.' % self.__class__.__name__) 12 | 13 | def handles_query(self, query): 14 | if re.search(self.request_re, query.query): 15 | return True 16 | 17 | def handle_query(self, query): 18 | search_results = re.findall(self.request_re, query.query) 19 | response_content = "" 20 | for keyword in search_results: 21 | url = self.get_url(keyword) 22 | if url: 23 | response_content = response_content + url + "\n" 24 | else: 25 | response_content = response_content + "URL not found for %s" % keyword + "\n" 26 | return ChatResponse(response_content) 27 | 28 | def get_url(self, keyword): 29 | "Returns either a URL associated with the keyword, or false if error." 30 | if not hasattr(self, 'url_format'): 31 | raise NotImplementedError('Attribute `url_format` or method `get_url` must be defined on %s.' % self.__class__.__name__) 32 | url = self.url_format % keyword 33 | try: 34 | page = urllib2.urlopen(url) 35 | return url 36 | except: 37 | return False 38 | 39 | 40 | class PyPIFeature(BaseURLFeature): 41 | request_re = r"pypi:([\w\-_]*)" 42 | url_format = r"http://pypi.python.org/pypi/%s/" 43 | 44 | 45 | class WikipediaFeature(BaseURLFeature): 46 | request_re = r"wiki:([\w\-_]*)" 47 | url_format = r"http://en.wikipedia.org/wiki/%s" 48 | 49 | def get_url(self, keyword): 50 | """ 51 | Wikipedia blocks bots from requesting URLs, so this just returns the 52 | url without checking it. 53 | """ 54 | return self.url_format % keyword 55 | 56 | 57 | class DictionaryFeature(BaseURLFeature): 58 | request_re = r"word:([\w\-_']*)" 59 | url_format = r"http://dictionary.reference.com/browse/%s" -------------------------------------------------------------------------------- /chatbot/settings.py: -------------------------------------------------------------------------------- 1 | default_settings = { 2 | 'message_max_length': 470 3 | } -------------------------------------------------------------------------------- /chatbot/utils.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | def import_class(string_path): 4 | bits = string_path.rpartition('.') 5 | module = import_module(bits[0]) 6 | return getattr(module, bits[2]) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from setuptools import setup, find_packages 5 | 6 | version = __import__('chatbot').VERSION 7 | 8 | setup( 9 | name='chatbot', 10 | version='.'.join([str(v) for v in version]), 11 | description='Extensible IRC chatbot written in python.', 12 | license='BSD', 13 | packages=find_packages(), 14 | include_package_data=True, 15 | zip_safe=False, 16 | install_requires=[ 17 | 'twisted', 18 | ], 19 | ) 20 | --------------------------------------------------------------------------------