Compare commits

..

No commits in common. "dev" and "microsoft" have entirely different histories.

26 changed files with 184 additions and 613 deletions

View file

@ -1,10 +1,10 @@
# Unix-style newlines with a newline ending every file # Unix-style newlines with a newline ending every file
[*] [*]
charset = utf-8
indent_style = tab
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
[*.py] [*.py]
charset = utf-8
indent_style = tab
indent_size = 4 indent_size = 4

3
.gitignore vendored
View file

@ -127,6 +127,3 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# Auto generated version file
src/treepuncher/__version__.py

111
README.md
View file

@ -1,111 +1,2 @@
# treepuncher # treepuncher
an hackable headless Minecraft client, built with **[aiocraft](https://git.alemi.dev/aiocraft.git/about)** An hackable Minecraft client, built with aiocraft
### Features
* persistent storage
* configuration file
* pluggable plugin system
* event system with callbacks
* world processing
## Quick Start
`treepuncher` is still in development and thus not available yet on PyPI, to install it fetch directly from git:
* `pip install "git+https://git.alemi.dev/treepuncher.git@v0.3.0"`
currently only 1.16.5 is being targeted, so while simple things _should_ work on any version, more complex interactions may break outside 1.16.5
`treepuncher` can both be run as a pluggable CLI application or as a library, depending on how much you need to customize its behaviour
### as an application
`treepuncher` ships as a standalone CLI application which you can run with `python -m treepuncher`
* prepare a runtime directory with this layout:
```
.
|- log/ # will contain rotating log files: MYBOT.log, MYBOT.log.1 ...
|- data/ # will contain session files: MYBOT.session
|- addons/ # put your addons here
|- MYBOT.ini # your configuration file for one session
```
* create your first addon (for example, a simple chat logger) inside `./addons/chat_logger.py`
```py
from dataclasses import dataclass
from treepuncher import Addon, ConfigObject
from treepuncher.events import ChatEvent
class ChatLogger(Addon):
@dataclass # must be a dataclass
class Options(ConfigObject): # must extend ConfigObject
prefix : str = ""
config : Options # must add this type annotation
def register(self): # register all callbacks and schedulers in here
@self.client.on(ChatEvent)
async def print_chat(event: ChatEvent):
print(f"{event.user} >> {event.text})
```
* create a config file for your session (for example, `MYBOT`): `MYBOT.ini`
```ini
[Treepuncher]
server = your.server.com
username = your_account_username
client_id = your_microsoft_authenticator_client_id
client_secret = your_microsoft_authenticator_client_secret
code = microsoft_auth_code
; you must specify the addon section to have it loaded,
; even if it doesn't take any config value
[ChatLogger]
prefix = CHAT |::
```
* run the treepuncher client : `python -m treepuncher MYBOT` (note that session name must be same as config file, minus `.ini`)
### as a library
under the hood `treepuncher` is just a library and it's possible to invoke it programmatically
* instantiate the `treepuncher` object
```py
from treepuncher import Treepuncher
client = Treepuncher(
"my_bot",
server="your.server.com",
)
```
* prepare your addons (must extend `treepuncher.Addon`) and install them
```py
from treepuncher import Addon
class MyAddon(Addon):
pass
addon = MyAddon()
client.install(addon)
```
* run your client
```py
client.run()
```
## Authentication
`treepuncher` supports both legacy Yggdrasil authentication (with options to override session and auth server) and modern Microsoft OAuth authentication. It will store the auth token inside a session file, to restart without requiring credentials again
to be able to use Microsoft authentication you will need to register an Azure application (see [community](https://wiki.vg/Microsoft_Authentication_Scheme) and [microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) docs on how to do that).
this is a tedious process but can be done just once for many accounts, sadly Microsoft decided that only kids play minecraft and we developers should just suffer...
**be warned that Microsoft may limit your account if they find your activity suspicious**
once you have your `client_id` and `client_secret` use [this page](https://fantabos.co/msauth) to generate a login code: put in your `client_id` and any state and press `auth`.
you will be brought to Microsoft login page, input your credentials, authorize your application and you will be redirected back to the `msauth` page, but now there should be a code in the `auth code` field
put this code in your config and you're good to go!
if you'd rather use classic Yggdrasil authentication, consider [ftbsc yggdrasil](https://yggdrasil.fantabos.co) ([src](https://git.fantabos.co/yggdrasil))
legacy Yggdrasil authentication supports both an hardcoded password or a pre-authorized access token
## Contributing
development is managed by [ftbsc](https://fantabos.co), mostly on [our git](https://git.fantabos.co). If you'd like to contribute, get in contact with any of us using any available channel!

View file

@ -1,30 +1,7 @@
[build-system] [build-system]
requires = ["setuptools", "setuptools-scm"] requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project]
name = "treepuncher"
authors = [
{name = "alemi", email = "me@alemi.dev"},
]
description = "An hackable Minecraft client, built with aiocraft"
readme = "README.md"
requires-python = ">=3.7"
keywords = ["minecraft", "client", "bot", "hackable"]
# license = {text = "MIT"}
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"setproctitle",
"termcolor",
"apscheduler",
"aioconsole",
"aiocraft @ git+https://git.fantabos.co/alemi/aiocraft.git@v0.3.0",
]
dynamic = ["version"]
[tool.setuptools_scm]
write_to = "src/treepuncher/__version__.py"

View file

@ -1,21 +0,0 @@
import sqlite3
def migrate_old_documents_to_namespaced_documents(db:str):
db = sqlite3.connect(db)
values = db.cursor().execute("SELECT * FROM documents", ()).fetchall();
for k,v in values:
if "_" in k:
addon, key = k.split("_", 1)
db.cursor().execute("CREATE TABLE IF NOT EXISTS documents_{addon} (name TEXT PRIMARY KEY, value TEXT)", ())
db.cursor().execute("INSERT INTO documents_{addon} VALUES (?, ?)", (key, v))
db.cursor().execute("DELETE FROM documents WHERE name = ?", k)
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("[!] No argument given")
exit(-1)
migrate_old_documents_to_namespaced_documents(sys.argv[1])

30
setup.cfg Normal file
View file

@ -0,0 +1,30 @@
[metadata]
name = treepuncher
version = 0.1.0
author = alemi
author_email = me@alemi.dev
description = An hackable Minecraft client, built with aiocraft
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/alemidev/treepuncher
project_urls =
Bug Tracker = https://github.com/alemidev/treepuncher/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
install_requires =
setproctitle
termcolor
apscheduler
aioconsole
aiocraft
package_dir =
= src
packages = find:
python_requires = >=3.6
[options.packages.find]
where = src

View file

@ -1,5 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
import os import os
import re
import sys
import asyncio
import logging import logging
import argparse import argparse
import inspect import inspect
@ -7,19 +10,18 @@ import inspect
from pathlib import Path from pathlib import Path
from importlib import import_module from importlib import import_module
import traceback import traceback
from typing import Type, Set, get_type_hints from typing import List, Type, Set, get_type_hints
from dataclasses import MISSING, fields from dataclasses import dataclass, MISSING, fields
from setproctitle import setproctitle from setproctitle import setproctitle
from .treepuncher import Treepuncher, MissingParameterError, Addon, Provider from .treepuncher import Treepuncher, MissingParameterError, Addon, ConfigObject, Provider
from .scaffold import ConfigObject
from .helpers import configure_logging from .helpers import configure_logging
def main(): 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 # 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
#root = Path(os.getcwd()) # addon_path = Path(args.path) if args.addon_path else ( root/'addons' )
#addon_path = Path(args.path) if args.addon_path else ( root/'addons' )
addon_path = Path('addons') addon_path = Path('addons')
addons : Set[Type[Addon]] = set() addons : Set[Type[Addon]] = set()
@ -31,7 +33,7 @@ def main():
obj = getattr(m, obj_name) obj = getattr(m, obj_name)
if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon): if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon):
addons.add(obj) addons.add(obj)
except Exception: except Exception as e:
print(f"Exception importing addon {py_path}") print(f"Exception importing addon {py_path}")
traceback.print_exc() traceback.print_exc()
pass pass
@ -84,19 +86,13 @@ def main():
if args.server: if args.server:
kwargs["server"] = args.server kwargs["server"] = args.server
if not os.path.isdir('log'):
os.mkdir('log')
if not os.path.isdir('data'):
os.mkdir('data')
try: try:
client = Treepuncher( client = Treepuncher(
args.name, args.name,
server=args.server or None, args.server,
online_mode=not args.offline, online_mode=not args.offline,
legacy=args.mojang, legacy=args.mojang,
use_packet_whitelist=args.use_packet_whitelist, use_packet_whitelist=args.use_packet_whitelist,
code=args.code,
) )
except MissingParameterError as e: except MissingParameterError as e:
return logging.error(e.args[0]) return logging.error(e.args[0])

View file

@ -1,12 +1,9 @@
from datetime import datetime
import json import json
import logging import logging
from typing import TYPE_CHECKING, Dict, Any, Optional, Union, List, Callable, get_type_hints, get_args, get_origin from typing import TYPE_CHECKING, Dict, Any, Union, List, Callable, get_type_hints, get_args, get_origin
from dataclasses import dataclass, MISSING, fields from dataclasses import dataclass, MISSING, fields
from treepuncher.storage import AddonStorage
from .scaffold import ConfigObject from .scaffold import ConfigObject
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,23 +29,18 @@ def parse_with_hint(val:str, hint:Any) -> Any:
if hint is dict or get_origin(hint) is dict: if hint is dict or get_origin(hint) is dict:
return json.loads(val) return json.loads(val)
if hint is Union or get_origin(hint) is Union: if hint is Union or get_origin(hint) is Union:
for t in get_args(hint): # TODO str will never fail, should be tried last.
if t is type(None) and val in ("null", ""): # cheap fix: sort keys by name so that "str" comes last
return None for t in sorted(get_args(hint), key=lambda x : str(x)):
if t is str:
continue # try this last, will always succeed
try: try:
return t(val) return t(val)
except ValueError: except ValueError:
pass pass
if any(t is str for t in get_args(hint)):
return str(val)
return (get_origin(hint) or hint)(val) # try to instantiate directly return (get_origin(hint) or hint)(val) # try to instantiate directly
class Addon: class Addon:
name: str name: str
config: ConfigObject config: ConfigObject
storage: AddonStorage
logger: logging.Logger logger: logging.Logger
_client: 'Treepuncher' _client: 'Treepuncher'
@ -85,16 +77,12 @@ class Addon:
else: # not really necessary since it's a dataclass but whatever else: # not really necessary since it's a dataclass but whatever
opts[field.name] = default opts[field.name] = default
self.config = self.Options(**opts) self.config = self.Options(**opts)
self.storage = self.init_storage()
self.logger = self._client.logger.getChild(self.name) self.logger = self._client.logger.getChild(self.name)
self.register() self.register()
def register(self): def register(self):
pass pass
def init_storage(self) -> AddonStorage:
return self.client.storage.addon_storage(self.name)
async def initialize(self): async def initialize(self):
pass pass

View file

@ -2,5 +2,3 @@ from .chat import ChatEvent
from .join_game import JoinGameEvent from .join_game import JoinGameEvent
from .death import DeathEvent from .death import DeathEvent
from .system import ConnectedEvent, DisconnectedEvent from .system import ConnectedEvent, DisconnectedEvent
from .connection import PlayerJoinEvent, PlayerLeaveEvent
from .block_update import BlockUpdateEvent

View file

@ -1,13 +0,0 @@
from aiocraft.types import BlockPos
from .base import BaseEvent
class BlockUpdateEvent(BaseEvent):
SENTINEL = object()
location : BlockPos
state : int
def __init__(self, location: BlockPos, state: int):
self.location = location
self.state = state

View file

@ -1,14 +0,0 @@
from aiocraft.types import Player
from .base import BaseEvent
class PlayerJoinEvent(BaseEvent):
player: Player
def __init__(self, p:Player):
self.player = p
class PlayerLeaveEvent(BaseEvent):
player: Player
def __init__(self, p:Player):
self.player = p

View file

@ -1,4 +1,4 @@
from aiocraft.types import Dimension, Difficulty, Gamemode from aiocraft.mc.definitions import Dimension, Difficulty, Gamemode
from .base import BaseEvent from .base import BaseEvent
@ -12,4 +12,4 @@ class JoinGameEvent(BaseEvent):
def __init__(self, dimension:Dimension, difficulty:Difficulty, gamemode:Gamemode): def __init__(self, dimension:Dimension, difficulty:Difficulty, gamemode:Gamemode):
self.gamemode = gamemode self.gamemode = gamemode
self.difficulty = difficulty self.difficulty = difficulty
self.dimension = dimension self.dimension = dimension

View file

@ -1,4 +1,4 @@
from aiocraft.packet import Packet from aiocraft.mc.packet import Packet
from .base import BaseEvent from .base import BaseEvent

View file

@ -3,4 +3,3 @@ from .inventory import GameInventory
from .tablist import GameTablist from .tablist import GameTablist
from .chat import GameChat from .chat import GameChat
from .world import GameWorld from .world import GameWorld
from .container import GameContainer

View file

@ -1,7 +1,9 @@
from aiocraft.proto.play.clientbound import PacketChat as PacketChatMessage from typing import Union
from aiocraft.proto.play.serverbound import PacketChat
from ..events.chat import ChatEvent from aiocraft.mc.proto.play.clientbound import PacketChat as PacketChatMessage
from aiocraft.mc.proto.play.serverbound import PacketChat
from ..events.chat import ChatEvent, MessageType
from ..scaffold import Scaffold from ..scaffold import Scaffold
class GameChat(Scaffold): class GameChat(Scaffold):
@ -13,11 +15,14 @@ class GameChat(Scaffold):
async def chat_event_callback(packet:PacketChatMessage): async def chat_event_callback(packet:PacketChatMessage):
self.run_callbacks(ChatEvent, ChatEvent(packet.message)) self.run_callbacks(ChatEvent, ChatEvent(packet.message))
async def chat(self, message:str, whisper:str="", wait:bool=False): async def chat(self, message:str, whisper:str=None, wait:bool=False):
if whisper: if whisper:
message = f"/w {whisper} {message}" message = f"/w {whisper} {message}"
await self.dispatcher.write( await self.dispatcher.write(
PacketChat(message=message), PacketChat(
self.dispatcher.proto,
message=message
),
wait=wait wait=wait
) )

View file

@ -1,87 +0,0 @@
from aiocraft.types import Item
from aiocraft.proto.play.clientbound import PacketTransaction
from aiocraft.proto.play.serverbound import PacketTransaction as PacketTransactionServerbound
from aiocraft.proto import (
PacketOpenWindow, PacketCloseWindow, PacketSetSlot
)
from ..events import DisconnectedEvent
from ..scaffold import Scaffold
class WindowContainer:
id: int
title: str
type: str
entity_id: int | None
transaction_id: int
inventory: list[Item | None]
def __init__(self, id:int, title: str, type: str, entity_id:int | None = None, slot_count:int = 27):
self.id = id
self.title = title
self.type = type
self.entity_id = entity_id
self.transaction_id = 0
self.inventory = [ None ] * (slot_count + 36)
@property
def next_tid(self) -> int:
self.transaction_id += 1
if self.transaction_id > 32767:
self.transaction_id = -32768 # force short overflow since this is sent over the socket as a short
return self.transaction_id
class GameContainer(Scaffold):
window: WindowContainer | None
@property
def is_container_open(self) -> bool:
return self.window is not None
async def close_container(self):
await self.dispatcher.write(
PacketCloseWindow(
self.dispatcher.proto,
windowId=self.window.id
)
)
self.window = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.window = None
@self.on(DisconnectedEvent)
async def disconnected_cb(_):
self.window = None
@self.on_packet(PacketOpenWindow)
async def on_player_open_window(packet:PacketOpenWindow):
assert isinstance(packet.inventoryType, str)
window_entity_id = packet.entityId if packet.inventoryType == "EntityHorse" and hasattr(packet, "entityId") else None
self.window = WindowContainer(
packet.windowId,
packet.windowTitle,
packet.inventoryType,
entity_id=window_entity_id,
slot_count=packet.slotCount or 27
)
@self.on_packet(PacketSetSlot)
async def on_set_slot(packet:PacketSetSlot):
if packet.windowId == 0:
self.window = None
elif self.window and packet.windowId == self.window.id:
self.window.inventory[packet.slot] = packet.item
@self.on_packet(PacketTransaction)
async def on_transaction_denied(packet:PacketTransaction):
if self.window and packet.windowId == self.window.id:
if not packet.accepted: # apologize to server automatically
await self.dispatcher.write(
PacketTransactionServerbound(
windowId=packet.windowId,
action=packet.action,
accepted=packet.accepted,
)
)

View file

@ -1,8 +1,8 @@
from typing import List from typing import List
from aiocraft.types import Item from aiocraft.mc.definitions import Item
from aiocraft.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange from aiocraft.mc.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange
from aiocraft.proto.play.serverbound import PacketHeldItemSlot from aiocraft.mc.proto.play.serverbound import PacketHeldItemSlot
from ..scaffold import Scaffold from ..scaffold import Scaffold
@ -13,7 +13,7 @@ class GameInventory(Scaffold):
async def set_slot(self, slot:int): async def set_slot(self, slot:int):
self.slot = slot self.slot = slot
await self.dispatcher.write(PacketHeldItemSlot(slotId=slot)) await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot))
@property @property
def hotbar(self) -> List[Item]: def hotbar(self) -> List[Item]:

