diff --git a/ExampleBot.py b/ExampleBot.py new file mode 100644 index 0000000..0a60d0b --- /dev/null +++ b/ExampleBot.py @@ -0,0 +1,21 @@ +import asyncio +import yaboli + +class ExampleBot(yaboli.Bot): + async def send(self, room, message): + ping = "ExamplePong!" + short_help = "Example bot for the yaboli bot library" + long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli" + + await self.botrulez_ping_general(room, message, ping_text=ping) + await self.botrulez_ping_specific(room, message, ping_text=ping) + await self.botrulez_help_general(room, message, help_text=short_help) + await self.botrulez_help_specific(room, message, help_text=long_help) + await self.botrulez_uptime(room, message) + await self.botrulez_kill(room, message) + await self.botrulez_restart(room, message) + + forward = send # should work without modifications for most bots + +bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"]) +asyncio.get_event_loop().run_forever() diff --git a/setup.py b/setup.py deleted file mode 100644 index c46f97b..0000000 --- a/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -from setuptools import setup - -setup(name='yaboli', - version='1.0', - description='Yet Another BOt LIbrary for euphoria.io', - author='Garmelon', - url='https://github.com/Garmelon/yaboli', - packages=['yaboli'], - install_requires=['websockets'] -) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index d258678..7f6b6ba 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -10,6 +10,7 @@ logging.getLogger("asyncio").setLevel(logging.DEBUG) logging.getLogger(__name__).setLevel(logging.DEBUG) # ----------- END DEV SECTION ----------- +from .bot import * from .cookiejar import * from .connection import * from .exceptions import * @@ -17,6 +18,7 @@ from .room import * from .utils import * __all__ = ( + bot.__all__ + connection.__all__ + cookiejar.__all__ + exceptions.__all__ + diff --git a/yaboli/bot.py b/yaboli/bot.py index 0672e70..ddac48e 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,178 +1,122 @@ -import asyncio -from collections import namedtuple import logging import re import time -from .callbacks import * -from .controller import * + +from .cookiejar import * +from .room import * from .utils import * + logger = logging.getLogger(__name__) __all__ = ["Bot"] +# Some command stuff -class Bot(Controller): - # ^ and $ not needed since we're doing a re.fullmatch - SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)" - GENERIC_RE = r"!(\S+)([\S\s]*)" - - ParsedMessage = namedtuple("ParsedMessage", ["command", "argstr"]) - TopicHelp = namedtuple("TopicHelp", ["text", "visible"]) - - def __init__(self, nick): - super().__init__(nick) - - self.restarting = False # whoever runs the bot can check if a restart is necessary - self.start_time = time.time() - - self._commands = Callbacks() - self._triggers = Callbacks() - self.register_default_commands() - - self._help_topics = {} - self.add_default_help_topics() - - # settings (modify in your bot's __init__) - self.help_general = None # None -> does not respond to general help - self.help_specific = "No help available" - self.killable = True - self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not - self.restartable = True - self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not - self.ping_message = "Pong!" # as specified by the botrulez - - def register_command(self, command, callback, specific=True): - self._commands.add((command, specific), callback) - - def register_trigger(self, regex, callback): - self._triggers.add(re.compile(regex), callback) - - def register_trigger_compiled(self, comp_regex, callback): - self._triggers.add(comp_regex, callback) - - def add_help(self, topic, text, visible=True): - info = self.TopicHelp(text, visible) - self._help_topics[topic] = info - - def get_help(self, topic): - info = self._help_topics.get(topic, None) - if info: - return self.format_help(info.text) - - def format_help(self, helptext): - return helptext.format( - nick=mention(self.nick) - ) - - def list_help_topics(self, max_characters=100): - # Magic happens here to ensure that the resulting lines are always - # max_characters or less characters long. - - lines = [] - curline = "" - wrapper = None - - for topic, info in sorted(self._help_topics.items()): - if not info.visible: - continue - - if wrapper: - curline += "," - lines.append(curline) - curline = wrapper - wrapper = None - - if not curline: - curline = topic - elif len(curline) + len(f", {topic},") <= max_characters: - curline += f", {topic}" - elif len(curline) + len(f", {topic}") <= max_characters: - wrapper = topic +SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") +GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") + +def command(commandname, specific=True, noargs=False): + def decorator(func): + async def wrapper(self, room, message, *args, **kwargs): + print(f"New message: {message.content!r}, current name: {room.session!r}") + if specific: + print(f"Trying specific: {message.content!r}") + result = self._parse_command(message.content, specific=room.session.nick) else: - curline += "," - lines.append(curline) - curline = topic - - if wrapper: - curline += "," - lines.append(curline) - lines.append(wrapper) - elif curline: - lines.append(curline) - - return "\n".join(lines) - - async def restart(self): - # After calling this, the bot is stopped, not yet restarted. - self.restarting = True - await self.stop() - - def noargs(func): - async def wrapper(self, message, argstr): - if not argstr: - return await func(self, message) + print(f"Trying general: {message.content!r}") + result = self._parse_command(message.content) + if result is None: return + cmd, argstr = result + if cmd != commandname: return + if noargs: + if argstr: return + return await func(self, room, message, *args, **kwargs) + else: + return await func(self, room, message, args*args, **kwargs) return wrapper - - async def on_send(self, message): - wait = [] - - # get specific command to call (if any) - specific = self.parse_message(message.content, specific=True) - if specific: - wait.append(self._commands.call( - (specific.command, True), - message, specific.argstr - )) - - # get generic command to call (if any) - general = self.parse_message(message.content, specific=False) - if general: - wait.append(self._commands.call( - (general.command, False), - message, general.argstr - )) - - # find triggers to call (if any) - for trigger in self._triggers.list(): - match = trigger.fullmatch(message.content) - if match: - wait.append(self._triggers.call(trigger, message, match)) - - if wait: - await asyncio.wait(wait) - - def parse_message(self, content, specific=True): + return decorator + + +# And now comes the real bot... + +class Bot(Inhabitant): + def __init__(self, nick, cookiefile=None, rooms=["test"]): + self.target_nick = nick + self.rooms = {} + self.cookiejar = CookieJar(cookiefile) + + for roomname in rooms: + self.join_room(roomname) + + # ROOM MANAGEMENT + + def join_room(self, roomname, password=None): + if roomname in self.rooms: + return + + self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar) + + async def part_room(self, roomname): + room = self.rooms.pop(roomname, None) + if room: + await room.exit() + + # BOTRULEZ + + @command("ping", specific=False, noargs=True) + async def botrulez_ping_general(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("ping", specific=True, noargs=True) + async def botrulez_ping_specific(self, room, message, ping_text="Pong!"): + await room.send(ping_text, message.mid) + + @command("help", specific=False, noargs=True) + async def botrulez_help_general(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("help", specific=True, noargs=True) + async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"): + await room.send(help_text, message.mid) + + @command("uptime", specific=True, noargs=True) + async def botrulez_uptime(self, room, message): + now = time.time() + startformat = format_time(room.start_time) + deltaformat = format_time_delta(now - room.start_time) + text = f"/me has been up since {startformat} ({deltaformat})" + await room.send(text, message.mid) + + @command("kill", specific=True, noargs=True) + async def botrulez_kill(self, room, message, kill_text="/me dies"): + await room.send(kill_text, message.mid) + await self.part_room(room.roomname) + + @command("restart", specific=True, noargs=True) + async def botrulez_restart(self, room, message, restart_text="/me restarts"): + await room.send(restart_text, message.mid) + await self.part_room(room.roomname) + self.join_room(room.roomname, password=room.password) + + # COMMAND PARSING + + @staticmethod + def parse_args(text): """ - ParsedMessage = parse_message(content) - - Returns None, not a (None, None) tuple, when message could not be parsed - """ - - if specific: - match = re.fullmatch(self.SPECIFIC_RE, content) - if match and similar(match.group(2), self.nick): - return self.ParsedMessage(match.group(1), match.group(3)) - else: - match = re.fullmatch(self.GENERIC_RE, content) - if match: - return self.ParsedMessage(match.group(1), match.group(2)) - - def parse_args(self, text): - """ - Use single- and double-quotes bash-style to include whitespace in arguments. + Use bash-style single- and double-quotes to include whitespace in arguments. A backslash always escapes the next character. Any non-escaped whitespace separates arguments. - + Returns a list of arguments. Deals with unclosed quotes and backslashes without crashing. """ - + escape = False quote = None args = [] arg = "" - + for character in text: if escape: arg += character @@ -192,20 +136,21 @@ class Bot(Controller): arg = "" else: arg += character - + #if escape or quote: #return None # syntax error - + if len(arg) > 0: args.append(arg) - + return args - - def parse_flags(self, arglist): + + @staticmethod + def parse_flags(arglist): flags = "" args = [] kwargs = {} - + for arg in arglist: # kwargs (--abc, --foo=bar) if arg[:2] == "--": @@ -222,112 +167,19 @@ class Bot(Controller): # args (normal arguments) else: args.append(arg) - + return flags, args, kwargs - - - - # BOTRULEZ AND YABOLI-SPECIFIC COMMANDS - - def register_default_commands(self): - self.register_command("ping", self.command_ping) - self.register_command("ping", self.command_ping, specific=False) - self.register_command("help", self.command_help) - self.register_command("help", self.command_help_general, specific=False) - self.register_command("uptime", self.command_uptime) - self.register_command("kill", self.command_kill) - self.register_command("restart", self.command_restart) - - def add_default_help_topics(self): - self.add_help("botrulez", ( - "This bot complies with the botrulez at https://github.com/jedevc/botrulez.\n" - "It implements the standard commands, and additionally !kill and !restart.\n\n" - "Standard commands:\n" - " !ping, !ping @{nick} - reply with a short pong message\n" - " !help, !help @{nick} - reply with help about the bot\n" - " !uptime @{nick} - reply with the bot's uptime\n\n" - "Non-standard commands:\n" - " !kill @{nick} - terminate this bot instance\n" - " !restart @{nick} - restart this bot instance\n\n" - "Command extensions:\n" - " !help @{nick} [ ...] - provide help on the topics listed" - )) - - self.add_help("yaboli", ( - "Yaboli is \"Yet Another BOt LIbrary for euphoria\", written by @Garmy in Python.\n" - "It relies heavily on the asyncio module from the standard library and uses f-strings.\n" - "Because of this, Python version >= 3.6 is required.\n\n" - "Github: https://github.com/Garmelon/yaboli" - )) - - @noargs - async def command_ping(self, message): - if self.ping_message: - await self.room.send(self.ping_message, message.mid) - - async def command_help(self, message, argstr): - args = self.parse_args(argstr.lower()) - if not args: - if self.help_specific: - await self.room.send( - self.format_help(self.help_specific), - message.mid - ) + + @staticmethod + def _parse_command(content, specific=None): + print(repr(specific)) + if specific is not None: + print("SPECIFIC") + match = SPECIFIC_RE.fullmatch(content) + if match and similar(match.group(2), specific): + return match.group(1), match.group(3) else: - # collect all valid topics - messages = [] - for topic in sorted(set(args)): - text = self.get_help(topic) - if text: - messages.append(f"Topic: {topic}\n{text}") - - # print result in separate messages - if messages: - for text in messages: - await self.room.send(text, message.mid) - else: - await self.room.send("None of those topics found.", message.mid) - - @noargs - async def command_help_general(self, message): - if self.help_general is not None: - await self.room.send(self.help_general, message.mid) - - @noargs - async def command_uptime(self, message): - now = time.time() - startformat = format_time(self.start_time) - deltaformat = format_time_delta(now - self.start_time) - text = f"/me has been up since {startformat} ({deltaformat})" - await self.room.send(text, message.mid) - - async def command_kill(self, message, args): - logging.warn(f"Kill attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") - - if self.kill_message is not None: - await self.room.send(self.kill_message, message.mid) - - if self.killable: - await self.stop() - - async def command_restart(self, message, args): - logging.warn(f"Restart attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}") - - if self.restart_message is not None: - await self.room.send(self.restart_message, message.mid) - - if self.restartable: - await self.restart() - -class Multibot(Bot): - def __init__(self, nick, keeper): - super().__init__(nick) - - self.keeper = keeper - -class MultibotKeeper(): - def __init__(self, configfile): - # TODO: load configfile - - # TODO: namedtuple botinfo (bot, task) - self._bots = {} # self._bots[roomname] = botinfo + print("GENERAL") + match = GENERAL_RE.fullmatch(content) + if match: + return match.group(1), match.group(2) diff --git a/yaboli/connection.py b/yaboli/connection.py index d56a527..0258ae3 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -109,6 +109,7 @@ class Connection: if self.cookiejar: for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): self.cookiejar.bake(set_cookie) + self.cookiejar.save() self._pingtask = asyncio.ensure_future(self._ping()) diff --git a/yaboli/controller.py b/yaboli/controller.py deleted file mode 100644 index 14faec3..0000000 --- a/yaboli/controller.py +++ /dev/null @@ -1,213 +0,0 @@ -import asyncio -import logging -from .room import Room - -logger = logging.getLogger(__name__) -__all__ = ["Controller"] - - - -class Controller: - """ - Callback order: - - on_start - self.room not available - while running: - - on_ping - always possible (until on_disconnected) - on_bounce - self.room only session - on_hello - self.room only session - - on_connected - self.room session and chat room (fully connected) - on_snapshot - self.room session and chat room - - self.room session and chat room - - on_disconnected - self.room not connected to room any more - on_stop - self.room not available - - """ - - def __init__(self, nick, human=False, cookie=None, connect_timeout=10): - """ - roomname - name of room to connect to - human - whether the human flag should be set on connections - cookie - cookie to use in HTTP request, if any - connect_timeout - time for authentication to complete - """ - self.nick = nick - self.human = human - self.cookie = cookie - - self.roomname = "test" - self.password = None - - self.room = None - self.connect_timeout = connect_timeout # in seconds - self._connect_result = None - - def _create_room(self, roomname): - return Room(roomname, self, human=self.human, cookie=self.cookie) - - def _set_connect_result(self, result): - logger.debug(f"Attempting to set connect result to {result}") - if self._connect_result and not self._connect_result.done(): - logger.debug(f"Setting connect result to {result}") - self._connect_result.set_result(result) - - async def connect(self, roomname, password=None, timeout=10): - """ - task, reason = await connect(roomname, password=None, timeout=10) - - Connect to a room and authenticate, if necessary. - - roomname - name of the room to connect to - password - password for the room, if needed - timeout - wait this long for a reply from the server - - Returns: - task - the task running the bot, or None on failure - reason - the reason for failure - "no room" = could not establish connection, room doesn't exist - "auth option" = can't authenticate with a password - "no password" = password needed to connect to room - "wrong password" = password given does not work - "disconnected" = connection closed before client could access the room - "timeout" = timed out while waiting for server - "success" = no failure - """ - - logger.info(f"Attempting to connect to &{roomname}") - - # make sure nothing is running any more - try: - await self.stop() - except asyncio.CancelledError: - logger.error("Calling connect from the controller itself.") - raise - - self.password = password - self.room = self._create_room(roomname) - - # prepare for if connect() is successful - self._connect_result = asyncio.Future() - - # attempt to connect to the room - task = await self.room.connect() - if not task: - logger.warn(f"Could not connect to &{roomname}.") - self.room = None - return None, "no room" - - # connection succeeded, now we need to know whether we can log in - # wait for success/authentication/disconnect - try: - await asyncio.wait_for(self._connect_result, self.connect_timeout) - except asyncio.TimeoutError: - result = "timeout" - else: - result = self._connect_result.result() - - logger.debug(f"&{roomname}._connect_result: {result!r}") - - # deal with result - if result == "success": - logger.info(f"Successfully connected to &{roomname}.") - return task, result - else: # not successful for some reason - logger.warn(f"Could not join &{roomname}: {result!r}") - await self.stop() - return None, result - - async def stop(self): - if self.room: - logger.info(f"@{self.nick}: Stopping") - await self.room.stop() - logger.debug(f"@{self.nick}: Stopped. Deleting room") - self.room = None - - async def set_nick(self, nick): - if nick != self.nick: - _, _, _, to_nick = await self.room.nick(nick) - self.nick = to_nick - - if to_nick != nick: - logger.warn(f"&{self.room.roomname}: Could not set nick to {nick!r}, set to {to_nick!r} instead.") - - async def on_connected(self): - """ - Client has successfully (re-)joined the room. - - Use: Actions that are meant to happen upon (re-)connecting to a room, - such as resetting the message history. - """ - - self._set_connect_result("success") - - async def on_disconnected(self): - """ - Client has disconnected from the room. - - This is the last time the old self.room can be accessed. - Use: Reconfigure self before next connection. - Need to store information from old room? - """ - - logger.debug(f"on_disconnected: self.room is {self.room}") - self._set_connect_result("disconnected") - - async def on_bounce(self, reason=None, auth_options=[], agent_id=None, ip=None): - if "passcode" not in auth_options: - self._set_connect_result("auth option") - elif self.password is None: - self._set_connect_result("no password") - else: - success, reason = await self.room.auth("passcode", passcode=self.password) - if not success: - self._set_connect_result("wrong password") - - async def on_disconnect(self, reason): - pass - - async def on_hello(self, user_id, session, room_is_private, version, account=None, - account_has_access=None, account_email_verified=None): - pass - - async def on_join(self, session): - pass - - async def on_login(self, account_id): - pass - - async def on_logout(self): - pass - - async def on_network(self, ntype, server_id, server_era): - pass - - async def on_nick(self, session_id, user_id, from_nick, to_nick): - pass - - async def on_edit_message(self, edit_id, message): - pass - - async def on_part(self, session): - pass - - async def on_ping(self, ptime, pnext): - """ - Default implementation, refer to api.euphoria.io - """ - - logger.debug(f"&{self.room.roomname}: Pong!") - await self.room.ping_reply(ptime) - - async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id): - pass - - async def on_send(self, message): - pass - - async def on_snapshot(self, user_id, session_id, version, sessions, messages, nick=None, - pm_with_nick=None, pm_with_user_id=None): - if nick != self.nick: - await self.room.nick(self.nick) diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py index 5f6c922..ac4f3bf 100644 --- a/yaboli/cookiejar.py +++ b/yaboli/cookiejar.py @@ -12,10 +12,14 @@ class CookieJar: Keeps your cookies in a file. """ - def __init__(self, filename): + def __init__(self, filename=None): self._filename = filename self._cookies = cookies.SimpleCookie() + if not self._filename: + logger.info("Could not load cookies, no filename given.") + return + with contextlib.suppress(FileNotFoundError): with open(self._filename, "r") as f: for line in f: @@ -45,6 +49,10 @@ class CookieJar: Saves all current cookies to the cookie jar file. """ + if not self._filename: + logger.info("Could not save cookies, no filename given.") + return + logger.debug(f"Saving cookies to {self._filename!r}") with open(self._filename, "w") as f: diff --git a/yaboli/room.py b/yaboli/room.py index 947e0ad..ff3337b 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from .connection import * from .exceptions import * @@ -19,12 +20,13 @@ class Room: DISCONNECTED = 2 CLOSED = 3 - def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None): + def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None): # TODO: Connect to room etc. # TODO: Deal with room/connection states of: # disconnected connecting, fast-forwarding, connected # Room info (all fields readonly!) + self.target_nick = nick self.roomname = roomname self.password = password self.human = human @@ -33,6 +35,8 @@ class Room: self.account = None self.listing = Listing() + self.start_time = time.time() + self.account_has_access = None self.account_email_verified = None self.room_is_private = None @@ -103,6 +107,8 @@ class Room: uid = data.get("id") from_nick = data.get("from") to_nick = data.get("to") + + self.session.nick = to_nick return sid, uid, from_nick, to_nick async def pm(self, uid): @@ -260,15 +266,13 @@ class Room: # Update room info self.pm_with_nick = data.get("pm_with_nick", None), self.pm_with_user_id = data.get("pm_with_user_id", None) + self.session.nick = data.get("nick", None) - # Remember old nick, because we're going to try to get it back - old_nick = self.session.nick if self.session else None - new_nick = data.get("nick", None) - self.session.nick = new_nick - - if old_nick and old_nick != new_nick: + # Make sure a room is not CONNECTED without a nick + if self.target_nick and self.target_nick != self.session.nick: try: - await self._connection.send("nick", data={"name": old_nick}) + _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) + self.session.nick = nick_data.get("to") except ConnectionClosed: return # Aww, we've lost connection again diff --git a/yaboli/utils.py b/yaboli/utils.py index 3d5df55..48b901f 100644 --- a/yaboli/utils.py +++ b/yaboli/utils.py @@ -2,12 +2,16 @@ import asyncio import time __all__ = [ + "parallel", "mention", "mention_reduced", "similar", "format_time", "format_time_delta", "Session", "Listing", "Message", ] +# alias for parallel message sending +parallel = asyncio.ensure_future + def mention(nick): return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace())