Compare commits
68 commits
Author | SHA1 | Date | |
---|---|---|---|
aaf36bdf5f | |||
4c333c7852 | |||
|
5be33deb81 | ||
|
9ec38961f5 | ||
8101a255ad | |||
783a619bae | |||
eb723f76f5 | |||
71d5f0363f | |||
39fea517cb | |||
f9c97c0c34 | |||
bd97b891f4 | |||
5586d1fc9c | |||
ff39260fa4 | |||
f0a4fe9872 | |||
caeb5215cc | |||
56065d62f8 | |||
989731574a | |||
f97bf06b5a | |||
0ed7f26c7d | |||
0b4847fb9c | |||
99a4a78dcb | |||
f920bbc838 | |||
e6a1ecc5c4 | |||
82cd2fbf1d | |||
8655c5ebde | |||
ef8ffac391 | |||
bcdb5a6694 | |||
36a2fece2f | |||
58ab0e0378 | |||
f5256da6bf | |||
98c82bcbcf | |||
e20f7a967f | |||
3b7aa42e0b | |||
aa0e804965 | |||
b750aa140f | |||
ba721a4b30 | |||
b14ca9d862 | |||
1499cab317 | |||
f5481ace65 | |||
8f3f8fd69b | |||
5465837478 | |||
bcf293a8ad | |||
2ad89a61d2 | |||
425a7c7f71 | |||
f04d873d21 | |||
acdb60cd2f | |||
c2ea55c699 | |||
aa6f2b03bb | |||
f6ee4b0d79 | |||
312f132c50 | |||
5abfb63bd3 | |||
0540011bb6 | |||
d8c62e57d2 | |||
363eeea3aa | |||
011531b1a4 | |||
48590d56bd | |||
a563984e48 | |||
61811f542b | |||
d54fb380e9 | |||
664cb64613 | |||
4527b866bf | |||
76c84006ad | |||
847bde3d79 | |||
6364f52d0b | |||
d2a13360a8 | |||
4c6d906d3a | |||
79886a2c64 | |||
5f2beac41b |
26 changed files with 613 additions and 184 deletions
|
@ -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
3
.gitignore
vendored
|
@ -127,3 +127,6 @@ dmypy.json
|
|||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Auto generated version file
|
||||
src/treepuncher/__version__.py
|
||||
|
|
111
README.md
111
README.md
|
@ -1,2 +1,111 @@
|
|||
# treepuncher
|
||||
An hackable Minecraft client, built with aiocraft
|
||||
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!
|
||||
|
||||
|
|
|
@ -1,7 +1,30 @@
|
|||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
"wheel"
|
||||
]
|
||||
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"
|
||||
|
|
21
scripts/migrate-old-documents-to-namespaced-documents.py
Normal file
21
scripts/migrate-old-documents-to-namespaced-documents.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
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
30
setup.cfg
|
@ -1,30 +0,0 @@
|
|||
[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
|
|
@ -1,8 +1,5 @@
|
|||
#!/usr/bin/env python
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import asyncio
|
||||
import logging
|
||||
import argparse
|
||||
import inspect
|
||||
|
@ -10,18 +7,19 @@ import inspect
|
|||
from pathlib import Path
|
||||
from importlib import import_module
|
||||
import traceback
|
||||
from typing import List, Type, Set, get_type_hints
|
||||
from dataclasses import dataclass, MISSING, fields
|
||||
from typing import Type, Set, get_type_hints
|
||||
from dataclasses import MISSING, fields
|
||||
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from .treepuncher import Treepuncher, MissingParameterError, Addon, ConfigObject, Provider
|
||||
from .treepuncher import Treepuncher, MissingParameterError, Addon, Provider
|
||||
from .scaffold import ConfigObject
|
||||
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
|
||||
# addon_path = Path(args.path) if args.addon_path else ( root/'addons' )
|
||||
#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()
|
||||
|
||||
|
@ -33,7 +31,7 @@ def main():
|
|||
obj = getattr(m, obj_name)
|
||||
if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon):
|
||||
addons.add(obj)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
print(f"Exception importing addon {py_path}")
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
@ -86,13 +84,19 @@ 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,
|
||||
args.server,
|
||||
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])
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING, Dict, Any, Union, List, Callable, get_type_hints, get_args, get_origin
|
||||
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:
|
||||
|
@ -29,18 +32,23 @@ 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:
|
||||
# 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)):
|
||||
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'
|
||||
|
@ -77,12 +85,16 @@ 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
|
||||
|
||||
|
|
|
@ -2,3 +2,5 @@ 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
|
||||
|
|
13
src/treepuncher/events/block_update.py
Normal file
13
src/treepuncher/events/block_update.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
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
|
14
src/treepuncher/events/connection.py
Normal file
14
src/treepuncher/events/connection.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
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
|
|
@ -1,4 +1,4 @@
|
|||
from aiocraft.mc.definitions import Dimension, Difficulty, Gamemode
|
||||
from aiocraft.types import Dimension, Difficulty, Gamemode
|
||||
|
||||
from .base import BaseEvent
|
||||
|
||||
|
@ -12,4 +12,4 @@ class JoinGameEvent(BaseEvent):
|
|||
def __init__(self, dimension:Dimension, difficulty:Difficulty, gamemode:Gamemode):
|
||||
self.gamemode = gamemode
|
||||
self.difficulty = difficulty
|
||||
self.dimension = dimension
|
||||
self.dimension = dimension
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from aiocraft.mc.packet import Packet
|
||||
from aiocraft.packet import Packet
|
||||
|
||||
from .base import BaseEvent
|
||||
|
||||
|
|
|
@ -3,3 +3,4 @@ from .inventory import GameInventory
|
|||
from .tablist import GameTablist
|
||||
from .chat import GameChat
|
||||
from .world import GameWorld
|
||||
from .container import GameContainer
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from typing import Union
|
||||
from aiocraft.proto.play.clientbound import PacketChat as PacketChatMessage
|
||||
from aiocraft.proto.play.serverbound import PacketChat
|
||||
|
||||
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 ..events.chat import ChatEvent
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class GameChat(Scaffold):
|
||||
|
@ -15,14 +13,11 @@ 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=None, wait:bool=False):
|
||||
async def chat(self, message:str, whisper:str="", wait:bool=False):
|
||||
if whisper:
|
||||
message = f"/w {whisper} {message}"
|
||||
await self.dispatcher.write(
|
||||
PacketChat(
|
||||
self.dispatcher.proto,
|
||||
message=message
|
||||
),
|
||||
PacketChat(message=message),
|
||||
wait=wait
|
||||
)
|
||||
|
||||
|
|
87
src/treepuncher/game/container.py
Normal file
87
src/treepuncher/game/container.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
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,
|
||||
)
|
||||
)
|
|
@ -1,8 +1,8 @@
|
|||
from typing import List
|
||||
|
||||
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 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
|
||||
|
||||
|
@ -13,7 +13,7 @@ class GameInventory(Scaffold):
|
|||
|
||||
async def set_slot(self, slot:int):
|
||||
self.slot = slot
|
||||
await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot))
|
||||
await self.dispatcher.write(PacketHeldItemSlot(slotId=slot))
|
||||
|
||||
@property
|
||||
def hotbar(self) -> List[Item]:
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
|
||||
#from aiocraft.client import MinecraftClient
|
||||
from aiocraft.mc.definitions import Gamemode, Dimension, Difficulty
|
||||
from aiocraft.mc.proto import (
|
||||
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings, PacketClientCommand, PacketAbilities
|
||||
from aiocraft.types import Gamemode, Dimension, Difficulty
|
||||
from aiocraft.proto import (
|
||||
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings,
|
||||
PacketClientCommand, PacketAbilities, PacketDifficulty
|
||||
)
|
||||
|
||||
from ..events import JoinGameEvent, DeathEvent, ConnectedEvent, DisconnectedEvent
|
||||
from ..events import JoinGameEvent, DeathEvent, DisconnectedEvent
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class GameState(Scaffold):
|
||||
|
@ -37,15 +39,16 @@ class GameState(Scaffold):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.in_game = False
|
||||
self.gamemode = Gamemode.SURVIVAL
|
||||
self.dimension = Dimension.OVERWORLD
|
||||
self.difficulty = Difficulty.HARD
|
||||
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(_):
|
||||
|
@ -54,8 +57,12 @@ class GameState(Scaffold):
|
|||
@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 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
|
||||
|
@ -68,11 +75,22 @@ 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)
|
||||
self.dimension = Dimension(packet.dimension)
|
||||
self.difficulty = Difficulty(packet.difficulty)
|
||||
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:
|
||||
|
@ -88,7 +106,6 @@ 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,
|
||||
|
@ -97,7 +114,7 @@ class GameState(Scaffold):
|
|||
mainHand=0,
|
||||
)
|
||||
)
|
||||
await self.dispatcher.write(PacketClientCommand(self.dispatcher.proto, actionId=0))
|
||||
await self.dispatcher.write(PacketClientCommand(actionId=0))
|
||||
|
||||
@self.on_packet(PacketUpdateHealth)
|
||||
async def player_hp_cb(packet:PacketUpdateHealth):
|
||||
|
@ -114,7 +131,7 @@ class GameState(Scaffold):
|
|||
self.logger.warning("Died, attempting to respawn")
|
||||
await asyncio.sleep(0.5) # TODO make configurable
|
||||
await self.dispatcher.write(
|
||||
PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn
|
||||
PacketClientCommand(actionId=0) # respawn
|
||||
)
|
||||
|
||||
@self.on_packet(PacketExperience)
|
||||
|
|
|
@ -2,13 +2,12 @@ import uuid
|
|||
import datetime
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List
|
||||
|
||||
from aiocraft.mc.definitions import Player
|
||||
from aiocraft.mc.proto import PacketPlayerInfo
|
||||
from aiocraft.types import Player
|
||||
from aiocraft.proto import PacketPlayerInfo
|
||||
|
||||
from ..scaffold import Scaffold
|
||||
from ..events import ConnectedEvent
|
||||
from ..events import ConnectedEvent, PlayerJoinEvent, PlayerLeaveEvent
|
||||
|
||||
class ActionType(Enum): # TODO move this in aiocraft
|
||||
ADD_PLAYER = 0
|
||||
|
@ -18,7 +17,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)
|
||||
|
@ -38,6 +37,7 @@ 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,6 +46,7 @@ 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)))
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,27 +1,77 @@
|
|||
import uuid
|
||||
import datetime
|
||||
import json
|
||||
from time import time
|
||||
|
||||
from typing import Dict, List
|
||||
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.mc.definitions import BlockPos
|
||||
from aiocraft.mc.proto.play.clientbound import PacketPosition
|
||||
from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm
|
||||
from aiocraft import Chunk, World # TODO these imports will hopefully change!
|
||||
|
||||
from ..scaffold import Scaffold
|
||||
from ..events import ConnectedEvent
|
||||
from ..events import BlockUpdateEvent
|
||||
|
||||
class GameWorld(Scaffold):
|
||||
position : BlockPos
|
||||
# TODO world
|
||||
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(ConnectedEvent)
|
||||
async def connected_cb(_):
|
||||
self.tablist.clear()
|
||||
@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):
|
||||
|
@ -31,8 +81,48 @@ class GameWorld(Scaffold):
|
|||
self.position.x, self.position.y, self.position.z
|
||||
)
|
||||
await self.dispatcher.write(
|
||||
PacketTeleportConfirm(
|
||||
self.dispatcher.proto,
|
||||
teleportId=packet.teleportId
|
||||
)
|
||||
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)
|
||||
|
|
|
@ -4,7 +4,7 @@ from typing import Dict
|
|||
|
||||
from termcolor import colored
|
||||
|
||||
def configure_logging(name:str, level=logging.INFO, color:bool = True):
|
||||
def configure_logging(name:str, level=logging.INFO, color:bool = True, path:str = "log"):
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
@ -27,7 +27,9 @@ def configure_logging(name:str, level=logging.INFO, color:bool = True):
|
|||
logger = logging.getLogger()
|
||||
logger.setLevel(level)
|
||||
# create file handler which logs even debug messages
|
||||
fh = RotatingFileHandler(f'log/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files
|
||||
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()
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
from configparser import ConfigParser, SectionProxy
|
||||
|
||||
from typing import Type, Any
|
||||
|
||||
from aiocraft.client import MinecraftClient
|
||||
from aiocraft.client import AbstractMinecraftClient
|
||||
from aiocraft.util import helpers
|
||||
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 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
|
||||
|
@ -17,35 +19,41 @@ class ConfigObject:
|
|||
return getattr(self, key)
|
||||
|
||||
class Scaffold(
|
||||
MinecraftClient,
|
||||
CallbacksHolder,
|
||||
Runnable,
|
||||
AbstractMinecraftClient,
|
||||
):
|
||||
entity_id : int
|
||||
|
||||
send_keep_alive : bool = True # TODO how to handle this?
|
||||
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]): # TODO maybe move in Treepuncher?
|
||||
def on(self, event:Type[BaseEvent]):
|
||||
def decorator(fun):
|
||||
return self.register(event, fun)
|
||||
return decorator
|
||||
|
||||
#Override
|
||||
async def _play(self) -> bool:
|
||||
self.dispatcher.state = ConnectionState.PLAY
|
||||
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.compression = packet.threshold
|
||||
self.dispatcher.update_compression_threshold(packet.threshold)
|
||||
elif isinstance(packet, PacketKeepAlive):
|
||||
if self.send_keep_alive:
|
||||
keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId)
|
||||
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))
|
||||
|
|
|
@ -20,15 +20,36 @@ class AuthenticatorState:
|
|||
token : Dict[str, Any]
|
||||
legacy : bool = False
|
||||
|
||||
class Storage:
|
||||
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(f"{name}.session")
|
||||
self.db = sqlite3.connect(f'{name}.session')
|
||||
init = not os.path.isfile(name)
|
||||
self.db = sqlite3.connect(name)
|
||||
if init:
|
||||
self._init_db()
|
||||
|
||||
|
@ -57,6 +78,9 @@ class Storage:
|
|||
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()
|
||||
|
@ -78,10 +102,9 @@ class Storage:
|
|||
token=json.loads(val[0][1]),
|
||||
legacy=val[0][2] or False
|
||||
)
|
||||
|
||||
|
||||
def get(self, key:str) -> Optional[Any]:
|
||||
cur = self.db.cursor()
|
||||
val = cur.execute("SELECT * FROM documents WHERE name = ?", (key,)).fetchall()
|
||||
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:
|
||||
|
@ -90,3 +113,4 @@ class Storage:
|
|||
cur.execute("INSERT INTO documents VALUES (?, ?)", (key, json.dumps(val, default=str)))
|
||||
self.db.commit()
|
||||
|
||||
|
||||
|
|
|
@ -5,19 +5,17 @@ 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):
|
||||
super().__init__()
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._callbacks = {}
|
||||
self._tasks = {}
|
||||
|
||||
def callback_keys(self, filter:Type = None) -> Set[Any]:
|
||||
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):
|
||||
|
@ -36,7 +34,7 @@ class CallbacksHolder:
|
|||
try:
|
||||
return await cb(*args)
|
||||
except Exception:
|
||||
logging.exception("Exception processing callback")
|
||||
logging.exception("Exception processing callback '%s'", cb.__name__)
|
||||
return None
|
||||
finally:
|
||||
self._tasks.pop(uid)
|
||||
|
|
|
@ -2,14 +2,15 @@ import asyncio
|
|||
import logging
|
||||
|
||||
from typing import Optional
|
||||
from signal import signal, SIGINT, SIGTERM, SIGABRT
|
||||
from signal import signal, SIGINT, SIGTERM
|
||||
|
||||
class Runnable:
|
||||
_is_running : bool
|
||||
_stop_task : Optional[asyncio.Task]
|
||||
_loop : asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._is_running = False
|
||||
self._stop_task = None
|
||||
self._loop = asyncio.get_event_loop()
|
||||
|
@ -31,6 +32,10 @@ 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)
|
||||
|
||||
|
|
|
@ -4,24 +4,26 @@ import asyncio
|
|||
import datetime
|
||||
import pkg_resources
|
||||
|
||||
from typing import Coroutine, List, Dict, Optional, Union, Any, Type, get_args, get_origin, get_type_hints, Set, Callable
|
||||
from typing import Any, Type
|
||||
from time import time
|
||||
from dataclasses import dataclass, MISSING, fields
|
||||
from configparser import ConfigParser, SectionProxy
|
||||
from configparser import ConfigParser
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from aiocraft.mc.packet import Packet
|
||||
from aiocraft.mc.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
|
||||
from aiocraft.packet import Packet
|
||||
from aiocraft.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
|
||||
from aiocraft.auth.microsoft import InvalidStateError
|
||||
|
||||
from .storage import Storage, SystemState, AuthenticatorState
|
||||
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld
|
||||
from .scaffold import ConfigObject
|
||||
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
|
||||
|
@ -30,49 +32,60 @@ class Treepuncher(
|
|||
GameState,
|
||||
GameChat,
|
||||
GameInventory,
|
||||
GameContainer,
|
||||
GameTablist,
|
||||
GameWorld
|
||||
GameWorld,
|
||||
# GameMovement
|
||||
):
|
||||
name: str
|
||||
config: ConfigParser
|
||||
storage: Storage
|
||||
storage: StorageDriver
|
||||
|
||||
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 = None,
|
||||
online_mode: bool = True,
|
||||
legacy: bool = False,
|
||||
config_file: str = "",
|
||||
**kwargs
|
||||
):
|
||||
self.ctx = dict()
|
||||
|
||||
self.name = name
|
||||
self.config = ConfigParser()
|
||||
config_path = config_file or f'{self.name}.ini'
|
||||
self.config.read(config_path)
|
||||
self.config.read(config_file or f"{self.name}.ini") # TODO wrap with pathlib
|
||||
|
||||
authenticator : AuthInterface
|
||||
|
||||
def opt(k:str, required=False, default=None) -> Any:
|
||||
v = kwargs.get(k) or self.cfg.get(k) or default
|
||||
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}'")
|
||||
return v
|
||||
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 online_mode:
|
||||
if not opt('online_mode', default=True, t=bool):
|
||||
authenticator = OfflineAuthenticator(self.name)
|
||||
elif legacy:
|
||||
elif opt('legacy', default=False, t=bool):
|
||||
authenticator = MojangAuthenticator(
|
||||
username= opt('username', default=name, required=True),
|
||||
password= opt('password')
|
||||
password= opt('password'),
|
||||
session_server_override= opt('session_server_override'),
|
||||
auth_server_override= opt('auth_server_override'),
|
||||
)
|
||||
if opt('legacy_token'):
|
||||
authenticator.deserialize(json.loads(opt('legacy_token')))
|
||||
|
@ -85,19 +98,26 @@ class Treepuncher(
|
|||
)
|
||||
|
||||
super().__init__(
|
||||
opt('server', required=True),
|
||||
online_mode=online_mode,
|
||||
authenticator=authenticator
|
||||
authenticator=authenticator,
|
||||
online_mode=opt('online_mode', default=True, t=bool),
|
||||
)
|
||||
|
||||
self.storage = Storage(self.name)
|
||||
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 = []
|
||||
|
||||
# tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # This doesn't work anymore
|
||||
self.scheduler = AsyncIOScheduler() # TODO APScheduler warns about timezone ugghh
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
|
||||
self.scheduler.start(paused=True)
|
||||
|
||||
|
@ -117,22 +137,27 @@ 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):
|
||||
await super().authenticate()
|
||||
state = AuthenticatorState(
|
||||
date=datetime.datetime.now(),
|
||||
token=self.authenticator.serialize(),
|
||||
legacy=isinstance(self.authenticator, MojangAuthenticator)
|
||||
)
|
||||
self.storage._set_auth(state)
|
||||
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
|
||||
|
@ -162,9 +187,11 @@ class Treepuncher(
|
|||
await self.join_callbacks()
|
||||
self.logger.debug("Joined callbacks")
|
||||
await asyncio.gather(
|
||||
*(m.cleanup() for m in self.modules)
|
||||
*(_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")
|
||||
|
||||
|
@ -181,22 +208,21 @@ class Treepuncher(
|
|||
async def _work(self):
|
||||
self.logger.debug("Worker started")
|
||||
try:
|
||||
if "force_proto" in self.cfg:
|
||||
self.dispatcher.set_proto(self.cfg.getint('force_proto'))
|
||||
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()
|
||||
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"]:
|
||||
self.dispatcher.set_proto(server_data['version']['protocol'])
|
||||
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()
|
||||
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))
|
||||
|
||||
|
@ -205,8 +231,12 @@ class Treepuncher(
|
|||
|
||||
except AuthException as e:
|
||||
self.logger.error("Auth exception : [%s|%d] %s (%s)", e.endpoint, e.code, e.data, e.kwargs)
|
||||
except Exception:
|
||||
self.logger.exception("Unhandled exception")
|
||||
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)
|
||||
|
|
Loading…
Reference in a new issue