View file

@ -1,15 +1,13 @@
import asyncio import asyncio
import datetime import datetime
import json
#from aiocraft.client import MinecraftClient #from aiocraft.client import MinecraftClient
from aiocraft.types import Gamemode, Dimension, Difficulty from aiocraft.mc.definitions import Gamemode, Dimension, Difficulty
from aiocraft.proto import ( from aiocraft.mc.proto import (
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings, PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings, PacketClientCommand, PacketAbilities
PacketClientCommand, PacketAbilities, PacketDifficulty
) )
from ..events import JoinGameEvent, DeathEvent, DisconnectedEvent from ..events import JoinGameEvent, DeathEvent, ConnectedEvent, DisconnectedEvent
from ..scaffold import Scaffold from ..scaffold import Scaffold
class GameState(Scaffold): class GameState(Scaffold):
@ -39,16 +37,15 @@ class GameState(Scaffold):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.in_game = False self.in_game = False
self.gamemode = Gamemode.UNKNOWN self.gamemode = Gamemode.SURVIVAL
self.dimension = Dimension.UNKNOWN self.dimension = Dimension.OVERWORLD
self.difficulty = Difficulty.UNKNOWN self.difficulty = Difficulty.HARD
self.join_time = datetime.datetime(2011, 11, 18) self.join_time = datetime.datetime(2011, 11, 18)
self.hp = 20.0 self.hp = 20.0
self.food = 20.0 self.food = 20.0
self.xp = 0.0 self.xp = 0.0
self.lvl = 0 self.lvl = 0
self.total_xp = 0
@self.on(DisconnectedEvent) @self.on(DisconnectedEvent)
async def disconnected_cb(_): async def disconnected_cb(_):
@ -57,12 +54,8 @@ class GameState(Scaffold):
@self.on_packet(PacketRespawn) @self.on_packet(PacketRespawn)
async def on_player_respawning(packet:PacketRespawn): async def on_player_respawning(packet:PacketRespawn):
self.gamemode = Gamemode(packet.gamemode) self.gamemode = Gamemode(packet.gamemode)
if isinstance(packet.dimension, dict): self.dimension = Dimension(packet.dimension)
self.logger.info("Received dimension data: %s", json.dumps(packet.dimension, indent=2)) self.difficulty = Difficulty(packet.difficulty)
self.dimension = Dimension.from_str(packet.dimension['effects'])
else:
self.dimension = Dimension(packet.dimension)
self.difficulty = Difficulty(packet.difficulty)
if self.difficulty != Difficulty.PEACEFUL \ if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR: and self.gamemode != Gamemode.SPECTATOR:
self.in_game = True self.in_game = True
@ -75,22 +68,11 @@ class GameState(Scaffold):
self.gamemode.name self.gamemode.name
) )
@self.on_packet(PacketDifficulty)
async def on_set_difficulty(packet:PacketDifficulty):
self.difficulty = Difficulty(packet.difficulty)
self.logger.info("Difficulty set to %s", self.difficulty.name)
@self.on_packet(PacketLogin) @self.on_packet(PacketLogin)
async def player_joining_cb(packet:PacketLogin): async def player_joining_cb(packet:PacketLogin):
self.entity_id = packet.entityId
self.gamemode = Gamemode(packet.gameMode) self.gamemode = Gamemode(packet.gameMode)
if isinstance(packet.dimension, dict): self.dimension = Dimension(packet.dimension)
with open('world_codec.json', 'w') as f: self.difficulty = Difficulty(packet.difficulty)
json.dump(packet.dimensionCodec, f)
self.dimension = Dimension.from_str(packet.dimension['effects'])
else:
self.dimension = Dimension(packet.dimension)
self.difficulty = Difficulty(packet.difficulty)
self.join_time = datetime.datetime.now() self.join_time = datetime.datetime.now()
if self.difficulty != Difficulty.PEACEFUL \ if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR: and self.gamemode != Gamemode.SPECTATOR:
@ -106,6 +88,7 @@ class GameState(Scaffold):
self.run_callbacks(JoinGameEvent, JoinGameEvent(self.dimension, self.difficulty, self.gamemode)) self.run_callbacks(JoinGameEvent, JoinGameEvent(self.dimension, self.difficulty, self.gamemode))
await self.dispatcher.write( await self.dispatcher.write(
PacketSettings( PacketSettings(
self.dispatcher.proto,
locale="en_US", locale="en_US",
viewDistance=4, viewDistance=4,
chatFlags=0, chatFlags=0,
@ -114,7 +97,7 @@ class GameState(Scaffold):
mainHand=0, mainHand=0,
) )
) )
await self.dispatcher.write(PacketClientCommand(actionId=0)) await self.dispatcher.write(PacketClientCommand(self.dispatcher.proto, actionId=0))
@self.on_packet(PacketUpdateHealth) @self.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth): async def player_hp_cb(packet:PacketUpdateHealth):
@ -131,7 +114,7 @@ class GameState(Scaffold):
self.logger.warning("Died, attempting to respawn") self.logger.warning("Died, attempting to respawn")
await asyncio.sleep(0.5) # TODO make configurable await asyncio.sleep(0.5) # TODO make configurable
await self.dispatcher.write( await self.dispatcher.write(
PacketClientCommand(actionId=0) # respawn PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn
) )
@self.on_packet(PacketExperience) @self.on_packet(PacketExperience)

