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
[*]
charset = utf-8
indent_style = tab
end_of_line = lf
insert_final_newline = true
[*.py]
charset = utf-8
indent_style = tab
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 +1,7 @@
[build-system]
requires = ["setuptools", "setuptools-scm"]
requires = [
"setuptools>=42",
"wheel"
]
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
import os
import re
import sys
import asyncio
import logging
import argparse
import inspect
@ -7,19 +10,18 @@ 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 typing import List, Type, Set, get_type_hints
from dataclasses import dataclass, MISSING, fields
from setproctitle import setproctitle
from .treepuncher import Treepuncher, MissingParameterError, Addon, Provider
from .scaffold import ConfigObject
from .treepuncher import Treepuncher, MissingParameterError, Addon, ConfigObject, Provider
from .helpers import configure_logging
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
#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')
addons : Set[Type[Addon]] = set()
@ -31,7 +33,7 @@ def main():
obj = getattr(m, obj_name)
if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon):
addons.add(obj)
except Exception:
except Exception as e:
print(f"Exception importing addon {py_path}")
traceback.print_exc()
pass
@ -84,19 +86,13 @@ def main():
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,
args.server,
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])

View file

@ -1,12 +1,9 @@
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 typing import TYPE_CHECKING, Dict, Any, 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:
@ -32,23 +29,18 @@ def parse_with_hint(val:str, hint:Any) -> Any:
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
# TODO str will never fail, should be tried last.
# cheap fix: sort keys by name so that "str" comes last
for t in sorted(get_args(hint), key=lambda x : str(x)):
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'
@ -85,16 +77,12 @@ class Addon:
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

View file

@ -2,5 +2,3 @@ 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,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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
from aiocraft.proto.play.clientbound import PacketChat as PacketChatMessage
from aiocraft.proto.play.serverbound import PacketChat
from typing import Union
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
class GameChat(Scaffold):
@ -13,11 +15,14 @@ class GameChat(Scaffold):
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):
async def chat(self, message:str, whisper:str=None, wait:bool=False):
if whisper:
message = f"/w {whisper} {message}"
await self.dispatcher.write(
PacketChat(message=message),
PacketChat(
self.dispatcher.proto,
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,8 +1,8 @@
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 aiocraft.mc.definitions import Item
from aiocraft.mc.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange
from aiocraft.mc.proto.play.serverbound import PacketHeldItemSlot
from ..scaffold import Scaffold
@ -13,7 +13,7 @@ class GameInventory(Scaffold):
async def set_slot(self, slot:int):
self.slot = slot
await self.dispatcher.write(PacketHeldItemSlot(slotId=slot))
await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot))
@property
def hotbar(self) -> List[Item]:

View file

@ -1,15 +1,13 @@
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 aiocraft.mc.definitions import Gamemode, Dimension, Difficulty
from aiocraft.mc.proto import (
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings, PacketClientCommand, PacketAbilities
)
from ..events import JoinGameEvent, DeathEvent, DisconnectedEvent
from ..events import JoinGameEvent, DeathEvent, ConnectedEvent, DisconnectedEvent
from ..scaffold import Scaffold
class GameState(Scaffold):
@ -39,16 +37,15 @@ class GameState(Scaffold):
super().__init__(*args, **kwargs)
self.in_game = False
self.gamemode = Gamemode.UNKNOWN
self.dimension = Dimension.UNKNOWN
self.difficulty = Difficulty.UNKNOWN
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.total_xp = 0
@self.on(DisconnectedEvent)
async def disconnected_cb(_):
@ -57,10 +54,6 @@ class GameState(Scaffold):
@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 \
@ -75,20 +68,9 @@ class GameState(Scaffold):
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()
@ -106,6 +88,7 @@ class GameState(Scaffold):
self.run_callbacks(JoinGameEvent, JoinGameEvent(self.dimension, self.difficulty, self.gamemode))
await self.dispatcher.write(
PacketSettings(
self.dispatcher.proto,
locale="en_US",
viewDistance=4,
chatFlags=0,
@ -114,7 +97,7 @@ class GameState(Scaffold):
mainHand=0,
)
)
await self.dispatcher.write(PacketClientCommand(actionId=0))
await self.dispatcher.write(PacketClientCommand(self.dispatcher.proto, actionId=0))
@self.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth):
@ -131,7 +114,7 @@ class GameState(Scaffold):
self.logger.warning("Died, attempting to respawn")
await asyncio.sleep(0.5) # TODO make configurable
await self.dispatcher.write(
PacketClientCommand(actionId=0) # respawn
PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn
)
@self.on_packet(PacketExperience)

