From a5af01f669ea627cf4998b69b0e53a59260edc70 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Apr 2019 09:02:09 +0000 Subject: [PATCH] Start rewrite (yet again) This will hopefully be the final rewrite. --- .gitignore | 14 +- example.py | 26 +++ examplebot.conf | 9 - examplebot.py | 48 ---- info.txt | 22 ++ mypy.ini | 3 + requirements.txt | 1 + yaboli/__init__.py | 36 +-- yaboli/bot.py | 271 ----------------------- yaboli/client.py | 23 ++ yaboli/connection.py | 229 ------------------- yaboli/cookiejar.py | 74 ------- yaboli/database.py | 38 ---- yaboli/exceptions.py | 56 ++++- yaboli/message.py | 108 +++++++++ yaboli/room.py | 510 ++++++++----------------------------------- yaboli/user.py | 91 ++++++++ yaboli/util.py | 15 ++ yaboli/utils.py | 225 ------------------- 19 files changed, 455 insertions(+), 1344 deletions(-) create mode 100644 example.py delete mode 100644 examplebot.conf delete mode 100644 examplebot.py create mode 100644 info.txt create mode 100644 mypy.ini create mode 100644 requirements.txt delete mode 100644 yaboli/bot.py create mode 100644 yaboli/client.py delete mode 100644 yaboli/connection.py delete mode 100644 yaboli/cookiejar.py delete mode 100644 yaboli/database.py create mode 100644 yaboli/message.py create mode 100644 yaboli/user.py create mode 100644 yaboli/util.py delete mode 100644 yaboli/utils.py diff --git a/.gitignore b/.gitignore index ce41371..bf7ff1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ -**/__pycache__/ -*.cookie +# python stuff +*/__pycache__/ + +# venv stuff +bin/ +include/ +lib/ +lib64 +pyvenv.cfg + +# mypy stuff +.mypy_cache/ 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/examplebot.conf b/examplebot.conf deleted file mode 100644 index a2386ee..0000000 --- a/examplebot.conf +++ /dev/null @@ -1,9 +0,0 @@ -[general] -nick = ExampleBot -cookiefile = examplebot.cookie - -[rooms] -# Format: -# room -# room=password -test diff --git a/examplebot.py b/examplebot.py deleted file mode 100644 index ba9bdb8..0000000 --- a/examplebot.py +++ /dev/null @@ -1,48 +0,0 @@ -import asyncio -import configparser -import logging - -import yaboli -from yaboli.utils import * - - -class ExampleBot(yaboli.Bot): - async def on_command_specific(self, room, message, command, nick, argstr): - long_help = ( - "I'm an example bot for the yaboli bot library," - " which can be found at https://github.com/Garmelon/yaboli" - ) - - if similar(nick, room.session.nick) and not argstr: - await self.botrulez_ping(room, message, command, text="ExamplePong!") - await self.botrulez_help(room, message, command, text=long_help) - await self.botrulez_uptime(room, message, command) - await self.botrulez_kill(room, message, command) - await self.botrulez_restart(room, message, command) - - async def on_command_general(self, room, message, command, argstr): - short_help = "Example bot for the yaboli bot library" - - if not argstr: - await self.botrulez_ping(room, message, command, text="ExamplePong!") - await self.botrulez_help(room, message, command, text=short_help) - -def main(configfile): - logging.basicConfig(level=logging.INFO) - - config = configparser.ConfigParser(allow_no_value=True) - config.read(configfile) - - nick = config.get("general", "nick") - cookiefile = config.get("general", "cookiefile", fallback=None) - bot = ExampleBot(nick, cookiefile=cookiefile) - - for room, password in config.items("rooms"): - if not password: - password = None - bot.join_room(room, password=password) - - asyncio.get_event_loop().run_forever() - -if __name__ == "__main__": - main("examplebot.conf") diff --git a/info.txt b/info.txt new file mode 100644 index 0000000..a17672c --- /dev/null +++ b/info.txt @@ -0,0 +1,22 @@ +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()) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..e91e90c --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +disallow_untyped_defs = True +disallow_incomplete_defs = True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4789da4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +websockets==7.0 diff --git a/yaboli/__init__.py b/yaboli/__init__.py index 89eccb2..4f04690 100644 --- a/yaboli/__init__.py +++ b/yaboli/__init__.py @@ -1,17 +1,21 @@ -from .bot import * -from .cookiejar import * -from .connection import * -from .database import * -from .exceptions import * -from .room import * -from .utils import * +from typing import List -__all__ = ( - bot.__all__ + - connection.__all__ + - cookiejar.__all__ + - database.__all__ + - exceptions.__all__ + - room.__all__ + - utils.__all__ -) +__all__: List[str] = [] + +from .client import * +__all__ += client.__all__ + +from .exceptions import * +__all__ += client.__all__ + +from .message import * +__all__ += exceptions.__all__ + +from .room import * +__all__ += message.__all__ + +__all__ += room.__all__ +from .user import * + +__all__ += user.__all__ +from .util import * diff --git a/yaboli/bot.py b/yaboli/bot.py deleted file mode 100644 index 666bb9e..0000000 --- a/yaboli/bot.py +++ /dev/null @@ -1,271 +0,0 @@ -import logging -import re -import time - -from .cookiejar import * -from .room import * -from .utils import * - - -logger = logging.getLogger(__name__) -__all__ = ["Bot", "command", "trigger", "Module", "ModuleBot"] - - -# Some command stuff - -SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)") -GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)") - -# Decorator magic for commands and triggers. -# I think commands could probably be implemented as some kind of triggers, -# but I'm not gonna do that now because commands are working fine this way. -def command(*commands): - def decorator(func): - async def wrapper(self, room, message, command, *args, **kwargs): - if command in commands: - await func(self, room, message, *args, **kwargs) - return True - else: - return False - return wrapper - return decorator - -def trigger(regex, fullmatch=True, flags=0): - def decorator(func): - compiled_regex = re.compile(regex, flags=flags) - async def wrapper(self, room, message, *args, **kwargs): - if fullmatch: - match = compiled_regex.fullmatch(message.content) - else: - match = compiled_regex.match(message.content) - if match is not None: - await func(self, room, message, match, *args, **kwargs) - return True - else: - return False - return wrapper - return decorator - - -# And now comes the real bot... - -class Bot(Inhabitant): - def __init__(self, nick, cookiefile=None): - self.target_nick = nick - self.rooms = {} - self.cookiejar = CookieJar(cookiefile) - - # ROOM MANAGEMENT - - def join_room(self, roomname, **kwargs): - if roomname in self.rooms: - return - - self.rooms[roomname] = Room(self, roomname, self.target_nick, cookiejar=self.cookiejar, **kwargs) - - async def part_room(self, roomname): - room = self.rooms.pop(roomname, None) - if room: - await room.exit() - - # COMMANDS - - async def on_command_specific(self, room, message, command, nick, argstr): - pass - - async def on_command_general(self, room, message, command, argstr): - pass - - # INHABITED FUNCTIONS - - async def on_send(self, room, message): - match = SPECIFIC_RE.fullmatch(message.content) - if match: - command, nick, argstr = match.groups() - await self.on_command_specific(room, message, command, nick, argstr) - - match = GENERAL_RE.fullmatch(message.content) - if match: - command, argstr = match.groups() - await self.on_command_general(room, message, command, argstr) - - async def on_stopped(self, room): - await self.part_room(room.roomname) - - # BOTRULEZ - - @command("ping") - async def botrulez_ping(self, room, message, text="Pong!"): - await room.send(text, message.mid) - - @command("help") - async def botrulez_help(self, room, message, text="Placeholder help text"): - await room.send(text, message.mid) - - @command("uptime") - async def botrulez_uptime(self, room, message): - now = time.time() - startformat = format_time(room.start_time) - deltaformat = format_time_delta(now - room.start_time) - text = f"/me has been up since {startformat} ({deltaformat})" - await room.send(text, message.mid) - - @command("kill") - async def botrulez_kill(self, room, message, text="/me dies"): - await room.send(text, message.mid) - await self.part_room(room.roomname) - - @command("restart") - async def botrulez_restart(self, room, message, text="/me restarts"): - await room.send(text, message.mid) - await self.part_room(room.roomname) - self.join_room(room.roomname, password=room.password) - - # COMMAND PARSING - - @staticmethod - def parse_args(text): - """ - Use bash-style single- and double-quotes to include whitespace in arguments. - A backslash always escapes the next character. - Any non-escaped whitespace separates arguments. - - Returns a list of arguments. - Deals with unclosed quotes and backslashes without crashing. - """ - - escape = False - quote = None - args = [] - arg = "" - - for character in text: - if escape: - arg += character - escape = False - elif character == "\\": - escape = True - elif quote: - if character == quote: - quote = None - else: - arg += character - elif character in "'\"": - quote = character - elif character.isspace(): - if len(arg) > 0: - args.append(arg) - arg = "" - else: - arg += character - - #if escape or quote: - #return None # syntax error - - if escape: - arg += "\\" - - if len(arg) > 0: - args.append(arg) - - return args - - @staticmethod - def parse_flags(arglist): - flags = "" - args = [] - kwargs = {} - - for arg in arglist: - # kwargs (--abc, --foo=bar) - if arg[:2] == "--": - arg = arg[2:] - if "=" in arg: - s = arg.split("=", maxsplit=1) - kwargs[s[0]] = s[1] - else: - kwargs[arg] = None - # flags (-x, -rw) - elif arg[:1] == "-": - arg = arg[1:] - flags += arg - # args (normal arguments) - else: - args.append(arg) - - return flags, args, kwargs - - @staticmethod - def _parse_command(content, specific=None): - if specific: - match = SPECIFIC_RE.fullmatch(content) - if match: - return match.group(1), match.group(3) - else: - match = GENERAL_RE.fullmatch(content) - if match: - return match.group(1), match.group(2) - -class Module(Inhabitant): - SHORT_DESCRIPTION = "short module description" - LONG_DESCRIPTION = "long module description" - SHORT_HELP = "short !help" - LONG_HELP = "long !help" - - async def on_command_specific(self, room, message, command, nick, argstr, mentioned): - pass - - async def on_command_general(self, room, message, command, argstr): - pass - -class ModuleBot(Bot): - def __init__(self, module, nick, *args, cookiefile=None, **kwargs): - super().__init__(nick, cookiefile=cookiefile) - self.module = module - - async def on_created(self, room): - await self.module.on_created(room) - - async def on_connected(self, room, log): - await self.module.on_connected(room, log) - - async def on_disconnected(self, room): - await self.module.on_disconnected(room) - - async def on_stopped(self, room): - await self.module.on_stopped(room) - - async def on_join(self, room, session): - await self.module.on_join(room, session) - - async def on_part(self, room, session): - await self.module.on_part(room, session) - - async def on_nick(self, room, sid, uid, from_nick, to_nick): - await self.module.on_nick(room, sid, uid, from_nick, to_nick) - - async def on_send(self, room, message): - await super().on_send(room, message) - - await self.module.on_send(room, message) - - async def on_command_specific(self, room, message, command, nick, argstr): - if similar(nick, room.session.nick): - await self.module.on_command_specific(room, message, command, nick, argstr, True) - - if not argstr: - await self.botrulez_ping(room, message, command) - await self.botrulez_help(room, message, command, text=self.module.LONG_HELP) - await self.botrulez_uptime(room, message, command) - await self.botrulez_kill(room, message, command) - await self.botrulez_restart(room, message, command) - - else: - await self.module.on_command_specific(room, message, command, nick, argstr, False) - - async def on_command_general(self, room, message, command, argstr): - await self.module.on_command_general(room, message, command, argstr) - - if not argstr: - await self.botrulez_ping(room, message, command) - await self.botrulez_help(room, message, command, text=self.module.SHORT_HELP) diff --git a/yaboli/client.py b/yaboli/client.py new file mode 100644 index 0000000..ee868cf --- /dev/null +++ b/yaboli/client.py @@ -0,0 +1,23 @@ +from .message import Message +from .room import Room +from .user import User + +from typing import List, Optional + +__all__ = ["Client"] + +class Client: + + # Joining and leaving rooms + + async def join(self, + room_name: str, + password: str = None, + nick: str = None) -> Room: + pass + + async def get(self, room_name: str) -> Optional[Room]: + pass + + async def get_all(self, room_name: str) -> List[Room]: + pass diff --git a/yaboli/connection.py b/yaboli/connection.py deleted file mode 100644 index 070503f..0000000 --- a/yaboli/connection.py +++ /dev/null @@ -1,229 +0,0 @@ -import asyncio -import json -import logging -import socket -import websockets - -from .exceptions import * - - -logger = logging.getLogger(__name__) -__all__ = ["Connection"] - - -class Connection: - def __init__(self, url, packet_callback, disconnect_callback, stop_callback, cookiejar=None, ping_timeout=10, ping_delay=30, reconnect_attempts=10): - self.url = url - self.packet_callback = packet_callback - self.disconnect_callback = disconnect_callback - self.stop_callback = stop_callback # is called when the connection stops on its own - self.cookiejar = cookiejar - self.ping_timeout = ping_timeout # how long to wait for websocket ping reply - self.ping_delay = ping_delay # how long to wait between pings - self.reconnect_attempts = reconnect_attempts - - self._ws = None - self._pid = 0 # successive packet ids - #self._spawned_tasks = set() - self._pending_responses = {} - - self._stopped = False - self._pingtask = None - self._runtask = asyncio.ensure_future(self._run()) - # ... aaand the connection is started. - - async def send(self, ptype, data=None, await_response=True): - if not self._ws: - raise ConnectionClosed - #raise asyncio.CancelledError - - pid = str(self._new_pid()) - packet = { - "type": ptype, - "id": pid - } - if data: - packet["data"] = data - - if await_response: - wait_for = self._wait_for_response(pid) - - logging.debug(f"Currently used websocket at self._ws: {self._ws}") - try: - await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size - except websockets.ConnectionClosed: - raise ConnectionClosed() - - if await_response: - await wait_for - return wait_for.result() - - async def stop(self): - """ - Close websocket connection and wait for running task to stop. - - No connection function are to be called after calling stop(). - This means that stop() can only be called once. - """ - - if not self._stopped: - self._stopped = True - await self.reconnect() # _run() does the cleaning up now. - await self._runtask - - async def reconnect(self): - """ - Reconnect to the url. - """ - - if self._ws: - await self._ws.close() - - async def _connect(self, tries, timeout=10): - """ - Attempt to connect to a room. - If the Connection is already connected, it attempts to reconnect. - - Returns True on success, False on failure. - - If tries is None, connect retries infinitely. - The delay between connection attempts doubles every attempt (starts with 1s). - """ - - # Assumes _disconnect() has already been called in _run() - - delay = 1 # seconds - while True: - try: - if self.cookiejar: - cookies = [("Cookie", cookie) for cookie in self.cookiejar.sniff()] - ws = asyncio.ensure_future( - websockets.connect(self.url, max_size=None, extra_headers=cookies) - ) - else: - ws = asyncio.ensure_future( - websockets.connect(self.url, max_size=None) - ) - self._ws = await asyncio.wait_for(ws, timeout) - except (websockets.InvalidHandshake, socket.gaierror, asyncio.TimeoutError): # not websockets.InvalidURI - logger.warn(f"Connection attempt failed, {tries} tries left.") - self._ws = None - - if tries is not None: - tries -= 1 - if tries <= 0: - logger.warn(f"{self.url}:Ran out of tries") - return False - - await asyncio.sleep(delay) - delay *= 2 - else: - if self.cookiejar: - for set_cookie in self._ws.response_headers.get_all("Set-Cookie"): - self.cookiejar.bake(set_cookie) - self.cookiejar.save() - - self._pingtask = asyncio.ensure_future(self._ping()) - - return True - - async def _disconnect(self): - """ - Disconnect and clean up all "residue", such as: - - close existing websocket connection - - cancel all pending response futures with a ConnectionClosed exception - - reset package ID counter - - make sure the ping task has finished - """ - - asyncio.ensure_future(self.disconnect_callback()) - - # stop ping task - if self._pingtask: - self._pingtask.cancel() - await self._pingtask - self._pingtask = None - - if self._ws: - await self._ws.close() - self._ws = None - - self._pid = 0 - - # clean up pending response futures - for _, future in self._pending_responses.items(): - logger.debug(f"Cancelling future with ConnectionClosed: {future}") - future.set_exception(ConnectionClosed("No server response")) - self._pending_responses = {} - - async def _run(self): - """ - Listen for packets and deal with them accordingly. - """ - - while not self._stopped: - logger.debug(f"{self.url}:Connecting...") - connected = await self._connect(self.reconnect_attempts) - if connected: - logger.debug(f"{self.url}:Connected") - try: - while True: - await self._handle_next_message() - except websockets.ConnectionClosed: - pass - finally: - await self._disconnect() # disconnect and clean up - else: - logger.debug(f"{self.url}:Stopping") - asyncio.ensure_future(self.stop_callback) - self._stopped = True - await self._disconnect() - - - async def _ping(self): - """ - Periodically ping the server to detect a timeout. - """ - - try: - while True: - logger.debug(f"{self.url}:Pinging...") - wait_for_reply = await self._ws.ping() - await asyncio.wait_for(wait_for_reply, self.ping_timeout) - logger.debug(f"{self.url}:Pinged!") - await asyncio.sleep(self.ping_delay) - except asyncio.TimeoutError: - logger.warning(f"{self.url}:Ping timed out") - await self.reconnect() - except (websockets.ConnectionClosed, ConnectionResetError, asyncio.CancelledError): - pass - - def _new_pid(self): - self._pid += 1 - return self._pid - - async def _handle_next_message(self): - response = await self._ws.recv() - packet = json.loads(response) - - ptype = packet.get("type") - data = packet.get("data", None) - error = packet.get("error", None) - if packet.get("throttled", False): - throttled = packet.get("throttled_reason") - else: - throttled = None - - # Deal with pending responses - pid = packet.get("id", None) - future = self._pending_responses.pop(pid, None) - if future: - future.set_result((ptype, data, error, throttled)) - - # Pass packet onto room - asyncio.ensure_future(self.packet_callback(ptype, data, error, throttled)) - - def _wait_for_response(self, pid): - future = asyncio.Future() - self._pending_responses[pid] = future - return future diff --git a/yaboli/cookiejar.py b/yaboli/cookiejar.py deleted file mode 100644 index 8b3a073..0000000 --- a/yaboli/cookiejar.py +++ /dev/null @@ -1,74 +0,0 @@ -import contextlib -import http.cookies as cookies -import logging - - -logger = logging.getLogger(__name__) -__all__ = ["CookieJar"] - - -class CookieJar: - """ - Keeps your cookies in a file. - """ - - def __init__(self, filename=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 sniff(self): - """ - Returns a list of Cookie headers containing all current cookies. - """ - - return [morsel.OutputString(attrs=[]) for morsel in self._cookies.values()] - - def bake(self, cookie_string): - """ - Parse cookie and add it to the jar. - Does not automatically save to the cookie file. - - Example cookie: "a=bcd; Path=/; Expires=Wed, 24 Jul 2019 14:57:52 GMT; HttpOnly; Secure" - """ - - logger.debug(f"Baking cookie: {cookie_string!r}") - - self._cookies.load(cookie_string) - - def save(self): - """ - 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") - f.write(cookie_string) - f.write("\n") - - def monster(self): - """ - Removes all cookies from the cookie jar. - Does not automatically save to the cookie file. - """ - - 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 7428be9..0000000 --- a/yaboli/database.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncio -import logging -import sqlite3 - -from .utils import * - - -logger = logging.getLogger(__name__) -__all__ = ["Database", "operation"] - - -def operation(func): - async def wrapper(self, *args, **kwargs): - 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): - self._connection = sqlite3.connect(database, check_same_thread=False) - self._lock = asyncio.Lock() - - self.initialize(self._connection) - - def initialize(self, db): - pass - - async def __aenter__(self, *args, **kwargs): - await self._lock.__aenter__(*args, **kwargs) - return self._connection - - async def __aexit__(self, *args, **kwargs): - return await self._lock.__aexit__(*args, **kwargs) diff --git a/yaboli/exceptions.py b/yaboli/exceptions.py index f9cce45..2c951a0 100644 --- a/yaboli/exceptions.py +++ b/yaboli/exceptions.py @@ -1,13 +1,51 @@ -__all__ = ["ConnectionClosed"] +__all__ = ["EuphException", "JoinException", "CouldNotConnectException", + "CouldNotAuthenticateException", "RoomClosedException", + "RateLimitException", "NotLoggedInException", "UnauthorizedException"] -class ConnectionClosed(Exception): - pass +class EuphException(Exception): + pass -class RoomException(Exception): - pass +# Joining a room -class AuthenticationRequired(RoomException): - pass +class JoinException(EuphException): + """ + An exception that happened while joining a room. + """ + pass -class RoomClosed(RoomException): - pass +class CouldNotConnectException(JoinException): + """ + Could not establish a websocket connection to euphoria. + """ + pass + +class CouldNotAuthenticateException(JoinException): + """ + The password is either incorrect or not set, even though authentication is + required. + """ + pass + +# Doing stuff in a room + +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 + +# exception for having no username? + +# Maybe these will become real exceptions one day? + +class RateLimitException(EuphException): + pass + +class NotLoggedInException(EuphException): + pass + +class UnauthorizedException(EuphException): + pass diff --git a/yaboli/message.py b/yaboli/message.py new file mode 100644 index 0000000..088cbe7 --- /dev/null +++ b/yaboli/message.py @@ -0,0 +1,108 @@ +from .user import User, LiveUser + +from typing import TYPE_CHECKING, Optional +import datetime + +if TYPE_CHECKING: + from .client import Client + from .room import Room + +__all__ = ["Message", "LiveMessage"] + +# "Offline" message +class Message: + def __init__(self, + room_name: str, + id_: str, + parent_id: Optional[str], + timestamp: int, + sender: User, + content: str, + deleted: bool, + truncated: bool): + self._room_name = room_name + self._id = id_ + self._parent_id = parent_id + self._timestamp = timestamp + self._sender = sender + self._content = content + self._deleted = deleted + self._truncated = truncated + + @property + def room_name(self) -> str: + return self._room_name + + @property + def id(self) -> str: + return self._id + + @property + def parent_id(self) -> Optional[str]: + return self._parent_id + + @property + def time(self) -> datetime.datetime: + return datetime.datetime.fromtimestamp(self.timestamp) + + @property + def timestamp(self) -> int: + return self._timestamp + + @property + def sender(self) -> User: + return self._sender + + @property + def content(self) -> str: + return self._content + + @property + def deleted(self) -> bool: + return self._deleted + + @property + def truncated(self) -> bool: + return self._truncated + +# "Online" message +# has a few nice functions +class LiveMessage(Message): + def __init__(self, + client: 'Client', + room: 'Room', + id_: str, + parent_id: Optional[str], + timestamp: int, + sender: LiveUser, + content: str, + deleted: bool, + truncated: bool): + self._client = client + super().__init__(room.name, id_, parent_id, timestamp, sender, content, + deleted, truncated) + self._room = room + # The typechecker can't use self._sender directly, because it has type + # User. + # + # TODO Find a way to satisfy the type checker without having this + # duplicate around, if possible? + self._livesender = sender + + @property + def room(self) -> 'Room': + return self._room + + @property + def sender(self) -> LiveUser: + return self._livesender + + async def reply(self, text: str) -> None: + pass + + # TODO add some sort of permission guard that checks the room + # UnauthorizedException + async def delete(self, + deleted: bool = True + ) -> None: + pass diff --git a/yaboli/room.py b/yaboli/room.py index 9d2a1c5..5d36c54 100644 --- a/yaboli/room.py +++ b/yaboli/room.py @@ -1,443 +1,107 @@ -import asyncio -import logging -import time - -from .connection import * from .exceptions import * -from .utils import * +from .message import LiveMessage +from .user import LiveUser +from typing import List, Optional -logger = logging.getLogger(__name__) -__all__ = ["Room", "Inhabitant"] - +__all__ = ["Room"] class Room: - """ - TODO - """ - - CONNECTED = 1 - DISCONNECTED = 2 - CLOSED = 3 - FORWARDING = 4 - - def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None, **kwargs): - # TODO: Connect to room etc. - # TODO: Deal with room/connection states of: - # disconnected connecting, fast-forwarding, connected - - # Room info (all fields readonly!) - self.target_nick = nick - self.roomname = roomname - self.password = password - self.human = human - - self.session = None - self.account = None - self.listing = Listing() - - self.start_time = time.time() - - self.account_has_access = None - self.account_email_verified = None - self.room_is_private = None - self.version = None # the version of the code being run and served by the server - self.pm_with_nick = None - self.pm_with_user_id = None - - self._inhabitant = inhabitant - self._status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - self._last_known_mid = None - self._forwarding = None # task that downloads messages and fowards - self._forward_new = [] # new messages received while downloading old messages - - # TODO: Allow for all parameters of Connection() to be specified in Room(). - self._connection = Connection( - self.format_room_url(self.roomname, human=self.human), - self._receive_packet, - self._disconnected, - self._stopped, - cookiejar, - **kwargs - ) - - asyncio.ensure_future(self._inhabitant.on_created(self)) - - async def exit(self): - self._status = Room.CLOSED - await self._connection.stop() - -# ROOM COMMANDS -# These always return a response from the server. -# If the connection is lost while one of these commands is called, -# the command will retry once the bot has reconnected. - - async def get_message(self, mid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "get-message", - id=mid - ) - - if data: - return Message.from_dict(data) - # else: message does not exist - - # The log returned is sorted from old to new - async def log(self, n, before=None): - if self._status == Room.CLOSED: - raise RoomClosed() - - if before: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n, - before=before - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "log", - n=n - ) - - return [Message.from_dict(d) for d in data.get("log")] - - async def nick(self, nick): - if self._status == Room.CLOSED: - raise RoomClosed() - - self.target_nick = nick - ptype, data, error, throttled = await self._send_while_connected( - "nick", - name=nick - ) - - sid = data.get("session_id") - uid = data.get("id") - from_nick = data.get("from") - to_nick = data.get("to") - - self.session.nick = to_nick - return sid, uid, from_nick, to_nick - - async def pm(self, uid): - if self._status == Room.CLOSED: - raise RoomClosed() - - ptype, data, error, throttled = await self._send_while_connected( - "pm-initiate", - user_id=uid - ) - - # Just ignoring non-authenticated errors - pm_id = data.get("pm_id") - to_nick = data.get("to_nick") - return pm_id, to_nick - - async def send(self, content, parent=None): - if parent: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content, - parent=parent - ) - else: - ptype, data, error, throttled = await self._send_while_connected( - "send", - content=content - ) - - message = Message.from_dict(data) - self._last_known_mid = message.mid - return message - - async def who(self): - ptype, data, error, throttled = await self._send_while_connected("who") - self.listing = Listing.from_dict(data.get("listing")) - self.listing.add(self.session) - -# COMMUNICATION WITH CONNECTION - - async def _disconnected(self): - # While disconnected, keep the last known session info, listing etc. - # All of this is instead reset when the hello/snapshot events are received. - logger.warn(f"&{self.roomname}:Lost connection.") - self.status = Room.DISCONNECTED - self._connected_future = asyncio.Future() - - if self._forwarding is not None: - self._forwarding.cancel() - - await self._inhabitant.on_disconnected(self) - - async def _stopped(self): - await self._inhabitant.on_stopped(self) - - async def _receive_packet(self, ptype, data, error, throttled): - # Ignoring errors and throttling for now - functions = { - "bounce-event": self._event_bounce, - #"disconnect-event": self._event_disconnect, # Not important, can ignore - "hello-event": self._event_hello, - "join-event": self._event_join, - #"login-event": self._event_login, - #"logout-event": self._event_logout, - "network-event": self._event_network, - "nick-event": self._event_nick, - "edit-message-event": self._event_edit_message, - "part-event": self._event_part, - "ping-event": self._event_ping, - "pm-initiate-event": self._event_pm_initiate, - "send-event": self._event_send, - "snapshot-event": self._event_snapshot, - } - - function = functions.get(ptype) - if function: - await function(data) - - async def _event_bounce(self, data): - logger.info(f"&{self.roomname}:Received bounce-event") - if self.password is not None: - try: - data = {"type": "passcode", "passcode": self.password} - ptype, rdata, error, throttled = await self._connection.send("auth", data=data) - success = rdata.get("success") - if not success: - reason = rdata.get("reason") - logger.warn(f"&{self.roomname}:Authentication failed: {reason}") - raise AuthenticationRequired(f"Could not join &{self.roomname}:{reason}") - else: - logger.info(f"&{self.roomname}:Authentication successful") - except ConnectionClosed: - pass - else: - logger.warn(f"&{self.roomname}:Could not authenticate: Password unknown") - raise AuthenticationRequired(f"&{self.roomname} is password locked but no password was given") - - async def _event_hello(self, data): - self.session = Session.from_dict(data.get("session")) - self.room_is_private = data.get("room_is_private") - self.version = data.get("version") - self.account = data.get("account", None) - self.account_has_access = data.get("account_has_access", None) - self.account_email_verified = data.get("account_email_verified", None) - - self.listing.add(self.session) - - async def _event_join(self, data): - session = Session.from_dict(data) - self.listing.add(session) - await self._inhabitant.on_join(self, session) - - async def _event_network(self, data): - server_id = data.get("server_id") - server_era = data.get("server_era") - logger.debug(f"&{self.roomname}:Received network-event: server_id: {server_id!r}, server_era: {server_era!r}") - - sessions = self.listing.remove_combo(server_id, server_era) - for session in sessions: - asyncio.ensure_future(self._inhabitant.on_part(self, session)) - - async def _event_nick(self, data): - sid = data.get("session_id") - uid = data.get("user_id") - from_nick = data.get("from") - to_nick = data.get("to") - - session = self.listing.by_sid(sid) - if session: - session.nick = to_nick - - await self._inhabitant.on_nick(self, sid, uid, from_nick, to_nick) - - async def _event_edit_message(self, data): - message = Message.from_dict(data) - await self._inhabitant.on_edit(self, message) - - async def _event_part(self, data): - session = Session.from_dict(data) - self.listing.remove(session.sid) - await self._inhabitant.on_part(self, session) - - async def _event_ping(self, data): - try: - new_data = {"time": data.get("time")} - await self._connection.send( "ping-reply", data=new_data, await_response=False) - except ConnectionClosed: - pass - - async def _event_pm_initiate(self, data): - from_uid = data.get("from") - from_nick = data.get("from_nick") - from_room = data.get("from_room") - pm_id = data.get("pm_id") - - await self._inhabitant.on_pm(self, from_uid, from_nick, from_room, pm_id) - - async def _event_send(self, data): - message = Message.from_dict(data) - - if self._status == Room.FORWARDING: - logger.info(f"&{self.roomname}:Received new message while forwarding, adding to queue") - self._forward_new.append(message) - else: - self._last_known_mid = message.mid - await self._inhabitant.on_send(self, message) - - # TODO: Figure out a way to bring fast-forwarding into this - - async def _event_snapshot(self, data): - logger.debug(f"&{self.roomname}:Received snapshot-event, gained access to the room") - log = [Message.from_dict(m) for m in data.get("log")] - sessions = [Session.from_dict(d) for d in data.get("listing")] - - # Update listing - self.listing = Listing() - for session in sessions: - self.listing.add(session) - self.listing.add(self.session) - - # Update room info - self.pm_with_nick = data.get("pm_with_nick", None), - self.pm_with_user_id = data.get("pm_with_user_id", None) - self.session.nick = data.get("nick", None) - - # Make sure a room is not CONNECTED without a nick - if self.target_nick and self.target_nick != self.session.nick: - logger.info(f"&{self.roomname}:Current nick doesn't match target nick {self.target_nick!r}, changing nick") - try: - _, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick}) - self.session.nick = nick_data.get("to") - except ConnectionClosed: - return # Aww, we've lost connection again - - # Now, we're finally connected again! - if self._last_known_mid is None: - logger.info(f"&{self.roomname}:Fully connected") - self._status = Room.CONNECTED - if log: # log goes from old to new - self._last_known_mid = log[-1].mid - else: - logger.info(f"&{self.roomname}:Not fully connected yet, starting message rewinding") - self._status = Room.FORWARDING - self._forward_new = [] - - if self._forwarding is not None: - self._forwarding.cancel() - self._forwarding = asyncio.ensure_future(self._forward(log)) - - if not self._connected_future.done(): # Should never be done already, I think - self._connected_future.set_result(None) - - # Let's let the inhabitant know. - await self._inhabitant.on_connected(self, log) - - # TODO: Figure out a way to bring fast-forwarding into this - # Should probably happen where this comment is - -# SOME USEFUL PUBLIC METHODS - - @staticmethod - def format_room_url(roomname, private=False, human=False): - if private: - roomname = f"pm:{roomname}" - - url = f"wss://euphoria.io/room/{roomname}/ws" - - if human: - url = f"{url}?h=1" - - return url - - async def connected(self): - await self._connected_future - -# REST OF THE IMPLEMENTATION - - async def _forward(self, log): - old_messages = [] - while True: - found_last_known = True - for message in reversed(log): - if message.mid <= self._last_known_mid: - break - old_messages.append(message) - else: - found_last_known = False - - if found_last_known: - break - - log = await self.log(100, before=log[0].mid) + """ + A Room represents one connection to a room on euphoria, i. e. what other + implementations might consider a "client". This means that each Room has + its own session (User) and nick. - logger.info(f"&{self.roomname}:Reached last known message, forwarding through messages") - for message in reversed(old_messages): - self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.on_forward(self, message)) - for message in self._forward_new: - self._last_known_mid = message.mid - asyncio.ensure_future(self._inhabitant.on_forward(self, message)) - - logger.info(f"&{self.roomname}:Forwarding complete, fully connected") - self._forward_new = [] - self._status = Room.CONNECTED - - async def _send_while_connected(self, *args, **kwargs): - while True: - if self._status == Room.CLOSED: - raise RoomClosed() - - try: - await self.connected() - return await self._connection.send(*args, data=kwargs) - except ConnectionClosed: - pass # just try again + A Room can only be used once in the sense that after it has been closed, + any further actions will result in a RoomClosedException. If you need to + manually reconnect, instead just create a new Room object. -class Inhabitant: - """ - TODO - """ -# ROOM EVENTS -# These functions are called by the room when something happens. -# They're launched via asyncio.ensure_future(), so they don't block execution of the room. -# Just overwrite the events you need (make sure to keep the arguments the same though). + Life cycle of a Room - async def on_created(self, room): - pass + 1. create a new Room and register callbacks + 2. await join() + 3. do room-related stuff + 4. await part() - async def on_connected(self, room, log): - pass - async def on_disconnected(self, room): - pass - async def on_stopped(self, room): - pass + IN PHASE 1, a password and a starting nick can be set. The password and + current nick are used when first connecting to the room, or when + reconnecting to the room after connection was lost. - async def on_join(self, room, session): - pass + Usually, event callbacks are also registered during this phase. - async def on_part(self, room, session): - pass - async def on_nick(self, room, sid, uid, from_nick, to_nick): - pass - async def on_send(self, room, message): - pass + IN PHASE 2, the Room creates the initial connection to euphoria and + performs initialisations (i. e. authentication or setting the nick) where + necessary. It also starts the Room's main event loop. The join() function + returns once one of the following cases has occurred: - async def on_forward(self, room, message): - await self.on_send(room, message) + 1. the room is now in phase 3, in which case join() returns None + 2. the room could not be joined, in which case one of the JoinExceptions is + returned - async def on_edit(self, room, message): - pass - async def on_pm(self, room, from_uid, from_nick, from_room, pm_id): - pass + + IN PHASE 3, the usual room-related functions like say() or nick() are + available. The Room's event loop is running. + + The room will automatically reconnect if it loses connection to euphoria. + The usual room-related functions will block until the room has successfully + reconnected. + + + + IN PHASE 4, the Room is disconnected and the event loop stopped. During and + after completion of this phase, the Room is considered closed. Any further + attempts to re-join or call room action functions will result in a + RoomClosedException. + """ + + # Phase 1 + + def __init__(self, + room_name: str, + nick: str = None, + password: str = None): + pass + + self.closed = False + + # Phase 2 + + # Phase 3 + + def _ensure_open(self) -> None: + if self.closed: + raise RoomClosedException() + + async def _ensure_joined(self) -> None: + pass + + async def _ensure(self) -> None: + self._ensure_open() + await self._ensure_joined() + + # Phase 4 + + # Other stuff + + @property + def name(self) -> str: + pass + + async def say(self, + text: str, + parent_id: Optional[str] = None + ) -> LiveMessage: + pass + + @property + def users(self) -> List[LiveUser]: + pass + + # retrieving messages diff --git a/yaboli/user.py b/yaboli/user.py new file mode 100644 index 0000000..053315c --- /dev/null +++ b/yaboli/user.py @@ -0,0 +1,91 @@ +from .util import mention, atmention + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .client import Client + from .room import Room + +__all__ = ["User", "LiveUser"] + +class User: + def __init__(self, + room_name: str, + id_: str, + name: str, + is_staff: bool, + is_manager: bool): + self._room_name = room_name + self._id = id_ + self._name = name + self._is_staff = is_staff + self._is_manager = is_manager + + @property + def room_name(self) -> str: + return self._room_name + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str: + # no name = empty str + return self._name + + @property + def is_staff(self) -> bool: + return self._is_staff + + @property + def is_manager(self) -> bool: + return self._is_manager + + @property + def is_account(self) -> bool: + pass + + @property + def is_agent(self) -> bool: + # TODO should catch all old ids too + pass + + @property + def is_bot(self) -> bool: + pass + + # TODO possibly add other fields + + # Properties here? Yeah sure, why not? + + @property + def mention(self) -> str: + return mention(self.name) + + @property + def atmention(self) -> str: + return atmention(self.name) + +class LiveUser(User): + def __init__(self, + client: 'Client', + room: 'Room', + id_: str, + name: str, + is_staff: bool, + is_manager: bool): + super().__init__(room.name, id_, name, is_staff, is_manager) + self._room = room + + @property + def room(self) -> 'Room': + return self._room + + # NotLoggedInException + async def pm(self) -> 'Room': + pass + + # kick + # ban + # ip_ban diff --git a/yaboli/util.py b/yaboli/util.py new file mode 100644 index 0000000..7dff6aa --- /dev/null +++ b/yaboli/util.py @@ -0,0 +1,15 @@ +__all__ = ["mention", "atmention", "normalize", "compare"] + +# Name/nick related functions + +def mention(name: str) -> str: + pass + +def atmention(name: str) -> str: + pass + +def normalize(name: str) -> str: + pass + +def compare(name_a: str, name_b: str) -> bool: + pass diff --git a/yaboli/utils.py b/yaboli/utils.py deleted file mode 100644 index ba45af7..0000000 --- a/yaboli/utils.py +++ /dev/null @@ -1,225 +0,0 @@ -import asyncio -import logging -import re -import time -import functools - - -logger = logging.getLogger(__name__) -__all__ = [ - "parallel", "asyncify", - "mention", "normalize", "similar", - "format_time", "format_time_delta", - "Session", "PersonalAccountView", "Listing", "Message", -] - - -# alias for parallel message sending -parallel = asyncio.ensure_future - -async def asyncify(func, *args, **kwargs): - func_with_args = functools.partial(func, *args, **kwargs) - return await asyncio.get_event_loop().run_in_executor(None, func_with_args) - -def mention(nick, ping=True): - nick = re.sub(r"""[,.!?;&<'"\s]""", "", nick) - return "@" + nick if ping else nick - -def normalize(nick): - return mention(nick, ping=False).lower() - -def similar(nick1, nick2): - return normalize(nick1) == normalize(nick2) - -def format_time(timestamp): - return time.strftime( - "%Y-%m-%d %H:%M:%S UTC", - time.gmtime(timestamp) - ) - -def format_time_delta(delta): - if delta < 0: - result = "-" - else: - result = "" - - delta = int(delta) - - second = 1 - minute = second*60 - hour = minute*60 - day = hour*24 - - if delta >= day: - result += f"{delta//day}d " - delta = delta%day - - if delta >= hour: - result += f"{delta//hour}h " - delta = delta%hour - - if delta >= minute: - result += f"{delta//minute}m " - delta = delta%minute - - result += f"{delta}s" - - return result - -class Session: - def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None, - is_manager=None, client_address=None, real_client_address=None): - self.user_id = user_id - self.nick = nick - self.server_id = server_id - self.server_era = server_era - self.session_id = session_id - self.is_staff = is_staff - self.is_manager = is_manager - self.client_address = client_address - self.real_client_address = real_client_address - - @property - def uid(self): - return self.user_id - - @uid.setter - def uid(self, new_uid): - self.user_id = new_uid - - @property - def sid(self): - return self.session_id - - @sid.setter - def sid(self, new_sid): - self.session_id = new_sid - - @classmethod - def from_dict(cls, d): - return cls( - d.get("id"), - d.get("name"), - d.get("server_id"), - d.get("server_era"), - d.get("session_id"), - d.get("is_staff", None), - d.get("is_manager", None), - d.get("client_address", None), - d.get("real_client_address", None) - ) - - @property - def client_type(self): - # account, agent or bot - return self.user_id.split(":")[0] - -class PersonalAccountView: - def __init__(self, account_id, name, email): - self.account_id = account_id - self.name = name - self.email = email - - @property - def aid(self): - return self.account_id - - @aid.setter - def aid(self, new_aid): - self.account_id = new_aid - -class Listing: - def __init__(self): - self._sessions = {} - - def __len__(self): - return len(self._sessions) - - def add(self, session): - self._sessions[session.session_id] = session - - def remove(self, session_id): - self._sessions.pop(session_id) - - def remove_combo(self, server_id, server_era): - removed = [ses for ses in self._sessions.items() - if ses.server_id == server_id and ses.server_era == server_era] - - self._sessions = {i: ses for i, ses in self._sessions.items() - if ses.server_id != server_id and ses.server_era != server_era} - - return removed - - def by_sid(self, session_id): - return self._sessions.get(session_id); - - def by_uid(self, user_id): - return [ses for ses in self._sessions if ses.user_id == user_id] - - def get(self, types=["agent", "account", "bot"], lurker=None): - sessions = [] - for uid, ses in self._sessions.items(): - if ses.client_type not in types: - continue - - is_lurker = not ses.nick # "" or None - if lurker is None or lurker == is_lurker: - sessions.append(ses) - - return sessions - - @classmethod - def from_dict(cls, d): - listing = cls() - for session in d: - listing.add(Session.from_dict(session)) - return listing - - #def get_people(self): - #return self.get(types=["agent", "account"]) - - #def get_accounts(self): - #return self.get(types=["account"]) - - #def get_agents(self): - #return self.get(types=["agent"]) - - #def get_bots(self): - #return self.get(types=["bot"]) - -class Message(): - def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None, - encryption_key_id=None, edited=None, deleted=None, truncated=None): - self.message_id = message_id - self.time = time - self.sender = sender - self.content = content - self.parent = parent - self.previous_edit_id = previous_edit_id - self.encryption_key_id = encryption_key_id - self.edited = edited - self.deleted = deleted - self.truncated = truncated - - @property - def mid(self): - return self.message_id - - @mid.setter - def mid(self, new_mid): - self.message_id = new_mid - - @classmethod - def from_dict(cls, d): - return cls( - d.get("id"), - d.get("time"), - Session.from_dict(d.get("sender")), - d.get("content"), - d.get("parent", None), - d.get("previous_edit_id", None), - d.get("encryption_key_id", None), - d.get("edited", None), - d.get("deleted", None), - d.get("truncated", None) - )