View file

@ -2,12 +2,13 @@ import uuid
import datetime import datetime
from enum import Enum from enum import Enum
from typing import Dict, List
from aiocraft.types import Player from aiocraft.mc.definitions import Player
from aiocraft.proto import PacketPlayerInfo from aiocraft.mc.proto import PacketPlayerInfo
from ..scaffold import Scaffold from ..scaffold import Scaffold
from ..events import ConnectedEvent, PlayerJoinEvent, PlayerLeaveEvent from ..events import ConnectedEvent
class ActionType(Enum): # TODO move this in aiocraft class ActionType(Enum): # TODO move this in aiocraft
ADD_PLAYER = 0 ADD_PLAYER = 0
@ -17,7 +18,7 @@ class ActionType(Enum): # TODO move this in aiocraft
REMOVE_PLAYER = 4 REMOVE_PLAYER = 4
class GameTablist(Scaffold): class GameTablist(Scaffold):
tablist : dict[uuid.UUID, Player] tablist : Dict[uuid.UUID, Player]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -37,7 +38,6 @@ class GameTablist(Scaffold):
if packet.action == ActionType.ADD_PLAYER.value: if packet.action == ActionType.ADD_PLAYER.value:
record['joinTime'] = datetime.datetime.now() record['joinTime'] = datetime.datetime.now()
self.tablist[uid] = Player.deserialize(record) # TODO have it be a Player type inside packet self.tablist[uid] = Player.deserialize(record) # TODO have it be a Player type inside packet
self.run_callbacks(PlayerJoinEvent, PlayerJoinEvent(Player.deserialize(record)))
elif packet.action == ActionType.UPDATE_GAMEMODE.value: elif packet.action == ActionType.UPDATE_GAMEMODE.value:
self.tablist[uid].gamemode = record['gamemode'] self.tablist[uid].gamemode = record['gamemode']
elif packet.action == ActionType.UPDATE_LATENCY.value: elif packet.action == ActionType.UPDATE_LATENCY.value:
@ -46,7 +46,6 @@ class GameTablist(Scaffold):
self.tablist[uid].displayName = record['displayName'] self.tablist[uid].displayName = record['displayName']
elif packet.action == ActionType.REMOVE_PLAYER.value: elif packet.action == ActionType.REMOVE_PLAYER.value:
self.tablist.pop(uid, None) self.tablist.pop(uid, None)
self.run_callbacks(PlayerLeaveEvent, PlayerLeaveEvent(Player.deserialize(record)))

