diff --git a/treepuncher/__init__.py b/treepuncher/__init__.py index a8663b1..6993f38 100644 --- a/treepuncher/__init__.py +++ b/treepuncher/__init__.py @@ -1 +1 @@ -from .treepuncher import Treepuncher +from .treepuncher import Treepuncher, Addon, ConfigObject diff --git a/treepuncher/__main__.py b/treepuncher/__main__.py index 4d1f332..5b82a6c 100644 --- a/treepuncher/__main__.py +++ b/treepuncher/__main__.py @@ -4,11 +4,12 @@ import re import asyncio import logging import argparse +import inspect from pathlib import Path from importlib import import_module from typing import get_type_hints -from dataclasses import dataclass +from dataclasses import dataclass, MISSING from setproctitle import setproctitle @@ -19,70 +20,28 @@ def main(): root = Path(os.getcwd()) # TODO would be cool if it was possible to configure addons path, but we need to load addons before doing argparse so we can do helptext # addon_path = Path(args.path) if args.addon_path else ( root/'addons' ) - addon_path = root/'addons' + addon_path = Path('addons') addons : List[Type[Addon]] = [] for path in sorted(addon_path.rglob('*.py')): - m = import_module(path) + py_path = str(path).replace('/', '.').replace('.py', '') + m = import_module(py_path) for obj_name in vars(m).keys(): obj = getattr(m, obj_name) - if issubclass(obj, Addon): + if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon): addons.append(obj) - class ChatLogger(Addon): - """print (optionally colored) game chat to terminal""" - REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") - @dataclass - class Options(ConfigObject): - test : str - something : int - color : bool = True - blah : str = 'porcodio' + help_text = '\n\naddons:' - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - @self.on_packet(PacketChatMessage) - async def print_chat_colored_to_terminal(packet:PacketChatMessage): - print(self.REMOVE_COLOR_FORMATS.sub("", parse_chat(packet.message, ansi_color=self.config.color))) - - addons.append(ChatLogger) - - class ChatInput(Addon): - """read input from stdin and send to game chat""" - task : asyncio.Task - running : bool - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.task = None - self.running = False - - async def initialize(self): - async def aio_input(): - while self.running: - try: - await self.client.chat(await asyncio.wait_for(ainput(""), 1)) - except asyncio.TimeoutError: - pass - except Exception: - client._logger.exception("Exception processing input from keyboard") - self.running = True - self.task = asyncio.get_event_loop().create_task(aio_input()) - - async def cleanup(self, force:bool=False): - self.running = False - if self.task and not force: - await self.task - - addons.append(ChatInput) - - help_text = '\n\naddons:\n' + str.join( # TODO do this iteratively to make it readable! - '\n', ( - f" {addon.__name__}\t\t{addon.__doc__ or '-no description-'}\n " + str.join('\n ', - (f"* {name} ({clazz.__name__}) {'[required]' if not hasattr(addon.Options, name) else ''}" for (name, clazz) in get_type_hints(addon.Options).items()) - ) for addon in addons - ) - ) + for addon in addons: + help_text += f"\n {addon.__name__} \t{addon.__doc__ or ''}" + cfg_clazz = get_type_hints(addon)['config'] + for name, field in cfg_clazz.__dataclass_fields__.items(): + default = field.default if field.default is not MISSING \ + else field.default_factory() if field.default_factory is not MISSING \ + else MISSING + help_text += f"\n * {name} ({field.type.__name__}) | {'-required-' if default is MISSING else f'{default}'}" + help_text += '\n' parser = argparse.ArgumentParser( prog='python -m treepuncher', @@ -93,6 +52,7 @@ def main(): parser.add_argument('name', help='name to use for this client session') parser.add_argument('server', help='server to connect to') + parser.add_argument('-c', '--code', dest='code', action='store_const', const=True, default=False, help="request new code to login") parser.add_argument('--client-id', dest='cid', default='c63ef189-23cb-453b-8060-13800b85d2dc', help='client_id of your Azure application') parser.add_argument('--secret', dest='secret', default='N2e7Q~ybYA0IO39KB1mFD4GmoYzISRaRNyi59', help='client_secret of your Azure application') parser.add_argument('--redirect-uri', dest='uri', default='https://fantabos.co/msauth', help='redirect_uri of your Azure application') @@ -100,20 +60,22 @@ def main(): parser.add_argument('--chat-log', dest='chat_log', action='store_const', const=True, default=False, help="print (colored) chat to terminal") parser.add_argument('--chat-input', dest='chat_input', action='store_const', const=True, default=False, help="read input from stdin and send it to chat") parser.add_argument('--debug', dest='_debug', action='store_const', const=True, default=False, help="enable debug logs") - parser.add_argument('--no-packet-whitelist', dest='use_packet_whitelist', action='store_const', const=False, default=True, help="disable packet whitelist") + parser.add_argument('--no-packet-filter', dest='use_packet_whitelist', action='store_const', const=False, default=True, help="disable packet whitelist, will decrease performance") args = parser.parse_args() configure_logging(args.name, level=logging.DEBUG if args._debug else logging.INFO) setproctitle(f"treepuncher[{args.name}]") - code = input(f"-> Go to 'https://fantabos.co/msauth?client_id={args.cid}&state=hardcoded', click 'Auth' and login, then copy here the code you received\n--> ") + code = None + if args.code: + code = input(f"-> Go to 'https://fantabos.co/msauth?client_id={args.cid}&state=hardcoded', click 'Auth' and login, then copy here the code you received\n--> ") client = Treepuncher( args.name, args.server, use_packet_whitelist=use_packet_whitelist, - notifier=notifier, + login_code=code, client_id=args.cid, client_secret=args.secret, redirect_uri=args.uri @@ -122,13 +84,6 @@ def main(): for addon in addons: client.install(addon) - if args.chat_log: - client.install(ChatLogger) - - if args.chat_input: - from aioconsole import ainput - client.install(ChatInput) - client.run() if __name__ == "__main__": diff --git a/treepuncher/helpers.py b/treepuncher/helpers.py index 2f6beab..03e2557 100644 --- a/treepuncher/helpers.py +++ b/treepuncher/helpers.py @@ -39,9 +39,9 @@ def configure_logging(name:str, level=logging.INFO, color:bool = True): file_formatter = logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", "%b %d %Y %H:%M:%S") print_formatter : logging.Formatter if color: - print_formatter = ColorFormatter("├🮥 %(message)s") + print_formatter = ColorFormatter("> %(message)s") else: - print_formatter = logging.Formatter("├🮥 %(message)s") + print_formatter = logging.Formatter("> %(message)s") fh.setFormatter(file_formatter) ch.setFormatter(print_formatter) # add the handlers to the logger diff --git a/treepuncher/storage.py b/treepuncher/storage.py index c3f39f9..d018e11 100644 --- a/treepuncher/storage.py +++ b/treepuncher/storage.py @@ -36,11 +36,11 @@ class Storage: cur.execute('INSERT INTO system VALUES (?, ?, ?)', (state.name, state.token, state.start_time)) self.db.commit() - def system(self) -> SystemState: + def system(self) -> Optional[SystemState]: cur = self.db.cursor() val = cur.execute('SELECT * FROM system').fetchall() if not val: - raise ValueError("No system state set") + return None return SystemState( name=val[0][0], token=val[0][1], diff --git a/treepuncher/treepuncher.py b/treepuncher/treepuncher.py index 952f560..e96652f 100644 --- a/treepuncher/treepuncher.py +++ b/treepuncher/treepuncher.py @@ -1,4 +1,5 @@ import re +import json import logging import asyncio import datetime @@ -14,7 +15,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from aiocraft.client import MinecraftClient from aiocraft.mc.packet import Packet -from .storage import Storage +from .storage import Storage, SystemState from .notifier import Notifier from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld @@ -26,34 +27,38 @@ class ConfigObject: class Addon: name : str - config : ConfigObject _client : 'Treepuncher' @dataclass(frozen=True) class Options(ConfigObject): pass + config : Options @property def client(self) -> 'Treepuncher': return self._client - def __init__(self, client:'Treepuncher'): + def __init__(self, client:'Treepuncher', *args, **kwargs): self._client = client self.name = type(self).__name__ cfg = self._client.config - kwargs : Dict[str, Any] = {} + opts : Dict[str, Any] = {} for name, clazz in get_type_hints(self.Options).items(): default = getattr(self.Options, name, None) if cfg.has_option(self.name, name): if clazz is bool: - kwargs[name] = self._client.config[self.name].getboolean(name) + opts[name] = self._client.config[self.name].getboolean(name) else: - kwargs[name] = clazz(self._client.config[self.name].get(name)) + opts[name] = clazz(self._client.config[self.name].get(name)) elif default is None: raise ValueError(f"Missing required value '{name}' of type '{clazz.__name__}' in section '{self.name}'") else: # not really necessary since it's a dataclass but whatever - kwargs[name] = default - self.config = self.Options(**kwargs) + opts[name] = default + self.config = self.Options(**opts) + self.register(client) + + def register(self, client:'Treepuncher'): + pass async def initialize(self): pass @@ -87,6 +92,11 @@ class Treepuncher( self.config.read(config_path) self.storage = Storage(self.name) + prev = self.storage.system() # if this isn't 1st time, this won't be None. Load token from there + if prev: + if self.name != prev.name: + self._logger.warning("Saved token session name differs from current") + self._authenticator.deserialize(json.loads(prev.token)) self.modules = [] @@ -107,6 +117,15 @@ class Treepuncher( return self._username raise ValueError("No username configured for offline mode") + async def authenticate(self): + await super().authenticate() + state = SystemState( + name=self.name, + token=json.dumps(self._authenticator.serialize()), + start_time=int(time()) + ) + self.storage._set_state(state) + async def start(self): await self.notifier.initialize(self) for m in self.modules: