Compare commits

..

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

43 changed files with 442 additions and 1598 deletions

View file

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

3
.gitignore vendored
View file

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

111
README.md
View file

@ -1,111 +1,2 @@
# treepuncher
an hackable headless Minecraft client, built with **[aiocraft](https://git.alemi.dev/aiocraft.git/about)**
### 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!
An hackable Minecraft client, built with aiocraft

View file

@ -1,30 +0,0 @@
[build-system]
requires = ["setuptools", "setuptools-scm"]
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"

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
apscheduler
aiocraft

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])

27
setup.py Normal file
View file

@ -0,0 +1,27 @@
from setuptools import setup, find_packages
with open("requirements.txt") as f:
requirements = f.read().split("\n")
setup(
name='treepuncher',
version='0.0.2',
description='An hackable Minecraft client, built with aiocraft',
url='https://github.com/alemidev/treepuncher',
author='alemi',
author_email='me@alemi.dev',
license='MIT',
packages=find_packages(),
package_data = {
'treepuncher': ['py.typed'],
},
install_requires=requirements,
classifiers=[
'Development Status :: 1 - Planning',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.8',
],
)

View file

@ -1,4 +0,0 @@
from .scaffold import ConfigObject
from .treepuncher import Treepuncher
from .addon import Addon
from .notifier import Notifier, Provider

View file

