diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee64372 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +*.pyc diff --git a/README.md b/README.md index 4330d8e..5ffd023 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,25 @@ # Fox-V3 +Cog Function +| Name | Status | Description (Click to see full status) +| --- | --- | --- | +| ccrole | **Beta** |
Create custom commands that also assign rolesMay have some bugs, please create an issue if you find any
| +| chatter | **Alpha** |
Chat-bot trained to talk like your guildMissing some key features, but currently functional
| +| coglint | **Alpha** |
Error check code in python syntax posted to discordWorks, but probably needs more turning to work for cogs
| +| fight | **Incomplete** |
Organize bracket tournaments within discordStill in-progress, a massive project
| +| flag | **Incomplete** |
Create temporary marks on users that expire after specified timeNot yet ported to v3
| +| hangman | **Alpha** |
Play a game of hangmanSome visual glitches and needs more customization
| +| howdoi | **Incomplete** |
Create temporary marks on users that expire after specified timeNot yet ported to v3
| +| leaver | **Incomplete** |
Send a message in a channel when a user leaves the serverNot yet ported to v3
| +| lseen | **Alpha** |
Track when a member was last onlineAlpha release, please report bugs
| +| reactrestrict | **Alpha** |
Removes reactions by role per channelA bit clunky, but functional
| +| sayurl | **Alpha** |
Convert any URL into text and post to discordNo error checking and pretty spammy
| +| secrethitler | **Incomplete** |
Play the Secret Hitler gameConcept, no work done yet
| +| stealemoji | **Alpha** |
Steals any custom emoji it sees in a reactionSome planned upgrades for server generation
| +| werewolf | **Alpha** |
Play the classic party game Werewolf within discordAnother massive project currently being developed, will be fully customizable
| -Cog Status - - ccrole: **Incomplete** - - challonge: **Incomplete** - Challonge integration with discord - - fight: **Incomplete** Still in-progress, a massive project - - flag: **Incomplete** Not yet ported to v3 - - hangman: **Incomplete** v2 version is functional at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs) - - immortal: **Private** Designed for a specific server, not recommended to install - - leaver: **Incomplete** Have not begun conversion yet - - reactrestrict: **Beta** - Removes reactions by role per channel - - stealemoji: **Incomplete** - Steals any custom emoji it sees \ No newline at end of file +Check out my V2 cogs at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs) + +Get support on the [Third Party Cog Server](https://discord.gg/GET4DVk) diff --git a/ccrole/__init__.py b/ccrole/__init__.py new file mode 100644 index 0000000..a22b3f1 --- /dev/null +++ b/ccrole/__init__.py @@ -0,0 +1,5 @@ +from .ccrole import CCRole + + +def setup(bot): + bot.add_cog(CCRole(bot)) diff --git a/ccrole/ccrole.py b/ccrole/ccrole.py index f60b26c..2459b6e 100644 --- a/ccrole/ccrole.py +++ b/ccrole/ccrole.py @@ -1,178 +1,318 @@ -from discord.ext import commands -from .utils.dataIO import dataIO -from .utils import checks -from .utils.chat_formatting import pagify, box -import os +import asyncio import re +import discord +from discord.ext import commands +from redbot.core import Config, checks +from redbot.core.utils.chat_formatting import pagify, box + class CCRole: - """Custom commands - Creates commands used to display text""" + """ + Custom commands + Creates commands used to display text and adjust roles + """ def __init__(self, bot): self.bot = bot - self.file_path = "data/ccrole/commands.json" - self.c_commands = dataIO.load_json(self.file_path) + self.config = Config.get_conf(self, identifier=9999114111108101) + default_guild = { + "cmdlist": {}, + "settings": {} + } + + self.config.register_guild(**default_guild) - @commands.group(pass_context=True, no_pm=True) + @commands.group(no_pm=True) async def ccrole(self, ctx): - """Custom commands management""" - if ctx.invoked_subcommand is None: - await self.bot.send_cmd_help(ctx) + """Custom commands management with roles - @ccrole.command(name="add", pass_context=True) + Highly customizable custom commands with role management.""" + if not ctx.invoked_subcommand: + await ctx.send_help() + + @ccrole.command(name="add") @checks.mod_or_permissions(administrator=True) - async def ccrole_add(self, ctx, command : str): - """Adds a custom command with roles""" - - server = ctx.message.server - author = ctx.message.author - msg = 'What roles should it add? (Must be comma separated) Example:\n\n' - for c, m in enumerate(self.settings[server.id]["GREETING"]): - msg += " {}. {}\n".format(c, m) - for page in pagify(msg, ['\n', ' '], shorten_by=20): - await self.bot.say("```\n{}\n```".format(page)) - answer = await self.bot.wait_for_message(timeout=120, author=author) + async def ccrole_add(self, ctx, command: str): + """Adds a custom command with roles + + When adding text, put arguments in `{}` to eval them + Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`""" + command = command.lower() + if command in self.bot.all_commands: + await ctx.send("That command is already a standard command.") + return + + guild = ctx.guild + author = ctx.author + channel = ctx.channel + + cmd_list = self.config.guild(guild).cmdlist + + if await cmd_list.get_raw(command, default=None): + await ctx.send("This command already exists. Delete it with `{}ccrole delete` first.".format(ctx.prefix)) + return + + # Roles to add + await ctx.send('What roles should it add? (Must be **comma separated**)\nSay `None` to skip adding roles') + + def check(m): + return m.author == author and m.channel == channel + try: - num = int(answer.content) - choice = self.settings[server.id]["GREETING"].pop(num) - except: - await self.bot.say("That's not a number in the list :/") + answer = await self.bot.wait_for('message', timeout=120, check=check) + except asyncio.TimeoutError: + await ctx.send("Timed out, canceling") return - server = ctx.message.server - command = command.lower() - if command in self.bot.commands: - await self.bot.say("That command is already a standard command.") + arole_list = [] + if answer.content.upper() != "NONE": + arole_list = await self._get_roles_from_content(ctx, answer.content) + if arole_list is None: + await ctx.send("Invalid answer, canceling") + return + + # Roles to remove + await ctx.send('What roles should it remove? (Must be comma separated)\nSay `None` to skip removing roles') + try: + answer = await self.bot.wait_for('message', timeout=120, check=check) + except asyncio.TimeoutError: + await ctx.send("Timed out, canceling") return - if server.id not in self.c_commands: - self.c_commands[server.id] = {} - cmdlist = self.c_commands[server.id] - if command not in cmdlist: - cmdlist[command] = text - self.c_commands[server.id] = cmdlist - dataIO.save_json(self.file_path, self.c_commands) - await self.bot.say("Custom command successfully added.") - else: - await self.bot.say("This command already exists. Use " - "`{}customcom edit` to edit it." - "".format(ctx.prefix)) - @customcom.command(name="edit", pass_context=True) - @checks.mod_or_permissions(administrator=True) - async def cc_edit(self, ctx, command : str, *, text): - """Edits a custom command - Example: - [p]customcom edit yourcommand Text you want - """ - server = ctx.message.server - command = command.lower() - if server.id in self.c_commands: - cmdlist = self.c_commands[server.id] - if command in cmdlist: - cmdlist[command] = text - self.c_commands[server.id] = cmdlist - dataIO.save_json(self.file_path, self.c_commands) - await self.bot.say("Custom command successfully edited.") - else: - await self.bot.say("That command doesn't exist. Use " - "`{}customcom add` to add it." - "".format(ctx.prefix)) + rrole_list = [] + if answer.content.upper() != "NONE": + rrole_list = await self._get_roles_from_content(ctx, answer.content) + if rrole_list is None: + await ctx.send("Invalid answer, canceling") + return + + # Roles to use + await ctx.send( + 'What roles are allowed to use this command? (Must be comma separated)\nSay `None` to allow all roles') + + try: + answer = await self.bot.wait_for('message', timeout=120, check=check) + except asyncio.TimeoutError: + await ctx.send("Timed out, canceling") + return + + prole_list = [] + if answer.content.upper() != "NONE": + prole_list = await self._get_roles_from_content(ctx, answer.content) + if prole_list is None: + await ctx.send("Invalid answer, canceling") + return + + # Selfrole + await ctx.send('Is this a targeted command?(yes/no)\nNo will make this a selfrole command') + + try: + answer = await self.bot.wait_for('message', timeout=120, check=check) + except asyncio.TimeoutError: + await ctx.send("Timed out, canceling") + return + + if answer.content.upper() in ["Y", "YES"]: + targeted = True + await ctx.send("This command will be **`targeted`**") else: - await self.bot.say("There are no custom commands in this server." - " Use `{}customcom add` to start adding some." - "".format(ctx.prefix)) + targeted = False + await ctx.send("This command will be **`selfrole`**") + + # Message to send + await ctx.send( + 'What message should the bot say when using this command?\n' + 'Say `None` to send the default `Success!` message\n' + 'Eval Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`\n' + 'For example: `Welcome {target.mention} to {server.name}!`') + + try: + answer = await self.bot.wait_for('message', timeout=120, check=check) + except asyncio.TimeoutError: + await ctx.send("Timed out, canceling") + return - @customcom.command(name="delete", pass_context=True) + text = "Success!" + if answer.content.upper() != "NONE": + text = answer.content + + # Save the command + + out = {'text': text, 'aroles': arole_list, 'rroles': rrole_list, "proles": prole_list, "targeted": targeted} + + await cmd_list.set_raw(command, value=out) + + await ctx.send("Custom Command **`{}`** successfully added".format(command)) + + @ccrole.command(name="delete") @checks.mod_or_permissions(administrator=True) - async def cc_delete(self, ctx, command : str): + async def ccrole_delete(self, ctx, command: str): """Deletes a custom command + Example: - [p]customcom delete yourcommand""" - server = ctx.message.server + `[p]ccrole delete yourcommand`""" + guild = ctx.guild command = command.lower() - if server.id in self.c_commands: - cmdlist = self.c_commands[server.id] - if command in cmdlist: - cmdlist.pop(command, None) - self.c_commands[server.id] = cmdlist - dataIO.save_json(self.file_path, self.c_commands) - await self.bot.say("Custom command successfully deleted.") - else: - await self.bot.say("That command doesn't exist.") + if not await self.config.guild(guild).cmdlist.get_raw(command, default=None): + await ctx.send("That command doesn't exist") else: - await self.bot.say("There are no custom commands in this server." - " Use `{}customcom add` to start adding some." - "".format(ctx.prefix)) + await self.config.guild(guild).cmdlist.set_raw(command, value=None) + await ctx.send("Custom command successfully deleted.") - @customcom.command(name="list", pass_context=True) - async def cc_list(self, ctx): - """Shows custom commands list""" - server = ctx.message.server - commands = self.c_commands.get(server.id, {}) + @ccrole.command(name="details") + async def ccrole_details(self, ctx, command: str): + """Provide details about passed custom command""" + guild = ctx.guild + command = command.lower() + cmd = await self.config.guild(guild).cmdlist.get_raw(command, default=None) + if cmd is None: + await ctx.send("That command doesn't exist") + return + + embed = discord.Embed(title=command, + description="{} custom command".format("Targeted" if cmd['targeted'] else "Non-Targeted")) - if not commands: - await self.bot.say("There are no custom commands in this server." - " Use `{}customcom add` to start adding some." - "".format(ctx.prefix)) + def process_roles(role_list): + if not role_list: + return "None" + return ", ".join([discord.utils.get(ctx.guild.roles, id=roleid).name for roleid in role_list]) + + embed.add_field(name="Text", value="```{}```".format(cmd['text'])) + embed.add_field(name="Adds Roles", value=process_roles(cmd['aroles']), inline=True) + embed.add_field(name="Removes Roles", value=process_roles(cmd['rroles']), inline=True) + embed.add_field(name="Role Restrictions", value=process_roles(cmd['proles']), inline=True) + + await ctx.send(embed=embed) + + @ccrole.command(name="list") + async def ccrole_list(self, ctx): + """Shows custom commands list""" + guild = ctx.guild + cmd_list = await self.config.guild(guild).cmdlist() + cmd_list = {k: v for k,v in cmd_list.items() if v} + if not cmd_list: + await ctx.send( + "There are no custom commands in this server. Use `{}ccrole add` to start adding some.".format( + ctx.prefix)) return - commands = ", ".join([ctx.prefix + c for c in sorted(commands)]) - commands = "Custom commands:\n\n" + commands + cmd_list = ", ".join([ctx.prefix + c for c in sorted(cmd_list.keys())]) + cmd_list = "Custom commands:\n\n" + cmd_list - if len(commands) < 1500: - await self.bot.say(box(commands)) + if len(cmd_list) < 1500: # I'm allowed to have arbitrary numbers for when it's too much to dm dammit + await ctx.send(box(cmd_list)) else: - for page in pagify(commands, delims=[" ", "\n"]): - await self.bot.whisper(box(page)) + for page in pagify(cmd_list, delims=[" ", "\n"]): + await ctx.author.send(box(page)) + await ctx.send("Command list DM'd") async def on_message(self, message): - if len(message.content) < 2 or message.channel.is_private: + if len(message.content) < 2 or message.guild is None: return - server = message.server - prefix = self.get_prefix(message) - - if not prefix: + guild = message.guild + try: + prefix = await self.get_prefix(message) + except ValueError: return - if server.id in self.c_commands and self.bot.user_allowed(message): - cmdlist = self.c_commands[server.id] - cmd = message.content[len(prefix):] - if cmd in cmdlist: - cmd = cmdlist[cmd] - cmd = self.format_cc(cmd, message) - await self.bot.send_message(message.channel, cmd) - elif cmd.lower() in cmdlist: - cmd = cmdlist[cmd.lower()] - cmd = self.format_cc(cmd, message) - await self.bot.send_message(message.channel, cmd) - - def get_prefix(self, message): - for p in self.bot.settings.get_prefixes(message.server): - if message.content.startswith(p): + cmdlist = self.config.guild(guild).cmdlist + cmd = message.content[len(prefix):].split()[0].lower() + cmd = await cmdlist.get_raw(cmd, default=None) + + if cmd is not None: + await self.eval_cc(cmd, message) + + async def _get_roles_from_content(self, ctx, content): + content_list = content.split(",") + try: + role_list = [discord.utils.get(ctx.guild.roles, name=role.strip(' ')).id for role in content_list] + except (discord.HTTPException, AttributeError): # None.id is attribute error + return None + else: + return role_list + + async def get_prefix(self, message: discord.Message) -> str: + """ + Borrowed from alias cog + Tries to determine what prefix is used in a message object. + Looks to identify from longest prefix to smallest. + + Will raise ValueError if no prefix is found. + :param message: Message object + :return: + """ + content = message.content + prefix_list = await self.bot.command_prefix(self.bot, message) + prefixes = sorted(prefix_list, + key=lambda pfx: len(pfx), + reverse=True) + for p in prefixes: + if content.startswith(p): return p - return False + raise ValueError + + async def eval_cc(self, cmd, message): + """Does all the work""" + if cmd['proles'] and not (set(role.id for role in message.author.roles) & set(cmd['proles'])): + return # Not authorized, do nothing + + if cmd['targeted']: + try: + target = discord.utils.get(message.guild.members, mention=message.content.split()[1]) + except IndexError: # .split() return list of len<2 + target = None + + if not target: + out_message = "This custom command is targeted! @mention a target\n`{} `".format( + message.content.split()[0]) + await message.channel.send(out_message) + return + else: + target = message.author - def format_cc(self, command, message): - results = re.findall("\{([^}]+)\}", command) + if cmd['aroles']: + arole_list = [discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd['aroles']] + # await self.bot.send_message(message.channel, "Adding: "+str([str(arole) for arole in arole_list])) + try: + await target.add_roles(*arole_list) + except discord.Forbidden: + await message.channel.send("Permission error: Unable to add roles") + await asyncio.sleep(1) + + if cmd['rroles']: + rrole_list = [discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd['rroles']] + # await self.bot.send_message(message.channel, "Removing: "+str([str(rrole) for rrole in rrole_list])) + try: + await target.remove_roles(*rrole_list) + except discord.Forbidden: + await message.channel.send("Permission error: Unable to remove roles") + + out_message = self.format_cc(cmd, message, target) + await message.channel.send(out_message) + + def format_cc(self, cmd, message, target): + out = cmd['text'] + results = re.findall("{([^}]+)\}", out) for result in results: - param = self.transform_parameter(result, message) - command = command.replace("{" + result + "}", param) - return command + param = self.transform_parameter(result, message, target) + out = out.replace("{" + result + "}", param) + return out - def transform_parameter(self, result, message): + def transform_parameter(self, result, message, target): """ For security reasons only specific objects are allowed Internals are ignored """ raw_result = "{" + result + "}" objects = { - "message" : message, - "author" : message.author, - "channel" : message.channel, - "server" : message.server + "message": message, + "author": message.author, + "channel": message.channel, + "server": message.guild, + "guild": message.guild, + "target": target } if result in objects: return str(objects[result]) @@ -185,22 +325,3 @@ class CCRole: else: return raw_result return str(getattr(first, second, raw_result)) - - -def check_folders(): - if not os.path.exists("data/customcom"): - print("Creating data/customcom folder...") - os.makedirs("data/customcom") - - -def check_files(): - f = "data/customcom/commands.json" - if not dataIO.is_valid_json(f): - print("Creating empty commands.json...") - dataIO.save_json(f, {}) - - -def setup(bot): - check_folders() - check_files() - bot.add_cog(CCRole(bot)) \ No newline at end of file diff --git a/ccrole/info.json b/ccrole/info.json index c2be724..73a1f79 100644 --- a/ccrole/info.json +++ b/ccrole/info.json @@ -1,9 +1,10 @@ { - "AUTHOR" : "Bobloy", - "INSTALL_MSG" : "Thank you for installing Custom Commands w/ Roles", - "NAME" : "CCRole", - "SHORT" : "Creates commands that adjust roles", - "DESCRIPTION" : "Creates custom commands to adjust roles and send custom messages", - "TAGS" : ["fox", "bobloy", "utilities", "tools", "roles"], - "HIDDEN" : false + "author" : ["Bobloy"], + "bot_version" : [3,0,0], + "description" : "[Incomplete] Creates custom commands to adjust roles and send custom messages", + "hidden" : false, + "install_msg" : "Thank you for installing Custom Commands w/ Roles.", + "requirements" : [], + "short" : "[Incomplete] Creates commands that adjust roles", + "tags" : ["fox", "bobloy", "utility", "tools", "roles"] } \ No newline at end of file diff --git a/challonge/__init__.py b/challonge/__init__.py deleted file mode 100644 index a1e7e01..0000000 --- a/challonge/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .challonge import Challonge - -def setup(bot): - n = Challonge(bot) - bot.add_cog(n) \ No newline at end of file diff --git a/challonge/challonge.py b/challonge/challonge.py deleted file mode 100644 index 3d468db..0000000 --- a/challonge/challonge.py +++ /dev/null @@ -1,134 +0,0 @@ -import os - -import challonge - -import discord -from discord.ext import commands - -from redbot.core.utils.chat_formatting import pagify -from redbot.core.utils.chat_formatting import box -from redbot.core import Config -from redbot.core import checks - - - - -class Challonge: - """Cog for organizing Challonge tourneys""" - - def __init__(self, bot): - self.bot = bot - self.config = Config.get_conf(self, identifier=6710497108108111110103101) - default_global = { - "username": None, - "apikey": None - } - default_guild = { - "reportchannel": None, - "announcechannel": None - } - - self.config.register_global(**default_global) - self.config.register_guild(**default_guild) - - await self._set_credentials() - -# ************************Challonge command group start************************ - - @commands.group() - @commands.guild_only() - async def challonge(self, ctx): - """Challonge command base""" - if ctx.invoked_subcommand is None: - await ctx.send_help() - # await ctx.send("I can do stuff!") - - - @challonge.command(name="apikey") - async def c_apikey(self, ctx, username, apikey): - """Sets challonge username and apikey""" - await self.config.username.set(username) - await self.config.apikey.set(apikey) - await self._set_credentials() - await ctx.send("Success!") - - @challonge.command(name="report") - async def c_report(self, ctx, channel: discord.TextChannel=None): - """Set the channel for self-reporting matches""" - if channel is None: - channel = ctx.channel - - await self.config.guild(ctx.guild).reportchnnl.set(channel.id) - - channel = (await self._get_reportchnnl(ctx.guild)) - await ctx.send("Self-Reporting Channel is now set to: " + channel.mention) - - @challonge.command(name="announce") - async def c_announce(self, ctx, channel: discord.TextChannel=None): - """Set the channel for tournament announcements""" - if channel is None: - channel = ctx.channel - - await self.config.guild(ctx.guild).announcechnnl.set(channel.id) - - channel = (await self._get_announcechnnl(ctx.guild)) - await ctx.send("Announcement Channel is now set to: " + channel.mention) - -# ************************Private command group start************************ - async def _print_tourney(self, guild: discord.Guild, tID: int): - channel = (await self._get_announcechnnl(ctx.guild)) - - await channel.send() - - async def _set_credentials(self): - username = await self.config.username - apikey = await self.config.apikey - if username and apikey: - challonge.set_credentials(username, apikey) - return True - return False - - async def _get_message_from_id(self, guild: discord.Guild, message_id: int): - """ - Tries to find a message by ID in the current guild context. - :param ctx: - :param message_id: - :return: - """ - for channel in guild.text_channels: - try: - return await channel.get_message(message_id) - except discord.NotFound: - pass - except AttributeError: # VoiceChannel object has no attribute 'get_message' - pass - - return None - - async def _get_announcechnnl(self, guild: discord.Guild): - channelid = await self.config.guild(guild).announcechnnl() - channel = self._get_channel_from_id(channelid) - return channel - - async def _get_reportchnnl(self, guild: discord.Guild): - channelid = await self.config.guild(guild).reportchnnl() - channel = self._get_channel_from_id(channelid) - return channel - - def _get_channel_from_id(self, channelid): - return self.bot.get_channel(channelid) - - def _get_user_from_id(self, userid): - # guild = self._get_guild_from_id(guildID) - # return discord.utils.get(guild.members, id=userid) - return self.bot.get_user(userid) - - def _get_guild_from_id(self, guildID): - return self.bot.get_guild(guildID) - - - - - - - \ No newline at end of file diff --git a/challonge/info.json b/challonge/info.json deleted file mode 100644 index 6dac0ca..0000000 --- a/challonge/info.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "author" : ["Bobloy"], - "bot_version" : [3,0,0], - "description" : "[Incomplete] Cog to organize tournaments within Discord using Challonge", - "hidden" : false, - "install_msg" : "Thank you for installing the Challonge Cog.", - "requirements" : ["iso8601", "challonge"], - "short" : "[Incomplete] Cog to organize Challonge tournaments", - "tags" : ["game", "fun", "fight", "tournament", "tourney", "challonge", "elimination", "bracket", "bobloy"] -} \ No newline at end of file diff --git a/chatter/__init__.py b/chatter/__init__.py new file mode 100644 index 0000000..cc101b7 --- /dev/null +++ b/chatter/__init__.py @@ -0,0 +1,11 @@ +from . import chatterbot +from .chat import Chatter + + +def setup(bot): + bot.add_cog(Chatter(bot)) + + +__all__ = ( + 'chatterbot' +) diff --git a/chatter/chat.py b/chatter/chat.py new file mode 100644 index 0000000..32d83a3 --- /dev/null +++ b/chatter/chat.py @@ -0,0 +1,157 @@ +import asyncio +from datetime import datetime, timedelta + +import discord +from discord.ext import commands +from redbot.core import Config + +from chatter.chatterbot import ChatBot +from chatter.chatterbot.trainers import ListTrainer + + + +class Chatter: + """ + This cog trains a chatbot that will talk like members of your Guild + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=6710497116116101114) + default_global = {} + default_guild = { + "whitelist": None, + "days": 1 + } + + self.chatbot = ChatBot( + "ChatterBot", + storage_adapter='chatter.chatterbot.storage.SQLStorageAdapter', + database='./database.sqlite3' + ) + self.chatbot.set_trainer(ListTrainer) + + self.config.register_global(**default_global) + self.config.register_guild(**default_guild) + + self.loop = asyncio.get_event_loop() + + async def _get_conversation(self, ctx, in_channel: discord.TextChannel = None): + """ + Compiles all conversation in the Guild this bot can get it's hands on + Currently takes a stupid long time + Returns a list of text + """ + out = [] + after = datetime.today() - timedelta(days=(await self.config.guild(ctx.guild).days())) + + for channel in ctx.guild.text_channels: + if in_channel: + channel = in_channel + await ctx.send("Gathering {}".format(channel.mention)) + user = None + try: + async for message in channel.history(limit=None, reverse=True, after=after): + if user == message.author: + out[-1] += "\n" + message.clean_content + else: + user = message.author + out.append(message.clean_content) + except discord.Forbidden: + pass + except discord.HTTPException: + pass + + if in_channel: + break + + return out + + def _train(self, data): + try: + self.chatbot.train(data) + except: + return False + return True + + @commands.group() + async def chatter(self, ctx: RedContext): + """ + Base command for this cog. Check help for the commands list. + """ + if ctx.invoked_subcommand is None: + await ctx.send_help() + + @chatter.command() + async def age(self, ctx: RedContext, days: int): + """ + Sets the number of days to look back + Will train on 1 day otherwise + """ + + await self.config.guild(ctx.guild).days.set(days) + await ctx.send("Success") + + @chatter.command() + async def backup(self, ctx, backupname): + """ + Backup your training data to a json for later use + """ + await ctx.send("Backing up data, this may take a while") + future = await self.loop.run_in_executor(None, self.chatbot.trainer.export_for_training, './{}.json'.format(backupname)) + + if future: + await ctx.send("Backup successful!") + else: + await ctx.send("Error occurred :(") + + @chatter.command() + async def train(self, ctx: commands.Context, channel: discord.TextChannel): + """ + Trains the bot based on language in this guild + """ + + conversation = await self._get_conversation(ctx, channel) + + if not conversation: + await ctx.send("Failed to gather training data") + return + + await ctx.send("Gather successful! Training begins now\n(**This will take a long time, be patient**)") + embed = discord.Embed(title="Loading") + embed.set_image(url="http://www.loop.universaleverything.com/animations/1295.gif") + temp_message = await ctx.send(embed=embed) + future = await self.loop.run_in_executor(None, self._train, conversation) + + try: + await temp_message.delete() + except: + pass + + if future: + await ctx.send("Training successful!") + else: + await ctx.send("Error occurred :(") + + async def on_message(self, message): + """ + Credit to https://github.com/Twentysix26/26-Cogs/blob/master/cleverbot/cleverbot.py + for on_message recognition of @bot + """ + author = message.author + channel = message.channel + + + if message.author.id != self.bot.user.id: + to_strip = "@" + author.guild.me.display_name + " " + text = message.clean_content + if not text.startswith(to_strip): + return + text = text.replace(to_strip, "", 1) + async with channel.typing(): + future = await self.loop.run_in_executor(None, self.chatbot.get_response, text) + + if future: + await channel.send(str(future)) + else: + await channel.send(':thinking:') diff --git a/chatter/chatterbot/__init__.py b/chatter/chatterbot/__init__.py new file mode 100644 index 0000000..7a127ee --- /dev/null +++ b/chatter/chatterbot/__init__.py @@ -0,0 +1,13 @@ +""" +ChatterBot is a machine learning, conversational dialog engine. +""" +from .chatterbot import ChatBot + +__version__ = '0.8.5' +__author__ = 'Gunther Cox' +__email__ = 'gunthercx@gmail.com' +__url__ = 'https://github.com/gunthercox/ChatterBot' + +__all__ = ( + 'ChatBot', +) diff --git a/chatter/chatterbot/__main__.py b/chatter/chatterbot/__main__.py new file mode 100644 index 0000000..0322854 --- /dev/null +++ b/chatter/chatterbot/__main__.py @@ -0,0 +1,22 @@ +import sys + +if __name__ == '__main__': + import importlib + + if '--version' in sys.argv: + chatterbot = importlib.import_module('chatterbot') + print(chatterbot.__version__) + + if 'list_nltk_data' in sys.argv: + import os + import nltk.data + + data_directories = [] + + # Find each data directory in the NLTK path that has content + for path in nltk.data.path: + if os.path.exists(path): + if os.listdir(path): + data_directories.append(path) + + print(os.linesep.join(data_directories)) diff --git a/chatter/chatterbot/adapters.py b/chatter/chatterbot/adapters.py new file mode 100644 index 0000000..83ce94c --- /dev/null +++ b/chatter/chatterbot/adapters.py @@ -0,0 +1,47 @@ +import logging + + +class Adapter(object): + """ + A superclass for all adapter classes. + + :param logger: A python logger. + """ + + def __init__(self, **kwargs): + self.logger = kwargs.get('logger', logging.getLogger(__name__)) + self.chatbot = kwargs.get('chatbot') + + def set_chatbot(self, chatbot): + """ + Gives the adapter access to an instance of the ChatBot class. + + :param chatbot: A chat bot instance. + :type chatbot: ChatBot + """ + self.chatbot = chatbot + + class AdapterMethodNotImplementedError(NotImplementedError): + """ + An exception to be raised when an adapter method has not been implemented. + Typically this indicates that the developer is expected to implement the + method in a subclass. + """ + + def __init__(self, message=None): + """ + Set the message for the esception. + """ + if not message: + message = 'This method must be overridden in a subclass method.' + self.message = message + + def __str__(self): + return self.message + + class InvalidAdapterTypeException(Exception): + """ + An exception to be raised when an adapter + of an unexpected class type is received. + """ + pass diff --git a/chatter/chatterbot/chatterbot.py b/chatter/chatterbot/chatterbot.py new file mode 100644 index 0000000..c7a92cb --- /dev/null +++ b/chatter/chatterbot/chatterbot.py @@ -0,0 +1,175 @@ +from __future__ import unicode_literals + +import logging + +from . import utils +from .input import InputAdapter +from .output import OutputAdapter +from .storage import StorageAdapter + + +class ChatBot(object): + """ + A conversational dialog chat bot. + """ + + def __init__(self, name, **kwargs): + from .logic import MultiLogicAdapter + + self.name = name + kwargs['name'] = name + kwargs['chatbot'] = self + + self.default_session = None + + storage_adapter = kwargs.get('storage_adapter', 'chatter.chatterbot.storage.SQLStorageAdapter') + + logic_adapters = kwargs.get('logic_adapters', [ + 'chatter.chatterbot.logic.BestMatch' + ]) + + input_adapter = kwargs.get('input_adapter', 'chatter.chatterbot.input.VariableInputTypeAdapter') + + output_adapter = kwargs.get('output_adapter', 'chatter.chatterbot.output.OutputAdapter') + + # Check that each adapter is a valid subclass of it's respective parent + utils.validate_adapter_class(storage_adapter, StorageAdapter) + utils.validate_adapter_class(input_adapter, InputAdapter) + utils.validate_adapter_class(output_adapter, OutputAdapter) + + self.logic = MultiLogicAdapter(**kwargs) + self.storage = utils.initialize_class(storage_adapter, **kwargs) + self.input = utils.initialize_class(input_adapter, **kwargs) + self.output = utils.initialize_class(output_adapter, **kwargs) + + filters = kwargs.get('filters', tuple()) + self.filters = tuple([utils.import_module(F)() for F in filters]) + + # Add required system logic adapter + self.logic.system_adapters.append( + utils.initialize_class('chatter.chatterbot.logic.NoKnowledgeAdapter', **kwargs) + ) + + for adapter in logic_adapters: + self.logic.add_adapter(adapter, **kwargs) + + # Add the chatbot instance to each adapter to share information such as + # the name, the current conversation, or other adapters + self.logic.set_chatbot(self) + self.input.set_chatbot(self) + self.output.set_chatbot(self) + + preprocessors = kwargs.get( + 'preprocessors', [ + 'chatter.chatterbot.preprocessors.clean_whitespace' + ] + ) + + self.preprocessors = [] + + for preprocessor in preprocessors: + self.preprocessors.append(utils.import_module(preprocessor)) + + # Use specified trainer or fall back to the default + trainer = kwargs.get('trainer', 'chatter.chatterbot.trainers.Trainer') + TrainerClass = utils.import_module(trainer) + self.trainer = TrainerClass(self.storage, **kwargs) + self.training_data = kwargs.get('training_data') + + self.default_conversation_id = None + + self.logger = kwargs.get('logger', logging.getLogger(__name__)) + + # Allow the bot to save input it receives so that it can learn + self.read_only = kwargs.get('read_only', False) + + if kwargs.get('initialize', True): + self.initialize() + + def initialize(self): + """ + Do any work that needs to be done before the responses can be returned. + """ + self.logic.initialize() + + def get_response(self, input_item, conversation_id=None): + """ + Return the bot's response based on the input. + + :param input_item: An input value. + :param conversation_id: The id of a conversation. + :returns: A response to the input. + :rtype: Statement + """ + if not conversation_id: + if not self.default_conversation_id: + self.default_conversation_id = self.storage.create_conversation() + conversation_id = self.default_conversation_id + + input_statement = self.input.process_input_statement(input_item) + + # Preprocess the input statement + for preprocessor in self.preprocessors: + input_statement = preprocessor(self, input_statement) + + statement, response = self.generate_response(input_statement, conversation_id) + + # Learn that the user's input was a valid response to the chat bot's previous output + previous_statement = self.storage.get_latest_response(conversation_id) + + if not self.read_only: + self.learn_response(statement, previous_statement) + self.storage.add_to_conversation(conversation_id, statement, response) + + # Process the response output with the output adapter + return self.output.process_response(response, conversation_id) + + def generate_response(self, input_statement, conversation_id): + """ + Return a response based on a given input statement. + """ + self.storage.generate_base_query(self, conversation_id) + + # Select a response to the input statement + response = self.logic.process(input_statement) + + return input_statement, response + + def learn_response(self, statement, previous_statement): + """ + Learn that the statement provided is a valid response. + """ + from .conversation import Response + + if previous_statement: + statement.add_response( + Response(previous_statement.text) + ) + self.logger.info('Adding "{}" as a response to "{}"'.format( + statement.text, + previous_statement.text + )) + + # Save the statement after selecting a response + self.storage.update(statement) + + def set_trainer(self, training_class, **kwargs): + """ + Set the module used to train the chatbot. + + :param training_class: The training class to use for the chat bot. + :type training_class: `Trainer` + + :param \**kwargs: Any parameters that should be passed to the training class. + """ + if 'chatbot' not in kwargs: + kwargs['chatbot'] = self + + self.trainer = training_class(self.storage, **kwargs) + + @property + def train(self): + """ + Proxy method to the chat bot's trainer class. + """ + return self.trainer.train diff --git a/chatter/chatterbot/comparisons.py b/chatter/chatterbot/comparisons.py new file mode 100644 index 0000000..59efa95 --- /dev/null +++ b/chatter/chatterbot/comparisons.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- + + +""" +This module contains various text-comparison algorithms +designed to compare one statement to another. +""" + +# Use python-Levenshtein if available +try: + from Levenshtein.StringMatcher import StringMatcher as SequenceMatcher +except ImportError: + from difflib import SequenceMatcher + + +class Comparator: + + def __call__(self, statement_a, statement_b): + return self.compare(statement_a, statement_b) + + def compare(self, statement_a, statement_b): + return 0 + + def get_initialization_functions(self): + """ + Return all initialization methods for the comparison algorithm. + Initialization methods must start with 'initialize_' and + take no parameters. + """ + initialization_methods = [ + ( + method, + getattr(self, method), + ) for method in dir(self) if method.startswith('initialize_') + ] + + return { + key: value for (key, value) in initialization_methods + } + + +class LevenshteinDistance(Comparator): + """ + Compare two statements based on the Levenshtein distance + of each statement's text. + + For example, there is a 65% similarity between the statements + "where is the post office?" and "looking for the post office" + based on the Levenshtein distance algorithm. + """ + + def compare(self, statement, other_statement): + """ + Compare the two input statements. + + :return: The percent of similarity between the text of the statements. + :rtype: float + """ + + # Return 0 if either statement has a falsy text value + if not statement.text or not other_statement.text: + return 0 + + # Get the lowercase version of both strings + + statement_text = str(statement.text.lower()) + other_statement_text = str(other_statement.text.lower()) + + similarity = SequenceMatcher( + None, + statement_text, + other_statement_text + ) + + # Calculate a decimal percent of the similarity + percent = round(similarity.ratio(), 2) + + return percent + + +class SynsetDistance(Comparator): + """ + Calculate the similarity of two statements. + This is based on the total maximum synset similarity between each word in each sentence. + + This algorithm uses the `wordnet`_ functionality of `NLTK`_ to determine the similarity + of two statements based on the path similarity between each token of each statement. + This is essentially an evaluation of the closeness of synonyms. + """ + + def initialize_nltk_wordnet(self): + """ + Download required NLTK corpora if they have not already been downloaded. + """ + from .utils import nltk_download_corpus + + nltk_download_corpus('corpora/wordnet') + + def initialize_nltk_punkt(self): + """ + Download required NLTK corpora if they have not already been downloaded. + """ + from .utils import nltk_download_corpus + + nltk_download_corpus('tokenizers/punkt') + + def initialize_nltk_stopwords(self): + """ + Download required NLTK corpora if they have not already been downloaded. + """ + from .utils import nltk_download_corpus + + nltk_download_corpus('corpora/stopwords') + + def compare(self, statement, other_statement): + """ + Compare the two input statements. + + :return: The percent of similarity between the closest synset distance. + :rtype: float + + .. _wordnet: http://www.nltk.org/howto/wordnet.html + .. _NLTK: http://www.nltk.org/ + """ + from nltk.corpus import wordnet + from nltk import word_tokenize + from chatter.chatterbot import utils + import itertools + + tokens1 = word_tokenize(statement.text.lower()) + tokens2 = word_tokenize(other_statement.text.lower()) + + # Remove all stop words from the list of word tokens + tokens1 = utils.remove_stopwords(tokens1, language='english') + tokens2 = utils.remove_stopwords(tokens2, language='english') + + # The maximum possible similarity is an exact match + # Because path_similarity returns a value between 0 and 1, + # max_possible_similarity is the number of words in the longer + # of the two input statements. + max_possible_similarity = max( + len(statement.text.split()), + len(other_statement.text.split()) + ) + + max_similarity = 0.0 + + # Get the highest matching value for each possible combination of words + for combination in itertools.product(*[tokens1, tokens2]): + + synset1 = wordnet.synsets(combination[0]) + synset2 = wordnet.synsets(combination[1]) + + if synset1 and synset2: + + # Get the highest similarity for each combination of synsets + for synset in itertools.product(*[synset1, synset2]): + similarity = synset[0].path_similarity(synset[1]) + + if similarity and (similarity > max_similarity): + max_similarity = similarity + + if max_possible_similarity == 0: + return 0 + + return max_similarity / max_possible_similarity + + +class SentimentComparison(Comparator): + """ + Calculate the similarity of two statements based on the closeness of + the sentiment value calculated for each statement. + """ + + def initialize_nltk_vader_lexicon(self): + """ + Download the NLTK vader lexicon for sentiment analysis + that is required for this algorithm to run. + """ + from .utils import nltk_download_corpus + + nltk_download_corpus('sentiment/vader_lexicon') + + def compare(self, statement, other_statement): + """ + Return the similarity of two statements based on + their calculated sentiment values. + + :return: The percent of similarity between the sentiment value. + :rtype: float + """ + from nltk.sentiment.vader import SentimentIntensityAnalyzer + + sentiment_analyzer = SentimentIntensityAnalyzer() + statement_polarity = sentiment_analyzer.polarity_scores(statement.text.lower()) + statement2_polarity = sentiment_analyzer.polarity_scores(other_statement.text.lower()) + + statement_greatest_polarity = 'neu' + statement_greatest_score = -1 + for polarity in sorted(statement_polarity): + if statement_polarity[polarity] > statement_greatest_score: + statement_greatest_polarity = polarity + statement_greatest_score = statement_polarity[polarity] + + statement2_greatest_polarity = 'neu' + statement2_greatest_score = -1 + for polarity in sorted(statement2_polarity): + if statement2_polarity[polarity] > statement2_greatest_score: + statement2_greatest_polarity = polarity + statement2_greatest_score = statement2_polarity[polarity] + + # Check if the polarity if of a different type + if statement_greatest_polarity != statement2_greatest_polarity: + return 0 + + values = [statement_greatest_score, statement2_greatest_score] + difference = max(values) - min(values) + + return 1.0 - difference + + +class JaccardSimilarity(Comparator): + """ + Calculates the similarity of two statements based on the Jaccard index. + + The Jaccard index is composed of a numerator and denominator. + In the numerator, we count the number of items that are shared between the sets. + In the denominator, we count the total number of items across both sets. + Let's say we define sentences to be equivalent if 50% or more of their tokens are equivalent. + Here are two sample sentences: + + The young cat is hungry. + The cat is very hungry. + + When we parse these sentences to remove stopwords, we end up with the following two sets: + + {young, cat, hungry} + {cat, very, hungry} + + In our example above, our intersection is {cat, hungry}, which has count of two. + The union of the sets is {young, cat, very, hungry}, which has a count of four. + Therefore, our `Jaccard similarity index`_ is two divided by four, or 50%. + Given our similarity threshold above, we would consider this to be a match. + + .. _`Jaccard similarity index`: https://en.wikipedia.org/wiki/Jaccard_index + """ + + SIMILARITY_THRESHOLD = 0.5 + + def initialize_nltk_wordnet(self): + """ + Download the NLTK wordnet corpora that is required for this algorithm + to run only if the corpora has not already been downloaded. + """ + from .utils import nltk_download_corpus + + nltk_download_corpus('corpora/wordnet') + + def compare(self, statement, other_statement): + """ + Return the calculated similarity of two + statements based on the Jaccard index. + """ + from nltk.corpus import wordnet + import nltk + import string + + a = statement.text.lower() + b = other_statement.text.lower() + + # Get default English stopwords and extend with punctuation + stopwords = nltk.corpus.stopwords.words('english') + stopwords.extend(string.punctuation) + stopwords.append('') + lemmatizer = nltk.stem.wordnet.WordNetLemmatizer() + + def get_wordnet_pos(pos_tag): + if pos_tag[1].startswith('J'): + return (pos_tag[0], wordnet.ADJ) + elif pos_tag[1].startswith('V'): + return (pos_tag[0], wordnet.VERB) + elif pos_tag[1].startswith('N'): + return (pos_tag[0], wordnet.NOUN) + elif pos_tag[1].startswith('R'): + return (pos_tag[0], wordnet.ADV) + else: + return (pos_tag[0], wordnet.NOUN) + + ratio = 0 + pos_a = map(get_wordnet_pos, nltk.pos_tag(nltk.tokenize.word_tokenize(a))) + pos_b = map(get_wordnet_pos, nltk.pos_tag(nltk.tokenize.word_tokenize(b))) + lemma_a = [ + lemmatizer.lemmatize( + token.strip(string.punctuation), + pos + ) for token, pos in pos_a if pos == wordnet.NOUN and token.strip( + string.punctuation + ) not in stopwords + ] + lemma_b = [ + lemmatizer.lemmatize( + token.strip(string.punctuation), + pos + ) for token, pos in pos_b if pos == wordnet.NOUN and token.strip( + string.punctuation + ) not in stopwords + ] + + # Calculate Jaccard similarity + try: + numerator = len(set(lemma_a).intersection(lemma_b)) + denominator = float(len(set(lemma_a).union(lemma_b))) + ratio = numerator / denominator + except Exception as e: + print('Error', e) + return ratio >= self.SIMILARITY_THRESHOLD + + +# ---------------------------------------- # + + +levenshtein_distance = LevenshteinDistance() +synset_distance = SynsetDistance() +sentiment_comparison = SentimentComparison() +jaccard_similarity = JaccardSimilarity() diff --git a/chatter/chatterbot/constants.py b/chatter/chatterbot/constants.py new file mode 100644 index 0000000..3a5ae7d --- /dev/null +++ b/chatter/chatterbot/constants.py @@ -0,0 +1,15 @@ +""" +ChatterBot constants +""" + +''' +The maximum length of characters that the text of a statement can contain. +This should be enforced on a per-model basis by the data model for each +storage adapter. +''' +STATEMENT_TEXT_MAX_LENGTH = 400 + +# The maximum length of characters that the name of a tag can contain +TAG_NAME_MAX_LENGTH = 50 + +DEFAULT_DJANGO_APP_NAME = 'django_chatterbot' diff --git a/chatter/chatterbot/conversation.py b/chatter/chatterbot/conversation.py new file mode 100644 index 0000000..1926420 --- /dev/null +++ b/chatter/chatterbot/conversation.py @@ -0,0 +1,222 @@ +class StatementMixin(object): + """ + This class has shared methods used to + normalize different statement models. + """ + tags = [] + + def get_tags(self): + """ + Return the list of tags for this statement. + """ + return self.tags + + def add_tags(self, tags): + """ + Add a list of strings to the statement as tags. + """ + for tag in tags: + self.tags.append(tag) + + +class Statement(StatementMixin): + """ + A statement represents a single spoken entity, sentence or + phrase that someone can say. + """ + + def __init__(self, text, **kwargs): + + # Try not to allow non-string types to be passed to statements + try: + text = str(text) + except UnicodeEncodeError: + pass + + self.text = text + self.tags = kwargs.pop('tags', []) + self.in_response_to = kwargs.pop('in_response_to', []) + + self.extra_data = kwargs.pop('extra_data', {}) + + # This is the confidence with which the chat bot believes + # this is an accurate response. This value is set when the + # statement is returned by the chat bot. + self.confidence = 0 + + self.storage = None + + def __str__(self): + return self.text + + def __repr__(self): + return '' % (self.text) + + def __hash__(self): + return hash(self.text) + + def __eq__(self, other): + if not other: + return False + + if isinstance(other, Statement): + return self.text == other.text + + return self.text == other + + def save(self): + """ + Save the statement in the database. + """ + self.storage.update(self) + + def add_extra_data(self, key, value): + """ + This method allows additional data to be stored on the statement object. + + Typically this data is something that pertains just to this statement. + For example, a value stored here might be the tagged parts of speech for + each word in the statement text. + + - key = 'pos_tags' + - value = [('Now', 'RB'), ('for', 'IN'), ('something', 'NN'), ('different', 'JJ')] + + :param key: The key to use in the dictionary of extra data. + :type key: str + + :param value: The value to set for the specified key. + """ + self.extra_data[key] = value + + def add_response(self, response): + """ + Add the response to the list of statements that this statement is in response to. + If the response is already in the list, increment the occurrence count of that response. + + :param response: The response to add. + :type response: `Response` + """ + if not isinstance(response, Response): + raise Statement.InvalidTypeException( + 'A {} was received when a {} instance was expected'.format( + type(response), + type(Response('')) + ) + ) + + updated = False + for index in range(0, len(self.in_response_to)): + if response.text == self.in_response_to[index].text: + self.in_response_to[index].occurrence += 1 + updated = True + + if not updated: + self.in_response_to.append(response) + + def remove_response(self, response_text): + """ + Removes a response from the statement's response list based + on the value of the response text. + + :param response_text: The text of the response to be removed. + :type response_text: str + """ + for response in self.in_response_to: + if response_text == response.text: + self.in_response_to.remove(response) + return True + return False + + def get_response_count(self, statement): + """ + Find the number of times that the statement has been used + as a response to the current statement. + + :param statement: The statement object to get the count for. + :type statement: `Statement` + + :returns: Return the number of times the statement has been used as a response. + :rtype: int + """ + for response in self.in_response_to: + if statement.text == response.text: + return response.occurrence + + return 0 + + def serialize(self): + """ + :returns: A dictionary representation of the statement object. + :rtype: dict + """ + data = {} + + data['text'] = self.text + data['in_response_to'] = [] + data['extra_data'] = self.extra_data + + for response in self.in_response_to: + data['in_response_to'].append(response.serialize()) + + return data + + @property + def response_statement_cache(self): + """ + This property is to allow ChatterBot Statement objects to + be swappable with Django Statement models. + """ + return self.in_response_to + + class InvalidTypeException(Exception): + + def __init__(self, value='Received an unexpected value type.'): + self.value = value + + def __str__(self): + return repr(self.value) + + +class Response(object): + """ + A response represents an entity which response to a statement. + """ + + def __init__(self, text, **kwargs): + from datetime import datetime + from dateutil import parser as date_parser + + self.text = text + self.created_at = kwargs.get('created_at', datetime.now()) + self.occurrence = kwargs.get('occurrence', 1) + + if not isinstance(self.created_at, datetime): + self.created_at = date_parser.parse(self.created_at) + + def __str__(self): + return self.text + + def __repr__(self): + return '' % (self.text) + + def __hash__(self): + return hash(self.text) + + def __eq__(self, other): + if not other: + return False + + if isinstance(other, Response): + return self.text == other.text + + return self.text == other + + def serialize(self): + data = {} + + data['text'] = self.text + data['created_at'] = self.created_at.isoformat() + + data['occurrence'] = self.occurrence + + return data diff --git a/chatter/chatterbot/corpus.py b/chatter/chatterbot/corpus.py new file mode 100644 index 0000000..4bf0e4b --- /dev/null +++ b/chatter/chatterbot/corpus.py @@ -0,0 +1,10 @@ +""" +Seamlessly import the external chatterbot corpus module. +View the corpus on GitHub at https://github.com/gunthercox/chatterbot-corpus +""" + +from chatterbot_corpus import Corpus + +__all__ = ( + 'Corpus', +) diff --git a/chatter/chatterbot/ext/__init__.py b/chatter/chatterbot/ext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatter/chatterbot/ext/sqlalchemy_app/__init__.py b/chatter/chatterbot/ext/sqlalchemy_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chatter/chatterbot/ext/sqlalchemy_app/models.py b/chatter/chatterbot/ext/sqlalchemy_app/models.py new file mode 100644 index 0000000..6a7dc00 --- /dev/null +++ b/chatter/chatterbot/ext/sqlalchemy_app/models.py @@ -0,0 +1,131 @@ +from sqlalchemy import Table, Column, Integer, DateTime, ForeignKey, PickleType +from sqlalchemy.ext.declarative import declared_attr, declarative_base +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from chatter.chatterbot.constants import TAG_NAME_MAX_LENGTH, STATEMENT_TEXT_MAX_LENGTH +from chatter.chatterbot.conversation import StatementMixin +from chatter.chatterbot.ext.sqlalchemy_app.types import UnicodeString + + +class ModelBase(object): + """ + An augmented base class for SqlAlchemy models. + """ + + @declared_attr + def __tablename__(cls): + """ + Return the lowercase class name as the name of the table. + """ + return cls.__name__.lower() + + id = Column( + Integer, + primary_key=True, + autoincrement=True + ) + + +Base = declarative_base(cls=ModelBase) + +tag_association_table = Table( + 'tag_association', + Base.metadata, + Column('tag_id', Integer, ForeignKey('tag.id')), + Column('statement_id', Integer, ForeignKey('statement.id')) +) + + +class Tag(Base): + """ + A tag that describes a statement. + """ + + name = Column(UnicodeString(TAG_NAME_MAX_LENGTH)) + + +class Statement(Base, StatementMixin): + """ + A Statement represents a sentence or phrase. + """ + + text = Column(UnicodeString(STATEMENT_TEXT_MAX_LENGTH), unique=True) + + tags = relationship( + 'Tag', + secondary=lambda: tag_association_table, + backref='statements' + ) + + extra_data = Column(PickleType) + + in_response_to = relationship( + 'Response', + back_populates='statement_table' + ) + + def get_tags(self): + """ + Return a list of tags for this statement. + """ + return [tag.name for tag in self.tags] + + def get_statement(self): + from chatter.chatterbot.conversation import Statement as StatementObject + from chatter.chatterbot.conversation import Response as ResponseObject + + statement = StatementObject( + self.text, + tags=[tag.name for tag in self.tags], + extra_data=self.extra_data + ) + for response in self.in_response_to: + statement.add_response( + ResponseObject(text=response.text, occurrence=response.occurrence) + ) + return statement + + +class Response(Base): + """ + Response, contains responses related to a given statement. + """ + + text = Column(UnicodeString(STATEMENT_TEXT_MAX_LENGTH)) + + created_at = Column( + DateTime(timezone=True), + server_default=func.now() + ) + + occurrence = Column(Integer, default=1) + + statement_text = Column(UnicodeString(STATEMENT_TEXT_MAX_LENGTH), ForeignKey('statement.text')) + + statement_table = relationship( + 'Statement', + back_populates='in_response_to', + cascade='all', + uselist=False + ) + + +conversation_association_table = Table( + 'conversation_association', + Base.metadata, + Column('conversation_id', Integer, ForeignKey('conversation.id')), + Column('statement_id', Integer, ForeignKey('statement.id')) +) + + +class Conversation(Base): + """ + A conversation. + """ + + statements = relationship( + 'Statement', + secondary=lambda: conversation_association_table, + backref='conversations' + ) diff --git a/chatter/chatterbot/ext/sqlalchemy_app/types.py b/chatter/chatterbot/ext/sqlalchemy_app/types.py new file mode 100644 index 0000000..ee9b123 --- /dev/null +++ b/chatter/chatterbot/ext/sqlalchemy_app/types.py @@ -0,0 +1,16 @@ +from sqlalchemy.types import TypeDecorator, Unicode + + +class UnicodeString(TypeDecorator): + """ + Type for unicode strings. + """ + + impl = Unicode + + def process_bind_param(self, value, dialect): + """ + Coerce Python bytestrings to unicode before + saving them to the database. + """ + return value diff --git a/chatter/chatterbot/filters.py b/chatter/chatterbot/filters.py new file mode 100644 index 0000000..9a07a09 --- /dev/null +++ b/chatter/chatterbot/filters.py @@ -0,0 +1,47 @@ +""" +Filters set the base query that gets passed to the storage adapter. +""" + + +class Filter(object): + """ + A base filter object from which all other + filters should be subclassed. + """ + + def filter_selection(self, chatterbot, conversation_id): + """ + Because this is the base filter class, this method just + returns the storage adapter's base query. Other filters + are expected to override this method. + """ + return chatterbot.storage.base_query + + +class RepetitiveResponseFilter(Filter): + """ + A filter that eliminates possibly repetitive responses to prevent + a chat bot from repeating statements that it has recently said. + """ + + def filter_selection(self, chatterbot, conversation_id): + + text_of_recent_responses = [] + + # TODO: Add a larger quantity of response history + latest_response = chatterbot.storage.get_latest_response(conversation_id) + if latest_response: + text_of_recent_responses.append(latest_response.text) + + # Return the query with no changes if there are no statements to exclude + if not text_of_recent_responses: + return super(RepetitiveResponseFilter, self).filter_selection( + chatterbot, + conversation_id + ) + + query = chatterbot.storage.base_query.statement_text_not_in( + text_of_recent_responses + ) + + return query diff --git a/chatter/chatterbot/input/__init__.py b/chatter/chatterbot/input/__init__.py new file mode 100644 index 0000000..53c53f9 --- /dev/null +++ b/chatter/chatterbot/input/__init__.py @@ -0,0 +1,17 @@ +from .input_adapter import InputAdapter +from .gitter import Gitter +from .hipchat import HipChat +from .mailgun import Mailgun +from .microsoft import Microsoft +from .terminal import TerminalAdapter +from .variable_input_type_adapter import VariableInputTypeAdapter + +__all__ = ( + 'InputAdapter', + 'Microsoft', + 'Gitter', + 'HipChat', + 'Mailgun', + 'TerminalAdapter', + 'VariableInputTypeAdapter', +) diff --git a/chatter/chatterbot/input/gitter.py b/chatter/chatterbot/input/gitter.py new file mode 100644 index 0000000..9018e37 --- /dev/null +++ b/chatter/chatterbot/input/gitter.py @@ -0,0 +1,178 @@ +from __future__ import unicode_literals + +from time import sleep + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter + + +class Gitter(InputAdapter): + """ + An input adapter that allows a ChatterBot instance to get + input statements from a Gitter room. + """ + + def __init__(self, **kwargs): + super(Gitter, self).__init__(**kwargs) + + self.gitter_host = kwargs.get('gitter_host', 'https://api.gitter.im/v1/') + self.gitter_room = kwargs.get('gitter_room') + self.gitter_api_token = kwargs.get('gitter_api_token') + self.only_respond_to_mentions = kwargs.get('gitter_only_respond_to_mentions', True) + self.sleep_time = kwargs.get('gitter_sleep_time', 4) + + authorization_header = 'Bearer {}'.format(self.gitter_api_token) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + # Join the Gitter room + room_data = self.join_room(self.gitter_room) + self.room_id = room_data.get('id') + + user_data = self.get_user_data() + self.user_id = user_data[0].get('id') + self.username = user_data[0].get('username') + + def _validate_status_code(self, response): + code = response.status_code + if code not in [200, 201]: + raise self.HTTPStatusException('{} status code recieved'.format(code)) + + def join_room(self, room_name): + """ + Join the specified Gitter room. + """ + import requests + + endpoint = '{}rooms'.format(self.gitter_host) + response = requests.post( + endpoint, + headers=self.headers, + json={'uri': room_name} + ) + self.logger.info('{} joining room {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def get_user_data(self): + import requests + + endpoint = '{}user'.format(self.gitter_host) + response = requests.get( + endpoint, + headers=self.headers + ) + self.logger.info('{} retrieving user data {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def mark_messages_as_read(self, message_ids): + """ + Mark the specified message ids as read. + """ + import requests + + endpoint = '{}user/{}/rooms/{}/unreadItems'.format( + self.gitter_host, self.user_id, self.room_id + ) + response = requests.post( + endpoint, + headers=self.headers, + json={'chat': message_ids} + ) + self.logger.info('{} marking messages as read {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def get_most_recent_message(self): + """ + Get the most recent message from the Gitter room. + """ + import requests + + endpoint = '{}rooms/{}/chatMessages?limit=1'.format(self.gitter_host, self.room_id) + response = requests.get( + endpoint, + headers=self.headers + ) + self.logger.info('{} getting most recent message'.format( + response.status_code + )) + self._validate_status_code(response) + data = response.json() + if data: + return data[0] + return None + + def _contains_mention(self, mentions): + for mention in mentions: + if self.username == mention.get('screenName'): + return True + return False + + def should_respond(self, data): + """ + Takes the API response data from a single message. + Returns true if the chat bot should respond. + """ + if data: + unread = data.get('unread', False) + + if self.only_respond_to_mentions: + if unread and self._contains_mention(data['mentions']): + return True + else: + return False + elif unread: + return True + + return False + + def remove_mentions(self, text): + """ + Return a string that has no leading mentions. + """ + import re + text_without_mentions = re.sub(r'@\S+', '', text) + + # Remove consecutive spaces + text_without_mentions = re.sub(' +', ' ', text_without_mentions.strip()) + + return text_without_mentions + + def process_input(self, statement): + new_message = False + + while not new_message: + data = self.get_most_recent_message() + if self.should_respond(data): + self.mark_messages_as_read([data['id']]) + new_message = True + sleep(self.sleep_time) + + text = self.remove_mentions(data['text']) + statement = Statement(text) + + return statement + + class HTTPStatusException(Exception): + """ + Exception raised when unexpected non-success HTTP + status codes are returned in a response. + """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/input/hipchat.py b/chatter/chatterbot/input/hipchat.py new file mode 100644 index 0000000..b5da731 --- /dev/null +++ b/chatter/chatterbot/input/hipchat.py @@ -0,0 +1,115 @@ +from __future__ import unicode_literals + +from time import sleep + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter + + +class HipChat(InputAdapter): + """ + An input adapter that allows a ChatterBot instance to get + input statements from a HipChat room. + """ + + def __init__(self, **kwargs): + super(HipChat, self).__init__(**kwargs) + + self.hipchat_host = kwargs.get('hipchat_host') + self.hipchat_access_token = kwargs.get('hipchat_access_token') + self.hipchat_room = kwargs.get('hipchat_room') + self.session_id = str(self.chatbot.default_session.uuid) + + import requests + self.session = requests.Session() + self.session.verify = kwargs.get('ssl_verify', True) + + authorization_header = 'Bearer {}'.format(self.hipchat_access_token) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json' + } + + # This is a list of the messages that have been responded to + self.recent_message_ids = self.get_initial_ids() + + def get_initial_ids(self): + """ + Returns a list of the most recent message ids. + """ + data = self.view_recent_room_history( + self.hipchat_room, + max_results=75 + ) + + results = set() + + for item in data['items']: + results.add(item['id']) + + return results + + def view_recent_room_history(self, room_id_or_name, max_results=1): + """ + https://www.hipchat.com/docs/apiv2/method/view_recent_room_history + """ + + recent_histroy_url = '{}/v2/room/{}/history?max-results={}'.format( + self.hipchat_host, + room_id_or_name, + max_results + ) + + response = self.session.get( + recent_histroy_url, + headers=self.headers + ) + + return response.json() + + def get_most_recent_message(self, room_id_or_name): + """ + Return the most recent message from the HipChat room. + """ + data = self.view_recent_room_history(room_id_or_name) + + items = data['items'] + + if not items: + return None + return items[-1] + + def process_input(self, statement): + """ + Process input from the HipChat room. + """ + new_message = False + + response_statement = self.chatbot.storage.get_latest_response( + self.session_id + ) + + if response_statement: + last_message_id = response_statement.extra_data.get( + 'hipchat_message_id', None + ) + if last_message_id: + self.recent_message_ids.add(last_message_id) + + while not new_message: + data = self.get_most_recent_message(self.hipchat_room) + + if data and data['id'] not in self.recent_message_ids: + self.recent_message_ids.add(data['id']) + new_message = True + else: + pass + sleep(3.5) + + text = data['message'] + + statement = Statement(text) + statement.add_extra_data('hipchat_message_id', data['id']) + + return statement diff --git a/chatter/chatterbot/input/input_adapter.py b/chatter/chatterbot/input/input_adapter.py new file mode 100644 index 0000000..49c63db --- /dev/null +++ b/chatter/chatterbot/input/input_adapter.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.adapters import Adapter + + +class InputAdapter(Adapter): + """ + This is an abstract class that represents the + interface that all input adapters should implement. + """ + + def process_input(self, *args, **kwargs): + """ + Returns a statement object based on the input source. + """ + raise self.AdapterMethodNotImplementedError() + + def process_input_statement(self, *args, **kwargs): + """ + Return an existing statement object (if one exists). + """ + input_statement = self.process_input(*args, **kwargs) + + self.logger.info('Received input statement: {}'.format(input_statement.text)) + + existing_statement = self.chatbot.storage.find(input_statement.text) + + if existing_statement: + self.logger.info('"{}" is a known statement'.format(input_statement.text)) + input_statement = existing_statement + else: + self.logger.info('"{}" is not a known statement'.format(input_statement.text)) + + return input_statement diff --git a/chatter/chatterbot/input/mailgun.py b/chatter/chatterbot/input/mailgun.py new file mode 100644 index 0000000..6fb78a8 --- /dev/null +++ b/chatter/chatterbot/input/mailgun.py @@ -0,0 +1,63 @@ +from __future__ import unicode_literals + +import datetime + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter + + +class Mailgun(InputAdapter): + """ + Get input from Mailgun. + """ + + def __init__(self, **kwargs): + super(Mailgun, self).__init__(**kwargs) + + # Use the bot's name for the name of the sender + self.name = kwargs.get('name') + self.from_address = kwargs.get('mailgun_from_address') + self.api_key = kwargs.get('mailgun_api_key') + self.endpoint = kwargs.get('mailgun_api_endpoint') + + def get_email_stored_events(self): + import requests + + yesterday = datetime.datetime.now() - datetime.timedelta(1) + return requests.get( + '{}/events'.format(self.endpoint), + auth=('api', self.api_key), + params={ + 'begin': yesterday.isoformat(), + 'ascending': 'yes', + 'limit': 1 + } + ) + + def get_stored_email_urls(self): + response = self.get_email_stored_events() + data = response.json() + + for item in data.get('items', []): + if 'storage' in item: + if 'url' in item['storage']: + yield item['storage']['url'] + + def get_message(self, url): + import requests + + return requests.get( + url, + auth=('api', self.api_key) + ) + + def process_input(self, statement): + urls = self.get_stored_email_urls() + url = list(urls)[0] + + response = self.get_message(url) + message = response.json() + + text = message.get('stripped-text') + + return Statement(text) diff --git a/chatter/chatterbot/input/microsoft.py b/chatter/chatterbot/input/microsoft.py new file mode 100644 index 0000000..3a255bf --- /dev/null +++ b/chatter/chatterbot/input/microsoft.py @@ -0,0 +1,117 @@ +from __future__ import unicode_literals + +from time import sleep + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter + + +class Microsoft(InputAdapter): + """ + An input adapter that allows a ChatterBot instance to get + input statements from a Microsoft Bot using *Directline client protocol*. + https://docs.botframework.com/en-us/restapi/directline/#navtitle + """ + + def __init__(self, **kwargs): + super(Microsoft, self).__init__(**kwargs) + import requests + from requests.packages.urllib3.exceptions import InsecureRequestWarning + requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + + self.directline_host = kwargs.get('directline_host', 'https://directline.botframework.com') + + # NOTE: Direct Line client credentials are different from your bot's + # credentials + self.direct_line_token_or_secret = kwargs. \ + get('direct_line_token_or_secret') + + authorization_header = 'BotConnector {}'. \ + format(self.direct_line_token_or_secret) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'charset': 'utf-8' + } + + conversation_data = self.start_conversation() + self.conversation_id = conversation_data.get('conversationId') + self.conversation_token = conversation_data.get('token') + + def _validate_status_code(self, response): + code = response.status_code + if not code == 200: + raise self.HTTPStatusException('{} status code recieved'. + format(code)) + + def start_conversation(self): + import requests + + endpoint = '{host}/api/conversations'.format(host=self.directline_host) + response = requests.post( + endpoint, + headers=self.headers, + verify=False + ) + self.logger.info('{} starting conversation {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def get_most_recent_message(self): + import requests + + endpoint = '{host}/api/conversations/{id}/messages' \ + .format(host=self.directline_host, + id=self.conversation_id) + + response = requests.get( + endpoint, + headers=self.headers, + verify=False + ) + + self.logger.info('{} retrieving most recent messages {}'.format( + response.status_code, endpoint + )) + + self._validate_status_code(response) + + data = response.json() + + if data['messages']: + last_msg = int(data['watermark']) + return data['messages'][last_msg - 1] + return None + + def process_input(self, statement): + new_message = False + data = None + while not new_message: + data = self.get_most_recent_message() + if data and data['id']: + new_message = True + else: + pass + sleep(3.5) + + text = data['text'] + statement = Statement(text) + self.logger.info('processing user statement {}'.format(statement)) + + return statement + + class HTTPStatusException(Exception): + """ + Exception raised when unexpected non-success HTTP + status codes are returned in a response. + """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/input/terminal.py b/chatter/chatterbot/input/terminal.py new file mode 100644 index 0000000..20cb3c2 --- /dev/null +++ b/chatter/chatterbot/input/terminal.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter +from chatter.chatterbot.utils import input_function + + +class TerminalAdapter(InputAdapter): + """ + A simple adapter that allows ChatterBot to + communicate through the terminal. + """ + + def process_input(self, *args, **kwargs): + """ + Read the user's input from the terminal. + """ + user_input = input_function() + return Statement(user_input) diff --git a/chatter/chatterbot/input/variable_input_type_adapter.py b/chatter/chatterbot/input/variable_input_type_adapter.py new file mode 100644 index 0000000..d2d598c --- /dev/null +++ b/chatter/chatterbot/input/variable_input_type_adapter.py @@ -0,0 +1,61 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.input import InputAdapter + + +class VariableInputTypeAdapter(InputAdapter): + JSON = 'json' + TEXT = 'text' + OBJECT = 'object' + VALID_FORMATS = (JSON, TEXT, OBJECT,) + + def detect_type(self, statement): + + string_types = str + + if hasattr(statement, 'text'): + return self.OBJECT + if isinstance(statement, string_types): + return self.TEXT + if isinstance(statement, dict): + return self.JSON + + input_type = type(statement) + + raise self.UnrecognizedInputFormatException( + 'The type {} is not recognized as a valid input type.'.format( + input_type + ) + ) + + def process_input(self, statement): + input_type = self.detect_type(statement) + + # Return the statement object without modification + if input_type == self.OBJECT: + return statement + + # Convert the input string into a statement object + if input_type == self.TEXT: + return Statement(statement) + + # Convert input dictionary into a statement object + if input_type == self.JSON: + input_json = dict(statement) + text = input_json['text'] + del input_json['text'] + + return Statement(text, **input_json) + + class UnrecognizedInputFormatException(Exception): + """ + Exception raised when an input format is specified that is + not in the VariableInputTypeAdapter.VALID_FORMATS variable. + """ + + def __init__(self, value='The input format was not recognized.'): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/logic/__init__.py b/chatter/chatterbot/logic/__init__.py new file mode 100644 index 0000000..1930556 --- /dev/null +++ b/chatter/chatterbot/logic/__init__.py @@ -0,0 +1,19 @@ +from .best_match import BestMatch +from .logic_adapter import LogicAdapter +from .low_confidence import LowConfidenceAdapter +from .mathematical_evaluation import MathematicalEvaluation +from .multi_adapter import MultiLogicAdapter +from .no_knowledge_adapter import NoKnowledgeAdapter +from .specific_response import SpecificResponseAdapter +from .time_adapter import TimeLogicAdapter + +__all__ = ( + 'LogicAdapter', + 'BestMatch', + 'LowConfidenceAdapter', + 'MathematicalEvaluation', + 'MultiLogicAdapter', + 'NoKnowledgeAdapter', + 'SpecificResponseAdapter', + 'TimeLogicAdapter', +) diff --git a/chatter/chatterbot/logic/best_match.py b/chatter/chatterbot/logic/best_match.py new file mode 100644 index 0000000..5c48121 --- /dev/null +++ b/chatter/chatterbot/logic/best_match.py @@ -0,0 +1,85 @@ +from __future__ import unicode_literals + +from .logic_adapter import LogicAdapter + + +class BestMatch(LogicAdapter): + """ + A logic adapter that returns a response based on known responses to + the closest matches to the input statement. + """ + + def get(self, input_statement): + """ + Takes a statement string and a list of statement strings. + Returns the closest matching statement from the list. + """ + statement_list = self.chatbot.storage.get_response_statements() + + if not statement_list: + if self.chatbot.storage.count(): + # Use a randomly picked statement + self.logger.info( + 'No statements have known responses. ' + + 'Choosing a random response to return.' + ) + random_response = self.chatbot.storage.get_random() + random_response.confidence = 0 + return random_response + else: + raise self.EmptyDatasetException() + + closest_match = input_statement + closest_match.confidence = 0 + + # Find the closest matching known statement + for statement in statement_list: + confidence = self.compare_statements(input_statement, statement) + + if confidence > closest_match.confidence: + statement.confidence = confidence + closest_match = statement + + return closest_match + + def can_process(self, statement): + """ + Check that the chatbot's storage adapter is available to the logic + adapter and there is at least one statement in the database. + """ + return self.chatbot.storage.count() + + def process(self, input_statement): + + # Select the closest match to the input statement + closest_match = self.get(input_statement) + self.logger.info('Using "{}" as a close match to "{}"'.format( + input_statement.text, closest_match.text + )) + + # Get all statements that are in response to the closest match + response_list = self.chatbot.storage.filter( + in_response_to__contains=closest_match.text + ) + + if response_list: + self.logger.info( + 'Selecting response from {} optimal responses.'.format( + len(response_list) + ) + ) + response = self.select_response(input_statement, response_list) + response.confidence = closest_match.confidence + self.logger.info('Response selected. Using "{}"'.format(response.text)) + else: + response = self.chatbot.storage.get_random() + self.logger.info( + 'No response to "{}" found. Selecting a random response.'.format( + closest_match.text + ) + ) + + # Set confidence to zero because a random response is selected + response.confidence = 0 + + return response diff --git a/chatter/chatterbot/logic/logic_adapter.py b/chatter/chatterbot/logic/logic_adapter.py new file mode 100644 index 0000000..1239cca --- /dev/null +++ b/chatter/chatterbot/logic/logic_adapter.py @@ -0,0 +1,101 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.adapters import Adapter +from chatter.chatterbot.utils import import_module + + +class LogicAdapter(Adapter): + """ + This is an abstract class that represents the interface + that all logic adapters should implement. + + :param statement_comparison_function: The dot-notated import path to a statement comparison function. + Defaults to ``levenshtein_distance``. + + :param response_selection_method: The a response selection method. + Defaults to ``get_first_response``. + """ + + def __init__(self, **kwargs): + super(LogicAdapter, self).__init__(**kwargs) + from chatter.chatterbot.comparisons import levenshtein_distance + from chatter.chatterbot.response_selection import get_first_response + + # Import string module parameters + if 'statement_comparison_function' in kwargs: + import_path = kwargs.get('statement_comparison_function') + if isinstance(import_path, str): + kwargs['statement_comparison_function'] = import_module(import_path) + + if 'response_selection_method' in kwargs: + import_path = kwargs.get('response_selection_method') + if isinstance(import_path, str): + kwargs['response_selection_method'] = import_module(import_path) + + # By default, compare statements using Levenshtein distance + self.compare_statements = kwargs.get( + 'statement_comparison_function', + levenshtein_distance + ) + + # By default, select the first available response + self.select_response = kwargs.get( + 'response_selection_method', + get_first_response + ) + + def get_initialization_functions(self): + """ + Return a dictionary of functions to be run once when the chat bot is instantiated. + """ + return self.compare_statements.get_initialization_functions() + + def initialize(self): + for function in self.get_initialization_functions().values(): + function() + + def can_process(self, statement): + """ + A preliminary check that is called to determine if a + logic adapter can process a given statement. By default, + this method returns true but it can be overridden in + child classes as needed. + + :rtype: bool + """ + return True + + def process(self, statement): + """ + Override this method and implement your logic for selecting a response to an input statement. + + A confidence value and the selected response statement should be returned. + The confidence value represents a rating of how accurate the logic adapter + expects the selected response to be. Confidence scores are used to select + the best response from multiple logic adapters. + + The confidence value should be a number between 0 and 1 where 0 is the + lowest confidence level and 1 is the highest. + + :param statement: An input statement to be processed by the logic adapter. + :type statement: Statement + + :rtype: Statement + """ + raise self.AdapterMethodNotImplementedError() + + @property + def class_name(self): + """ + Return the name of the current logic adapter class. + This is typically used for logging and debugging. + """ + return str(self.__class__.__name__) + + class EmptyDatasetException(Exception): + + def __init__(self, value='An empty set was received when at least one statement was expected.'): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/logic/low_confidence.py b/chatter/chatterbot/logic/low_confidence.py new file mode 100644 index 0000000..585cf20 --- /dev/null +++ b/chatter/chatterbot/logic/low_confidence.py @@ -0,0 +1,59 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.conversation import Statement +from .best_match import BestMatch + + +class LowConfidenceAdapter(BestMatch): + """ + Returns a default response with a high confidence + when a high confidence response is not known. + + :kwargs: + * *threshold* (``float``) -- + The low confidence value that triggers this adapter. + Defaults to 0.65. + * *default_response* (``str``) or (``iterable``)-- + The response returned by this logic adaper. + * *response_selection_method* (``str``) or (``callable``) + The a response selection method. + Defaults to ``get_first_response``. + """ + + def __init__(self, **kwargs): + super(LowConfidenceAdapter, self).__init__(**kwargs) + + self.confidence_threshold = kwargs.get('threshold', 0.65) + + default_responses = kwargs.get( + 'default_response', "I'm sorry, I do not understand." + ) + + # Convert a single string into a list + if isinstance(default_responses, str): + default_responses = [ + default_responses + ] + + self.default_responses = [ + Statement(text=default) for default in default_responses + ] + + def process(self, input_statement): + """ + Return a default response with a high confidence if + a high confidence response is not known. + """ + # Select the closest match to the input statement + closest_match = self.get(input_statement) + + # Choose a response from the list of options + response = self.select_response(input_statement, self.default_responses) + + # Confidence should be high only if it is less than the threshold + if closest_match.confidence < self.confidence_threshold: + response.confidence = 1 + else: + response.confidence = 0 + + return response diff --git a/chatter/chatterbot/logic/mathematical_evaluation.py b/chatter/chatterbot/logic/mathematical_evaluation.py new file mode 100644 index 0000000..af27548 --- /dev/null +++ b/chatter/chatterbot/logic/mathematical_evaluation.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals + +from chatter.chatterbot.conversation import Statement +from chatter.chatterbot.logic import LogicAdapter + + +class MathematicalEvaluation(LogicAdapter): + """ + The MathematicalEvaluation logic adapter parses input to determine + whether the user is asking a question that requires math to be done. + If so, the equation is extracted from the input and returned with + the evaluated result. + + For example: + User: 'What is three plus five?' + Bot: 'Three plus five equals eight' + + :kwargs: + * *language* (``str``) -- + The language is set to 'ENG' for English by default. + """ + + def __init__(self, **kwargs): + super(MathematicalEvaluation, self).__init__(**kwargs) + + self.language = kwargs.get('language', 'ENG') + self.cache = {} + + def can_process(self, statement): + """ + Determines whether it is appropriate for this + adapter to respond to the user input. + """ + response = self.process(statement) + self.cache[statement.text] = response + return response.confidence == 1 + + def process(self, statement): + """ + Takes a statement string. + Returns the equation from the statement with the mathematical terms solved. + """ + from mathparse import mathparse + + input_text = statement.text + + # Use the result cached by the process method if it exists + if input_text in self.cache: + cached_result = self.cache[input_text] + self.cache = {} + return cached_result + + # Getting the mathematical terms within the input statement + expression = mathparse.extract_expression(input_text, language=self.language) + + response = Statement(text=expression) + + try: + response.text += ' = ' + str( + mathparse.parse(expression, language=self.language) + ) + + # The confidence is 1 if the expression could be evaluated + response.confidence = 1 + except mathparse.PostfixTokenEvaluationException: + response.confidence = 0 + + return response diff --git a/chatter/chatterbot/logic/multi_adapter.py b/chatter/chatterbot/logic/multi_adapter.py new file mode 100644 index 0000000..6cfe30f --- /dev/null +++ b/chatter/chatterbot/logic/multi_adapter.py @@ -0,0 +1,155 @@ +from __future__ import unicode_literals + +from collections import Counter + +from chatter.chatterbot import utils +from .logic_adapter import LogicAdapter + + +class MultiLogicAdapter(LogicAdapter): + """ + MultiLogicAdapter allows ChatterBot to use multiple logic + adapters. It has methods that allow ChatterBot to add an + adapter, set the chat bot, and process an input statement + to get a response. + """ + + def __init__(self, **kwargs): + super(MultiLogicAdapter, self).__init__(**kwargs) + + # Logic adapters added by the chat bot + self.adapters = [] + + # Required logic adapters that must always be present + self.system_adapters = [] + + def get_initialization_functions(self): + """ + Get the initialization functions for each logic adapter. + """ + functions_dict = {} + + # Iterate over each adapter and get its initialization functions + for logic_adapter in self.get_adapters(): + functions = logic_adapter.get_initialization_functions() + functions_dict.update(functions) + + return functions_dict + + def process(self, statement): + """ + Returns the output of a selection of logic adapters + for a given input statement. + + :param statement: The input statement to be processed. + """ + results = [] + result = None + max_confidence = -1 + + for adapter in self.get_adapters(): + if adapter.can_process(statement): + + output = adapter.process(statement) + results.append((output.confidence, output,)) + + self.logger.info( + '{} selected "{}" as a response with a confidence of {}'.format( + adapter.class_name, output.text, output.confidence + ) + ) + + if output.confidence > max_confidence: + result = output + max_confidence = output.confidence + else: + self.logger.info( + 'Not processing the statement using {}'.format(adapter.class_name) + ) + + # If multiple adapters agree on the same statement, + # then that statement is more likely to be the correct response + if len(results) >= 3: + statements = [s[1] for s in results] + count = Counter(statements) + most_common = count.most_common() + if most_common[0][1] > 1: + result = most_common[0][0] + max_confidence = self.get_greatest_confidence(result, results) + + result.confidence = max_confidence + return result + + def get_greatest_confidence(self, statement, options): + """ + Returns the greatest confidence value for a statement that occurs + multiple times in the set of options. + + :param statement: A statement object. + :param options: A tuple in the format of (confidence, statement). + """ + values = [] + for option in options: + if option[1] == statement: + values.append(option[0]) + + return max(values) + + def get_adapters(self): + """ + Return a list of all logic adapters being used, including system logic adapters. + """ + adapters = [] + adapters.extend(self.adapters) + adapters.extend(self.system_adapters) + return adapters + + def add_adapter(self, adapter, **kwargs): + """ + Appends a logic adapter to the list of logic adapters being used. + + :param adapter: The logic adapter to be added. + :type adapter: `LogicAdapter` + """ + utils.validate_adapter_class(adapter, LogicAdapter) + adapter = utils.initialize_class(adapter, **kwargs) + self.adapters.append(adapter) + + def insert_logic_adapter(self, logic_adapter, insert_index, **kwargs): + """ + Adds a logic adapter at a specified index. + + :param logic_adapter: The string path to the logic adapter to add. + :type logic_adapter: str + + :param insert_index: The index to insert the logic adapter into the list at. + :type insert_index: int + """ + utils.validate_adapter_class(logic_adapter, LogicAdapter) + + NewAdapter = utils.import_module(logic_adapter) + adapter = NewAdapter(**kwargs) + + self.adapters.insert(insert_index, adapter) + + def remove_logic_adapter(self, adapter_name): + """ + Removes a logic adapter from the chat bot. + + :param adapter_name: The class name of the adapter to remove. + :type adapter_name: str + """ + for index, adapter in enumerate(self.adapters): + if adapter_name == type(adapter).__name__: + del self.adapters[index] + return True + return False + + def set_chatbot(self, chatbot): + """ + Set the chatbot for each of the contained logic adapters. + """ + super(MultiLogicAdapter, self).set_chatbot(chatbot) + + for adapter in self.get_adapters(): + adapter.set_chatbot(chatbot) diff --git a/chatter/chatterbot/logic/no_knowledge_adapter.py b/chatter/chatterbot/logic/no_knowledge_adapter.py new file mode 100644 index 0000000..55208b4 --- /dev/null +++ b/chatter/chatterbot/logic/no_knowledge_adapter.py @@ -0,0 +1,27 @@ +from __future__ import unicode_literals + +from .logic_adapter import LogicAdapter + + +class NoKnowledgeAdapter(LogicAdapter): + """ + This is a system adapter that is automatically added + to the list of logic adapters during initialization. + This adapter is placed at the beginning of the list + to be given the highest priority. + """ + + def process(self, statement): + """ + If there are no known responses in the database, + then a confidence of 1 should be returned with + the input statement. + Otherwise, a confidence of 0 should be returned. + """ + + if self.chatbot.storage.count(): + statement.confidence = 0 + else: + statement.confidence = 1 + + return statement diff --git a/chatter/chatterbot/logic/specific_response.py b/chatter/chatterbot/logic/specific_response.py new file mode 100644 index 0000000..101dd3b --- /dev/null +++ b/chatter/chatterbot/logic/specific_response.py @@ -0,0 +1,39 @@ +from __future__ import unicode_literals + +from .logic_adapter import LogicAdapter + + +class SpecificResponseAdapter(LogicAdapter): + """ + Return a specific response to a specific input. + + :kwargs: + * *input_text* (``str``) -- + The input text that triggers this logic adapter. + * *output_text* (``str``) -- + The output text returned by this logic adapter. + """ + + def __init__(self, **kwargs): + super(SpecificResponseAdapter, self).__init__(**kwargs) + from chatter.chatterbot.conversation import Statement + + self.input_text = kwargs.get('input_text') + + output_text = kwargs.get('output_text') + self.response_statement = Statement(output_text) + + def can_process(self, statement): + if statement == self.input_text: + return True + + return False + + def process(self, statement): + + if statement == self.input_text: + self.response_statement.confidence = 1 + else: + self.response_statement.confidence = 0 + + return self.response_statement diff --git a/chatter/chatterbot/logic/time_adapter.py b/chatter/chatterbot/logic/time_adapter.py new file mode 100644 index 0000000..72902e2 --- /dev/null +++ b/chatter/chatterbot/logic/time_adapter.py @@ -0,0 +1,93 @@ +from __future__ import unicode_literals + +from datetime import datetime + +from .logic_adapter import LogicAdapter + + +class TimeLogicAdapter(LogicAdapter): + """ + The TimeLogicAdapter returns the current time. + + :kwargs: + * *positive* (``list``) -- + The time-related questions used to identify time questions. + Defaults to a list of English sentences. + * *negative* (``list``) -- + The non-time-related questions used to identify time questions. + Defaults to a list of English sentences. + """ + + def __init__(self, **kwargs): + super(TimeLogicAdapter, self).__init__(**kwargs) + from nltk import NaiveBayesClassifier + + self.positive = kwargs.get('positive', [ + 'what time is it', + 'hey what time is it', + 'do you have the time', + 'do you know the time', + 'do you know what time it is', + 'what is the time' + ]) + + self.negative = kwargs.get('negative', [ + 'it is time to go to sleep', + 'what is your favorite color', + 'i had a great time', + 'thyme is my favorite herb', + 'do you have time to look at my essay', + 'how do you have the time to do all this' + 'what is it' + ]) + + labeled_data = ( + [(name, 0) for name in self.negative] + + [(name, 1) for name in self.positive] + ) + + train_set = [ + (self.time_question_features(text), n) for (text, n) in labeled_data + ] + + self.classifier = NaiveBayesClassifier.train(train_set) + + def time_question_features(self, text): + """ + Provide an analysis of significant features in the string. + """ + features = {} + + # A list of all words from the known sentences + all_words = " ".join(self.positive + self.negative).split() + + # A list of the first word in each of the known sentence + all_first_words = [] + for sentence in self.positive + self.negative: + all_first_words.append( + sentence.split(' ', 1)[0] + ) + + for word in text.split(): + features['first_word({})'.format(word)] = (word in all_first_words) + + for word in text.split(): + features['contains({})'.format(word)] = (word in all_words) + + for letter in 'abcdefghijklmnopqrstuvwxyz': + features['count({})'.format(letter)] = text.lower().count(letter) + features['has({})'.format(letter)] = (letter in text.lower()) + + return features + + def process(self, statement): + from chatter.chatterbot.conversation import Statement + + now = datetime.now() + + time_features = self.time_question_features(statement.text.lower()) + confidence = self.classifier.classify(time_features) + response = Statement('The current time is ' + now.strftime('%I:%M %p')) + + response.confidence = confidence + return response diff --git a/chatter/chatterbot/output/__init__.py b/chatter/chatterbot/output/__init__.py new file mode 100644 index 0000000..80abe4f --- /dev/null +++ b/chatter/chatterbot/output/__init__.py @@ -0,0 +1,15 @@ +from .gitter import Gitter +from .hipchat import HipChat +from .mailgun import Mailgun +from .microsoft import Microsoft +from .output_adapter import OutputAdapter +from .terminal import TerminalAdapter + +__all__ = ( + 'OutputAdapter', + 'Microsoft', + 'TerminalAdapter', + 'Mailgun', + 'Gitter', + 'HipChat', +) diff --git a/chatter/chatterbot/output/gitter.py b/chatter/chatterbot/output/gitter.py new file mode 100644 index 0000000..ba01fa8 --- /dev/null +++ b/chatter/chatterbot/output/gitter.py @@ -0,0 +1,86 @@ +from __future__ import unicode_literals + +from .output_adapter import OutputAdapter + + +class Gitter(OutputAdapter): + """ + An output adapter that allows a ChatterBot instance to send + responses to a Gitter room. + """ + + def __init__(self, **kwargs): + super(Gitter, self).__init__(**kwargs) + + self.gitter_host = kwargs.get('gitter_host', 'https://api.gitter.im/v1/') + self.gitter_room = kwargs.get('gitter_room') + self.gitter_api_token = kwargs.get('gitter_api_token') + + authorization_header = 'Bearer {}'.format(self.gitter_api_token) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json' + } + + # Join the Gitter room + room_data = self.join_room(self.gitter_room) + self.room_id = room_data.get('id') + + def _validate_status_code(self, response): + code = response.status_code + if code not in [200, 201]: + raise self.HTTPStatusException('{} status code recieved'.format(code)) + + def join_room(self, room_name): + """ + Join the specified Gitter room. + """ + import requests + + endpoint = '{}rooms'.format(self.gitter_host) + response = requests.post( + endpoint, + headers=self.headers, + json={'uri': room_name} + ) + self.logger.info('{} status joining room {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def send_message(self, text): + """ + Send a message to a Gitter room. + """ + import requests + + endpoint = '{}rooms/{}/chatMessages'.format(self.gitter_host, self.room_id) + response = requests.post( + endpoint, + headers=self.headers, + json={'text': text} + ) + self.logger.info('{} sending message to {}'.format( + response.status_code, endpoint + )) + self._validate_status_code(response) + return response.json() + + def process_response(self, statement, session_id=None): + self.send_message(statement.text) + return statement + + class HTTPStatusException(Exception): + """ + Exception raised when unexpected non-success HTTP + status codes are returned in a response. + """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/output/hipchat.py b/chatter/chatterbot/output/hipchat.py new file mode 100644 index 0000000..2546092 --- /dev/null +++ b/chatter/chatterbot/output/hipchat.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals + +import json + +from .output_adapter import OutputAdapter + + +class HipChat(OutputAdapter): + """ + An output adapter that allows a ChatterBot instance to send + responses to a HipChat room. + """ + + def __init__(self, **kwargs): + super(HipChat, self).__init__(**kwargs) + + self.hipchat_host = kwargs.get("hipchat_host") + self.hipchat_access_token = kwargs.get("hipchat_access_token") + self.hipchat_room = kwargs.get("hipchat_room") + + authorization_header = "Bearer {}".format(self.hipchat_access_token) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json' + } + + import requests + self.session = requests.Session() + self.session.verify = kwargs.get('ssl_verify', True) + + def send_message(self, room_id_or_name, message): + """ + Send a message to a HipChat room. + https://www.hipchat.com/docs/apiv2/method/send_message + """ + message_url = "{}/v2/room/{}/message".format( + self.hipchat_host, + room_id_or_name + ) + + response = self.session.post( + message_url, + headers=self.headers, + data=json.dumps({ + 'message': message + }) + ) + + return response.json() + + def reply_to_message(self): + """ + The HipChat api supports responding to a given message. + This may be a good feature to implement in the future to + help with multi-user conversations. + https://www.hipchat.com/docs/apiv2/method/reply_to_message + """ + raise self.AdapterMethodNotImplementedError() + + def process_response(self, statement, session_id=None): + data = self.send_message(self.hipchat_room, statement.text) + + # Update the output statement with the message id + self.chatbot.storage.update( + statement.add_extra_data('hipchat_message_id', data['id']) + ) + + return statement diff --git a/chatter/chatterbot/output/mailgun.py b/chatter/chatterbot/output/mailgun.py new file mode 100644 index 0000000..71a9a7a --- /dev/null +++ b/chatter/chatterbot/output/mailgun.py @@ -0,0 +1,50 @@ +from __future__ import unicode_literals + +from .output_adapter import OutputAdapter + + +class Mailgun(OutputAdapter): + + def __init__(self, **kwargs): + super(Mailgun, self).__init__(**kwargs) + + # Use the bot's name for the name of the sender + self.name = kwargs.get('name') + self.from_address = kwargs.get('mailgun_from_address') + self.api_key = kwargs.get('mailgun_api_key') + self.endpoint = kwargs.get('mailgun_api_endpoint') + self.recipients = kwargs.get('mailgun_recipients') + + def send_message(self, subject, text, from_address, recipients): + """ + * subject: Subject of the email. + * text: Text body of the email. + * from_email: The email address that the message will be sent from. + * recipients: A list of recipient email addresses. + """ + import requests + + return requests.post( + self.endpoint, + auth=('api', self.api_key), + data={ + 'from': '%s <%s>' % (self.name, from_address), + 'to': recipients, + 'subject': subject, + 'text': text + }) + + def process_response(self, statement, session_id=None): + """ + Send the response statement as an email. + """ + subject = 'Message from %s' % (self.name) + + self.send_message( + subject, + statement.text, + self.from_address, + self.recipients + ) + + return statement diff --git a/chatter/chatterbot/output/microsoft.py b/chatter/chatterbot/output/microsoft.py new file mode 100644 index 0000000..816fc97 --- /dev/null +++ b/chatter/chatterbot/output/microsoft.py @@ -0,0 +1,111 @@ +from __future__ import unicode_literals + +import json + +from .output_adapter import OutputAdapter + + +class Microsoft(OutputAdapter): + """ + An output adapter that allows a ChatterBot instance to send + responses to a Microsoft bot using *Direct Line client protocol*. + """ + + def __init__(self, **kwargs): + super(Microsoft, self).__init__(**kwargs) + + self.directline_host = kwargs.get( + 'directline_host', + 'https://directline.botframework.com' + ) + self.direct_line_token_or_secret = kwargs.get( + 'direct_line_token_or_secret' + ) + self.conversation_id = kwargs.get('conversation_id') + + authorization_header = 'BotConnector {}'.format( + self.direct_line_token_or_secret + ) + + self.headers = { + 'Authorization': authorization_header, + 'Content-Type': 'application/json' + } + + def _validate_status_code(self, response): + status_code = response.status_code + if status_code not in [200, 204]: + raise self.HTTPStatusException('{} status code recieved'.format(status_code)) + + def get_most_recent_message(self): + """ + Return the most recently sent message. + """ + import requests + endpoint = '{host}/api/conversations/{id}/messages'.format( + host=self.directline_host, + id=self.conversation_id + ) + + response = requests.get( + endpoint, + headers=self.headers, + verify=False + ) + + self.logger.info('{} retrieving most recent messages {}'.format( + response.status_code, endpoint + )) + + self._validate_status_code(response) + + data = response.json() + + if data['messages']: + last_msg = int(data['watermark']) + return data['messages'][last_msg - 1] + return None + + def send_message(self, conversation_id, message): + """ + Send a message to a HipChat room. + https://www.hipchat.com/docs/apiv2/method/send_message + """ + import requests + + message_url = "{host}/api/conversations/{conversationId}/messages".format( + host=self.directline_host, + conversationId=conversation_id + ) + + response = requests.post( + message_url, + headers=self.headers, + data=json.dumps({ + 'message': message + }) + ) + + self.logger.info('{} sending message {}'.format( + response.status_code, message_url + )) + self._validate_status_code(response) + # Microsoft return 204 on operation succeeded and no content was returned. + return self.get_most_recent_message() + + def process_response(self, statement, session_id=None): + data = self.send_message(self.conversation_id, statement.text) + self.logger.info('processing user response {}'.format(data)) + return statement + + class HTTPStatusException(Exception): + """ + Exception raised when unexpected non-success HTTP + status codes are returned in a response. + """ + + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) diff --git a/chatter/chatterbot/output/output_adapter.py b/chatter/chatterbot/output/output_adapter.py new file mode 100644 index 0000000..5d13dd7 --- /dev/null +++ b/chatter/chatterbot/output/output_adapter.py @@ -0,0 +1,20 @@ +from chatter.chatterbot.adapters import Adapter + + +class OutputAdapter(Adapter): + """ + A generic class that can be overridden by a subclass to provide extended + functionality, such as delivering a response to an API endpoint. + """ + + def process_response(self, statement, session_id=None): + """ + Override this method in a subclass to implement customized functionality. + + :param statement: The statement that the chat bot has produced in response to some input. + + :param session_id: The unique id of the current chat session. + + :returns: The response statement. + """ + return statement diff --git a/chatter/chatterbot/output/terminal.py b/chatter/chatterbot/output/terminal.py new file mode 100644 index 0000000..8ab63e1 --- /dev/null +++ b/chatter/chatterbot/output/terminal.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals + +from .output_adapter import OutputAdapter + + +class TerminalAdapter(OutputAdapter): + """ + A simple adapter that allows ChatterBot to + communicate through the terminal. + """ + + def process_response(self, statement, session_id=None): + """ + Print the response to the user's input. + """ + print(statement.text) + return statement.text diff --git a/chatter/chatterbot/parsing.py b/chatter/chatterbot/parsing.py new file mode 100644 index 0000000..5aafa75 --- /dev/null +++ b/chatter/chatterbot/parsing.py @@ -0,0 +1,752 @@ +# -*- coding: utf-8 -*- +import calendar +import re +from datetime import timedelta, datetime + +# Variations of dates that the parser can capture +year_variations = ['year', 'years', 'yrs'] +day_variations = ['days', 'day'] +minute_variations = ['minute', 'minutes', 'mins'] +hour_variations = ['hrs', 'hours', 'hour'] +week_variations = ['weeks', 'week', 'wks'] +month_variations = ['month', 'months'] + +# Variables used for RegEx Matching +day_names = 'monday|tuesday|wednesday|thursday|friday|saturday|sunday' +month_names_long = ( + 'january|february|march|april|may|june|july|august|september|october|november|december' +) +month_names = month_names_long + '|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec' +day_nearest_names = 'today|yesterday|tomorrow|tonight|tonite' +numbers = ( + '(^a(?=\s)|one|two|three|four|five|six|seven|eight|nine|ten|' + 'eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|' + 'eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|' + 'eighty|ninety|hundred|thousand)' +) +re_dmy = '(' + '|'.join(day_variations + minute_variations + year_variations + week_variations + month_variations) + ')' +re_duration = '(before|after|earlier|later|ago|from\snow)' +re_year = '(19|20)\d{2}|^(19|20)\d{2}' +re_timeframe = 'this|coming|next|following|previous|last|end\sof\sthe' +re_ordinal = 'st|nd|rd|th|first|second|third|fourth|fourth|' + re_timeframe +re_time = r'(?P\d{1,2})(\:(?P\d{1,2})|(?Pam|pm))' +re_separator = 'of|at|on' + +# A list tuple of regular expressions / parser fn to match +# Start with the widest match and narrow it down because the order of the match in this list matters +regex = [ + ( + re.compile( + r''' + ( + ((?P%s)[,\s]\s*)? #Matches Monday, 12 Jan 2012, 12 Jan 2012 etc + (?P\d{1,2}) # Matches a digit + (%s)? + [-\s] # One or more space + (?P%s) # Matches any month name + [-\s] # Space + (?P%s) # Year + ((\s|,\s|\s(%s))?\s*(%s))? + ) + ''' % (day_names, re_ordinal, month_names, re_year, re_separator, re_time), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: datetime( + int(m.group('year') if m.group('year') else base_date.year), + HASHMONTHS[m.group('month').strip().lower()], + int(m.group('day') if m.group('day') else 1), + ) + timedelta(**convert_time_to_hour_minute( + m.group('hour'), + m.group('minute'), + m.group('convention') + )) + ), + ( + re.compile( + r''' + ( + ((?P%s)[,\s][-\s]*)? #Matches Monday, Jan 12 2012, Jan 12 2012 etc + (?P%s) # Matches any month name + [-\s] # Space + ((?P\d{1,2})) # Matches a digit + (%s)? + ([-\s](?P%s))? # Year + ((\s|,\s|\s(%s))?\s*(%s))? + ) + ''' % (day_names, month_names, re_ordinal, re_year, re_separator, re_time), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: datetime( + int(m.group('year') if m.group('year') else base_date.year), + HASHMONTHS[m.group('month').strip().lower()], + int(m.group('day') if m.group('day') else 1) + ) + timedelta(**convert_time_to_hour_minute( + m.group('hour'), + m.group('minute'), + m.group('convention') + )) + ), + ( + re.compile( + r''' + ( + (?P%s) # Matches any month name + [-\s] # One or more space + (?P\d{1,2}) # Matches a digit + (%s)? + [-\s]\s*? + (?P%s) # Year + ((\s|,\s|\s(%s))?\s*(%s))? + ) + ''' % (month_names, re_ordinal, re_year, re_separator, re_time), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: datetime( + int(m.group('year') if m.group('year') else base_date.year), + HASHMONTHS[m.group('month').strip().lower()], + int(m.group('day') if m.group('day') else 1), + ) + timedelta(**convert_time_to_hour_minute( + m.group('hour'), + m.group('minute'), + m.group('convention') + )) + ), + ( + re.compile( + r''' + ( + ((?P\d+|(%s[-\s]?)+)\s)? # Matches any number or string 25 or twenty five + (?P%s)s?\s # Matches days, months, years, weeks, minutes + (?P%s) # before, after, earlier, later, ago, from now + (\s*(?P(%s)))? + ((\s|,\s|\s(%s))?\s*(%s))? + ) + ''' % (numbers, re_dmy, re_duration, day_nearest_names, re_separator, re_time), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: date_from_duration( + base_date, + m.group('number'), + m.group('unit').lower(), + m.group('duration').lower(), + m.group('base_time') + ) + timedelta(**convert_time_to_hour_minute( + m.group('hour'), + m.group('minute'), + m.group('convention') + )) + ), + ( + re.compile( + r''' + ( + (?P%s) # First quarter of 2014 + \s+ + quarter\sof + \s+ + (?P%s) + ) + ''' % (re_ordinal, re_year), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: date_from_quarter( + base_date, + HASHORDINALS[m.group('ordinal').lower()], + int(m.group('year') if m.group('year') else base_date.year) + ) + ), + ( + re.compile( + r''' + ( + (?P\d+) + (?P%s) # 1st January 2012 + ((\s|,\s|\s(%s))?\s*)? + (?P%s) + ([,\s]\s*(?P%s))? + ) + ''' % (re_ordinal, re_separator, month_names, re_year), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: datetime( + int(m.group('year') if m.group('year') else base_date.year), + int(HASHMONTHS[m.group('month').lower()] if m.group('month') else 1), + int(m.group('ordinal_value') if m.group('ordinal_value') else 1), + ) + ), + ( + re.compile( + r''' + ( + (?P%s) + \s+ + (?P\d+) + (?P%s) # January 1st 2012 + ([,\s]\s*(?P%s))? + ) + ''' % (month_names, re_ordinal, re_year), + (re.VERBOSE | re.IGNORECASE) + ), + lambda m, base_date: datetime( + int(m.group('year') if m.group('year') else base_date.year), + int(HASHMONTHS[m.group('month').lower()] if m.group('month') else 1), + int(m.group('ordinal_value') if m.group('ordinal_value') else 1), + ) + ), + ( + re.compile( + r''' + (?P