View file

@ -2,12 +2,13 @@ import uuid
import datetime
from enum import Enum
from typing import Dict, List
from aiocraft.types import Player
from aiocraft.proto import PacketPlayerInfo
from aiocraft.mc.definitions import Player
from aiocraft.mc.proto import PacketPlayerInfo
from ..scaffold import Scaffold
from ..events import ConnectedEvent, PlayerJoinEvent, PlayerLeaveEvent
from ..events import ConnectedEvent
class ActionType(Enum): # TODO move this in aiocraft
ADD_PLAYER = 0
@ -17,7 +18,7 @@ class ActionType(Enum): # TODO move this in aiocraft
REMOVE_PLAYER = 4
class GameTablist(Scaffold):
tablist : dict[uuid.UUID, Player]
tablist : Dict[uuid.UUID, Player]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -37,7 +38,6 @@ class GameTablist(Scaffold):
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:
@ -46,7 +46,6 @@ class GameTablist(Scaffold):
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,77 +1,27 @@
import json
from time import time
import uuid
import datetime
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 typing import Dict, List
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 ..events import BlockUpdateEvent
from ..events import ConnectedEvent
class GameWorld(Scaffold):
position : BlockPos
vehicle_id : int | None
world : World
_last_steer_vehicle : float
# TODO world
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(ConnectedEvent)
async def connected_cb(_):
self.tablist.clear()
@self.on_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
)
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
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
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.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 = RotatingFileHandler(f'log/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files
fh.setLevel(logging.DEBUG)
# create console handler with a higher log level
ch = logging.StreamHandler()

View file

@ -1,14 +1,12 @@
from configparser import ConfigParser, SectionProxy
from typing import Type, Any
from aiocraft.client import AbstractMinecraftClient
from aiocraft.client import MinecraftClient
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 aiocraft.mc.packet import Packet
from aiocraft.mc.definitions import ConnectionState
from aiocraft.mc.proto import PacketKickDisconnect, PacketSetCompression
from aiocraft.mc.proto.play.clientbound import PacketKeepAlive
from aiocraft.mc.proto.play.serverbound import PacketKeepAlive as PacketKeepAliveResponse
from .traits import CallbacksHolder, Runnable
from .events import ConnectedEvent, DisconnectedEvent
@ -19,41 +17,35 @@ class ConfigObject:
return getattr(self, key)
class Scaffold(
MinecraftClient,
CallbacksHolder,
Runnable,
AbstractMinecraftClient,
):
entity_id : int
config: ConfigParser
@property
def cfg(self) -> SectionProxy:
return SectionProxy(self.config, "Treepuncher")
send_keep_alive : bool = True # TODO how to handle this?
def on_packet(self, packet:Type[Packet]):
def decorator(fun):
return self.register(packet, fun)
return decorator
def on(self, event:Type[BaseEvent]):
def on(self, event:Type[BaseEvent]): # TODO maybe move in Treepuncher?
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.dispatcher.state = 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)
self.dispatcher.compression = packet.threshold
elif isinstance(packet, PacketKeepAlive):
if self.cfg.getboolean("send_keep_alive", fallback=True):
keep_alive_packet = PacketKeepAliveResponse(keepAliveId=packet.keepAliveId)
if self.send_keep_alive:
keep_alive_packet = PacketKeepAliveResponse(340, 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))

View file