View file

@ -1,77 +1,27 @@
import json import uuid
from time import time import datetime
from aiocraft.types import BlockPos from typing import Dict, List
from aiocraft.proto import (
PacketMapChunk, PacketBlockChange, PacketMultiBlockChange, PacketSetPassengers, PacketEntityTeleport,
PacketSteerVehicle, PacketRelEntityMove, PacketTeleportConfirm
)
from aiocraft.proto.play.clientbound import PacketPosition
from aiocraft.primitives import twos_comp
from aiocraft import Chunk, World # TODO these imports will hopefully change! from aiocraft.mc.definitions import BlockPos
from aiocraft.mc.proto.play.clientbound import PacketPosition
from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm
from ..scaffold import Scaffold from ..scaffold import Scaffold
from ..events import BlockUpdateEvent from ..events import ConnectedEvent
class GameWorld(Scaffold): class GameWorld(Scaffold):
position : BlockPos position : BlockPos
vehicle_id : int | None # TODO world
world : World
_last_steer_vehicle : float
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.world = World()
self.position = BlockPos(0, 0, 0) self.position = BlockPos(0, 0, 0)
self.vehicle_id = None
self._last_steer_vehicle = time()
@self.on_packet(PacketSetPassengers) @self.on(ConnectedEvent)
async def player_enters_vehicle_cb(packet:PacketSetPassengers): async def connected_cb(_):
if self.vehicle_id is None: # might get mounted on a vehicle self.tablist.clear()
for entity_id in packet.passengers:
if entity_id == self.entity_id:
self.vehicle_id = packet.entityId
else: # might get dismounted from vehicle
if packet.entityId == self.vehicle_id:
if self.entity_id not in packet.passengers:
self.vehicle_id = None
@self.on_packet(PacketEntityTeleport)
async def entity_rubberband_cb(packet:PacketEntityTeleport):
if self.vehicle_id is None:
return
if self.vehicle_id != packet.entityId:
return
self.position = BlockPos(packet.x, packet.y, packet.z)
self.logger.info(
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f) (vehicle)",
self.position.x, self.position.y, self.position.z
)
@self.on_packet(PacketRelEntityMove)
async def entity_relative_move_cb(packet:PacketRelEntityMove):
if self.vehicle_id is None:
return
if self.vehicle_id != packet.entityId:
return
self.position = BlockPos(
self.position.x + packet.dX,
self.position.y + packet.dY,
self.position.z + packet.dZ
)
self.logger.debug(
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f) (relMove vehicle)",
self.position.x, self.position.y, self.position.z
)
if time() - self._last_steer_vehicle >= 5:
self._last_steer_vehicle = time()
await self.dispatcher.write(
PacketSteerVehicle(forward=0, sideways=0, jump=0)
)
@self.on_packet(PacketPosition) @self.on_packet(PacketPosition)
async def player_rubberband_cb(packet:PacketPosition): async def player_rubberband_cb(packet:PacketPosition):
@ -81,48 +31,8 @@ class GameWorld(Scaffold):
self.position.x, self.position.y, self.position.z self.position.x, self.position.y, self.position.z
) )
await self.dispatcher.write( await self.dispatcher.write(
PacketTeleportConfirm(teleportId=packet.teleportId) PacketTeleportConfirm(
self.dispatcher.proto,
teleportId=packet.teleportId
)
) )
# Since this might require more resources, allow to disable it
if not self.cfg.getboolean("process_world", fallback=False):
return
@self.on_packet(PacketMapChunk)
async def map_chunk_cb(packet:PacketMapChunk):
assert isinstance(packet.bitMap, int)
c = Chunk(packet.x, packet.z, packet.bitMap, packet.groundUp, json.dumps(packet.blockEntities)) # TODO a solution which is not jank!
c.read(packet.chunkData)
self.world.put(c, packet.x, packet.z, not packet.groundUp)
@self.on_packet(PacketBlockChange)
async def block_change_cb(packet:PacketBlockChange):
self.world.put_block(packet.location[0], packet.location[1], packet.location[2], packet.type)
pos = BlockPos(packet.location[0], packet.location[1], packet.location[2])
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, packet.type))
@self.on_packet(PacketMultiBlockChange)
async def multi_block_change_cb(packet:PacketMultiBlockChange):
if self.dispatcher.proto < 751:
chunk_x_off = packet.chunkX * 16
chunk_z_off = packet.chunkZ * 16
for entry in packet.records:
x_off = (entry['horizontalPos'] >> 4 ) & 15
z_off = entry['horizontalPos'] & 15
pos = BlockPos(x_off + chunk_x_off, entry['y'], z_off + chunk_z_off)
self.world.put_block(pos.i_x, pos.i_y, pos.i_z, entry['blockId'])
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, entry['blockId']))
elif self.dispatcher.proto < 760:
x = twos_comp((packet.chunkCoordinates >> 42) & 0x3FFFFF, 22)
z = twos_comp((packet.chunkCoordinates >> 20) & 0x3FFFFF, 22)
y = twos_comp((packet.chunkCoordinates ) & 0xFFFFF , 20)
for loc in packet.records:
state = loc >> 12
dx = ((loc & 0x0FFF) >> 8 ) & 0x0F
dz = ((loc & 0x0FFF) >> 4 ) & 0x0F
dy = ((loc & 0x0FFF) ) & 0x0F
pos = BlockPos(16*x + dx, 16*y + dy, 16*z + dz)
self.world.put_block(pos.i_x, pos.i_y, pos.i_z, state)
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, state))
else:
self.logger.error("Cannot process MultiBlockChange for protocol %d", self.dispatcher.proto)

