diff --git a/.gitignore b/.gitignore index 1d164cd..7ce48d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,4 @@ -# python stuff __pycache__/ - -# venv stuff -bin/ -include/ -lib/ -lib64 -pyvenv.cfg - -# mypy stuff -.mypy_cache/ +*.egg-info/ +/.mypy_cache/ +/.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a77a9bd..e0f1801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,44 @@ ## Next version -# 1.0.0 (2019-04-13) +## 1.2.0 (2022-08-21) + +- update websockets dependency +- switch to pyproject.toml style setuptools config + +## 1.1.5 (2020-01-26) + +- more stability (I think) + +## 1.1.4 (2019-06-21) + +- add docstrings to `Bot` +- change `KILL_REPLY` and `RESTART_REPLY` to be optional in `Bot` +- fix imports +- fix room firing incorrect event +- update echobot example to newest version +- update example gitignore to newest version + +## 1.1.3 (2019-04-19) + +- add timeout for creating ws connections +- fix config file not reloading when restarting bots + +## 1.1.2 (2019-04-14) + +- fix room authentication +- resolve to test yaboli more thoroughly before publishing a new version + +## 1.1.1 (2019-04-14) + +- add database class for easier sqlite3 access + +## 1.1.0 (2019-04-14) + +- change how config files are passed along +- change module system to support config file changes + +## 1.0.0 (2019-04-13) - add fancy argument parsing - add login and logout command to room @@ -14,9 +51,9 @@ ## 0.2.0 (2019-04-12) -- change config file format - add `ALIASES` variable to `Bot` - add `on_connected` function to `Client` +- change config file format ## 0.1.0 (2019-04-12) diff --git a/README.md b/README.md index c42a5d6..2cd4eb1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ creating bots for [euphoria.io](https://euphoria.io). Ensure that you have at least Python 3.7 installed. +To install yaboli or update your installation to the latest version, run: ``` -$ pip install git+https://github.com/Garmelon/yaboli@v1.0.0 +$ pip install git+https://github.com/Garmelon/yaboli@v1.2.0 ``` The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. @@ -38,7 +39,17 @@ class EchoBot(yaboli.Bot): await message.reply(args.raw) ``` -The bot's nick, cookie file and default rooms are specified in a config file. +The bot's nick, cookie file and default rooms are specified in a config file, +like so: + +```ini +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test +``` The help command from the botrulez uses the `HELP_GENERAL` and `HELP_SPECIFIC` fields. @@ -51,6 +62,9 @@ In the `cmd_echo` function, the echo command is implemented. In this case, the bot replies to the message containing the command with the raw argument string, i. e. the text between the end of the "!echo" and the end of the whole message. +The full version of this echobot can be found [in the +examples](examples/echo/). + ## TODOs - [ ] document yaboli (markdown files in a "docs" folder?) diff --git a/docs/bot_setup.md b/docs/bot_setup.md new file mode 100644 index 0000000..cf6722d --- /dev/null +++ b/docs/bot_setup.md @@ -0,0 +1,13 @@ +# Setting up and running a bot + +## Installing yaboli + +TODO + +## Configuring the bot + +TODO + +## Running the bot + +TODO diff --git a/docs/index.md b/docs/index.md index 3311e56..9f4835f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,17 @@ # Index for yaboli docs -Links to specific sections will be added here. + - [Setting up and running a bot](bot_setup.md) + - Classes + - [Bot](bot.md) + +## Getting started + +First, read the [overview](#library-structure-overview) below. + +To set up your project, follow the [setup guide](bot_setup.md). + +To get a feel for how bots are structured, have a look at the example bots or +read through the docstrings in the `Bot` class. ## Library structure overview diff --git a/examples/echo/.gitignore b/examples/echo/.gitignore new file mode 100644 index 0000000..da78a19 --- /dev/null +++ b/examples/echo/.gitignore @@ -0,0 +1,5 @@ +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. +*.conf +*.cookie diff --git a/examples/echo/bot.conf b/examples/echo/bot.conf.default similarity index 60% rename from examples/echo/bot.conf rename to examples/echo/bot.conf.default index 8d48222..940e8e4 100644 --- a/examples/echo/bot.conf +++ b/examples/echo/bot.conf.default @@ -1,5 +1,6 @@ [general] nick = EchoBot +cookie_file = bot.cookie [rooms] test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py index 4804992..e404f3c 100644 --- a/examples/echo/echobot.py +++ b/examples/echo/echobot.py @@ -8,13 +8,14 @@ class EchoBot(yaboli.Bot): "!echo – reply with exactly ", ] - def __init__(self, config_file): - super().__init__(config_file) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.register_botrulez(kill=True) self.register_general("echo", self.cmd_echo) async def cmd_echo(self, room, message, args): - await message.reply(args.raw) + text = args.raw.strip() # ignoring leading and trailing whitespace + await message.reply(text) if __name__ == "__main__": diff --git a/examples/gitignore_with_venv b/examples/gitignore_with_venv new file mode 100644 index 0000000..f69b963 --- /dev/null +++ b/examples/gitignore_with_venv @@ -0,0 +1,17 @@ +# python stuff +__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# bot stuff +# +# These files are ignored because they may contain sensitive information you +# wouldn't want in your repo. If you need to have a config file in your repo, +# store a bot.conf.default with default settings. +*.conf +*.cookie diff --git a/setup.py b/pyproject.toml similarity index 78% rename from setup.py rename to pyproject.toml index 0ae307f..79ad530 100644 --- a/setup.py +++ b/pyproject.toml @@ -1,11 +1,13 @@ -from setuptools import setup +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" -setup( - name="yaboli", - version="1.0.0", - packages=["yaboli"], - install_requires=["websockets==7.0"], -) +[project] +name = "yaboli" +version = "1.2.0" +dependencies = [ + "websockets >=10.3, <11" +] # When updating the version, also: # - update the README.md installation instructions diff --git a/yaboli/__init__.py b/yaboli/__init__.py index a964c61..527eaeb 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,11 +1,13 @@ import asyncio +import configparser import logging -from typing import Callable +from typing import Callable, Dict from .bot import * from .client import * from .command import * from .connection import * +from .database import * from .events import * from .exceptions import * from .message import * @@ -15,12 +17,13 @@ from .session import * from .util import * __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", - "run"] + "run", "run_modulebot"] __all__ += bot.__all__ __all__ += client.__all__ __all__ += command.__all__ __all__ += connection.__all__ +__all__ += database.__all__ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ @@ -48,12 +51,33 @@ def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None: logger.addHandler(handler) def run( - client: Callable[[str], Client], - config_file: str = "bot.conf" + bot_constructor: BotConstructor, + config_file: str = "bot.conf", ) -> None: async def _run() -> None: while True: - client_ = client(config_file) - await client_.run() + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + + bot = bot_constructor(config, config_file) + await bot.run() + + asyncio.run(_run()) + +def run_modulebot( + modulebot_constructor: ModuleBotConstructor, + module_constructors: Dict[str, ModuleConstructor], + config_file: str = "bot.conf", + ) -> None: + async def _run() -> None: + while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + + modulebot = modulebot_constructor(config, config_file, + module_constructors) + await modulebot.run() asyncio.run(_run()) diff --git a/yaboli/bot.py b/yaboli/bot.py index 3006773..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,7 +1,7 @@ import configparser import datetime import logging -from typing import List, Optional +from typing import Callable, List, Optional from .client import Client from .command import * @@ -11,26 +11,57 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Bot"] +__all__ = ["Bot", "BotConstructor"] class Bot(Client): + """ + A Bot is a Client that responds to commands and uses a config file to + automatically set its nick and join rooms. + + The config file is loaded as a ConfigParser by the run() or run_modulebot() + functions and has the following structure: + + A "general" section which contains: + - nick - the default nick of the bot (set to the empty string if you don't + want to set a nick) + - cookie_file (optional) - the file the cookie should be saved in + + A "rooms" section which contains a list of rooms that the bot should + automatically join. This section is optional if you overwrite started(). + The room list should have the format "roomname" or "roomname = password". + + A bot has the following attributes: + - ALIASES - list of alternate nicks the bot responds to (see + process_commands()) + - PING_REPLY - used by cmd_ping() + - HELP_GENERAL - used by cmd_help_general() + - HELP_SPECIFIC - used by cmd_help_specific() + - KILL_REPLY - used by cmd_kill() + - RESTART_REPLY - used by cmd_restart() + - GENERAL_SECTION - the name of the "general" section in the config file + (see above) (default: "general") + - ROOMS_SECTION - the name of the "rooms" section in the config file (see + above) (default: "rooms") + """ + ALIASES: List[str] = [] PING_REPLY: str = "Pong!" HELP_GENERAL: Optional[str] = None HELP_SPECIFIC: Optional[List[str]] = None - KILL_REPLY: str = "/me dies" - RESTART_REPLY: str = "/me restarts" + KILL_REPLY: Optional[str] = "/me dies" + RESTART_REPLY: Optional[str] = "/me restarts" GENERAL_SECTION = "general" ROOMS_SECTION = "rooms" - def __init__(self, config_file: str) -> None: + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + ) -> None: + self.config = config self.config_file = config_file - self.config = configparser.ConfigParser(allow_no_value=True) - self.config.read(self.config_file) - nick = self.config[self.GENERAL_SECTION].get("nick") if nick is None: logger.warn(("'nick' not set in config file. Defaulting to empty" @@ -49,10 +80,26 @@ class Bot(Client): self.start_time = datetime.datetime.now() def save_config(self) -> None: + """ + Save the current state of self.config to the file passed in __init__ as + the config_file parameter. + + Usually, this is the file that self.config was loaded from (if you use + run or run_modulebot). + """ + with open(self.config_file, "w") as f: self.config.write(f) async def started(self) -> None: + """ + This Client function is overwritten in order to join all the rooms + listed in the "rooms" section of self.config. + + If you need to overwrite this function but want to keep the auto-join + functionality, make sure to await super().started(). + """ + for room, password in self.config[self.ROOMS_SECTION].items(): if password is None: await self.join(room) @@ -62,6 +109,12 @@ class Bot(Client): # Registering commands def register(self, command: Command) -> None: + """ + Register a Command (from the yaboli.command submodule). + + Usually, you don't have to call this function yourself. + """ + self._commands.append(command) def register_general(self, @@ -69,6 +122,23 @@ class Bot(Client): cmdfunc: GeneralCommandFunction, args: bool = True ) -> None: + """ + Register a function as general bot command (i. e. no @mention of the + bot nick after the !command). This function will be called by + process_commands() when the bot encounters a matching command. + + name - the name of the command (If you want your command to be !hello, + the name is "hello".) + + cmdfunc - the function that is called with the Room, LiveMessage and + ArgumentData when the bot encounters a matching command + + args - whether the command may have arguments (If set to False, the + ArgumentData's has_args() function must also return False for the + command function to be called. If set to True, all ArgumentData is + valid.) + """ + command = GeneralCommand(name, cmdfunc, args) self.register(command) @@ -77,6 +147,21 @@ class Bot(Client): cmdfunc: SpecificCommandFunction, args: bool = True ) -> None: + """ + Register a function as specific bot command (i. e. @mention of the bot + nick after the !command is required). This function will be called by + process_commands() when the bot encounters a matching command. + + name - the name of the command (see register_general() for an + explanation) + + cmdfunc - the function that is called with the Room, LiveMessage and + SpecificArgumentData when the bot encounters a matching command + + args - whether the command may have arguments (see register_general() + for an explanation) + """ + command = SpecificCommand(name, cmdfunc, args) self.register(command) @@ -87,6 +172,13 @@ class Bot(Client): message: LiveMessage, aliases: List[str] = [] ) -> None: + """ + If the message contains a command, call all matching command functions + that were previously registered. + + This function is usually called by the overwritten on_send() function. + """ + nicks = [room.session.nick] + aliases data = CommandData.from_string(message.content) @@ -96,11 +188,31 @@ class Bot(Client): await command.run(room, message, nicks, data) async def on_send(self, room: Room, message: LiveMessage) -> None: + """ + This Client function is overwritten in order to automatically call + process_commands() with self.ALIASES. + + If you need to overwrite this function, make sure to await + process_commands() with self.ALIASES somewhere in your function, or + await super().on_send(). + """ + await self.process_commands(room, message, aliases=self.ALIASES) # Help util def format_help(self, room: Room, lines: List[str]) -> str: + """ + Format a list of strings into a string, replacing certain placeholders + with the actual values. + + This function uses the str.format() function to replace the following: + + - {nick} - the bot's current nick + - {mention} - the bot's current nick, run through mention() + - {atmention} - the bot's current nick, run through atmention() + """ + text = "\n".join(lines) params = { "nick": room.session.nick, @@ -118,6 +230,36 @@ class Bot(Client): kill: bool = False, restart: bool = False, ) -> None: + """ + Register the commands necessary for the bot to conform to the botrulez + (https://github.com/jedevc/botrulez). Also includes a few optional + botrulez commands that are disabled by default. + + - ping - register general and specific cmd_ping() + - help_ - register cmd_help_general() and cmd_help_specific() + - uptime - register specific cmd_uptime + - kill - register specific cmd_kill (disabled by default) + - uptime - register specific cmd_uptime (disabled by default) + + All commands are registered with args=False. + + If you want to implement your own versions of these commands, it is + recommended that you set the respective argument to False in your call + to register_botrulez(), overwrite the existing command functions or + create your own, and then register them manually. + + For help, that might look something like this, if you've written a + custom specific help that takes extra arguments but are using the + botrulez general help: + + self.register_botrulez(help_=False) + self.register_general("help", self.cmd_help_general, args=False) + self.register_specific("help", self.cmd_help_custom) + + In case you're asking, the help_ parameter has an underscore at the end + so it doesn't overlap the help() function. + """ + if ping: self.register_general("ping", self.cmd_ping, args=False) self.register_specific("ping", self.cmd_ping, args=False) @@ -143,6 +285,10 @@ class Bot(Client): message: LiveMessage, args: ArgumentData ) -> None: + """ + Reply with self.PING_REPLY. + """ + await message.reply(self.PING_REPLY) async def cmd_help_general(self, @@ -150,6 +296,10 @@ class Bot(Client): message: LiveMessage, args: ArgumentData ) -> None: + """ + Reply with self.HELP_GENERAL, if it is not None. Uses format_help(). + """ + if self.HELP_GENERAL is not None: await message.reply(self.format_help(room, [self.HELP_GENERAL])) @@ -158,6 +308,10 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Reply with self.HELP_SPECIFIC, if it is not None. Uses format_help(). + """ + if self.HELP_SPECIFIC is not None: await message.reply(self.format_help(room, self.HELP_SPECIFIC)) @@ -166,6 +320,15 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Reply with the bot's uptime in the format specified by the botrulez. + + This uses the time that the Bot was first started, not the time the + respective Room was created. A !restart (see register_botrulez()) will + reset the bot uptime, but leaving and re-joining a room or losing + connection won't. + """ + time = format_time(self.start_time) delta = format_delta(datetime.datetime.now() - self.start_time) text = f"/me has been up since {time} UTC ({delta})" @@ -176,8 +339,18 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Remove the bot from this room. + + If self.KILL_REPLY is not None, replies with that before leaving the + room. + """ + logger.info(f"Killed in &{room.name} by {message.sender.atmention}") - await message.reply(self.KILL_REPLY) + + if self.KILL_REPLY is not None: + await message.reply(self.KILL_REPLY) + await self.part(room) async def cmd_restart(self, @@ -185,6 +358,20 @@ class Bot(Client): message: LiveMessage, args: SpecificArgumentData ) -> None: + """ + Restart the whole Bot. + + This is done by stopping the Bot, since the run() or run_modulebot() + functions start the Bot in a while True loop. + + If self.RESTART_REPLY is not None, replies with that before restarting. + """ + logger.info(f"Restarted in &{room.name} by {message.sender.atmention}") - await message.reply(self.RESTART_REPLY) + + if self.RESTART_REPLY is not None: + await message.reply(self.RESTART_REPLY) + await self.stop() + +BotConstructor = Callable[[configparser.ConfigParser, str], Bot] diff --git a/yaboli/connection.py b/yaboli/connection.py index af31d1c..fcc27fe 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -82,6 +82,9 @@ class Connection: "part-event" and "ping". """ + # Timeout for waiting for the ws connection to be established + CONNECT_TIMEOUT = 10 # seconds + # Maximum duration between euphoria's ping messages. Euphoria usually sends # ping messages every 20 to 30 seconds. PING_TIMEOUT = 40 # seconds @@ -183,8 +186,12 @@ class Connection: try: logger.debug(f"Creating ws connection to {self._url!r}") - ws = await websockets.connect(self._url, - extra_headers=self._cookie_jar.get_cookies_as_headers()) + ws = await asyncio.wait_for( + websockets.connect(self._url, + extra_headers=self._cookie_jar.get_cookies_as_headers()), + self.CONNECT_TIMEOUT + ) + logger.debug(f"Established ws connection to {self._url!r}") self._ws = ws self._awaiting_replies = {} @@ -200,7 +207,7 @@ class Connection: return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - socket.gaierror): + OSError, asyncio.TimeoutError): logger.debug("Connection failed") return False diff --git a/yaboli/database.py b/yaboli/database.py new file mode 100644 index 0000000..84af548 --- /dev/null +++ b/yaboli/database.py @@ -0,0 +1,40 @@ +import asyncio +import logging +import sqlite3 +from typing import Any, Awaitable, Callable, TypeVar + +from .util import asyncify + +logger = logging.getLogger(__name__) + +__all__ = ["Database", "operation"] + +T = TypeVar('T') + +def operation(func: Callable[..., T]) -> Callable[..., Awaitable[T]]: + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> T: + async with self as db: + while True: + try: + return await asyncify(func, self, db, *args, **kwargs) + except sqlite3.OperationalError as e: + logger.warn(f"Operational error encountered: {e}") + await asyncio.sleep(5) + return wrapper + +class Database: + def __init__(self, database: str) -> None: + self._connection = sqlite3.connect(database, check_same_thread=False) + self._lock = asyncio.Lock() + + self.initialize(self._connection) + + def initialize(self, db: Any) -> None: + pass + + async def __aenter__(self) -> Any: + await self._lock.__aenter__() + return self._connection + + async def __aexit__(self, *args: Any, **kwargs: Any) -> Any: + return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/module.py b/yaboli/module.py index 2dc9b0f..ac750bf 100644 --- a/yaboli/module.py +++ b/yaboli/module.py @@ -1,5 +1,6 @@ +import configparser import logging -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional from .bot import Bot from .command import * @@ -10,49 +11,77 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Module", "ModuleBot"] +__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"] class Module(Bot): DESCRIPTION: Optional[str] = None - def __init__(self, config_file: str, standalone: bool) -> None: - super().__init__(config_file) + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + standalone: bool = True, + ) -> None: + super().__init__(config, config_file) self.standalone = standalone +ModuleConstructor = Callable[[configparser.ConfigParser, str, bool], Module] + class ModuleBot(Bot): HELP_PRE: Optional[List[str]] = [ "This bot contains the following modules:" ] HELP_POST: Optional[List[str]] = [ - "" - "Use \"!help {atmention} \" to get more information on a" - " specific module." + "", + "For module-specific help, try \"!help {atmention} \".", ] MODULE_HELP_LIMIT = 5 - def __init__(self, config_file: str) -> None: - super().__init__(config_file) + MODULES_SECTION = "modules" + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + module_constructors: Dict[str, ModuleConstructor], + ) -> None: + super().__init__(config, config_file) + + self.module_constructors = module_constructors self.modules: Dict[str, Module] = {} - self.register_botrulez(help_=False) - self.register_general("help", self.cmd_help_general, args=False) - self.register_specific("help", self.cmd_help_specific, args=True) + # Load initial modules + for module_name in self.config[self.MODULES_SECTION]: + module_constructor = self.module_constructors.get(module_name) + if module_constructor is None: + logger.warn(f"Module {module_name} not found") + continue + # standalone is set to False + module = module_constructor(self.config, self.config_file, False) + self.load_module(module_name, module) - def register_module(self, name: str, module: Module) -> None: + def load_module(self, name: str, module: Module) -> None: if name in self.modules: logger.warn(f"Module {name!r} is already registered, overwriting...") self.modules[name] = module + def unload_module(self, name: str) -> None: + if name in self.modules: + del self.modules[name] + + # Better help messages + def compile_module_overview(self) -> List[str]: lines = [] if self.HELP_PRE is not None: lines.extend(self.HELP_PRE) + any_modules = False + modules_without_desc: List[str] = [] for module_name in sorted(self.modules): + any_modules = True + module = self.modules[module_name] if module.DESCRIPTION is None: @@ -62,7 +91,10 @@ class ModuleBot(Bot): lines.append(line) if modules_without_desc: - lines.append(", ".join(modules_without_desc)) + lines.append("\t" + ", ".join(modules_without_desc)) + + if not any_modules: + lines.append("No modules loaded.") if self.HELP_POST is not None: lines.extend(self.HELP_POST) @@ -79,8 +111,7 @@ class ModuleBot(Bot): return module.HELP_SPECIFIC - # Overwriting the botrulez help function - async def cmd_help_specific(self, + async def cmd_modules_help(self, room: Room, message: LiveMessage, args: SpecificArgumentData @@ -100,6 +131,12 @@ class ModuleBot(Bot): # Sending along all kinds of events + async def on_connected(self, room: Room) -> None: + await super().on_connected(room) + + for module in self.modules.values(): + await module.on_connected(room) + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: await super().on_snapshot(room, messages) @@ -141,6 +178,18 @@ class ModuleBot(Bot): for module in self.modules.values(): await module.on_edit(room, message) + async def on_login(self, room: Room, account_id: str) -> None: + await super().on_login(room, account_id) + + for module in self.modules.values(): + await module.on_login(room, account_id) + + async def on_logout(self, room: Room) -> None: + await super().on_logout(room) + + for module in self.modules.values(): + await module.on_logout(room) + async def on_pm(self, room: Room, from_id: str, @@ -158,3 +207,8 @@ class ModuleBot(Bot): for module in self.modules.values(): await module.on_disconnect(room, reason) + +ModuleBotConstructor = Callable[ + [configparser.ConfigParser, str, Dict[str, ModuleConstructor]], + Bot +] diff --git a/yaboli/room.py b/yaboli/room.py index 905162f..d1304ee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -180,10 +180,10 @@ class Room: if nick is not None and self._session is not None: self._session = self.session.with_nick(nick) - # Send "session" event + # Send "snapshot" event messages = [LiveMessage.from_data(self, msg_data) for msg_data in data["log"]] - self._events.fire("session", messages) + self._events.fire("snapshot", messages) self._snapshot_received = True await self._try_set_connected() @@ -191,8 +191,11 @@ class Room: async def _on_bounce_event(self, packet: Any) -> None: data = packet["data"] - # Can we even authenticate? - if not "passcode" in data.get("auth_options", []): + # Can we even authenticate? (Assuming that passcode authentication is + # available if no authentication options are given: Euphoria doesn't + # (always) send authentication options, even when passcode + # authentication works.) + if not "passcode" in data.get("auth_options", ["passcode"]): self._set_connected_failed() return diff --git a/yaboli/util.py b/yaboli/util.py index 6439799..e8395d9 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,8 +1,16 @@ +import asyncio import datetime +import functools import re +from typing import Any, Callable -__all__ = ["mention", "atmention", "normalize", "similar", "plural", - "format_time", "format_delta"] +__all__ = ["asyncify", "mention", "atmention", "normalize", "similar", + "plural", "format_time", "format_delta"] + +async def asyncify(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + func_with_args = functools.partial(func, *args, **kwargs) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, func_with_args) # Name/nick related functions