@ -1,130 +0,0 @@
#!/usr/bin/env python
import os
import logging
import argparse
import inspect
from pathlib import Path
from importlib import import_module
import traceback
from typing import Type, Set, get_type_hints
from dataclasses import MISSING, fields
from setproctitle import setproctitle
from .treepuncher import Treepuncher, MissingParameterError, Addon, Provider
from .scaffold import ConfigObject
from .helpers import configure_logging
def main():
# 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('addons')
addons : Set[Type[Addon]] = set()
for path in sorted(addon_path.rglob('*.py')):
py_path = str(path).replace('/', '.').replace('\\', '.').replace('.py', '')
try:
m = import_module(py_path)
for obj_name in vars(m).keys():
obj = getattr(m, obj_name)
if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon):
addons.add(obj)
except Exception:
print(f"Exception importing addon {py_path}")
traceback.print_exc()
pass
help_text = '\n\naddons (enabled via config file):'
for addon in addons:
help_text += f"\n {addon.__name__} \t{addon.__doc__ or ''}"
cfg_clazz = get_type_hints(addon, localns={'Treepuncher':Treepuncher})['config']
if cfg_clazz is ConfigObject:
continue # it's the superclass type hint
for field in fields(cfg_clazz):
default = field.default if field.default is not MISSING \
else field.default_factory() if field.default_factory is not MISSING \
else MISSING
repr_type = field.type.__name__ if isinstance(field.type, type) else str(field.type) # TODO fix for 3.8 I think?
help_text += f"\n * {field.name} ({repr_type}) | {'-required-' if default is MISSING else f'{default}'}"
help_text += '\n'
parser = argparse.ArgumentParser(
prog='python -m treepuncher',
description='Treepuncher | Block Game automation framework',
epilog=help_text, # TODO maybe build this afterwards?
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument('name', help='name to use for this client session')
parser.add_argument('--server', dest='server', default='', help='server to connect to')
parser.add_argument('--debug', dest='_debug', action='store_const', const=True, default=False, help="enable debug logs")
parser.add_argument('--no-packet-filter', dest='use_packet_whitelist', action='store_const', const=False, default=True, help="disable packet whitelist, will decrease performance")
parser.add_argument('--offline', dest='offline', action='store_const', const=True, default=False, help="run client in offline mode")
parser.add_argument('--code', dest='code', default='', help='login code for oauth2 flow')
parser.add_argument('--mojang', dest='mojang', action='store_const', const=True, default=False, help="use legacy Mojang authenticator")
parser.add_argument('--print-token', dest='print_token', action='store_const', const=True, default=False, help="show legacy token before stopping")
parser.add_argument('--addons', dest='add', metavar="A", nargs='+', type=str, default=None, help='specify addons to enable, defaults to all')
# parser.add_argument('--addon-path', dest='path', default='', help='path for loading addons') # TODO make this possible
args = parser.parse_args()
configure_logging(args.name, level=logging.DEBUG if args._debug else logging.INFO)
setproctitle(f"treepuncher[{args.name}]")
kwargs = {}
if 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:
client = Treepuncher(
args.name,
server=args.server or None,
online_mode=not args.offline,
legacy=args.mojang,
use_packet_whitelist=args.use_packet_whitelist,
code=args.code,
)
except MissingParameterError as e:
return logging.error(e.args[0])
enabled_addons = set(
a.lower() for a in (
args.add if args.add is not None else client.config.sections()
)
)
# TODO ugly af! providers get installed first tho
for addon in addons:
if addon.__name__.lower() in enabled_addons and issubclass(addon, Provider):
logging.info("Installing '%s'", addon.__name__)
client.install(addon)
for addon in addons:
if addon.__name__.lower() in enabled_addons and not issubclass(addon, Provider):
logging.info("Installing '%s'", addon.__name__)
client.install(addon)
client.run()
if args.print_token:
logging.info("Token: %s", client.authenticator.serialize())
if __name__ == "__main__":
main()

View file

@ -1,102 +0,0 @@
from datetime import datetime
import json
import logging
from typing import TYPE_CHECKING, Dict, Any, Optional, Union, List, Callable, get_type_hints, get_args, get_origin
from dataclasses import dataclass, MISSING, fields
from treepuncher.storage import AddonStorage
from .scaffold import ConfigObject
if TYPE_CHECKING:
from .treepuncher import Treepuncher
def parse_with_hint(val:str, hint:Any) -> Any:
if hint is bool:
if val.lower() in ['1', 'true', 't', 'on', 'enabled']:
return True
return False
if hint is list or get_origin(hint) is list:
if get_args(hint):
return list( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
return val.split()
if hint is tuple or get_origin(hint) is tuple:
if get_args(hint):
return tuple( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
return val.split()
if hint is set or get_origin(hint) is set:
if get_args(hint):
return set( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
return set(val.split())
if hint is dict or get_origin(hint) is dict:
return json.loads(val)
if hint is Union or get_origin(hint) is Union:
for t in get_args(hint):
if t is type(None) and val in ("null", ""):
return None
if t is str:
continue # try this last, will always succeed
try:
return t(val)
except ValueError:
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
class Addon:
name: str
config: ConfigObject
storage: AddonStorage
logger: logging.Logger
_client: 'Treepuncher'
@dataclass(frozen=True)
class Options(ConfigObject):
pass
@property
def client(self) -> 'Treepuncher':
return self._client
def __init__(self, client: 'Treepuncher', *args, **kwargs):
self._client = client
self.name = type(self).__name__
cfg = self._client.config
opts: Dict[str, Any] = {}
# get_type_hints attempts to instantiate all string hints (such as 'Treepuncher').
# But we can't import Treepuncher here: would be a cyclic import!
# We don't care about Treepuncher annotation, so we force it to be None
cfg_clazz = get_type_hints(type(self), localns={'Treepuncher': None})['config'] # TODO jank localns override
if cfg_clazz is not ConfigObject:
for field in fields(cfg_clazz):
default = field.default if field.default is not MISSING \
else field.default_factory() if field.default_factory is not MISSING \
else MISSING
if cfg.has_option(self.name, field.name):
opts[field.name] = parse_with_hint(self._client.config[self.name].get(field.name), field.type)
elif default is MISSING:
repr_type = field.type.__name__ if isinstance(field.type, type) else str(field.type) # TODO fix for 3.8 I think?
raise ValueError(
f"Missing required value '{field.name}' of type '{repr_type}' in section '{self.name}'"
)
else: # not really necessary since it's a dataclass but whatever
opts[field.name] = default
self.config = self.Options(**opts)
self.storage = self.init_storage()
self.logger = self._client.logger.getChild(self.name)
self.register()
def register(self):
pass
def init_storage(self) -> AddonStorage:
return self.client.storage.addon_storage(self.name)
async def initialize(self):
pass
async def cleanup(self):
pass

View file

@ -1,6 +0,0 @@
from .chat import ChatEvent
from .join_game import JoinGameEvent
from .death import DeathEvent
from .system import ConnectedEvent, DisconnectedEvent
from .connection import PlayerJoinEvent, PlayerLeaveEvent
from .block_update import BlockUpdateEvent

View file

@ -1,2 +0,0 @@
class BaseEvent:
pass

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 +0,0 @@
from .base import BaseEvent
class DeathEvent(BaseEvent):
SENTINEL = object()

View file

@ -1,15 +0,0 @@
from aiocraft.types import Dimension, Difficulty, Gamemode
from .base import BaseEvent
class JoinGameEvent(BaseEvent):
SENTINEL = object()
dimension : Dimension
difficulty : Difficulty
gamemode : Gamemode
def __init__(self, dimension:Dimension, difficulty:Difficulty, gamemode:Gamemode):
self.gamemode = gamemode
self.difficulty = difficulty
self.dimension = dimension

View file

@ -1,8 +0,0 @@
from aiocraft.packet import Packet
from .base import BaseEvent
class PacketEvent(BaseEvent):
packet : Packet
def __init__(self, p:Packet):
self.packet = p

View file

@ -1,8 +0,0 @@
from .base import BaseEvent
class ConnectedEvent(BaseEvent):
pass
class DisconnectedEvent(BaseEvent):
pass

View file

@ -1,6 +0,0 @@
from .state import GameState
from .inventory import GameInventory
from .tablist import GameTablist
from .chat import GameChat
from .world import GameWorld
from .container import GameContainer

View file

@ -1,23 +0,0 @@
from aiocraft.proto.play.clientbound import PacketChat as PacketChatMessage
from aiocraft.proto.play.serverbound import PacketChat
from ..events.chat import ChatEvent
from ..scaffold import Scaffold
class GameChat(Scaffold):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@self.on_packet(PacketChatMessage)
async def chat_event_callback(packet:PacketChatMessage):
self.run_callbacks(ChatEvent, ChatEvent(packet.message))
async def chat(self, message:str, whisper:str="", wait:bool=False):
if whisper:
message = f"/w {whisper} {message}"
await self.dispatcher.write(
PacketChat(message=message),
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,39 +0,0 @@
from typing import List
from aiocraft.types import Item
from aiocraft.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange
from aiocraft.proto.play.serverbound import PacketHeldItemSlot
from ..scaffold import Scaffold
class GameInventory(Scaffold):
slot : int
inventory : List[Item]
# TODO inventory
async def set_slot(self, slot:int):
self.slot = slot
await self.dispatcher.write(PacketHeldItemSlot(slotId=slot))
@property
def hotbar(self) -> List[Item]:
return self.inventory[36:45]
@property
def selected(self) -> Item:
return self.hotbar[self.slot]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.slot = 0
self.inventory = [ Item() for _ in range(46) ]
@self.on_packet(PacketSetSlot)
async def on_set_slot(packet:PacketSetSlot):
if packet.windowId == 0: # player inventory
self.inventory[packet.slot] = packet.item
@self.on_packet(PacketHeldItemChange)
async def on_held_item_change(packet:PacketHeldItemChange):
self.slot = packet.slot

View file

@ -1,150 +0,0 @@
import asyncio
import datetime
import json
#from aiocraft.client import MinecraftClient
from aiocraft.types import Gamemode, Dimension, Difficulty
from aiocraft.proto import (
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings,
PacketClientCommand, PacketAbilities, PacketDifficulty
)
from ..events import JoinGameEvent, DeathEvent, DisconnectedEvent
from ..scaffold import Scaffold
class GameState(Scaffold):
hp : float
food : float
xp : float
lvl : int
total_xp : int
# TODO player abilities
# walk_speed : float
# fly_speed : float
# flags : int
in_game : bool
gamemode : Gamemode
dimension : Dimension
difficulty : Difficulty
join_time : datetime.datetime
# Abilities
flags : int
flyingSpeed : float
walkingSpeed : float
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.in_game = False
self.gamemode = Gamemode.UNKNOWN
self.dimension = Dimension.UNKNOWN
self.difficulty = Difficulty.UNKNOWN
self.join_time = datetime.datetime(2011, 11, 18)
self.hp = 20.0
self.food = 20.0
self.xp = 0.0
self.lvl = 0
self.total_xp = 0
@self.on(DisconnectedEvent)
async def disconnected_cb(_):
self.in_game = False
@self.on_packet(PacketRespawn)
async def on_player_respawning(packet:PacketRespawn):
self.gamemode = Gamemode(packet.gamemode)
if isinstance(packet.dimension, dict):
self.logger.info("Received dimension data: %s", json.dumps(packet.dimension, indent=2))
self.dimension = Dimension.from_str(packet.dimension['effects'])
else:
self.dimension = Dimension(packet.dimension)
self.difficulty = Difficulty(packet.difficulty)
if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR:
self.in_game = True
else:
self.in_game = False
self.logger.info(
"Reloading world: %s (%s) in %s",
self.dimension.name,
self.difficulty.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)
async def player_joining_cb(packet:PacketLogin):
self.entity_id = packet.entityId
self.gamemode = Gamemode(packet.gameMode)
if isinstance(packet.dimension, dict):
with open('world_codec.json', 'w') as f:
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()
if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR:
self.in_game = True
else:
self.in_game = False
self.logger.info(
"Joined world: %s (%s) in %s",
self.dimension.name,
self.difficulty.name,
self.gamemode.name
)
self.run_callbacks(JoinGameEvent, JoinGameEvent(self.dimension, self.difficulty, self.gamemode))
await self.dispatcher.write(
PacketSettings(
locale="en_US",
viewDistance=4,
chatFlags=0,
chatColors=True,
skinParts=0xF,
mainHand=0,
)
)
await self.dispatcher.write(PacketClientCommand(actionId=0))
@self.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth):
died = packet.health != self.hp and packet.health <= 0
if self.hp != packet.health:
if self.hp < packet.health:
self.logger.info("Healed by %.1f (%.1f HP)", packet.health - self.hp, packet.health)
else:
self.logger.info("Took %.1f damage (%.1f HP)", self.hp - packet.health, packet.health)
self.hp = packet.health
self.food = packet.food + packet.foodSaturation
if died:
self.run_callbacks(DeathEvent, DeathEvent())
self.logger.warning("Died, attempting to respawn")
await asyncio.sleep(0.5) # TODO make configurable
await self.dispatcher.write(
PacketClientCommand(actionId=0) # respawn
)
@self.on_packet(PacketExperience)
async def player_xp_cb(packet:PacketExperience):
if packet.level != self.lvl:
self.logger.info("Level up : %d", packet.level)
self.xp = packet.experienceBar
self.lvl = packet.level
self.total_xp = packet.totalExperience
@self.on_packet(PacketAbilities)
async def player_abilities_cb(packet:PacketAbilities):
self.flags = packet.flags
self.flyingSpeed = packet.flyingSpeed
self.walkingSpeed = packet.walkingSpeed

View file

@ -1,52 +0,0 @@
import uuid
import datetime
from enum import Enum
from aiocraft.types import Player
from aiocraft.proto import PacketPlayerInfo
from ..scaffold import Scaffold
from ..events import ConnectedEvent, PlayerJoinEvent, PlayerLeaveEvent
class ActionType(Enum): # TODO move this in aiocraft
ADD_PLAYER = 0
UPDATE_GAMEMODE = 1
UPDATE_LATENCY = 2
UPDATE_DISPLAY_NAME = 3
REMOVE_PLAYER = 4
class GameTablist(Scaffold):
tablist : dict[uuid.UUID, Player]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tablist = {}
@self.on(ConnectedEvent)
async def connected_cb(_):
self.tablist.clear()
@self.on_packet(PacketPlayerInfo)
async def tablist_update(packet:PacketPlayerInfo):
for record in packet.data:
uid = record['UUID']
if packet.action != ActionType.ADD_PLAYER.value and uid not in self.tablist:
continue # TODO this happens kinda often but doesn't seem to be an issue?
if packet.action == ActionType.ADD_PLAYER.value:
record['joinTime'] = datetime.datetime.now()
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:
self.tablist[uid].gamemode = record['gamemode']
elif packet.action == ActionType.UPDATE_LATENCY.value:
self.tablist[uid].ping = record['ping']
elif packet.action == ActionType.UPDATE_DISPLAY_NAME.value:
self.tablist[uid].displayName = record['displayName']
elif packet.action == ActionType.REMOVE_PLAYER.value:
self.tablist.pop(uid, None)
self.run_callbacks(PlayerLeaveEvent, PlayerLeaveEvent(Player.deserialize(record)))

View file

@ -1,128 +0,0 @@
import json
from time import time
from aiocraft.types import BlockPos
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 ..scaffold import Scaffold
from ..events import BlockUpdateEvent
class GameWorld(Scaffold):
position : BlockPos
vehicle_id : int | None
world : World
_last_steer_vehicle : float
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.world = World()
self.position = BlockPos(0, 0, 0)
self.vehicle_id = None
self._last_steer_vehicle = time()
@self.on_packet(PacketSetPassengers)
async def player_enters_vehicle_cb(packet:PacketSetPassengers):
if self.vehicle_id is None: # might get mounted on a vehicle
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)
async def player_rubberband_cb(packet:PacketPosition):
self.position = BlockPos(packet.x, packet.y, packet.z)
self.logger.info(
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f)",
self.position.x, self.position.y, self.position.z
)
await self.dispatcher.write(
PacketTeleportConfirm(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

@ -1,44 +0,0 @@
import logging
from typing import Dict
from termcolor import colored
def configure_logging(name:str, level=logging.INFO, color:bool = True, path:str = "log"):
import os
from logging.handlers import RotatingFileHandler
class ColorFormatter(logging.Formatter):
def __init__(self, fmt:str, datefmt:str=None):
self.fmt : str = fmt
self.formatters : Dict[int, logging.Formatter] = {
logging.DEBUG: logging.Formatter(colored(fmt, color='grey'), datefmt),
logging.INFO: logging.Formatter(colored(fmt), datefmt),
logging.WARNING: logging.Formatter(colored(fmt, color='yellow'), datefmt),
logging.ERROR: logging.Formatter(colored(fmt, color='red'), datefmt),
logging.CRITICAL: logging.Formatter(colored(fmt, color='red', attrs=['bold']), datefmt),
}
def format(self, record:logging.LogRecord) -> str:
if record.exc_text: # jank way to color the stacktrace but will do for now
record.exc_text = colored(record.exc_text, color='grey', attrs=['bold'])
return self.formatters[record.levelno].format(record)
logger = logging.getLogger()
logger.setLevel(level)
# create file handler which logs even debug messages
if not os.path.isdir(path):
os.mkdir(path)
fh = RotatingFileHandler(f'{path}/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files
fh.setLevel(logging.DEBUG)
# create console handler with a higher log level
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
# create formatter and add it to the handlers
file_formatter = logging.Formatter("[%(asctime)s.%(msecs)03d] [%(name)s] [%(levelname)s] %(message)s", "%b %d %Y %H:%M:%S")
print_formatter = ColorFormatter("%(asctime)s| %(message)s", "%H:%M:%S") if color else logging.Formatter("> %(message)s")
fh.setFormatter(file_formatter)
ch.setFormatter(print_formatter)
# add the handlers to the logger
logger.addHandler(fh)
logger.addHandler(ch)

View file

@ -1,59 +0,0 @@
import asyncio
import logging
from typing import List, Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .treepuncher import Treepuncher
from .addon import Addon
class Provider(Addon):
async def notify(self, text, log:bool = False, **kwargs):
raise NotImplementedError
class Notifier:
_report_functions : List[Callable]
_providers : List[Provider]
_client : 'Treepuncher'
logger : logging.Logger
def __init__(self, client:'Treepuncher'):
self._report_functions = []
self._providers = []
self._client = client
self.logger = client.logger.getChild("notifier")
@property
def providers(self) -> List[Provider]:
return self._providers
def add_reporter(self, fn:Callable):
self._report_functions.append(fn)
return fn
def add_provider(self, p:Provider):
self._providers.append(p)
def get_provider(self, name:str) -> Optional[Provider]:
for p in self.providers:
if p.name == name:
return p
return None
def report(self) -> str:
return '\n'.join(str(fn()).strip() for fn in self._report_functions)
async def notify(self, text, log:bool = False, **kwargs):
self.logger.info("%s %s (%s)", "[n]" if log else "[N]", text, str(kwargs))
await asyncio.gather(
*(p.notify(text, log=log, **kwargs) for p in self.providers)
)
async def start(self):
await asyncio.gather(
*(p.initialize() for p in self.providers)
)
async def stop(self):
await asyncio.gather(
*(p.cleanup() for p in self.providers)
)

View file

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

View file

@ -1,116 +0,0 @@
import os
import json
import sqlite3
from dataclasses import dataclass
from typing import Optional, Any, Dict
from datetime import datetime
__DATE_FORMAT__ : str = "%Y-%m-%d %H:%M:%S.%f"
@dataclass
class SystemState:
name : str
version : str
start_time : int
@dataclass
class AuthenticatorState:
date : datetime
token : Dict[str, Any]
legacy : bool = False
class AddonStorage:
# 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
db : sqlite3.Connection
def __init__(self, name:str):
self.name = name
init = not os.path.isfile(name)
self.db = sqlite3.connect(name)
if init:
self._init_db()
def __del__(self):
self.close()
def close(self) -> None:
self.db.close()
def _init_db(self):
cur = self.db.cursor()
cur.execute('CREATE TABLE system (name TEXT PRIMARY KEY, version TEXT, start_time LONG)')
cur.execute('CREATE TABLE documents (name TEXT PRIMARY KEY, value TEXT)')
cur.execute('CREATE TABLE authenticator (date TEXT PRIMARY KEY, token TEXT, legacy BOOL)')
self.db.commit()
def _set_state(self, state:SystemState):
cur = self.db.cursor()
cur.execute('DELETE FROM system')
cur.execute('INSERT INTO system VALUES (?, ?, ?)', (state.name, state.version, int(state.start_time)))
self.db.commit()
def _set_auth(self, state:AuthenticatorState):
cur = self.db.cursor()
cur.execute('DELETE FROM authenticator')
cur.execute('INSERT INTO authenticator VALUES (?, ?, ?)', (state.date.strftime(__DATE_FORMAT__), json.dumps(state.token), state.legacy))
self.db.commit()
def addon_storage(self, name:str) -> AddonStorage:
return AddonStorage(self.db, name)
def system(self) -> Optional[SystemState]:
cur = self.db.cursor()
val = cur.execute('SELECT * FROM system').fetchall()
if not val:
return None
return SystemState(
name=val[0][0],
version=val[0][1],
start_time=val[0][2]
)
def auth(self) -> Optional[AuthenticatorState]:
cur = self.db.cursor()
val = cur.execute('SELECT * FROM authenticator').fetchall()
if not val:
return None
return AuthenticatorState(
date=datetime.strptime(val[0][0], __DATE_FORMAT__),
token=json.loads(val[0][1]),
legacy=val[0][2] or False
)
def get(self, key:str) -> Optional[Any]:
val = self.db.cursor().execute("SELECT * FROM documents WHERE name = ?", (key,)).fetchall()
return json.loads(val[0][1]) if val else None
def put(self, key:str, val:Any) -> None:
cur = self.db.cursor()
cur.execute("DELETE FROM documents WHERE name = ?", (key,))
cur.execute("INSERT INTO documents VALUES (?, ?)", (key, json.dumps(val, default=str)))
self.db.commit()

View file

@ -1,3 +0,0 @@
from .callbacks import CallbacksHolder
from .runnable import Runnable

View file

@ -1,50 +0,0 @@
import asyncio
import uuid
import logging
from inspect import isclass
from typing import Dict, List, Set, Any, Callable, Type
class CallbacksHolder:
_callbacks : Dict[Any, List[Callable]]
_tasks : Dict[uuid.UUID, asyncio.Task]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._callbacks = {}
self._tasks = {}
def callback_keys(self, filter:Type | None = None) -> Set[Any]:
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):
if key not in self._callbacks:
self._callbacks[key] = []
self._callbacks[key].append(callback)
return callback
def trigger(self, key:Any) -> List[Callable]:
if key not in self._callbacks:
return []
return self._callbacks[key]
def _wrap(self, cb:Callable, uid:uuid.UUID) -> Callable:
async def wrapper(*args):
try:
return await cb(*args)
except Exception:
logging.exception("Exception processing callback '%s'", cb.__name__)
return None
finally:
self._tasks.pop(uid)
return wrapper
def run_callbacks(self, key:Any, *args) -> None:
for cb in self.trigger(key):
task_id = uuid.uuid4()
self._tasks[task_id] = asyncio.get_event_loop().create_task(self._wrap(cb, task_id)(*args))
async def join_callbacks(self):
await asyncio.gather(*list(self._tasks.values()))

View file

@ -1,50 +0,0 @@
import asyncio
import logging
from typing import Optional
from signal import signal, SIGINT, SIGTERM
class Runnable:
_is_running : bool
_stop_task : Optional[asyncio.Task]
_loop : asyncio.AbstractEventLoop
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_running = False
self._stop_task = None
self._loop = asyncio.get_event_loop()
async def start(self):
self._is_running = True
async def stop(self, force:bool=False):
self._is_running = False
def run(self):
logging.info("Starting process")
def signal_handler(signum, __):
if signum == SIGINT:
if self._stop_task:
self._stop_task.cancel()
logging.info("Received SIGINT, terminating")
else:
logging.info("Received SIGINT, stopping gracefully...")
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)
async def main():
await self.start()
while self._is_running:
await asyncio.sleep(1)
self._loop.run_until_complete(main())
logging.info("Process finished")

View file

@ -1,243 +0,0 @@
import json
import logging
import asyncio
import datetime
import pkg_resources
from typing import Any, Type
from time import time
from configparser import ConfigParser
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiocraft.packet import Packet
from aiocraft.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
from aiocraft.auth.microsoft import InvalidStateError
from .storage import StorageDriver, SystemState, AuthenticatorState
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld, GameContainer
from .addon import Addon
from .notifier import Notifier, Provider
__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):
pass
class Treepuncher(
GameState,
GameChat,
GameInventory,
GameContainer,
GameTablist,
GameWorld,
# GameMovement
):
name: str
storage: StorageDriver
notifier: Notifier
scheduler: AsyncIOScheduler
modules: list[Addon]
ctx: dict[Any, Any]
_processing: bool
_proto_override: int
_host: str
_port: int
def __init__(
self,
name: str,
config_file: str = "",
**kwargs
):
self.ctx = dict()
self.name = name
self.config = ConfigParser()
self.config.read(config_file or f"{self.name}.ini") # TODO wrap with pathlib
authenticator : AuthInterface
def opt(k, required=False, default=None, t=str):
v = kwargs.get(k)
if v is None:
v = self.cfg.get(k)
if v is None:
v = default
if not v and required:
raise MissingParameterError(f"Missing configuration parameter '{k}'")
if t is bool and isinstance(v, str) and v.lower().strip() == 'false': # hardcoded special case
return False
if v is None:
return None
return t(v)
if not opt('online_mode', default=True, t=bool):
authenticator = OfflineAuthenticator(self.name)
elif opt('legacy', default=False, t=bool):
authenticator = MojangAuthenticator(
username= opt('username', default=name, required=True),
password= opt('password'),
session_server_override= opt('session_server_override'),
auth_server_override= opt('auth_server_override'),
)
if opt('legacy_token'):
authenticator.deserialize(json.loads(opt('legacy_token')))
else:
authenticator = MicrosoftAuthenticator(
client_id= opt('client_id', required=True),
client_secret= opt('client_secret', required=True),
redirect_uri= opt('redirect_uri', required=True),
code= opt('code'),
)
super().__init__(
authenticator=authenticator,
online_mode=opt('online_mode', default=True, t=bool),
)
self._proto_override = opt('force_proto', t=int)
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.modules = []
self.scheduler = AsyncIOScheduler()
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
self.scheduler.start(paused=True)
prev = self.storage.system() # if this isn't 1st time, this won't be None. Load token from there
state = SystemState(self.name, __VERSION__, 0)
if prev:
state.start_time = prev.start_time
if self.name != prev.name:
self.logger.warning("Saved session belong to another user")
if prev.version != state.version:
self.logger.warning("Saved session uses a different version")
prev_auth = self.storage.auth()
if prev_auth:
if prev_auth.legacy ^ isinstance(authenticator, MojangAuthenticator):
self.logger.warning("Saved session is incompatible with configured authenticator")
authenticator.deserialize(prev_auth.token)
self.logger.info("Loaded session from %s", prev_auth.date)
self.storage._set_state(state)
@property
def playerName(self) -> str:
return self.authenticator.selectedProfile.name
async def authenticate(self):
sleep_interval = self.cfg.getfloat("auth_retry_interval", fallback=60.0)
for _ in range(self.cfg.getint("auth_retry_count", fallback=5)):
try:
await super().authenticate()
state = AuthenticatorState(
date=datetime.datetime.now(),
token=self.authenticator.serialize(),
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):
# if self.started: # TODO readd check
# return
await super().start()
await self.notifier.start()
self.logger.debug("Notifier started")
await asyncio.gather(
*(m.initialize() for m in self.modules)
)
self.logger.debug("Addons initialized")
self._processing = True
self._worker = asyncio.get_event_loop().create_task(self._work())
self.scheduler.resume()
self.logger.info("Treepuncher started")
self.storage._set_state(SystemState(self.name, __VERSION__, time()))
async def stop(self, force: bool = False):
self._processing = False
self.scheduler.pause()
if self.dispatcher.connected:
await self.dispatcher.disconnect(block=not force)
if not force:
await self._worker
self.logger.debug("Joined worker")
await self.join_callbacks()
self.logger.debug("Joined callbacks")
await asyncio.gather(
*(_cleanup(m, self.logger) for m in self.modules)
)
self.logger.debug("Cleaned up addons")
await self.notifier.stop()
self.logger.debug("Notifier stopped")
await super().stop()
self.logger.info("Treepuncher stopped")
def install(self, module: Type[Addon]) -> Addon:
m = module(self)
if isinstance(m, Provider):
self.notifier.add_provider(m)
elif isinstance(m, Addon):
self.modules.append(m)
else:
raise ValueError("Given type is not an addon")
return m
async def _work(self):
self.logger.debug("Worker started")
try:
log_ignored_packets = self.cfg.getboolean('log_ignored_packets', fallback=False)
whitelist = self.callback_keys(filter=Packet)
if self._proto_override:
proto = self._proto_override
else:
try:
server_data = await self.info(self._host, self._port, whitelist=whitelist, log_ignored_packets=log_ignored_packets)
if "version" in server_data and "protocol" in server_data["version"]:
proto = server_data['version']['protocol']
except OSError as e:
self.logger.error("Connection error : %s", str(e))
while self._processing:
try:
await self.join(self._host, self._port, proto, whitelist=whitelist, log_ignored_packets=log_ignored_packets)
except OSError as e:
self.logger.error("Connection error : %s", str(e))
if self._processing: # don't sleep if Treepuncher is stopping
await asyncio.sleep(self.cfg.getfloat('reconnect_delay', fallback=5))
except AuthException as e:
self.logger.error("Auth exception : [%s|%d] %s (%s)", e.endpoint, e.code, e.data, e.kwargs)
except InvalidStateError:
self.logger.error("Invalid authenticator state")
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:
await self.stop(force=True)
self.logger.debug("Worker finished")

1
treepuncher/__init__.py Normal file
View file

@ -0,0 +1 @@
from .treepuncher import Treepuncher

View file

@ -0,0 +1 @@
from .chat import ChatEvent

View file

@ -5,8 +5,6 @@ from enum import Enum
from aiocraft.util.helpers import parse_chat
from .base import BaseEvent
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
@ -21,7 +19,7 @@ class MessageType(Enum):
LEAVE = "leave"
SYSTEM = "system"
class ChatEvent(BaseEvent):
class ChatEvent:
text : str
type : MessageType
user : str

View file

@ -0,0 +1 @@
from .module import LogicModule

View file

@ -0,0 +1,81 @@
from ..treepuncher import Treepuncher, TreepuncherEvents
from .module import LogicModule
from aiocraft.mc.proto.play.clientbound import (
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience,
PacketAbilities, PacketChat as PacketChatMessage
)
from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm, PacketClientCommand, PacketChat
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, Position
class CoreLogic(LogicModule):
def register(self, client:Treepuncher):
@client.on_disconnected()
async def on_disconnected():
client.in_game = False
@client.on_packet(PacketRespawn)
async def on_player_respawning(packet:PacketRespawn):
client.gamemode = Gamemode(packet.gamemode)
client.dimension = Dimension(packet.dimension)
client.difficulty = Difficulty(packet.difficulty)
if client.difficulty != Difficulty.PEACEFUL \
and client.gamemode != Gamemode.SPECTATOR:
client.in_game = True
else:
client.in_game = False
client._logger.info(
"Reloading world: %s (%s) in %s",
client.dimension.name,
client.difficulty.name,
client.gamemode.name
)
@client.on_packet(PacketLogin)
async def player_joining_cb(packet:PacketLogin):
client.gamemode = Gamemode(packet.gameMode)
client.dimension = Dimension(packet.dimension)
client.difficulty = Difficulty(packet.difficulty)
if client.difficulty != Difficulty.PEACEFUL \
and client.gamemode != Gamemode.SPECTATOR:
client.in_game = True
else:
client.in_game = False
client._logger.info(
"Joined world: %s (%s) in %s",
client.dimension.name,
client.difficulty.name,
client.gamemode.name
)
client.run_callbacks(TreepuncherEvents.IN_GAME)
@client.on_packet(PacketPosition)
async def player_rubberband_cb(packet:PacketPosition):
client._logger.info("Position synchronized")
client.position = Position(packet.x, packet.y, packet.z)
await client.dispatcher.write(
PacketTeleportConfirm(
client.dispatcher.proto,
teleportId=packet.teleportId
)
)
@client.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth):
if packet.health != client.hp and packet.health <= 0:
client._logger.info("Dead, respawning...")
await client.dispatcher.write(
PacketClientCommand(client.dispatcher.proto, actionId=0) # respawn
)
client.run_callbacks(TreepuncherEvents.DIED)
client.hp = packet.health
client.food = packet.food
@client.on_packet(PacketExperience)
async def player_xp_cb(packet:PacketExperience):
if packet.level != client.lvl:
client._logger.info("Level up : %d", packet.level)
client.xp = packet.experienceBar
client.lvl = packet.level
client.total_xp = packet.totalExperience

View file

@ -0,0 +1,11 @@
class LogicModule:
def register(self, client:'Treepuncher') -> None:
pass # override to register callbacks on client
async def initialize(self, client:'Treepuncher') -> None:
pass # override to register stuff on client start
async def cleanup(self, client:'Treepuncher') -> None:
pass # override to register stuff on client stop

24
treepuncher/notifier.py Normal file
View file

@ -0,0 +1,24 @@
from typing import Callable, List
class Notifier:
_report_functions : List[Callable]
def __init__(self):
self._report_functions = []
def register(self, fn:Callable):
self._report_functions.append(fn)
return fn
def report(self) -> str:
return '\n'.join(str(fn()).strip() for fn in self._report_functions)
def notify(self, text, log:bool = False, **kwargs):
print(text)
async def initialize(self, _client:'Treepuncher'):
pass
async def cleanup(self, _client:'Treepuncher'):
pass

0
treepuncher/py.typed Normal file
View file

292
treepuncher/treepuncher.py Normal file
View file

@ -0,0 +1,292 @@
import re
import logging
import asyncio
import datetime
import uuid
from typing import List, Dict, Union, Optional, Any, Type
from enum import Enum
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiocraft.client import MinecraftClient
from aiocraft.mc.packet import Packet
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, BlockPos, Item
from aiocraft.mc.proto.play.clientbound import (
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience, PacketSetSlot,
PacketAbilities, PacketPlayerInfo, PacketChat as PacketChatMessage, PacketHeldItemSlot as PacketHeldItemChange
)
from aiocraft.mc.proto.play.serverbound import (
PacketTeleportConfirm, PacketClientCommand, PacketSettings, PacketChat,
PacketHeldItemSlot
)
from .notifier import Notifier
from .events import ChatEvent
from .events.chat import MessageType
from .modules.module import LogicModule
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
class TreepuncherEvents(Enum):
DIED = 0
IN_GAME = 1
class Treepuncher(MinecraftClient):
in_game : bool
gamemode : Gamemode
dimension : Dimension
difficulty : Difficulty
join_time : datetime.datetime
hp : float
food : float
xp : float
lvl : int
total_xp : int
slot : int
inventory : List[Item]
# TODO inventory
position : BlockPos
# TODO world
tablist : Dict[uuid.UUID, dict]
# TODO player abilities
# walk_speed : float
# fly_speed : float
# flags : int
notifier : Notifier
scheduler : AsyncIOScheduler
modules : List[LogicModule]
ctx : Dict[Any, Any]
def __init__(self, *args, notifier:Notifier=None, **kwargs):
super().__init__(*args, **kwargs)
self.ctx = dict()
self.in_game = False
self.gamemode = Gamemode.SURVIVAL
self.dimension = Dimension.OVERWORLD
self.difficulty = Difficulty.HARD
self.join_time = datetime.datetime(2011, 11, 18)
self.hp = 20.0
self.food = 20.0
self.xp = 0.0
self.lvl = 0
self.slot = 0
self.inventory = [ Item() for _ in range(46) ]
self.position = BlockPos(0, 0, 0)
self.tablist = {}
self._register_handlers()
self.modules = []
self.notifier = notifier or Notifier()
tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # APScheduler will complain if I don't specify a timezone...
self.scheduler = AsyncIOScheduler(timezone=tz)
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
self.scheduler.start(paused=True)
@property
def name(self) -> str:
if self.online_mode and self.token:
return self.token.selectedProfile.name
if not self.online_mode and self.username:
return self.username
raise ValueError("No token or username given")
@property
def hotbar(self) -> List[Item]:
return self.inventory[36:45]
@property
def selected(self) -> Item:
return self.hotbar[self.slot]
async def start(self):
await self.notifier.initialize(self)
for m in self.modules:
await m.initialize(self)
await super().start()
self.scheduler.resume()
async def stop(self, force:bool=False):
self.scheduler.pause()
await super().stop(force=force)
for m in self.modules:
await m.cleanup(self)
await self.notifier.cleanup(self)
def add(self, module:LogicModule):
module.register(self)
self.modules.append(module)
def on_chat(self, msg_type:Union[str, MessageType] = None):
if isinstance(msg_type, str):
msg_type = MessageType(msg_type)
def wrapper(fun):
async def process_chat_packet(packet:PacketChatMessage):
msg = ChatEvent(packet.message)
if not msg_type or msg.type == msg_type:
return await fun(msg)
self.register(PacketChatMessage, process_chat_packet)
return fun
return wrapper
def on_death(self):
def wrapper(fun):
return self.register(TreepuncherEvents.DIED, fun)
return wrapper
def on_joined_world(self):
def wrapper(fun):
return self.register(TreepuncherEvents.IN_GAME, fun)
return wrapper
async def write(self, packet:Packet, wait:bool=False):
await self.dispatcher.write(packet, wait)
async def chat(self, message:str, whisper:str=None, wait:bool=False):
if whisper:
message = f"/w {whisper} {message}"
await self.dispatcher.write(
PacketChat(
self.dispatcher.proto,
message=message
),
wait=wait
)
async def set_slot(self, slot:int):
self.slot = slot
await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot))
def _register_handlers(self):
@self.on_disconnected()
async def disconnected_cb():
self.in_game = False
@self.on_connected()
async def connected_cb():
self.tablist.clear()
@self.on_packet(PacketSetSlot)
async def on_set_slot(packet:PacketSetSlot):
if packet.windowId == 0: # player inventory
self.inventory[packet.slot] = packet.item
@self.on_packet(PacketHeldItemChange)
async def on_held_item_change(packet:PacketHeldItemChange):
self.slot = packet.slot
@self.on_packet(PacketRespawn)
async def on_player_respawning(packet:PacketRespawn):
self.gamemode = Gamemode(packet.gamemode)
self.dimension = Dimension(packet.dimension)
self.difficulty = Difficulty(packet.difficulty)
if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR:
self.in_game = True
else:
self.in_game = False
self._logger.info(
"Reloading world: %s (%s) in %s",
self.dimension.name,
self.difficulty.name,
self.gamemode.name
)
@self.on_packet(PacketLogin)
async def player_joining_cb(packet:PacketLogin):
self.gamemode = Gamemode(packet.gameMode)
self.dimension = Dimension(packet.dimension)
self.difficulty = Difficulty(packet.difficulty)
self.join_time = datetime.datetime.now()
if self.difficulty != Difficulty.PEACEFUL \
and self.gamemode != Gamemode.SPECTATOR:
self.in_game = True
else:
self.in_game = False
self._logger.info(
"Joined world: %s (%s) in %s",
self.dimension.name,
self.difficulty.name,
self.gamemode.name
)
self.run_callbacks(TreepuncherEvents.IN_GAME)
await self.write(
PacketSettings(
self.dispatcher.proto,
locale="en_US",
viewDistance=4,
chatFlags=0,
chatColors=True,
skinParts=0xF,
mainHand=0,
)
)
await self.write(PacketClientCommand(self.dispatcher.proto, actionId=0))
@self.on_packet(PacketPosition)
async def player_rubberband_cb(packet:PacketPosition):
self.position = BlockPos(packet.x, packet.y, packet.z)
self._logger.info(
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f)",
self.position.x, self.position.y, self.position.z
)
await self.dispatcher.write(
PacketTeleportConfirm(
self.dispatcher.proto,
teleportId=packet.teleportId
)
)
@self.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth):
died = packet.health != self.hp and packet.health <= 0
self.hp = packet.health
self.food = packet.food + packet.foodSaturation
if died:
self.run_callbacks(TreepuncherEvents.DIED)
self._logger.info("Dead, respawning...")
await asyncio.sleep(0.5)
await self.dispatcher.write(
PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn
)
@self.on_packet(PacketExperience)
async def player_xp_cb(packet:PacketExperience):
if packet.level != self.lvl:
self._logger.info("Level up : %d", packet.level)
self.xp = packet.experienceBar
self.lvl = packet.level
self.total_xp = packet.totalExperience
@self.on_packet(PacketPlayerInfo)
async def tablist_update(packet:PacketPlayerInfo):
for record in packet.data:
uid = record['UUID']
if packet.action != 0 and uid not in self.tablist:
continue # TODO this happens kinda often but doesn't seem to be an issue?
if packet.action == 0:
self.tablist[uid] = record
self.tablist[uid]['joinTime'] = datetime.datetime.now()
elif packet.action == 1:
self.tablist[uid]['gamemode'] = record['gamemode']
elif packet.action == 2:
self.tablist[uid]['ping'] = record['ping']
elif packet.action == 3:
self.tablist[uid]['displayName'] = record['displayName']
elif packet.action == 4:
self.tablist.pop(uid, None)