View file

@ -4,7 +4,7 @@ from typing import Dict
from termcolor import colored from termcolor import colored
def configure_logging(name:str, level=logging.INFO, color:bool = True, path:str = "log"): def configure_logging(name:str, level=logging.INFO, color:bool = True):
import os import os
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
@ -27,9 +27,7 @@ def configure_logging(name:str, level=logging.INFO, color:bool = True, path:str
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(level) logger.setLevel(level)
# create file handler which logs even debug messages # create file handler which logs even debug messages
if not os.path.isdir(path): fh = RotatingFileHandler(f'log/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files
os.mkdir(path)
fh = RotatingFileHandler(f'{path}/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
# create console handler with a higher log level # create console handler with a higher log level
ch = logging.StreamHandler() ch = logging.StreamHandler()

View file

@ -1,14 +1,12 @@
from configparser import ConfigParser, SectionProxy
from typing import Type, Any from typing import Type, Any
from aiocraft.client import AbstractMinecraftClient from aiocraft.client import MinecraftClient
from aiocraft.util import helpers from aiocraft.util import helpers
from aiocraft.packet import Packet from aiocraft.mc.packet import Packet
from aiocraft.types import ConnectionState from aiocraft.mc.definitions import ConnectionState
from aiocraft.proto import PacketKickDisconnect, PacketSetCompression from aiocraft.mc.proto import PacketKickDisconnect, PacketSetCompression
from aiocraft.proto.play.clientbound import PacketKeepAlive from aiocraft.mc.proto.play.clientbound import PacketKeepAlive
from aiocraft.proto.play.serverbound import PacketKeepAlive as PacketKeepAliveResponse from aiocraft.mc.proto.play.serverbound import PacketKeepAlive as PacketKeepAliveResponse
from .traits import CallbacksHolder, Runnable from .traits import CallbacksHolder, Runnable
from .events import ConnectedEvent, DisconnectedEvent from .events import ConnectedEvent, DisconnectedEvent
@ -19,41 +17,35 @@ class ConfigObject:
return getattr(self, key) return getattr(self, key)
class Scaffold( class Scaffold(
MinecraftClient,
CallbacksHolder, CallbacksHolder,
Runnable, Runnable,
AbstractMinecraftClient,
): ):
entity_id : int
config: ConfigParser send_keep_alive : bool = True # TODO how to handle this?
@property
def cfg(self) -> SectionProxy:
return SectionProxy(self.config, "Treepuncher")
def on_packet(self, packet:Type[Packet]): def on_packet(self, packet:Type[Packet]):
def decorator(fun): def decorator(fun):
return self.register(packet, fun) return self.register(packet, fun)
return decorator return decorator
def on(self, event:Type[BaseEvent]): def on(self, event:Type[BaseEvent]): # TODO maybe move in Treepuncher?
def decorator(fun): def decorator(fun):
return self.register(event, fun) return self.register(event, fun)
return decorator return decorator
#Override #Override
async def _play(self) -> bool: async def _play(self) -> bool:
assert self.dispatcher is not None self.dispatcher.state = ConnectionState.PLAY
self.dispatcher.promote(ConnectionState.PLAY)
self.run_callbacks(ConnectedEvent, ConnectedEvent()) self.run_callbacks(ConnectedEvent, ConnectedEvent())
async for packet in self.dispatcher.packets(): async for packet in self.dispatcher.packets():
self.logger.debug("[ * ] Processing %s", packet.__class__.__name__) self.logger.debug("[ * ] Processing %s", packet.__class__.__name__)
if isinstance(packet, PacketSetCompression): if isinstance(packet, PacketSetCompression):
self.logger.info("Compression updated") self.logger.info("Compression updated")
self.dispatcher.update_compression_threshold(packet.threshold) self.dispatcher.compression = packet.threshold
elif isinstance(packet, PacketKeepAlive): elif isinstance(packet, PacketKeepAlive):
if self.cfg.getboolean("send_keep_alive", fallback=True): if self.send_keep_alive:
keep_alive_packet = PacketKeepAliveResponse(keepAliveId=packet.keepAliveId) keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId)
await self.dispatcher.write(keep_alive_packet) await self.dispatcher.write(keep_alive_packet)
elif isinstance(packet, PacketKickDisconnect): elif isinstance(packet, PacketKickDisconnect):
self.logger.error("Kicked while in game : %s", helpers.parse_chat(packet.reason)) self.logger.error("Kicked while in game : %s", helpers.parse_chat(packet.reason))

View file

@ -20,36 +20,15 @@ class AuthenticatorState:
token : Dict[str, Any] token : Dict[str, Any]
legacy : bool = False legacy : bool = False
class AddonStorage: class Storage:
# TODO this uses py formatting in SQL queries, can we avoid it?
db: sqlite3.Connection
name: str
def __init__(self, db:sqlite3.Connection, name:str):
self.db = db
self.name = name
self.db.cursor().execute(f'CREATE TABLE IF NOT EXISTS documents_{self.name} (name TEXT PRIMARY KEY, value TEXT)')
self.db.commit()
# fstrings in queries are evil but if you go to this length to fuck up you kinda deserve it :)
def get(self, key:str) -> Optional[Any]:
res = self.db.cursor().execute(f"SELECT * FROM documents_{self.name} WHERE name = ?", (key,)).fetchall()
return json.loads(res[0][1]) if res else None
def put(self, key:str, val:Any) -> None:
cur = self.db.cursor()
cur.execute(f"DELETE FROM documents_{self.name} WHERE name = ?", (key,))
cur.execute(f"INSERT INTO documents_{self.name} VALUES (?, ?)", (key, json.dumps(val, default=str),))
self.db.commit()
class StorageDriver:
name : str name : str
db : sqlite3.Connection db : sqlite3.Connection
def __init__(self, name:str): def __init__(self, name:str):
self.name = name self.name = name
init = not os.path.isfile(name) init = not os.path.isfile(f"{name}.session")
self.db = sqlite3.connect(name) self.db = sqlite3.connect(f'{name}.session')
if init: if init:
self._init_db() self._init_db()
@ -78,9 +57,6 @@ class StorageDriver:
cur.execute('INSERT INTO authenticator VALUES (?, ?, ?)', (state.date.strftime(__DATE_FORMAT__), json.dumps(state.token), state.legacy)) cur.execute('INSERT INTO authenticator VALUES (?, ?, ?)', (state.date.strftime(__DATE_FORMAT__), json.dumps(state.token), state.legacy))
self.db.commit() self.db.commit()
def addon_storage(self, name:str) -> AddonStorage:
return AddonStorage(self.db, name)
def system(self) -> Optional[SystemState]: def system(self) -> Optional[SystemState]:
cur = self.db.cursor() cur = self.db.cursor()
val = cur.execute('SELECT * FROM system').fetchall() val = cur.execute('SELECT * FROM system').fetchall()
@ -102,9 +78,10 @@ class StorageDriver:
token=json.loads(val[0][1]), token=json.loads(val[0][1]),
legacy=val[0][2] or False legacy=val[0][2] or False
) )
def get(self, key:str) -> Optional[Any]: def get(self, key:str) -> Optional[Any]:
val = self.db.cursor().execute("SELECT * FROM documents WHERE name = ?", (key,)).fetchall() cur = self.db.cursor()
val = cur.execute("SELECT * FROM documents WHERE name = ?", (key,)).fetchall()
return json.loads(val[0][1]) if val else None return json.loads(val[0][1]) if val else None
def put(self, key:str, val:Any) -> None: def put(self, key:str, val:Any) -> None:
@ -113,4 +90,3 @@ class StorageDriver:
cur.execute("INSERT INTO documents VALUES (?, ?)", (key, json.dumps(val, default=str))) cur.execute("INSERT INTO documents VALUES (?, ?)", (key, json.dumps(val, default=str)))
self.db.commit() self.db.commit()