@ -20,36 +20,15 @@ class AuthenticatorState:
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:
class Storage:
name : str
db : sqlite3.Connection
def __init__(self, name:str):
self.name = name
init = not os.path.isfile(name)
self.db = sqlite3.connect(name)
init = not os.path.isfile(f"{name}.session")
self.db = sqlite3.connect(f'{name}.session')
if init:
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))
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()
@ -104,7 +80,8 @@ class StorageDriver:
)
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
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)))
self.db.commit()

View file

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

View file

@ -2,15 +2,14 @@ import asyncio
import logging
from typing import Optional
from signal import signal, SIGINT, SIGTERM
from signal import signal, SIGINT, SIGTERM, SIGABRT
class Runnable:
_is_running : bool
_stop_task : Optional[asyncio.Task]
_loop : asyncio.AbstractEventLoop
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self):
self._is_running = False
self._stop_task = None
self._loop = asyncio.get_event_loop()
@ -32,10 +31,6 @@ class Runnable:
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)

View file

@ -4,26 +4,24 @@ import asyncio
import datetime
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 configparser import ConfigParser
from dataclasses import dataclass, MISSING, fields
from configparser import ConfigParser, SectionProxy
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 aiocraft.mc.packet import Packet
from aiocraft.mc.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
from .storage import StorageDriver, SystemState, AuthenticatorState
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld, GameContainer
from .storage import Storage, SystemState, AuthenticatorState
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld
from .scaffold import ConfigObject
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
@ -32,60 +30,49 @@ class Treepuncher(
GameState,
GameChat,
GameInventory,
GameContainer,
GameTablist,
GameWorld,
# GameMovement
GameWorld
):
name: str
storage: StorageDriver
config: ConfigParser
storage: Storage
notifier: Notifier
scheduler: AsyncIOScheduler
modules: list[Addon]
ctx: dict[Any, Any]
modules: List[Addon]
ctx: Dict[Any, Any]
_processing: bool
_proto_override: int
_host: str
_port: int
def __init__(
self,
name: str,
config_file: str = "",
config_file: str = None,
online_mode: bool = True,
legacy: bool = False,
**kwargs
):
self.ctx = dict()
self.name = name
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
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
def opt(k:str, required=False, default=None) -> Any:
v = kwargs.get(k) or self.cfg.get(k) or 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)
return v
if not opt('online_mode', default=True, t=bool):
if not online_mode:
authenticator = OfflineAuthenticator(self.name)
elif opt('legacy', default=False, t=bool):
elif legacy:
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'),
password= opt('password')
)
if opt('legacy_token'):
authenticator.deserialize(json.loads(opt('legacy_token')))
@ -98,26 +85,19 @@ class Treepuncher(
)
super().__init__(
authenticator=authenticator,
online_mode=opt('online_mode', default=True, t=bool),
opt('server', required=True),
online_mode=online_mode,
authenticator=authenticator
)
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.storage = Storage(self.name)
self.notifier = Notifier(self)
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
self.scheduler.start(paused=True)
@ -137,14 +117,15 @@ class Treepuncher(
self.logger.info("Loaded session from %s", prev_auth.date)
self.storage._set_state(state)
@property
def cfg(self) -> SectionProxy:
return SectionProxy(self.config, "Treepuncher")
@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(),
@ -152,12 +133,6 @@ class Treepuncher(
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
@ -187,11 +162,9 @@ class Treepuncher(
await self.join_callbacks()
self.logger.debug("Joined callbacks")
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")
await self.notifier.stop()
self.logger.debug("Notifier stopped")
await super().stop()
self.logger.info("Treepuncher stopped")
@ -208,21 +181,22 @@ class Treepuncher(
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
if "force_proto" in self.cfg:
self.dispatcher.set_proto(self.cfg.getint('force_proto'))
else:
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"]:
proto = server_data['version']['protocol']
self.dispatcher.set_proto(server_data['version']['protocol'])
except OSError as 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:
try:
await self.join(self._host, self._port, proto, whitelist=whitelist, log_ignored_packets=log_ignored_packets)
await self.join()
except OSError as e:
self.logger.error("Connection error : %s", str(e))
@ -231,12 +205,8 @@ class Treepuncher(
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))
except Exception:
self.logger.exception("Unhandled exception")
if self._processing:
await self.stop(force=True)