diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 0443c38..8aed845 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,3 +1,5 @@ +from .bot import Bot +from .botmanager import BotManager from .callbacks import Callbacks from .connection import Connection from .exceptions import * diff --git a/yaboli/bot.py b/yaboli/bot.py new file mode 100644 index 0000000..12093a6 --- /dev/null +++ b/yaboli/bot.py @@ -0,0 +1,390 @@ +from . import callbacks +import time + +from . import room +from . import exceptions + +class Bot(): + """ + Empty bot class that can be built upon. + Takes care of extended botrulez. + """ + + def __init__(self, roomname, nick="yaboli", password=None, manager=None): + """ + roomname - name of the room to connect to + nick - nick to assume, None -> no nick + password - room password (in case the room is private) + """ + + self.start_time = time.time() + + self.created_by = None + self.created_in = None + + self.manager = manager + + self.helptexts = {} + self.detailed_helptexts = {} + + self.room = room.Room(roomname, nick=nick, password=password) + self.room.add_callback("message", self.on_message) + + self.commands = callbacks.Callbacks() + #self.commands.add("create", create_command) + #self.commands.add("kill", kill_command) + #self.commands.add("send", send_command) + #self.commands.add("uptime", uptime_command) + + self.add_command("help", self.help_command, "Shows help information about the bot.", + ("!help @bot [ -s | ]\n" + "-s : general syntax help\n\n" + "Shows detailed help for a command if you specify a command name.\n" + "Shows a list of commands and short description if no arguments are given.")) + + self.add_command("ping", self.ping_command, "Replies 'Pong!'.", + ("!ping @bot\n\n" + "This command was originally used to help distinguish bots from\n" + "people. Since the Great UI Change, this is no longer necessary as\n" + "bots and people are displayed separately.")) + + self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)") + + self.room.launch() + + def stop(self): + """ + stop() -> None + + Kill this bot. + """ + + self.room.stop() + + def add_command(self, command, function, helptext=None, detailed_helptext=None): + """ + add_command(command, function, helptext, detailed_helptext) -> None + + Subscribe a function to a command and add a help text. + If no help text is provided, the command won't be displayed by the !help command. + + You can "hide" commands by specifying only the detailed helptext, + or no helptext at all. + """ + + command = command.lower() + + self.commands.add(command, function) + + if helptext: + self.helptexts[command] = helptext + + if detailed_helptext: + self.detailed_helptexts[command] = detailed_helptext + + def call_command(self, message): + """ + call_command(message) -> None + + Calls all functions subscribed to the command with the arguments supplied in the message. + Deals with the situation that multiple bots of the same type and name are in the same room. + """ + + try: + command, bot_id, name, arguments, flags, options = self.parse(message.content) + except exceptions.ParseMessageException: + return + + if not self.commands.exists(command): + return + + if not name == self.room.session.mentionable(): + return + + if bot_id is not None: # id specified + if self.manager.get(bot_id) == self: + self.commands.call(command, message, arguments, flags, options) + else: + return + + else: # no id specified + bots = self.manager.get_similar(self.get_room(), name) + if self.manager.get_id(self) == min(bots): + if len(bots) > 1: + msg = ("There are multiple bots with that name in this room. To select one,\n" + "please specify its id (from the list below) as follows:\n" + "!{} @{} [your arguments...]\n").format(command, name) + + print("constructing table") + for bot_id in sorted(bots): + bot = bots[bot_id] + msg += "\n{} - @{} ({})".format(bot_id, bot.get_nick(), bot.creation_info()) + + print("sending help message") + self.room.send_message(msg, parent=message.id) + + else: # name is unique + self.commands.call(command, message, arguments, flags, options) + + def get_room(self): + """ + get_room() -> roomname + + The room the bot is connected to. + """ + + return self.room.room + + def get_mentionable_nick(self): + """ + get_mentionable_nick() -> nick + + The The bot's nick in a mentionable format. + """ + + if self.room.session: + return self.room.session.mentionable() + + def get_nick(self): + """ + get_nick() -> nick + + The bot's nick. + """ + + if self.room.session: + return self.room.session.name + + def creation_info(self): + """ + creation_info() -> str + + Formatted info about the bot's creation + """ + + ftime = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.start_time)) + info = "created at {}".format(ftime) + + if self.created_by: + info += " by @{}".format(self.created_by) + + if self.created_in: + info += " in &{}".format(self.created_in) + + return info + + def parse_command(self, message): + """ + parse_command(message_content) -> command, bot_id, name, argpart + + Parse the "!command[ bot_id] @botname[ argpart]" part of a command. + """ + + print("PARSING - COMMAND") + + # command name (!command) + split = message.split(maxsplit=1) + + if split[0][:1] != "!": + raise exceptions.ParseMessageException("Not a command") + elif not len(split) > 1: + raise exceptions.ParseMessageException("No bot name") + + command = split[0][1:].lower() + message = split[1] + split = message.split(maxsplit=1) + + # bot id + try: + bot_id = int(split[0]) + except ValueError: + bot_id = None + else: + if not len(split) > 1: + raise exceptions.ParseMessageException("No bot name") + + message = split[1] + split = message.split(maxsplit=1) + + # bot name (@mention) + if split[0][:1] != "@": + raise exceptions.ParseMessageException("No bot name") + + name = split[0][1:].lower() + + # arguments to the command + if len(split) > 1: + argpart = split[1] + else: + argpart = None + + return command, bot_id, name, argpart + + def parse_arguments(self, argstr): + """ + parse_arguments(argstr) -> arguments, flags, options + + Parse the argument part of a command. + """ + + print("PARSING - ARGPART") + + argstr += " " # so the last argument will also be captured + + escaping = False + quot_marks = None + type_signs = 0 + option = None + word = "" + + arguments = [] + flags = "" + options = {} + + for char in argstr: + + # backslash-escaping + if escaping: + word += char + escaping = False + elif char == "\\": + escaping = True + + # quotation mark escaped strings + elif quot_marks: + if char == quot_marks: + quot_marks = None + else: + word += char + elif char in ['"', "'"]: + quot_marks = char + + # type signs + elif char == "-": + if type_signs < 2 and not word: + type_signs += 1 + else: + word += char + + # "=" in options + elif char == "=" and type_signs == 2 and word and not option: + option = word + word = "" + + # space - evaluate information collected so far + elif char == " ": + if word: + if type_signs == 0: # argument + arguments.append(word) + elif type_signs == 1: # flag(s) + for flag in word: + if not flag in flags: + flags += flag + elif type_signs == 2: # option + if option: + options[option] = word + else: + options[word] = True + + # reset all state variables + escaping = False + quot_marks = None + type_signs = 0 + option = None + word = "" + + # all other chars and situations + else: + word += char + + return arguments, flags, options + + def parse(self, message): + """ + parse(message_content) -> bool + + Parse a message. + """ + + print("PARSING") + + command, bot_id, name, argpart = self.parse_command(message) + + if argpart: + arguments, flags, options = self.parse_arguments(argpart) + else: + arguments = [] + flags = "" + options = {} + + return command, bot_id, name, arguments, flags, options + + # ----- HANDLING OF EVENTS ----- + + def on_message(self, message): + """ + on_message(message) -> None + + Gets called when a message is received (see __init__). + If you want to add a command to your bot, consider using add_command instead of overwriting + this function. + """ + + self.call_command(message) + + # ----- COMMANDS ----- + + def help_command(self, message, arguments, flags, options): + """ + help_command(message, *arguments, flags, options) -> None + + Show help about the bot. + """ + + if "s" in flags: # detailed syntax help + msg = "SYNTAX HELP PLACEHOLDER" + + elif arguments: # detailed help for one command + command = arguments[0] + if command[:1] == "!": + command = command[1:] + + if command in self.detailed_helptexts: + msg = self.detailed_helptexts[command] + else: + msg = "No detailed help text found for !{}.".format(command) + if command in self.helptexts: + msg += "\n\n" + self.helptexts[command] + + else: # just list all commands + msg = "This bot supports the following commands:\n" + + for command in sorted(self.helptexts): + helptext = self.helptexts[command] + msg += "\n!{} - {}".format(command, helptext) + + msg += ("\n\nFor detailed help on the command syntax, try:\n" + "!help @{0} -s\n" + "For detailed help on a command, try:\n" + "!help {0} ").format(self.get_mentionable_nick()) + + self.room.send_message(msg, parent=message.id) + + def ping_command(self, message, arguments, flags, options): + """ + ping_command(message, *arguments, flags, options) -> None + + Send a "Pong!" reply on a !ping command. + """ + + self.room.send_message("Pong!", parent=message.id) + + def show_command(self, message, arguments, flags, options): + """ + show_command(message, arguments, flags, options) -> None + + Show arguments, flags and options. + """ + + msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options) + self.room.send_message(msg, parent=message.id) diff --git a/yaboli/botmanager.py b/yaboli/botmanager.py new file mode 100644 index 0000000..46dd075 --- /dev/null +++ b/yaboli/botmanager.py @@ -0,0 +1,85 @@ +from . import bot +from . import exceptions + +class BotManager(): + """ + Keep track of multiple bots in different rooms. + """ + + def __init__(self, bot_class, default_nick="yaboli", max_bots=100): + """ + bot_class - class to create instances of + default_nick - default nick for all bots to assume when no nick is specified + max_bots - maximum number of bots allowed to exist simultaneously + None or 0 - no limit + """ + + self.bot_class = bot_class + self.max_bots = max_bots + self.default_nick = default_nick + + self.bots = {} + self.bot_id = 0 + + def create(self, room, password=None, nick=None): + """ + create(room, password, nick) -> bot + + Create a new bot in room. + """ + + if nick is None: + nick = self.default_nick + + if self.max_bots and len(self.bots) >= self.max_bots: + raise exceptions.CreateBotException("max_bots limit hit") + else: + bot = self.bot_class(room, nick=nick, password=password, manager=self) + self.bots[self.bot_id] = bot + self.bot_id += 1 + + return bot + + def remove(self, bot_id): + """ + remove(bot_id) -> None + + Kill a bot and remove it from the list of bots. + """ + + if not bot_id in self.bots: + raise exceptions.BotNotFoundException("Bot not in bots list") + + self.bots[bot_id].stop() + del self.bots[bot_id] + + def get(self, bot_id): + """ + get(self, bot_id) -> bot + + Return bot with that id, if found. + """ + + if bot_id in self.bots: + return self.bots[bot_id] + + def get_id(self, bot): + """ + get_id(bot) -> bot_id + + Return the bot id, if the bot is known. + """ + + for bot_id, own_bot in self.bots.items(): + if bot == own_bot: + return bot_id + + def get_similar(self, room, nick): + """ + get_by_room(room, nick) -> dict + + Collect all bots that are connected to the room and have that nick. + """ + + return {bot_id: bot for bot_id, bot in self.bots.items() + if bot.get_room() == room and bot.get_mentionable_nick() == nick}