View file

@ -5,17 +5,19 @@ import logging
from inspect import isclass from inspect import isclass
from typing import Dict, List, Set, Any, Callable, Type from typing import Dict, List, Set, Any, Callable, Type
from ..events.base import BaseEvent
class CallbacksHolder: class CallbacksHolder:
_callbacks : Dict[Any, List[Callable]] _callbacks : Dict[Any, List[Callable]]
_tasks : Dict[uuid.UUID, asyncio.Task] _tasks : Dict[uuid.UUID, asyncio.Task]
def __init__(self, *args, **kwargs): def __init__(self):
super().__init__(*args, **kwargs) super().__init__()
self._callbacks = {} self._callbacks = {}
self._tasks = {} self._tasks = {}
def callback_keys(self, filter:Type | None = None) -> Set[Any]: def callback_keys(self, filter:Type = None) -> Set[Any]:
return set(x for x in self._callbacks.keys() if not filter or (isclass(x) and issubclass(x, filter))) return set(x for x in self._callbacks.keys() if not filter or (isclass(x) and issubclass(x, filter)))
def register(self, key:Any, callback:Callable): def register(self, key:Any, callback:Callable):
@ -34,7 +36,7 @@ class CallbacksHolder:
try: try:
return await cb(*args) return await cb(*args)
except Exception: except Exception:
logging.exception("Exception processing callback '%s'", cb.__name__) logging.exception("Exception processing callback")
return None return None
finally: finally:
self._tasks.pop(uid) self._tasks.pop(uid)

