Compare commits

...

68 commits

Author SHA1 Message Date
aaf36bdf5f docs: improved installation instructions 2024-02-20 16:23:49 +01:00
4c333c7852 docs: fix aiocraft repo link 2024-02-20 16:03:54 +01:00
John
5be33deb81 aiocraft url updated pt2 2024-02-10 12:07:06 +01:00
John
9ec38961f5 Updated aiocraft url as per url renaming 2024-02-10 11:58:33 +01:00
8101a255ad
docs: explain about addons and sections 2023-11-20 19:27:37 +01:00
783a619bae
docs: wording, consistency 2023-11-20 19:23:11 +01:00
eb723f76f5
docs: improved README.md a ton 2023-11-20 18:55:14 +01:00
71d5f0363f
fix: lock aiocraft dep version 2023-11-20 18:55:01 +01:00
39fea517cb
fix: pass login code from CLI 2023-11-20 18:54:50 +01:00
f9c97c0c34
chore: updated aiocraft 2023-11-20 17:52:49 +01:00
bd97b891f4
fix: handle SIGTERM too 2023-11-20 17:51:40 +01:00
5586d1fc9c
fix: still default to not processing the world 2023-11-02 06:23:04 +01:00
ff39260fa4
fix: wrong packet, damn prismarinejs... 2023-11-02 06:21:36 +01:00
f0a4fe9872
fix: oops initialize the field 2023-11-02 05:41:45 +01:00
caeb5215cc
Merge branch 'dev' of fantabos.co:treepuncher into dev 2023-11-02 05:39:51 +01:00
56065d62f8
fix: disabled multi block change, refactored 2023-11-02 05:32:50 +01:00
989731574a
fix: typing, create log folder if missing 2023-11-02 05:32:21 +01:00
f97bf06b5a chore: updated build system 2023-06-03 13:43:02 +02:00
0ed7f26c7d chore: version bump 2023-06-02 15:36:22 +02:00
0b4847fb9c fix: why was I awaiting run_callbacks 2023-06-02 15:34:27 +02:00
99a4a78dcb fix: wrong field 2023-06-02 15:30:29 +02:00
f920bbc838 feat: split world and position, added BlockUpdateEvent 2023-03-17 11:07:13 +01:00
e6a1ecc5c4 fix: correctly process 1.16 dimension and difficulty 2023-03-17 10:33:38 +01:00
82cd2fbf1d
fix: set _last_steer_vehicle 2023-03-16 16:32:07 +01:00
8655c5ebde
fix: there isn't a scheduler yet when registering 2023-03-16 16:31:03 +01:00
ef8ffac391 feat: send SteerVehicle packets every 5s if riding
This is not the best way to do it. Mostly an ad-hoc fix for a tunnel
bore addon...
2023-03-16 14:13:36 +01:00
bcdb5a6694
feat: also follow vehicle movement 2023-03-14 21:05:19 +01:00
36a2fece2f
fix: temporary cheap fix for weird stuff? 2023-02-16 22:09:16 +01:00
58ab0e0378
fix: don't lock proto version... 2023-02-16 22:07:52 +01:00
f5256da6bf
fix: must be None 2023-02-16 18:21:26 +01:00
98c82bcbcf
fix: server is not positional anymore 2023-02-16 18:11:59 +01:00
e20f7a967f
feat: allow passing session/auth server overrides 2023-02-16 18:10:06 +01:00
3b7aa42e0b
fix: delete from namespaced storage when putting... 2022-08-25 20:50:43 +02:00
aa0e804965
fix: return None for missing keys 2022-08-25 20:15:04 +02:00
b750aa140f
fix: i actually sorta finished it and added a migration script 2022-08-25 20:03:33 +02:00
ba721a4b30
fix: this was wip, im not ready to migrate yet 2022-08-25 19:52:52 +02:00
b14ca9d862
feat: automatically attempt reauth when timed out
sometimes MS times out our refresh request, making clients disconnect.
Implemented a retry loop, which will attempt to authenticate up to 5
times (configurable) waiting 60 seconds in between (configurable).
Only timeouts will be handled, any other exception will make it crash
immediately.
2022-08-25 19:42:53 +02:00
1499cab317
feat: added namespaced AddonStorage helper
Co-authored-by: f-tlm <f-tlm@users.noreply.github.com>
2022-08-13 14:14:57 +02:00
f5481ace65
feat: Added PlayerJoin/PlayerLeave events
Co-authored-by: f-tlm <f-tlm@users.noreply.github.com>
2022-08-13 14:13:17 +02:00
8f3f8fd69b
fix: cheap way to get the auth url 2022-07-14 13:50:57 +02:00
5465837478
fix: correctly reset window state 2022-07-06 12:42:44 +02:00
bcf293a8ad
style: moved settings into globals 2022-07-06 12:42:29 +02:00
2ad89a61d2
chore: show which callback threw an exc 2022-07-03 19:10:36 +02:00
425a7c7f71
fix: setBlock was messing up the world... 2022-07-03 17:24:31 +02:00
f04d873d21
fix: I actually read wiki.vg 2022-07-03 17:13:25 +02:00
acdb60cd2f
fix: maybe I'm supposed to close containers when I receive windowId < 0 ? 2022-07-03 17:09:05 +02:00
c2ea55c699
fix: oversize container slots because minecraft is dumb 2022-07-03 16:54:40 +02:00
aa6f2b03bb
fix: add 36 slots for inventory 2022-07-03 02:02:45 +02:00
f6ee4b0d79
fix: slots + 1 2022-07-03 01:57:36 +02:00
312f132c50
fix: stop notifier 2022-07-03 01:19:00 +02:00
5abfb63bd3
feat: log module cleanup in debug 2022-07-03 01:15:50 +02:00
0540011bb6
fix: jank way to store block entities 2022-07-03 00:33:08 +02:00
d8c62e57d2
fix: use window_id 0 for "no container" 2022-07-03 00:27:29 +02:00
363eeea3aa
fix: initialize attributes 2022-07-03 00:16:00 +02:00
011531b1a4
fix: damn auto imports 2022-07-03 00:12:36 +02:00
48590d56bd
feat: process world (default off) 2022-07-03 00:06:15 +02:00
a563984e48
feat: parse Optional types in config object 2022-07-03 00:02:39 +02:00
61811f542b
feat: track container windows 2022-07-03 00:02:00 +02:00
d54fb380e9
fix: set total_xp in constructor 2022-06-18 16:13:26 +02:00
664cb64613
change MRO 2022-05-23 03:01:26 +02:00
4527b866bf
also show str(exception) 2022-05-23 02:41:01 +02:00
76c84006ad
put session files under data folder 2022-05-23 02:36:44 +02:00
847bde3d79
resolve srv default true 2022-05-23 02:27:33 +02:00
6364f52d0b
Don't str(None) 2022-05-23 02:22:19 +02:00
d2a13360a8
Don't mix falsy value with no value 2022-05-23 02:14:17 +02:00
4c6d906d3a
accept and pass args and kwargs in trait constructors 2022-05-23 02:01:28 +02:00
79886a2c64
small fixes 2022-05-23 01:45:04 +02:00
5f2beac41b
Moved config into scaffold, tweaks to init options
Changes for SRV resolution, get online_mode and legacy also from config
2022-05-23 01:11:06 +02:00
26 changed files with 613 additions and 184 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,3 +127,6 @@ dmypy.json
# Pyre type checker
.pyre/
# Auto generated version file
src/treepuncher/__version__.py

111
README.md
View file

@ -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!

View file

@ -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"

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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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

View file

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

View file

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

View file

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

View 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,
)
)

View file

@ -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]:

View file

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

View file

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

View file

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

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):
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()

View file

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

View file

@ -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()

View file

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

View file

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

View file

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