diff --git a/.gitignore b/.gitignore index bf7ff1a..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/ +__pycache__/ +*.egg-info/ +/.mypy_cache/ +/.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0f1801 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## 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 +- 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 +- add pm command to room +- add cookie support +- add !restart to botrulez +- add Bot config file saving +- fix the Room not setting its nick correctly upon reconnecting + +## 0.2.0 (2019-04-12) + +- add `ALIASES` variable to `Bot` +- add `on_connected` function to `Client` +- change config file format + +## 0.1.0 (2019-04-12) + +- use setuptools diff --git a/LICENSE b/LICENSE index 8c068df..f2fd14f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Garmelon +Copyright (c) 2018 - 2019 Garmelon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cd4eb1 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Yaboli + +Yaboli (**Y**et **A**nother **Bo**t **Li**brary) is a Python library for +creating bots for [euphoria.io](https://euphoria.io). + +- [Documentation](docs/index.md) +- [Changelog](CHANGELOG.md) + +## Installation + +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.2.0 +``` + +The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. + +## Example echo bot + +A simple echo bot that conforms to the +[botrulez](https://github.com/jedevc/botrulez) can be written like so: + +```python +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + def __init__(self, config_file): + super().__init__(config_file) + 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) +``` + +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. + +In the `__init__` function, the bot's commands are registered. The required +botrulez commands (!ping, !help, !uptime) are enabled by default. Other +commands like !kill need to be enabled explicitly. + +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?) +- [ ] document new classes (docstrings, maybe comments) +- [ ] write examples +- [ ] make yaboli package play nice with mypy +- [x] implement !uptime for proper botrulez conformity +- [x] implement !kill +- [x] untruncate LiveMessage-s +- [x] config file support for bots, used by default +- [x] make it easier to enable log messages +- [x] make it easier to run bots +- [x] package in a distutils-compatible way (users should be able to install + yaboli using `pip install git+https://github.com/Garmelon/yaboli`) +- [x] implement !restart +- [x] write project readme +- [x] cookie support +- [x] fancy argument parsing 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 new file mode 100644 index 0000000..9f4835f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,89 @@ +# Index for yaboli docs + + - [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 + +### Message, Session + +A `Message` represents a single message. It contains all the fields [specified +in the API](http://api.euphoria.io/#message), in addition to a few utility +functions. + +Similar to a `Message`, a `Session` represents a [session +view](http://api.euphoria.io/#sessionview) and also contains almost all the +fields specified in the API, in addition to a few utility functions. + +`Message`s and `Session`s also both contain the name of the room they +originated from. + +### Room + +A `Room` represents a single connection to a room on euphoria. It tries to keep +connected and reconnects if it loses connection. When connecting and +reconnecting, it automatically authenticates and sets a nick. + +In addition, a `Room` also keeps track of its own session and the sessions of +all other people and bots connected to the room. It doesn't remember any +messages though, since no "correct" solution to do that exists and the method +depends on the design of the bot using the `Room` (keeping the last few +messages in memory, storing messages in a database etc.). + +### LiveMessage, LiveSession + +`LiveMessage`s and `LiveSession`s function the same as `Message`s and +`Session`s, with the difference that they contain the `Room` object they +originated from, instead of just a room name. This allows them to also include +a few convenience functions, like `Message.reply`. + +Usually, `Room`s and `Client`s (and thus `Bot`s) will pass `LiveMessage`s and +`LiveSession`s instead of their `Message` and `Session` counterparts. + +### Client + +A `Client` may be connected to a few rooms on euphoria and thus manages a few +`Room` objects. It has functions for joining and leaving rooms on euphoria, and +it can also be connected to the same room multiple times (resulting in multiple +`Room` objects). + +The `Client` has a few `on_` functions (e. g. `on_message`, `on_join`) +that are triggered by events in any of the `Room` objects it manages. This +allows a `Client` to react to various things happening in its `Room`s. + +### Bot + +A `Bot` is a client that: + +- is configured using a config file +- reacts to commands using a command system +- implements most commands specified in the + [botrulez](https://github.com/jedevc/botrulez) + +The config file includes the bot's default nick, initial rooms and bot-specific +configuration. Upon starting a `Bot`, it joins the rooms specified in the +config, setting its nick to the default nick. + +The command system can react to general and specific commands as specified in +the botrulez, and can parse command arguments with or without bash-style string +escaping, and with or without unix-like syntax (flags and optional arguments). + +### Module, ModuleBot + +A `Module` is a `Bot` that can also be used as a module in a `ModuleBot`. This +is like combining multiple bots into a single bot. + +The most notable differences are the new `DESCRIPTION` and `standalone` fields. +The `DESCRIPTION` field contains a short description of the module, whereas the +`standalone` field answers the question whether the `Module` is being run as +standalone bot or part of a `ModuleBot`. diff --git a/example.py b/example.py deleted file mode 100644 index 97aff03..0000000 --- a/example.py +++ /dev/null @@ -1,26 +0,0 @@ -import yyb - -class MyClient(yyb.Client): - async def on_join(self, room): - await room.say("Hello!") - - async def on_message(self, message): - if message.content == "reply to me"): - reply = await message.reply("reply") - await reply.reply("reply to the reply") - await message.room.say("stuff going on") - - elif message.content == "hey, join &test!": - # returns room in phase 3, or throws JoinException - room = await self.join("test") - if room: - room.say("hey, I joined!") - else: - message.reply("didn't work :(") - - async def before_part(self, room): - await room.say("Goodbye!") - -# Something like this, I guess. It's still missing password fields though. -c = MyClient("my:bot:") -c.run("test", "bots") 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.default b/examples/echo/bot.conf.default new file mode 100644 index 0000000..940e8e4 --- /dev/null +++ b/examples/echo/bot.conf.default @@ -0,0 +1,6 @@ +[general] +nick = EchoBot +cookie_file = bot.cookie + +[rooms] +test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py new file mode 100644 index 0000000..e404f3c --- /dev/null +++ b/examples/echo/echobot.py @@ -0,0 +1,23 @@ +import yaboli + + +class EchoBot(yaboli.Bot): + HELP_GENERAL = "/me echoes back what you said" + HELP_SPECIFIC = [ + "This bot only has one command:", + "!echo – reply with exactly ", + ] + + 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): + text = args.raw.strip() # ignoring leading and trailing whitespace + await message.reply(text) + + +if __name__ == "__main__": + yaboli.enable_logging() + yaboli.run(EchoBot) 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/info.txt b/info.txt deleted file mode 100644 index f33cfb7..0000000 --- a/info.txt +++ /dev/null @@ -1,39 +0,0 @@ -Signature of a normal function: - -def a(b: int, c: str) -> bool: - pass - -a # type: Callable[[int, str], bool] - -Signature of an async function: - -async def a(b: int, c: str) -> bool: - pass - -a # type: Callable[[int, str], Awaitable[bool]] - - - -Enable logging (from the websockets docs): - -import logging -logger = logging.getLogger('websockets') -logger.setLevel(logging.INFO) -logger.addHandler(logging.StreamHandler()) - -Output format: See https://docs.python.org/3/library/logging.html#formatter-objects - -Example formatting: - -FORMAT = "{asctime} [{levelname:<7}] <{name}> {funcName}(): {message}" -DATE_FORMAT = "%F %T" -handler = logging.StreamHandler() -handler.setFormatter(logging.Formatter( - fmt=FORMAT, - datefmt=DATE_FORMAT, - style="{" -)) - -logger = logging.getLogger('yaboli') -logger.setLevel(logging.DEBUG) -logger.addHandler(handler) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79ad530 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "yaboli" +version = "1.2.0" +dependencies = [ + "websockets >=10.3, <11" +] + +# When updating the version, also: +# - update the README.md installation instructions +# - update the changelog +# - set a tag to the update commit + +# Meanings of version numbers +# +# Format: a.b.c +# +# a - increased when: major change such as a rewrite +# b - increased when: changes breaking backwards compatibility +# c - increased when: minor changes preserving backwards compatibility +# +# To specify version requirements for yaboli, the following format is +# recommended if you need version a.b.c: +# +# yaboli >=a.b.c, – output the arguments, each in its own line" - #"!fancyecho – same as !echo, but different parser" - ] - - def __init__(self, standalone: bool) -> None: - super().__init__(standalone) - - self.register_general("echo", self.cmd_echo) - #self.register_general("fancyecho", self.cmd_fancyecho) - - async def cmd_echo(self, room, message, args): - if args.has_args(): - lines = [repr(arg) for arg in args.basic()] - await message.reply("\n".join(lines)) - else: - await message.reply("No arguments") - -class TestBot(yaboli.ModuleBot): - DEFAULT_NICK = "testbot" - - async def started(self): - await self.join("test") - -async def main(): - tb = TestBot() - tb.register_module("test", TestModule(standalone=False)) - tb.register_module("echo", EchoModule(standalone=False)) - await tb.run() - -asyncio.run(main()) diff --git a/yaboli/__init__.py b/yaboli/__init__.py index e749ce5..527eaeb 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,9 +1,13 @@ -from typing import List +import asyncio +import configparser +import logging +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 * @@ -12,11 +16,14 @@ from .room import * from .session import * from .util import * -__all__: List[str] = [] +__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", + "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__ @@ -24,3 +31,53 @@ __all__ += module.__all__ __all__ += room.__all__ __all__ += session.__all__ __all__ += util.__all__ + +STYLE = "{" +FORMAT = "{asctime} [{levelname:<7}] <{name}>: {message}" +DATE_FORMAT = "%F %T" + +FORMATTER = logging.Formatter( + fmt=FORMAT, + datefmt=DATE_FORMAT, + style=STYLE +) + +def enable_logging(name: str = "yaboli", level: int = logging.INFO) -> None: + handler = logging.StreamHandler() + handler.setFormatter(FORMATTER) + + logger = logging.getLogger(name) + logger.setLevel(level) + logger.addHandler(handler) + +def run( + bot_constructor: BotConstructor, + 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) + + 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 942470d..97385cb 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,28 +1,120 @@ +import configparser +import datetime import logging -from typing import List, Optional +from typing import Callable, List, Optional from .client import Client from .command import * from .message import LiveMessage, Message from .room import Room +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: Optional[str] = "/me dies" + RESTART_REPLY: Optional[str] = "/me restarts" - def __init__(self) -> None: - super().__init__() + GENERAL_SECTION = "general" + ROOMS_SECTION = "rooms" + + def __init__(self, + config: configparser.ConfigParser, + config_file: str, + ) -> None: + self.config = config + self.config_file = 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" + " nick")) + nick = "" + + cookie_file = self.config[self.GENERAL_SECTION].get("cookie_file") + if cookie_file is None: + logger.warn(("'cookie_file' not set in config file. Using no cookie" + " file.")) + + super().__init__(nick, cookie_file=cookie_file) self._commands: List[Command] = [] + 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) + else: + await self.join(room, password=password) + # 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, @@ -30,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) @@ -38,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) @@ -48,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) @@ -57,11 +188,31 @@ class Bot(Client): await command.run(room, message, nicks, data) async def on_send(self, room: Room, message: LiveMessage) -> None: - await self.process_commands(room, message) + """ + 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, @@ -74,8 +225,41 @@ class Bot(Client): def register_botrulez(self, ping: bool = True, - help_: bool = True + help_: bool = True, + uptime: bool = True, + 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) @@ -87,11 +271,24 @@ class Bot(Client): self.register_general("help", self.cmd_help_general, args=False) self.register_specific("help", self.cmd_help_specific, args=False) + if uptime: + self.register_specific("uptime", self.cmd_uptime, args=False) + + if kill: + self.register_specific("kill", self.cmd_kill, args=False) + + if restart: + self.register_specific("restart", self.cmd_restart, args=False) + async def cmd_ping(self, room: Room, message: LiveMessage, args: ArgumentData ) -> None: + """ + Reply with self.PING_REPLY. + """ + await message.reply(self.PING_REPLY) async def cmd_help_general(self, @@ -99,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])) @@ -107,5 +308,70 @@ 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)) + + async def cmd_uptime(self, + room: Room, + 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})" + await message.reply(text) + + async def cmd_kill(self, + room: Room, + 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}") + + if self.KILL_REPLY is not None: + await message.reply(self.KILL_REPLY) + + await self.part(room) + + async def cmd_restart(self, + room: Room, + 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}") + + 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/client.py b/yaboli/client.py index c1f19ff..75806fb 100644 --- a/yaboli/client.py +++ b/yaboli/client.py @@ -1,7 +1,7 @@ import asyncio import functools import logging -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from .message import LiveMessage from .room import Room @@ -12,9 +12,12 @@ logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: - DEFAULT_NICK = "" - - def __init__(self) -> None: + def __init__(self, + default_nick: str, + cookie_file: Optional[str] = None, + ) -> None: + self._default_nick = default_nick + self._cookie_file = cookie_file self._rooms: Dict[str, List[Room]] = {} self._stop = asyncio.Event() @@ -49,14 +52,34 @@ class Client: async def join(self, room_name: str, password: Optional[str] = None, - nick: Optional[str] = None + nick: Optional[str] = None, + cookie_file: Union[str, bool] = True, ) -> Optional[Room]: + """ + cookie_file is the name of the file to store the cookies in. If it is + True, the client default is used. If it is False, no cookie file name + will be used. + """ + logger.info(f"Joining &{room_name}") if nick is None: - nick = self.DEFAULT_NICK - room = Room(room_name, password=password, target_nick=nick) + nick = self._default_nick + this_cookie_file: Optional[str] + + if isinstance(cookie_file, str): # This way, mypy doesn't complain + this_cookie_file = cookie_file + elif cookie_file: + this_cookie_file = self._cookie_file + else: + this_cookie_file = None + + room = Room(room_name, password=password, target_nick=nick, + cookie_file=this_cookie_file) + + room.register_event("connected", + functools.partial(self.on_connected, room)) room.register_event("snapshot", functools.partial(self.on_snapshot, room)) room.register_event("send", @@ -103,6 +126,9 @@ class Client: # Event stuff - overwrite these functions + async def on_connected(self, room: Room) -> None: + pass + async def on_snapshot(self, room: Room, messages: List[LiveMessage]) -> None: pass @@ -126,6 +152,12 @@ class Client: async def on_edit(self, room: Room, message: LiveMessage) -> None: pass + async def on_login(self, room: Room, account_id: str) -> None: + pass + + async def on_logout(self, room: Room) -> None: + pass + async def on_pm(self, room: Room, from_id: str, diff --git a/yaboli/command.py b/yaboli/command.py index 44d629d..08ac3f7 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -23,13 +23,74 @@ __all__ = ["FancyArgs", "ArgumentData", "SpecificArgumentData", "CommandData", "SpecificCommandFunction", "SpecificCommand"] class FancyArgs(NamedTuple): + """ + The fancy argument parser supports arguments of the following formats: + + + FLAGS: + + These are one or more characters preceded by a single dash. Examples: + + -a, -fghf, -vv + + The fancy argument parser counts how often each character (also called + flag) appears. Each flag that appears once or more gets an entry in the + "flags" dict of the form: flags[flag] = amount + + Exception: A single dash ("-") is interpreted as a positional argument. + + + OPTIONAL: + + These are arguments of the form -- or --=, where + is the name of the optional argument and is its (optional) value. + + Due to this syntax, the may not include any "=" signs. + + The optional arguments are collected in a dict of the form: + + optional[name] = value or None + + If the optional argument included a "=" after the name, but no further + characters, its value is the empty string. If it didn't include a "=" after + the name, its value is None. + + If more than one optional argument appears with the same name, the last + argument's value is kept and all previous values discarded. + + + POSITIONAL: + + Positional arguments are all arguments that don't start with "-" or "--". + They are compiled in a list and ordered in the same order they appeared in + after the command. + + + RAW: + + At any time, a single "--" argument may be inserted. This separates the + positional and optional arguments and the flags from the raw arguments. All + arguments after the "--" are interpreted as raw arguments, even flags, + optional arguments and further "--"s. + + For example, consider the following arguments: + + ab -cd -c --ef=g --h i -- j --klm -nop -- qr + + positional: ["ab", "i"] + optional: {"ef": "g", "h": None} + flags: {"c": 2, "d": 1} + raw: ["j", "--klm", "-nop", "--", "qr"] + """ + positional: List[str] optional: Dict[str, Optional[str]] flags: Dict[str, int] + raw: List[str] class ArgumentData: - def __init__(self, argstr: str) -> None: - self._argstr = argstr + def __init__(self, raw: str) -> None: + self._raw = raw self._basic: Optional[List[str]] = None self._basic_escaped: Optional[List[str]] = None @@ -94,31 +155,62 @@ class ArgumentData: return text.split() def _parse_fancy(self, args: List[str]) -> FancyArgs: - raise NotImplementedError + positional: List[str] = [] + optional: Dict[str, Optional[str]] = {} + flags: Dict[str, int] = {} + raw: List[str] = [] + + is_raw = False + + for arg in args: + # raw arguments + if is_raw: + raw.append(arg) + # raw arguments separator + elif arg == "--": + is_raw = True + # optional arguments + elif arg[:2] == "--": + split = arg[2:].split("=", maxsplit=1) + name = split[0] + value = split[1] if len(split) == 2 else None + optional[name] = value + # the "-" exception + elif arg == "-": + positional.append(arg) + # flags + elif arg[:1] == "-": + for char in arg[1:]: + flags[char] = flags.get(char, 0) + 1 + # positional arguments + else: + positional.append(arg) + + return FancyArgs(positional, optional, flags, raw) @property - def argstr(self) -> str: - return self._argstr + def raw(self) -> str: + return self._raw def basic(self, escaped: bool = True) -> List[str]: if escaped: if self._basic_escaped is None: - self._basic_escaped = self._split(self._argstr, escaped) + self._basic_escaped = self._split(self._raw, escaped) return self._basic_escaped else: if self._basic is None: - self._basic = self._split(self._argstr, escaped) + self._basic = self._split(self._raw, escaped) return self._basic def fancy(self, escaped: bool = True) -> FancyArgs: if escaped: if self._fancy_escaped is None: - basic = self._split(self._argstr, escaped) + basic = self._split(self._raw, escaped) self._fancy_escaped = self._parse_fancy(basic) return self._fancy_escaped else: if self._fancy is None: - basic = self._split(self._argstr, escaped) + basic = self._split(self._raw, escaped) self._fancy = self._parse_fancy(basic) return self._fancy @@ -126,8 +218,8 @@ class ArgumentData: return bool(self.basic()) # The list of arguments is empty class SpecificArgumentData(ArgumentData): - def __init__(self, nick: str, argstr: str) -> None: - super().__init__(argstr) + def __init__(self, nick: str, raw: str) -> None: + super().__init__(raw) self._nick = nick diff --git a/yaboli/connection.py b/yaboli/connection.py index aa8dd60..fcc27fe 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional import websockets +from .cookiejar import CookieJar from .events import Events from .exceptions import * @@ -81,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 @@ -97,8 +101,9 @@ class Connection: # Initialising - def __init__(self, url: str) -> None: + def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: self._url = url + self._cookie_jar = CookieJar(cookie_file) self._events = Events() self._packet_id = 0 @@ -181,7 +186,12 @@ class Connection: try: logger.debug(f"Creating ws connection to {self._url!r}") - ws = await websockets.connect(self._url) + 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 = {} @@ -189,10 +199,15 @@ class Connection: self._ping_check = asyncio.create_task( self._disconnect_in(self.PING_TIMEOUT)) + # Put received cookies into cookie jar + for set_cookie in ws.response_headers.get_all("Set-Cookie"): + self._cookie_jar.add_cookie(set_cookie) + self._cookie_jar.save() + return True except (websockets.InvalidHandshake, websockets.InvalidStatusCode, - socket.gaierror): + OSError, asyncio.TimeoutError): logger.debug("Connection failed") return False @@ -438,10 +453,11 @@ class Connection: # to http://api.euphoria.io/#packets. # First, notify whoever's waiting for this packet - packet_id = packet.get("id", None) + packet_id = packet.get("id") if packet_id is not None and self._awaiting_replies is not None: - future = self._awaiting_replies.get(packet_id, None) + future = self._awaiting_replies.get(packet_id) if future is not None: + del self._awaiting_replies[packet_id] future.set_result(packet) # Then, send the corresponding event diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py new file mode 100644 index 0000000..833dbcb --- /dev/null +++ b/yaboli/cookiejar.py @@ -0,0 +1,77 @@ +import contextlib +import http.cookies as cookies +import logging +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + +__all__ = ["CookieJar"] + +class CookieJar: + """ + Keeps your cookies in a file. + + CookieJar doesn't attempt to discard old cookies, but that doesn't appear + to be necessary for keeping euphoria session cookies. + """ + + def __init__(self, filename: Optional[str] = None) -> None: + self._filename = filename + self._cookies = cookies.SimpleCookie() + + if not self._filename: + logger.warning("Could not load cookies, no filename given.") + return + + with contextlib.suppress(FileNotFoundError): + logger.info(f"Loading cookies from {self._filename!r}") + with open(self._filename, "r") as f: + for line in f: + self._cookies.load(line) + + def get_cookies(self) -> List[str]: + return [morsel.OutputString(attrs=[]) + for morsel in self._cookies.values()] + + def get_cookies_as_headers(self) -> List[Tuple[str, str]]: + """ + Return all stored cookies as tuples in a list. The first tuple entry is + always "Cookie". + """ + + return [("Cookie", cookie) for cookie in self.get_cookies()] + + def add_cookie(self, cookie: str) -> None: + """ + Parse cookie and add it to the jar. + + Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; + HttpOnly; Secure" + """ + + logger.debug(f"Adding cookie {cookie!r}") + self._cookies.load(cookie) + + def save(self) -> None: + """ + Saves all current cookies to the cookie jar file. + """ + + if not self._filename: + logger.warning("Could not save cookies, no filename given.") + return + + logger.info(f"Saving cookies to {self._filename!r}") + + with open(self._filename, "w") as f: + for morsel in self._cookies.values(): + cookie_string = morsel.OutputString() + f.write(f"{cookie_string}\n") + + def clear(self) -> None: + """ + Removes all cookies from the cookie jar. + """ + + logger.debug("OMNOMNOM, cookies are all gone!") + self._cookies = cookies.SimpleCookie() 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/exceptions.py b/yaboli/exceptions.py index cf9d94e..034aaad 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -10,7 +10,6 @@ __all__ = [ # Doing stuff in a room "RoomNotConnectedException", "EuphError", - "RoomClosedException", ] class EuphException(Exception): @@ -66,14 +65,3 @@ class EuphError(EuphException): The euphoria server has sent back an "error" field in its response. """ pass - -# TODO This exception is not used currently, decide on whether to keep it or -# throw it away -class RoomClosedException(EuphException): - """ - The room has been closed already. - - This means that phase 4 (see the docstring of Room) has been initiated or - completed. - """ - pass diff --git a/yaboli/message.py b/yaboli/message.py index 0e3c24b..ebad87c 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -166,5 +166,8 @@ class LiveMessage(Message): async def reply(self, content: str) -> "LiveMessage": return await self.room.send(content, parent_id=self.message_id) + async def get(self) -> "LiveMessage": + return await self.room.get(self.message_id) + async def before(self, amount: int) -> List["LiveMessage"]: return await self.room.log(amount, before_id=self.message_id) diff --git a/yaboli/module.py b/yaboli/module.py index 3fe1baf..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, standalone: bool) -> None: - super().__init__() + 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) -> None: - super().__init__() + 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 458cdea..d1304ee 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import Any, Awaitable, Callable, List, Optional, TypeVar +from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar from .connection import Connection from .events import Events @@ -19,6 +19,10 @@ class Room: """ Events and parameters: + "connected" - fired after the Room has authenticated, joined and set its + nick, meaning that now, messages can be sent + no parameters + "snapshot" - snapshot of the room's messages at the time of joining messages: List[LiveMessage] @@ -39,6 +43,12 @@ class Room: "edit" - a message in the room has been modified or deleted message: LiveMessage + "login" - this session has been logged in from another session + account_id: str + + "logout" - this session has been logged out from another session + no parameters + "pm" - another session initiated a pm with you from: str - the id of the user inviting the client to chat privately from_nick: str - the nick of the inviting user @@ -56,7 +66,8 @@ class Room: name: str, password: Optional[str] = None, target_nick: str = "", - url_format: str = URL_FORMAT + url_format: str = URL_FORMAT, + cookie_file: Optional[str] = None, ) -> None: self._name = name self._password = password @@ -74,7 +85,7 @@ class Room: # Connected management self._url = self._url_format.format(self._name) - self._connection = Connection(self._url) + self._connection = Connection(self._url, cookie_file=cookie_file) self._events = Events() self._connected = asyncio.Event() @@ -112,11 +123,22 @@ class Room: # Connecting, reconnecting and disconnecting - def _set_connected(self) -> None: + async def _try_set_connected(self) -> None: packets_received = self._hello_received and self._snapshot_received if packets_received and not self._connected.is_set(): - self._connected_successfully = True - self._connected.set() + await self._set_nick_if_necessary() + self._set_connected() + + async def _set_nick_if_necessary(self) -> None: + nick_needs_updating = (self._session is None + or self._target_nick != self._session.nick) + + if self._target_nick and nick_needs_updating: + await self._nick(self._target_nick) + + def _set_connected(self) -> None: + self._connected_successfully = True + self._connected.set() def _set_connected_failed(self) -> None: if not self._connected.is_set(): @@ -143,7 +165,7 @@ class Room: self._account = Account.from_data(data) self._hello_received = True - self._set_connected() + await self._try_set_connected() async def _on_snapshot_event(self, packet: Any) -> None: data = packet["data"] @@ -158,19 +180,22 @@ 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 - self._set_connected() + await self._try_set_connected() 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 @@ -202,11 +227,7 @@ class Room: if not self._connected_successfully: return False - nick_needs_updating = (self._session is None - or self._target_nick != self._session.nick) - if self._target_nick and nick_needs_updating: - await self._nick(self._target_nick) - + self._events.fire("connected") return True async def disconnect(self) -> None: @@ -237,14 +258,34 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_join(session) - logger.info(f"{session.atmention} joined") + logger.info(f"&{self.name}: {session.atmention} joined") self._events.fire("join", session) async def _on_login_event(self, packet: Any) -> None: - pass # TODO implement once cookie support is here + """ + Just reconnect, see + https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276 + """ + + data = packet["data"] + + account_id = data["account_id"] + + self._events.fire("login", account_id) + logger.info(f"&{self.name}: Got logged in to {account_id}, reconnecting") + + await self._connection.reconnect() async def _on_logout_event(self, packet: Any) -> None: - pass # TODO implement once cookie support is here + """ + Just reconnect, see + https://github.com/euphoria-io/heim/blob/master/client/lib/stores/chat.js#L275-L276 + """ + + self._events.fire("logout") + logger.info(f"&{self.name}: Got logged out, reconnecting") + + await self._connection.reconnect() async def _on_network_event(self, packet: Any) -> None: data = packet["data"] @@ -258,7 +299,7 @@ class Room: for user in self.users: if user.server_id == server_id and user.server_era == server_era: users = users.with_part(user) - logger.info(f"{user.atmention} left") + logger.info(f"&{self.name}: {user.atmention} left") self._events.fire("part", user) self._users = users @@ -275,7 +316,7 @@ class Room: else: await self.who() # recalibrating self._users - logger.info(f"{atmention(nick_from)} is now called {atmention(nick_to)}") + logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}") self._events.fire("nick", session, nick_from, nick_to) async def _on_edit_message_event(self, packet: Any) -> None: @@ -291,7 +332,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_part(session) - logger.info(f"{session.atmention} left") + logger.info(f"&{self.name}: {session.atmention} left") self._events.fire("part", session) async def _on_pm_initiate_event(self, packet: Any) -> None: @@ -368,10 +409,6 @@ class Room: # Functionality - # These functions require cookie support and are thus not implemented yet: - # - # login, logout, pm - def _extract_data(self, packet: Any) -> Any: error = packet.get("error") if error is not None: @@ -471,3 +508,55 @@ class Room: self._users = users return self._users + + async def login(self, email: str, password: str) -> Tuple[bool, str]: + """ + Since euphoria appears to only support email authentication, this way + of logging in is hardcoded here. + + Returns whether the login was successful. If it was, the second + parameter is the account id. If it wasn't, the second parameter is the + reason why the login failed. + """ + + data: Any = { + "namespace": "email", + "id": email, + "password": password, + } + + reply = await self._connection.send("login", data) + data = self._extract_data(reply) + + success: bool = data["success"] + account_id_or_reason = data.get("account_id") or data["reason"] + + if success: + logger.info(f"&{self.name}: Logged in as {account_id_or_reason}") + else: + logger.info(f"&{self.name}: Failed to log in with {email} because {account_id_or_reason}") + + await self._connection.reconnect() + + return success, account_id_or_reason + + async def logout(self) -> None: + await self._connection.send("logout", {}) + + logger.info(f"&{self.name}: Logged out") + + await self._connection.reconnect() + + async def pm(self, user_id: str) -> Tuple[str, str]: + """ + Returns the pm_id of the pm and the nick of the person being pinged. + """ + + data = {"user_id": user_id} + + reply = await self._connection.send("pm-initiate", data) + data = self._extract_data(reply) + + pm_id = data["pm_id"] + to_nick = data["to_nick"] + return pm_id, to_nick diff --git a/yaboli/session.py b/yaboli/session.py index 5adcbcb..e59c81a 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -1,5 +1,6 @@ import re -from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional +from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, + Optional, Tuple) from .util import mention, normalize @@ -238,7 +239,12 @@ class LiveSession(Session): # Live stuff - # TODO pm, once pm support is there. + async def pm(self) -> Tuple[str, str]: + """ + See Room.pm + """ + + return await self.room.pm(self.user_id) class LiveSessionListing: def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None: diff --git a/yaboli/util.py b/yaboli/util.py index 5353ec1..e8395d9 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,6 +1,16 @@ +import asyncio +import datetime +import functools import re +from typing import Any, Callable -__all__ = ["mention", "atmention", "normalize", "similar", "plural"] +__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 @@ -28,3 +38,36 @@ def plural( return if_singular else: return if_plural + +def format_time(time: datetime.datetime) -> str: + return time.strftime("%F %T") + +def format_delta(delta: datetime.timedelta) -> str: + seconds = int(delta.total_seconds()) + negative = seconds < 0 + seconds = abs(seconds) + + days = seconds // (60 * 60 * 24) + seconds -= days * (60 * 60 * 24) + + hours = seconds // (60 * 60) + seconds -= hours * (60 * 60) + + minutes = seconds // 60 + seconds -= minutes * 60 + + text: str + + if days > 0: + text = f"{days}d {hours}h {minutes}m {seconds}s" + elif hours > 0: + text = f"{hours}h {minutes}m {seconds}s" + elif minutes > 0: + text = f"{minutes}m {seconds}s" + else: + text = f"{seconds}s" + + if negative: + text = "- " + text + + return text