diff --git a/.gitignore b/.gitignore index 7ce48d0..bf7ff1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ -__pycache__/ -*.egg-info/ -/.mypy_cache/ -/.venv/ +# python stuff +*/__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# mypy stuff +.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e0f1801..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 f2fd14f..8c068df 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 - 2019 Garmelon +Copyright (c) 2018 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 deleted file mode 100644 index 2cd4eb1..0000000 --- a/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# 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 deleted file mode 100644 index cf6722d..0000000 --- a/docs/bot_setup.md +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index 9f4835f..0000000 --- a/docs/index.md +++ /dev/null @@ -1,89 +0,0 @@ -# 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 new file mode 100644 index 0000000..97aff03 --- /dev/null +++ b/example.py @@ -0,0 +1,26 @@ +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 deleted file mode 100644 index da78a19..0000000 --- a/examples/echo/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 940e8e4..0000000 --- a/examples/echo/bot.conf.default +++ /dev/null @@ -1,6 +0,0 @@ -[general] -nick = EchoBot -cookie_file = bot.cookie - -[rooms] -test diff --git a/examples/echo/echobot.py b/examples/echo/echobot.py deleted file mode 100644 index e404f3c..0000000 --- a/examples/echo/echobot.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index f69b963..0000000 --- a/examples/gitignore_with_venv +++ /dev/null @@ -1,17 +0,0 @@ -# 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 new file mode 100644 index 0000000..f33cfb7 --- /dev/null +++ b/info.txt @@ -0,0 +1,39 @@ +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 deleted file mode 100644 index 79ad530..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[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 527eaeb..e749ce5 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,13 +1,9 @@ -import asyncio -import configparser -import logging -from typing import Callable, Dict +from typing import List from .bot import * from .client import * from .command import * from .connection import * -from .database import * from .events import * from .exceptions import * from .message import * @@ -16,14 +12,11 @@ from .room import * from .session import * from .util import * -__all__ = ["STYLE", "FORMAT", "DATE_FORMAT", "FORMATTER", "enable_logging", - "run", "run_modulebot"] - +__all__: List[str] = [] __all__ += bot.__all__ __all__ += client.__all__ __all__ += command.__all__ __all__ += connection.__all__ -__all__ += database.__all__ __all__ += events.__all__ __all__ += exceptions.__all__ __all__ += message.__all__ @@ -31,53 +24,3 @@ __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 97385cb..942470d 100644 --- a/yaboli/bot.py +++ b/yaboli/bot.py @@ -1,120 +1,28 @@ -import configparser -import datetime import logging -from typing import Callable, List, Optional +from typing import 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", "BotConstructor"] +__all__ = ["Bot"] 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" - 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) + def __init__(self) -> None: + super().__init__() 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, @@ -122,23 +30,6 @@ 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) @@ -147,21 +38,6 @@ 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) @@ -172,13 +48,6 @@ 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) @@ -188,31 +57,11 @@ 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) + await self.process_commands(room, message) # 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, @@ -225,41 +74,8 @@ class Bot(Client): def register_botrulez(self, ping: bool = True, - help_: bool = True, - uptime: bool = True, - kill: bool = False, - restart: bool = False, + help_: bool = True ) -> 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) @@ -271,24 +87,11 @@ 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, @@ -296,10 +99,6 @@ 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])) @@ -308,70 +107,5 @@ 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 75806fb..c1f19ff 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, Union +from typing import Dict, List, Optional from .message import LiveMessage from .room import Room @@ -12,12 +12,9 @@ logger = logging.getLogger(__name__) __all__ = ["Client"] class Client: - def __init__(self, - default_nick: str, - cookie_file: Optional[str] = None, - ) -> None: - self._default_nick = default_nick - self._cookie_file = cookie_file + DEFAULT_NICK = "" + + def __init__(self) -> None: self._rooms: Dict[str, List[Room]] = {} self._stop = asyncio.Event() @@ -52,34 +49,14 @@ class Client: async def join(self, room_name: str, password: Optional[str] = None, - nick: Optional[str] = None, - cookie_file: Union[str, bool] = True, + nick: Optional[str] = None ) -> 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 + nick = self.DEFAULT_NICK + room = Room(room_name, password=password, target_nick=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", @@ -126,9 +103,6 @@ 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 @@ -152,12 +126,6 @@ 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 08ac3f7..44d629d 100644 --- a/yaboli/command.py +++ b/yaboli/command.py @@ -23,74 +23,13 @@ __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, raw: str) -> None: - self._raw = raw + def __init__(self, argstr: str) -> None: + self._argstr = argstr self._basic: Optional[List[str]] = None self._basic_escaped: Optional[List[str]] = None @@ -155,62 +94,31 @@ class ArgumentData: return text.split() def _parse_fancy(self, args: List[str]) -> FancyArgs: - 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) + raise NotImplementedError @property - def raw(self) -> str: - return self._raw + def argstr(self) -> str: + return self._argstr def basic(self, escaped: bool = True) -> List[str]: if escaped: if self._basic_escaped is None: - self._basic_escaped = self._split(self._raw, escaped) + self._basic_escaped = self._split(self._argstr, escaped) return self._basic_escaped else: if self._basic is None: - self._basic = self._split(self._raw, escaped) + self._basic = self._split(self._argstr, escaped) return self._basic def fancy(self, escaped: bool = True) -> FancyArgs: if escaped: if self._fancy_escaped is None: - basic = self._split(self._raw, escaped) + basic = self._split(self._argstr, escaped) self._fancy_escaped = self._parse_fancy(basic) return self._fancy_escaped else: if self._fancy is None: - basic = self._split(self._raw, escaped) + basic = self._split(self._argstr, escaped) self._fancy = self._parse_fancy(basic) return self._fancy @@ -218,8 +126,8 @@ class ArgumentData: return bool(self.basic()) # The list of arguments is empty class SpecificArgumentData(ArgumentData): - def __init__(self, nick: str, raw: str) -> None: - super().__init__(raw) + def __init__(self, nick: str, argstr: str) -> None: + super().__init__(argstr) self._nick = nick diff --git a/yaboli/connection.py b/yaboli/connection.py index fcc27fe..aa8dd60 100644 --- a/yaboli/connection.py +++ b/yaboli/connection.py @@ -6,7 +6,6 @@ from typing import Any, Awaitable, Callable, Dict, Optional import websockets -from .cookiejar import CookieJar from .events import Events from .exceptions import * @@ -82,9 +81,6 @@ 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 @@ -101,9 +97,8 @@ class Connection: # Initialising - def __init__(self, url: str, cookie_file: Optional[str] = None) -> None: + def __init__(self, url: str) -> None: self._url = url - self._cookie_jar = CookieJar(cookie_file) self._events = Events() self._packet_id = 0 @@ -186,12 +181,7 @@ class Connection: try: logger.debug(f"Creating ws connection to {self._url!r}") - 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}") + ws = await websockets.connect(self._url) self._ws = ws self._awaiting_replies = {} @@ -199,15 +189,10 @@ 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, - OSError, asyncio.TimeoutError): + socket.gaierror): logger.debug("Connection failed") return False @@ -453,11 +438,10 @@ class Connection: # to http://api.euphoria.io/#packets. # First, notify whoever's waiting for this packet - packet_id = packet.get("id") + packet_id = packet.get("id", None) if packet_id is not None and self._awaiting_replies is not None: - future = self._awaiting_replies.get(packet_id) + future = self._awaiting_replies.get(packet_id, None) 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 deleted file mode 100644 index 833dbcb..0000000 --- a/yaboli/cookiejar.py +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 84af548..0000000 --- a/yaboli/database.py +++ /dev/null @@ -1,40 +0,0 @@ -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 034aaad..cf9d94e 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -10,6 +10,7 @@ __all__ = [ # Doing stuff in a room "RoomNotConnectedException", "EuphError", + "RoomClosedException", ] class EuphException(Exception): @@ -65,3 +66,14 @@ 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 ebad87c..0e3c24b 100644 --- a/yaboli/message.py +++ b/yaboli/message.py @@ -166,8 +166,5 @@ 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 ac750bf..3fe1baf 100644 --- a/yaboli/module.py +++ b/yaboli/module.py @@ -1,6 +1,5 @@ -import configparser import logging -from typing import Callable, Dict, List, Optional +from typing import Dict, List, Optional from .bot import Bot from .command import * @@ -11,77 +10,49 @@ from .util import * logger = logging.getLogger(__name__) -__all__ = ["Module", "ModuleConstructor", "ModuleBot", "ModuleBotConstructor"] +__all__ = ["Module", "ModuleBot"] class Module(Bot): DESCRIPTION: Optional[str] = None - def __init__(self, - config: configparser.ConfigParser, - config_file: str, - standalone: bool = True, - ) -> None: - super().__init__(config, config_file) + def __init__(self, standalone: bool) -> None: + super().__init__() 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]] = [ - "", - "For module-specific help, try \"!help {atmention} \".", + "" + "Use \"!help {atmention} \" to get more information on a" + " specific module." ] MODULE_HELP_LIMIT = 5 - MODULES_SECTION = "modules" + def __init__(self) -> None: + super().__init__() - 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] = {} - # 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) + 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) - def load_module(self, name: str, module: Module) -> None: + def register_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: @@ -91,10 +62,7 @@ class ModuleBot(Bot): lines.append(line) if modules_without_desc: - lines.append("\t" + ", ".join(modules_without_desc)) - - if not any_modules: - lines.append("No modules loaded.") + lines.append(", ".join(modules_without_desc)) if self.HELP_POST is not None: lines.extend(self.HELP_POST) @@ -111,7 +79,8 @@ class ModuleBot(Bot): return module.HELP_SPECIFIC - async def cmd_modules_help(self, + # Overwriting the botrulez help function + async def cmd_help_specific(self, room: Room, message: LiveMessage, args: SpecificArgumentData @@ -131,12 +100,6 @@ 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) @@ -178,18 +141,6 @@ 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, @@ -207,8 +158,3 @@ 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 d1304ee..458cdea 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, Tuple, TypeVar +from typing import Any, Awaitable, Callable, List, Optional, TypeVar from .connection import Connection from .events import Events @@ -19,10 +19,6 @@ 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] @@ -43,12 +39,6 @@ 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 @@ -66,8 +56,7 @@ class Room: name: str, password: Optional[str] = None, target_nick: str = "", - url_format: str = URL_FORMAT, - cookie_file: Optional[str] = None, + url_format: str = URL_FORMAT ) -> None: self._name = name self._password = password @@ -85,7 +74,7 @@ class Room: # Connected management self._url = self._url_format.format(self._name) - self._connection = Connection(self._url, cookie_file=cookie_file) + self._connection = Connection(self._url) self._events = Events() self._connected = asyncio.Event() @@ -123,22 +112,11 @@ class Room: # Connecting, reconnecting and disconnecting - async def _try_set_connected(self) -> None: + def _set_connected(self) -> None: packets_received = self._hello_received and self._snapshot_received if packets_received and not self._connected.is_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() + self._connected_successfully = True + self._connected.set() def _set_connected_failed(self) -> None: if not self._connected.is_set(): @@ -165,7 +143,7 @@ class Room: self._account = Account.from_data(data) self._hello_received = True - await self._try_set_connected() + self._set_connected() async def _on_snapshot_event(self, packet: Any) -> None: data = packet["data"] @@ -180,22 +158,19 @@ class Room: if nick is not None and self._session is not None: self._session = self.session.with_nick(nick) - # Send "snapshot" event + # Send "session" event messages = [LiveMessage.from_data(self, msg_data) for msg_data in data["log"]] - self._events.fire("snapshot", messages) + self._events.fire("session", messages) self._snapshot_received = True - await self._try_set_connected() + self._set_connected() async def _on_bounce_event(self, packet: Any) -> None: data = packet["data"] - # 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"]): + # Can we even authenticate? + if not "passcode" in data.get("auth_options", []): self._set_connected_failed() return @@ -227,7 +202,11 @@ class Room: if not self._connected_successfully: return False - self._events.fire("connected") + 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) + return True async def disconnect(self) -> None: @@ -258,34 +237,14 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_join(session) - logger.info(f"&{self.name}: {session.atmention} joined") + logger.info(f"{session.atmention} joined") self._events.fire("join", session) async def _on_login_event(self, packet: Any) -> None: - """ - 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() + pass # TODO implement once cookie support is here async def _on_logout_event(self, packet: Any) -> None: - """ - 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() + pass # TODO implement once cookie support is here async def _on_network_event(self, packet: Any) -> None: data = packet["data"] @@ -299,7 +258,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"&{self.name}: {user.atmention} left") + logger.info(f"{user.atmention} left") self._events.fire("part", user) self._users = users @@ -316,7 +275,7 @@ class Room: else: await self.who() # recalibrating self._users - logger.info(f"&{self.name}: {atmention(nick_from)} is now called {atmention(nick_to)}") + logger.info(f"{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: @@ -332,7 +291,7 @@ class Room: session = LiveSession.from_data(self, data) self._users = self.users.with_part(session) - logger.info(f"&{self.name}: {session.atmention} left") + logger.info(f"{session.atmention} left") self._events.fire("part", session) async def _on_pm_initiate_event(self, packet: Any) -> None: @@ -409,6 +368,10 @@ 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: @@ -508,55 +471,3 @@ 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 e59c81a..5adcbcb 100644 --- a/yaboli/session.py +++ b/yaboli/session.py @@ -1,6 +1,5 @@ import re -from typing import (TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, - Optional, Tuple) +from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Optional from .util import mention, normalize @@ -239,12 +238,7 @@ class LiveSession(Session): # Live stuff - async def pm(self) -> Tuple[str, str]: - """ - See Room.pm - """ - - return await self.room.pm(self.user_id) + # TODO pm, once pm support is there. class LiveSessionListing: def __init__(self, room: "Room", sessions: Iterable[LiveSession]) -> None: diff --git a/yaboli/util.py b/yaboli/util.py index e8395d9..5353ec1 100644 --- a/yaboli/util.py +++ b/yaboli/util.py @@ -1,16 +1,6 @@ -import asyncio -import datetime -import functools import re -from typing import Any, Callable -__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) +__all__ = ["mention", "atmention", "normalize", "similar", "plural"] # Name/nick related functions @@ -38,36 +28,3 @@ 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