2021-10-11 02:12:02 +02:00
|
|
|
import asyncio
|
2021-11-10 18:56:14 +01:00
|
|
|
import logging
|
2021-11-22 12:57:10 +01:00
|
|
|
import json
|
2021-11-19 20:30:53 +01:00
|
|
|
import uuid
|
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
from dataclasses import dataclass
|
2021-10-11 02:12:02 +02:00
|
|
|
from asyncio import Task
|
|
|
|
from enum import Enum
|
|
|
|
|
2021-11-17 16:57:02 +01:00
|
|
|
from typing import Dict, List, Callable, Type, Optional, Tuple, AsyncIterator
|
2021-10-15 01:11:43 +02:00
|
|
|
|
2021-11-17 16:57:02 +01:00
|
|
|
from .dispatcher import Dispatcher
|
2021-11-22 02:27:42 +01:00
|
|
|
from .traits import CallbacksHolder, Runnable
|
2021-10-15 01:11:43 +02:00
|
|
|
from .mc.packet import Packet
|
2021-11-17 16:57:02 +01:00
|
|
|
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
|
|
|
|
)
|
2021-11-22 12:57:10 +01:00
|
|
|
from .util import encryption, helpers
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-11 12:25:07 +01:00
|
|
|
LOGGER = logging.getLogger(__name__)
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
@dataclass
|
|
|
|
class ClientOptions:
|
|
|
|
reconnect : bool
|
|
|
|
reconnect_delay : float
|
|
|
|
keep_alive : bool
|
|
|
|
poll_interval : float
|
|
|
|
|
|
|
|
class ClientEvent(Enum):
|
|
|
|
CONNECTED = 0
|
|
|
|
DISCONNECTED = 1
|
|
|
|
|
|
|
|
class MinecraftClient(CallbacksHolder, Runnable):
|
2021-10-11 02:12:02 +02:00
|
|
|
host:str
|
|
|
|
port:int
|
2021-11-22 02:27:42 +01:00
|
|
|
options:ClientOptions
|
2021-11-11 12:25:07 +01:00
|
|
|
|
2021-11-10 22:44:47 +01:00
|
|
|
username:Optional[str]
|
|
|
|
password:Optional[str]
|
|
|
|
token:Optional[Token]
|
2021-10-15 01:11:43 +02:00
|
|
|
|
|
|
|
dispatcher : Dispatcher
|
2021-10-11 02:12:02 +02:00
|
|
|
_processing : bool
|
2021-11-11 13:00:17 +01:00
|
|
|
_authenticated : bool
|
2021-10-11 02:12:02 +02:00
|
|
|
_worker : Task
|
2021-11-19 20:30:53 +01:00
|
|
|
_callbacks = Dict[str, Task]
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-11 12:25:07 +01:00
|
|
|
_logger : logging.Logger
|
2021-11-10 18:56:14 +01:00
|
|
|
|
2021-10-11 02:12:02 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
2021-11-10 18:56:14 +01:00
|
|
|
host:str,
|
2021-11-11 14:05:19 +01:00
|
|
|
port:int = 25565,
|
2021-11-10 22:44:47 +01:00
|
|
|
username:Optional[str] = None,
|
|
|
|
password:Optional[str] = None,
|
|
|
|
token:Optional[Token] = None,
|
2021-11-22 02:27:42 +01:00
|
|
|
reconnect:bool = True,
|
|
|
|
reconnect_delay:float = 10.0,
|
|
|
|
keep_alive:bool = True,
|
|
|
|
poll_interval:float = 1.0,
|
|
|
|
|
2021-10-11 02:12:02 +02:00
|
|
|
):
|
2021-11-22 02:52:51 +01:00
|
|
|
super().__init__()
|
2021-10-11 02:12:02 +02:00
|
|
|
self.host = host
|
|
|
|
self.port = port
|
2021-11-11 14:38:13 +01:00
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
self.options = ClientOptions(
|
|
|
|
reconnect=reconnect,
|
|
|
|
reconnect_delay=reconnect_delay,
|
|
|
|
keep_alive=keep_alive,
|
|
|
|
poll_interval=poll_interval
|
|
|
|
)
|
2021-11-10 22:44:47 +01:00
|
|
|
|
2021-11-10 18:56:14 +01:00
|
|
|
self.token = token
|
2021-11-10 22:44:47 +01:00
|
|
|
self.username = username
|
|
|
|
self.password = password
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-11 12:25:07 +01:00
|
|
|
self.dispatcher = Dispatcher()
|
2021-10-11 02:12:02 +02:00
|
|
|
self._processing = False
|
2021-11-11 13:00:17 +01:00
|
|
|
self._authenticated = False
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-11 12:25:07 +01:00
|
|
|
self._logger = LOGGER.getChild(f"{self.host}:{self.port}")
|
|
|
|
|
|
|
|
@property
|
|
|
|
def started(self) -> bool:
|
|
|
|
return self._processing
|
|
|
|
|
|
|
|
@property
|
|
|
|
def connected(self) -> bool:
|
|
|
|
return self.started and self.dispatcher.connected
|
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
def on_connected(self) -> Callable:
|
|
|
|
def wrapper(fun):
|
|
|
|
self.register(ClientEvent.CONNECTED, fun)
|
|
|
|
return fun
|
|
|
|
return wrapper
|
2021-11-19 20:30:53 +01:00
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
def on_disconnected(self) -> Callable:
|
|
|
|
def wrapper(fun):
|
|
|
|
self.register(ClientEvent.DISCONNECTED, fun)
|
|
|
|
return fun
|
|
|
|
return wrapper
|
2021-11-19 20:30:53 +01:00
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
def on_packet(self, packet:Type[Packet]) -> Callable:
|
2021-11-10 18:56:14 +01:00
|
|
|
def wrapper(fun):
|
2021-11-22 02:27:42 +01:00
|
|
|
self.register(packet, fun)
|
2021-11-10 18:56:14 +01:00
|
|
|
return fun
|
|
|
|
return wrapper
|
|
|
|
|
2021-11-10 22:44:47 +01:00
|
|
|
async def authenticate(self) -> bool:
|
2021-11-11 14:12:07 +01:00
|
|
|
if self._authenticated:
|
|
|
|
return True # Don't spam Auth endpoint!
|
2021-11-10 22:44:47 +01:00
|
|
|
if not self.token:
|
|
|
|
if self.username and self.password:
|
|
|
|
self.token = await Token.authenticate(self.username, self.password)
|
2021-11-11 12:25:07 +01:00
|
|
|
self._logger.info("Authenticated from credentials")
|
2021-11-11 22:53:03 +01:00
|
|
|
self._authenticated = True
|
2021-11-10 22:44:47 +01:00
|
|
|
return True
|
2021-11-11 13:00:17 +01:00
|
|
|
raise AuthException("No token or credentials provided")
|
2021-11-10 22:44:47 +01:00
|
|
|
try:
|
|
|
|
await self.token.validate() # will raise an exc if token is invalid
|
2021-11-11 22:53:03 +01:00
|
|
|
self._authenticated = True
|
2021-11-11 13:00:17 +01:00
|
|
|
except AuthException:
|
|
|
|
await self.token.refresh()
|
2021-11-11 22:53:03 +01:00
|
|
|
self._authenticated = True
|
2021-11-11 13:00:17 +01:00
|
|
|
self._logger.warning("Refreshed Token")
|
2021-11-10 22:44:47 +01:00
|
|
|
return True
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-11 12:25:07 +01:00
|
|
|
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()
|
|
|
|
|
2021-11-10 22:44:47 +01:00
|
|
|
async def start(self):
|
2021-11-22 02:27:42 +01:00
|
|
|
if self.started:
|
|
|
|
return
|
2021-11-10 18:56:14 +01:00
|
|
|
self._processing = True
|
|
|
|
self._worker = asyncio.get_event_loop().create_task(self._client_worker())
|
2021-11-11 12:25:07 +01:00
|
|
|
self._logger.info("Minecraft client started")
|
2021-10-11 02:12:02 +02:00
|
|
|
|
2021-11-22 02:27:42 +01:00
|
|
|
async def stop(self, force:bool=False):
|
2021-11-10 18:56:14 +01:00
|
|
|
self._processing = False
|
2021-11-17 16:57:02 +01:00
|
|
|
if self.dispatcher.connected:
|
2021-11-22 02:27:42 +01:00
|
|
|
await self.dispatcher.disconnect(block=not force)
|
|
|
|
if not force:
|
2021-11-10 18:56:14 +01:00
|
|
|
await self._worker
|
2021-11-17 16:57:02 +01:00
|
|
|
self._logger.info("Minecraft client stopped")
|
2021-11-22 02:27:42 +01:00
|
|
|
if not force:
|
|
|
|
await self.join_callbacks()
|
2021-11-10 18:56:14 +01:00
|
|
|
|
|
|
|
async def _client_worker(self):
|
2021-10-11 02:12:02 +02:00
|
|
|
while self._processing:
|
2021-11-11 13:00:17 +01:00
|
|
|
try:
|
|
|
|
await self.authenticate()
|
2021-11-17 16:57:02 +01:00
|
|
|
except AuthException as e:
|
|
|
|
self._logger.error(str(e))
|
|
|
|
break
|
2021-11-10 22:44:47 +01:00
|
|
|
try:
|
2021-11-11 12:25:07 +01:00
|
|
|
await self.dispatcher.connect(self.host, self.port)
|
2021-11-17 16:57:02 +01:00
|
|
|
await self._handshake()
|
|
|
|
if await self._login():
|
|
|
|
await self._play()
|
2021-11-11 12:25:07 +01:00
|
|
|
except ConnectionRefusedError:
|
|
|
|
self._logger.error("Server rejected connection")
|
2021-11-22 10:48:11 +01:00
|
|
|
except OSError as e:
|
|
|
|
self._logger.error("Connection error : %s", str(e))
|
2021-11-10 22:44:47 +01:00
|
|
|
except Exception:
|
2021-11-11 12:25:07 +01:00
|
|
|
self._logger.exception("Exception in Client connection")
|
2021-11-17 16:57:02 +01:00
|
|
|
if self.dispatcher.connected:
|
|
|
|
await self.dispatcher.disconnect()
|
2021-11-22 02:27:42 +01:00
|
|
|
if not self.options.reconnect:
|
2021-11-11 14:28:56 +01:00
|
|
|
break
|
2021-11-29 03:50:33 +01:00
|
|
|
if self._processing: # if client was stopped exit immediately
|
|
|
|
await asyncio.sleep(self.options.reconnect_delay)
|
2021-11-22 03:21:26 +01:00
|
|
|
await self.stop(force=True)
|
2021-11-10 22:44:47 +01:00
|
|
|
|
2021-11-17 16:57:02 +01:00
|
|
|
async def _handshake(self) -> bool: # TODO make this fancier! poll for version and status first
|
|
|
|
await self.dispatcher.write(
|
|
|
|
PacketSetProtocol(
|
2021-11-10 22:44:47 +01:00
|
|
|
340, # TODO!!!!
|
|
|
|
protocolVersion=340,
|
|
|
|
serverHost=self.host,
|
|
|
|
serverPort=self.port,
|
|
|
|
nextState=2, # play
|
2021-11-17 16:57:02 +01:00
|
|
|
)
|
|
|
|
)
|
|
|
|
await self.dispatcher.write(
|
|
|
|
PacketLoginStart(
|
2021-11-10 22:44:47 +01:00
|
|
|
340,
|
|
|
|
username=self.token.profile.name if self.token else self.username
|
|
|
|
)
|
|
|
|
)
|
2021-11-17 16:57:02 +01:00
|
|
|
return True
|
2021-11-10 22:44:47 +01:00
|
|
|
|
2021-11-17 16:57:02 +01:00
|
|
|
async def _login(self) -> bool:
|
|
|
|
self.dispatcher.state = ConnectionState.LOGIN
|
|
|
|
async for packet in self.dispatcher.packets():
|
|
|
|
if isinstance(packet, PacketEncryptionBegin):
|
|
|
|
secret = encryption.generate_shared_secret()
|
|
|
|
token, encrypted_secret = encryption.encrypt_token_and_secret(
|
|
|
|
packet.publicKey,
|
|
|
|
packet.verifyToken,
|
|
|
|
secret
|
2021-11-10 22:44:47 +01:00
|
|
|
)
|
2021-11-17 16:57:02 +01:00
|
|
|
if packet.serverId != '-' and self.token:
|
|
|
|
try:
|
|
|
|
await self.token.join(
|
|
|
|
encryption.generate_verification_hash(
|
|
|
|
packet.serverId,
|
|
|
|
secret,
|
|
|
|
packet.publicKey
|
|
|
|
)
|
|
|
|
)
|
|
|
|
except AuthException:
|
|
|
|
self._logger.error("Could not authenticate with Mojang")
|
|
|
|
break
|
|
|
|
encryption_response = PacketEncryptionResponse(
|
|
|
|
340, # TODO!!!!
|
|
|
|
sharedSecret=encrypted_secret,
|
|
|
|
verifyToken=token
|
2021-11-10 22:44:47 +01:00
|
|
|
)
|
2021-11-17 16:57:02 +01:00
|
|
|
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):
|
2021-11-22 12:57:10 +01:00
|
|
|
self._logger.error("Kicked while logging in : %s", helpers.parse_chat(packet.reason))
|
2021-11-17 16:57:02 +01:00
|
|
|
break
|
|
|
|
return False
|
|
|
|
|
|
|
|
async def _play(self) -> bool:
|
|
|
|
self.dispatcher.state = ConnectionState.PLAY
|
2021-11-22 02:27:42 +01:00
|
|
|
self.run_callbacks(ClientEvent.CONNECTED)
|
2021-11-17 16:57:02 +01:00
|
|
|
async for packet in self.dispatcher.packets():
|
|
|
|
self._logger.debug("[ * ] Processing | %s", str(packet))
|
|
|
|
if isinstance(packet, PacketSetCompression):
|
|
|
|
self._logger.info("Compression updated")
|
|
|
|
self.dispatcher.compression = packet.threshold
|
|
|
|
elif isinstance(packet, PacketKeepAlive):
|
2021-11-22 02:27:42 +01:00
|
|
|
if self.options.keep_alive:
|
2021-11-17 16:57:02 +01:00
|
|
|
keep_alive_packet = PacketKeepAliveResponse(340, keepAliveId=packet.keepAliveId)
|
|
|
|
await self.dispatcher.write(keep_alive_packet)
|
|
|
|
elif isinstance(packet, PacketKickDisconnect):
|
2021-11-22 12:57:10 +01:00
|
|
|
self._logger.error("Kicked while in game : %s", helpers.parse_chat(packet.reason))
|
2021-11-17 16:57:02 +01:00
|
|
|
break
|
2021-11-22 02:27:42 +01:00
|
|
|
self.run_callbacks(Packet, packet)
|
|
|
|
self.run_callbacks(type(packet), packet)
|
|
|
|
self.run_callbacks(ClientEvent.DISCONNECTED)
|
2021-11-17 16:57:02 +01:00
|
|
|
return False
|