View file

@ -2,15 +2,14 @@ import asyncio
import logging import logging
from typing import Optional from typing import Optional
from signal import signal, SIGINT, SIGTERM from signal import signal, SIGINT, SIGTERM, SIGABRT
class Runnable: class Runnable:
_is_running : bool _is_running : bool
_stop_task : Optional[asyncio.Task] _stop_task : Optional[asyncio.Task]
_loop : asyncio.AbstractEventLoop _loop : asyncio.AbstractEventLoop
def __init__(self, *args, **kwargs): def __init__(self):
super().__init__(*args, **kwargs)
self._is_running = False self._is_running = False
self._stop_task = None self._stop_task = None
self._loop = asyncio.get_event_loop() self._loop = asyncio.get_event_loop()
@ -32,10 +31,6 @@ class Runnable:
else: else:
logging.info("Received SIGINT, stopping gracefully...") logging.info("Received SIGINT, stopping gracefully...")
self._stop_task = asyncio.get_event_loop().create_task(self.stop(force=self._stop_task is not None)) self._stop_task = asyncio.get_event_loop().create_task(self.stop(force=self._stop_task is not None))
if signum == SIGTERM:
logging.info("Received SIGTERM, terminating")
self._stop_task = asyncio.get_event_loop().create_task(self.stop(force=True))
signal(SIGINT, signal_handler) signal(SIGINT, signal_handler)

View file

