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 86bd86a..e0f1801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## Next version +## 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 @@ -16,7 +39,7 @@ - change how config files are passed along - change module system to support config file changes -# 1.0.0 (2019-04-13) +## 1.0.0 (2019-04-13) - add fancy argument parsing - add login and logout command to room @@ -28,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 92f1e14..2cd4eb1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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.1.2 +$ 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. @@ -39,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. @@ -52,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/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 index 191feb7..f69b963 100644 --- a/examples/gitignore_with_venv +++ b/examples/gitignore_with_venv @@ -8,6 +8,10 @@ lib/ lib64 pyvenv.cfg -# config files +# 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_jar +*.cookie diff --git a/setup.py b/pyproject.toml similarity index 78% rename from setup.py rename to pyproject.toml index b3b1208..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.1.2", - 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 b138c88..527eaeb 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -17,7 +17,7 @@ from .session import * from .util import * __all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", - "run"] + "run", "run_modulebot"] __all__ += bot.__all__ __all__ += client.__all__ @@ -54,12 +54,12 @@ def run( bot_constructor: BotConstructor, config_file: str = "bot.conf", ) -> None: - # Load the config file - config = configparser.ConfigParser(allow_no_value=True) - config.read(config_file) - async def _run() -> None: while True: + # Load the config file + config = configparser.ConfigParser(allow_no_value=True) + config.read(config_file) + bot = bot_constructor(config, config_file) await bot.run() @@ -70,12 +70,12 @@ def run_modulebot( module_constructors: Dict[str, ModuleConstructor], config_file: str = "bot.conf", ) -> None: - # Load the config file - config = configparser.ConfigParser(allow_no_value=True) - config.read(config_file) - 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() diff --git a/yaboli/bot.py b/yaboli/bot.py index c696820..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -14,13 +14,43 @@ logger = logging.getLogger(__name__) __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" @@ -50,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) @@ -63,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, @@ -70,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) @@ -78,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) @@ -88,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) @@ -97,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, @@ -119,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) @@ -144,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, @@ -151,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])) @@ -159,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)) @@ -167,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})" @@ -177,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, @@ -186,8 +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/room.py b/yaboli/room.py index 5ea5e03..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()