295 lines
8.8 KiB
Python
295 lines
8.8 KiB
Python
import asyncio
|
|
import logging
|
|
import json
|
|
import uuid
|
|
|
|
from dataclasses import dataclass
|
|
from asyncio import Task
|
|
from enum import Enum
|
|
|
|
from typing import Dict, List, Callable, Type, Optional, Tuple, AsyncIterator
|
|
|
|
from .dispatcher import Dispatcher
|
|
from .traits import CallbacksHolder, Runnable
|
|
from .mc.packet import Packet
|
|
from .mc.token import Token, AuthException
|
|
from .mc.definitions import Dimension, Difficulty, Gamemode, ConnectionState
|
|
from .mc.proto.handshaking.serverbound import PacketSetProtocol
|
|
from .mc.proto.play.serverbound import PacketKeepAlive as PacketKeepAliveResponse
|
|
from .mc.proto.play.clientbound import PacketKeepAlive, PacketSetCompression, PacketKickDisconnect
|
|
from .mc.proto.login.serverbound import PacketLoginStart, PacketEncryptionBegin as PacketEncryptionResponse
|
|
from .mc.proto.login.clientbound import (
|
|
PacketCompress, PacketDisconnect, PacketEncryptionBegin, PacketLoginPluginRequest, PacketSuccess
|
|
)
|
|
from .util import encryption, helpers
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class ClientOptions:
|
|
reconnect : bool = True
|
|
reconnect_delay : float = 10.0
|
|
keep_alive : bool = True
|
|
poll_interval : float = 1.0
|
|
use_packet_whitelist : bool = True
|
|
|
|
class ClientEvent(Enum):
|
|
CONNECTED = 0
|
|
DISCONNECTED = 1
|
|
|
|
class MinecraftClient(CallbacksHolder, Runnable):
|
|
host:str
|
|
port:int
|
|
options:ClientOptions
|
|
|
|
username:Optional[str]
|
|
password:Optional[str]
|
|
token:Optional[Token]
|
|
|
|
dispatcher : Dispatcher
|
|
_processing : bool
|
|
_authenticated : bool
|
|
_worker : Task
|
|
_callbacks = Dict[str, Task]
|
|
|
|
_logger : logging.Logger
|
|
|
|
def __init__(
|
|
self,
|
|
host:str,
|
|
port:int = 25565,
|
|
username:Optional[str] = None,
|
|
password:Optional[str] = None,
|
|
token:Optional[Token] = None,
|
|
online_mode:bool = True,
|
|
**kwargs
|
|
):
|
|
super().__init__()
|
|
self.host = host
|
|
self.port = port
|
|
|
|
self.options = ClientOptions(**kwargs)
|
|
|
|
self.token = token
|
|
self.username = username
|
|
self.password = password
|
|
self.online_mode = online_mode
|
|
|
|
if self.online_mode and not self.token and not self.username and not self.password:
|
|
raise ValueError("Cannot instantiate an online-mode client without token or credentials")
|
|
|
|
self.dispatcher = Dispatcher()
|
|
self._processing = False
|
|
self._authenticated = False
|
|
|
|
self._logger = LOGGER.getChild(f"on({self.host}:{self.port})")
|
|
|
|
@property
|
|
def started(self) -> bool:
|
|
return self._processing
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
return self.started and self.dispatcher.connected
|
|
|
|
def on_connected(self) -> Callable:
|
|
def wrapper(fun):
|
|
self.register(ClientEvent.CONNECTED, fun)
|
|
return fun
|
|
return wrapper
|
|
|
|
def on_disconnected(self) -> Callable:
|
|
def wrapper(fun):
|
|
self.register(ClientEvent.DISCONNECTED, fun)
|
|
return fun
|
|
return wrapper
|
|
|
|
def on_packet(self, packet:Type[Packet]) -> Callable:
|
|
def wrapper(fun):
|
|
self.register(packet, fun)
|
|
return fun
|
|
return wrapper
|
|
|
|
async def authenticate(self):
|
|
if self._authenticated:
|
|
return # Don't spam Auth endpoint!
|
|
if self.token:
|
|
try:
|
|
await self.token.validate() # will raise an exc if token is invalid
|
|
self._authenticated = True
|
|
return
|
|
except AuthException:
|
|
try:
|
|
await self.token.refresh()
|
|
self._logger.warning("Refreshed Token")
|
|
self._authenticated = True
|
|
return
|
|
except AuthException as e:
|
|
self._logger.warning("Token is not refreshable : %s", e.message)
|
|
if not self.username and not self.password:
|
|
raise e # we don't have credentials to make a new token, nothing we can really do here
|
|
if self.username and self.password:
|
|
self.token = await Token.authenticate(self.username, self.password)
|
|
self._logger.info("Authenticated from credentials")
|
|
self._authenticated = True
|
|
return
|
|
raise ValueError("No token or credentials to authenticate") # This should never happen
|
|
|
|
async def change_server(self, server:str):
|
|
restart = self.started
|
|
if restart:
|
|
await self.stop()
|
|
|
|
if ":" in server:
|
|
_host, _port = server.split(":", 1)
|
|
self.host = _host.strip()
|
|
self.port = int(_port)
|
|
else:
|
|
self.host = server.strip()
|
|
self.port = 25565
|
|
self._logger = LOGGER.getChild(f"{self.host}:{self.port}")
|
|
|
|
if restart:
|
|
await self.start()
|
|
|
|
async def start(self):
|
|
await super().start()
|
|
if self.started:
|
|
return
|
|
self._processing = True
|
|
self._worker = asyncio.get_event_loop().create_task(self._client_worker())
|
|
self._logger.info("Minecraft client started")
|
|
|
|
async def stop(self, force:bool=False):
|
|
self._processing = False
|
|
if self.dispatcher.connected:
|
|
await self.dispatcher.disconnect(block=not force)
|
|
if not force:
|
|
await self._worker
|
|
self._logger.info("Minecraft client stopped")
|
|
if not force:
|
|
await self.join_callbacks()
|
|
await super().stop(force)
|
|
|
|
async def _client_worker(self):
|
|
while self._processing:
|
|
if self.online_mode:
|
|
try:
|
|
await self.authenticate()
|
|
except AuthException as e:
|
|
self._logger.error(str(e))
|
|
break
|
|
try:
|
|
packet_whitelist = self.callback_keys(filter=Packet) if self.options.use_packet_whitelist else set()
|
|
await self.dispatcher.connect(
|
|
self.host,
|
|
self.port,
|
|
queue_timeout=self.options.poll_interval,
|
|
packet_whitelist=packet_whitelist
|
|
)
|
|
await self._handshake()
|
|
if await self._login():
|
|
await self._play()
|
|
except ConnectionRefusedError:
|
|
self._logger.error("Server rejected connection")
|
|
except OSError as e:
|
|
self._logger.error("Connection error : %s", str(e))
|
|
except Exception:
|
|
self._logger.exception("Exception in Client connection")
|
|
if self.dispatcher.connected:
|
|
await self.dispatcher.disconnect()
|
|
if not self.options.reconnect:
|
|
break
|
|
if self._processing: # if client was stopped exit immediately
|
|
await asyncio.sleep(self.options.reconnect_delay)
|
|
if self._processing:
|
|
await self.stop(force=True)
|
|
|
|
async def _handshake(self) -> bool: # TODO make this fancier! poll for version and status first
|
|
await self.dispatcher.write(
|
|
PacketSetProtocol(
|
|
340, # TODO!!!!
|
|
protocolVersion=340,
|
|
serverHost=self.host,
|
|
serverPort=self.port,
|
|
nextState=2, # play
|
|
)
|
|
)
|
|
await self.dispatcher.write(
|
|
PacketLoginStart(
|
|
340,
|
|
username=self.token.selectedProfile.name if self.online_mode and self.token else self.username
|
|
)
|
|
)
|
|
return True
|
|
|
|
async def _login(self) -> bool:
|
|
self.dispatcher.state = ConnectionState.LOGIN
|
|
async for packet in self.dispatcher.packets():
|
|
if isinstance(packet, PacketEncryptionBegin):
|
|
if not self.online_mode:
|
|
self._logger.error("Cannot answer Encryption Request in offline mode")
|
|
return False
|
|
if not self.token:
|
|
self._logger.error("No available token to enable encryption")
|
|
return False
|
|
secret = encryption.generate_shared_secret()
|
|
token, encrypted_secret = encryption.encrypt_token_and_secret(
|
|
packet.publicKey,
|
|
packet.verifyToken,
|
|
secret
|
|
)
|
|
if packet.serverId != '-':
|
|
try:
|
|
await self.token.join(
|
|
encryption.generate_verification_hash(
|
|
packet.serverId,
|
|
secret,
|
|
packet.publicKey
|
|
)
|
|
)
|
|
except AuthException:
|
|
self._logger.error("Could not authenticate with Mojang")
|
|
self._authenticated = False
|
|
return False
|
|
else:
|
|
self._logger.warning("Server gave an offline-mode serverId but still requested Encryption")
|
|
encryption_response = PacketEncryptionResponse(
|
|
340, # TODO!!!!
|
|
sharedSecret=encrypted_secret,
|
|
verifyToken=token
|
|
)
|
|
await self.dispatcher.write(encryption_response, wait=True)
|
|
self.dispatcher.encrypt(secret)
|
|
elif isinstance(packet, PacketCompress):
|
|
self._logger.info("Compression enabled")
|
|
self.dispatcher.compression = packet.threshold
|
|
elif isinstance(packet, PacketLoginPluginRequest):
|
|
self._logger.info("Ignoring plugin request") # TODO ?
|
|
elif isinstance(packet, PacketSuccess):
|
|
self._logger.info("Login success, joining world...")
|
|
return True
|
|
elif isinstance(packet, PacketDisconnect):
|
|
self._logger.error("Kicked while logging in : %s", helpers.parse_chat(packet.reason))
|
|
return False
|
|
return False
|
|
|
|
async def _play(self) -> bool:
|
|
self.dispatcher.state = ConnectionState.PLAY
|
|
self.run_callbacks(ClientEvent.CONNECTED)
|
|
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
|
|
elif isinstance(packet, PacketKeepAlive):
|
|
if self.options.keep_alive:
|
|
keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId)
|
|
await self.dispatcher.write(keep_alive_packet)
|
|
elif isinstance(packet, PacketKickDisconnect):
|
|
self._logger.error("Kicked while in game : %s", helpers.parse_chat(packet.reason))
|
|
break
|
|
self.run_callbacks(Packet, packet)
|
|
self.run_callbacks(type(packet), packet)
|
|
self.run_callbacks(ClientEvent.DISCONNECTED)
|
|
return False
|