@ -4,26 +4,24 @@ import asyncio
import datetime import datetime
import pkg_resources import pkg_resources
from typing import Any, Type from typing import Coroutine, List, Dict, Optional, Union, Any, Type, get_args, get_origin, get_type_hints, Set, Callable
from time import time from time import time
from configparser import ConfigParser from dataclasses import dataclass, MISSING, fields
from configparser import ConfigParser, SectionProxy
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiocraft.packet import Packet from aiocraft.mc.packet import Packet
from aiocraft.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator from aiocraft.mc.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
from aiocraft.auth.microsoft import InvalidStateError
from .storage import StorageDriver, SystemState, AuthenticatorState from .storage import Storage, SystemState, AuthenticatorState
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld, GameContainer from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld
from .scaffold import ConfigObject
from .addon import Addon from .addon import Addon
from .notifier import Notifier, Provider from .notifier import Notifier, Provider
__VERSION__ = pkg_resources.get_distribution('treepuncher').version __VERSION__ = pkg_resources.get_distribution('treepuncher').version
async def _cleanup(m: Addon, l: logging.Logger):
await m.cleanup()
l.debug("Cleaned up addon %s", m.name)
class MissingParameterError(Exception): class MissingParameterError(Exception):
pass pass
@ -32,60 +30,49 @@ class Treepuncher(
GameState, GameState,
GameChat, GameChat,
GameInventory, GameInventory,
GameContainer,
GameTablist, GameTablist,
GameWorld, GameWorld
# GameMovement
): ):
name: str name: str
storage: StorageDriver config: ConfigParser
storage: Storage
notifier: Notifier notifier: Notifier
scheduler: AsyncIOScheduler scheduler: AsyncIOScheduler
modules: list[Addon] modules: List[Addon]
ctx: dict[Any, Any] ctx: Dict[Any, Any]
_processing: bool _processing: bool
_proto_override: int
_host: str
_port: int
def __init__( def __init__(
self, self,
name: str, name: str,
config_file: str = "", config_file: str = None,
online_mode: bool = True,
legacy: bool = False,
**kwargs **kwargs
): ):
self.ctx = dict() self.ctx = dict()
self.name = name self.name = name
self.config = ConfigParser() self.config = ConfigParser()
self.config.read(config_file or f"{self.name}.ini") # TODO wrap with pathlib config_path = config_file or f'{self.name}.ini'
self.config.read(config_path)
authenticator : AuthInterface authenticator : AuthInterface
def opt(k, required=False, default=None, t=str): def opt(k:str, required=False, default=None) -> Any:
v = kwargs.get(k) v = kwargs.get(k) or self.cfg.get(k) or default
if v is None:
v = self.cfg.get(k)
if v is None:
v = default
if not v and required: if not v and required:
raise MissingParameterError(f"Missing configuration parameter '{k}'") raise MissingParameterError(f"Missing configuration parameter '{k}'")
if t is bool and isinstance(v, str) and v.lower().strip() == 'false': # hardcoded special case return v
return False
if v is None:
return None
return t(v)
if not opt('online_mode', default=True, t=bool): if not online_mode:
authenticator = OfflineAuthenticator(self.name) authenticator = OfflineAuthenticator(self.name)
elif opt('legacy', default=False, t=bool): elif legacy:
authenticator = MojangAuthenticator( authenticator = MojangAuthenticator(
username= opt('username', default=name, required=True), username= opt('username', default=name, required=True),
password= opt('password'), password= opt('password')
session_server_override= opt('session_server_override'),
auth_server_override= opt('auth_server_override'),
) )
if opt('legacy_token'): if opt('legacy_token'):
authenticator.deserialize(json.loads(opt('legacy_token'))) authenticator.deserialize(json.loads(opt('legacy_token')))
@ -98,26 +85,19 @@ class Treepuncher(
) )
super().__init__( super().__init__(
authenticator=authenticator, opt('server', required=True),
online_mode=opt('online_mode', default=True, t=bool), online_mode=online_mode,
authenticator=authenticator
) )
self._proto_override = opt('force_proto', t=int) self.storage = Storage(self.name)
self._host = opt('server', required=True)
if ":" in self._host:
h, p = self._host.split(":", 1)
self._host = h
self._port = int(p)
else:
self._host, self._port = self.resolve_srv(self._host)
self.storage = StorageDriver(opt('session_file') or f"data/{name}.session") # TODO wrap with pathlib
self.notifier = Notifier(self) self.notifier = Notifier(self)
self.modules = [] self.modules = []
self.scheduler = AsyncIOScheduler() # tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # This doesn't work anymore
self.scheduler = AsyncIOScheduler() # TODO APScheduler warns about timezone ugghh
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
self.scheduler.start(paused=True) self.scheduler.start(paused=True)
@ -137,27 +117,22 @@ class Treepuncher(
self.logger.info("Loaded session from %s", prev_auth.date) self.logger.info("Loaded session from %s", prev_auth.date)
self.storage._set_state(state) self.storage._set_state(state)
@property
def cfg(self) -> SectionProxy:
return SectionProxy(self.config, "Treepuncher")
@property @property
def playerName(self) -> str: def playerName(self) -> str:
return self.authenticator.selectedProfile.name return self.authenticator.selectedProfile.name
async def authenticate(self): async def authenticate(self):
sleep_interval = self.cfg.getfloat("auth_retry_interval", fallback=60.0) await super().authenticate()
for _ in range(self.cfg.getint("auth_retry_count", fallback=5)): state = AuthenticatorState(
try: date=datetime.datetime.now(),
await super().authenticate() token=self.authenticator.serialize(),
state = AuthenticatorState( legacy=isinstance(self.authenticator, MojangAuthenticator)
date=datetime.datetime.now(), )
token=self.authenticator.serialize(), self.storage._set_auth(state)
legacy=isinstance(self.authenticator, MojangAuthenticator)
)
self.storage._set_auth(state)
return
except AuthException as e:
if e.data["error"] == "request timed out":
await asyncio.sleep(sleep_interval)
continue
raise e # retrying won't help anyway
async def start(self): async def start(self):
# if self.started: # TODO readd check # if self.started: # TODO readd check
@ -187,11 +162,9 @@ class Treepuncher(
await self.join_callbacks() await self.join_callbacks()
self.logger.debug("Joined callbacks") self.logger.debug("Joined callbacks")
await asyncio.gather( await asyncio.gather(
*(_cleanup(m, self.logger) for m in self.modules) *(m.cleanup() for m in self.modules)
) )
self.logger.debug("Cleaned up addons") self.logger.debug("Cleaned up addons")
await self.notifier.stop()
self.logger.debug("Notifier stopped")
await super().stop() await super().stop()
self.logger.info("Treepuncher stopped") self.logger.info("Treepuncher stopped")
@ -208,21 +181,22 @@ class Treepuncher(
async def _work(self): async def _work(self):
self.logger.debug("Worker started") self.logger.debug("Worker started")
try: try:
log_ignored_packets = self.cfg.getboolean('log_ignored_packets', fallback=False) if "force_proto" in self.cfg:
whitelist = self.callback_keys(filter=Packet) self.dispatcher.set_proto(self.cfg.getint('force_proto'))
if self._proto_override:
proto = self._proto_override
else: else:
try: try:
server_data = await self.info(self._host, self._port, whitelist=whitelist, log_ignored_packets=log_ignored_packets) server_data = await self.info()
if "version" in server_data and "protocol" in server_data["version"]: if "version" in server_data and "protocol" in server_data["version"]:
proto = server_data['version']['protocol'] self.dispatcher.set_proto(server_data['version']['protocol'])
except OSError as e: except OSError as e:
self.logger.error("Connection error : %s", str(e)) self.logger.error("Connection error : %s", str(e))
self.dispatcher.whitelist(self.callback_keys(filter=Packet))
self.dispatcher.log_ignored_packets(self.cfg.getboolean('log_ignored_packets', fallback=False))
while self._processing: while self._processing:
try: try:
await self.join(self._host, self._port, proto, whitelist=whitelist, log_ignored_packets=log_ignored_packets) await self.join()
except OSError as e: except OSError as e:
self.logger.error("Connection error : %s", str(e)) self.logger.error("Connection error : %s", str(e))
@ -231,12 +205,8 @@ class Treepuncher(
except AuthException as e: except AuthException as e:
self.logger.error("Auth exception : [%s|%d] %s (%s)", e.endpoint, e.code, e.data, e.kwargs) self.logger.error("Auth exception : [%s|%d] %s (%s)", e.endpoint, e.code, e.data, e.kwargs)
except InvalidStateError: except Exception:
self.logger.error("Invalid authenticator state") self.logger.exception("Unhandled exception")
if isinstance(self.authenticator, MicrosoftAuthenticator):
self.logger.info("Obtain an auth code by visiting %s", self.authenticator.url())
except Exception as e:
self.logger.exception("Unhandled exception : %s", str(e))
if self._processing: if self._processing:
await self.stop(force=True) await self.stop(force=True)