Files
minecraft-bot/mosfet.py
T

433 lines
15 KiB
Python

import importlib
import json
import queue
import re
import urllib.error
import urllib.request
import threading
import time
import traceback
import sys
from pathlib import Path
import logging
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
from flask import Flask, abort, jsonify, request, send_file
from flask_cors import CORS
build_folder = 'web_interface/build'
app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='')
CORS(app)
from munch import Munch
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from mosfet import connect
global_state = Munch()
g = global_state
g.connection = False
g.connected = False
g.connection_status = 'disconnected'
g.player_position = None
g.last_chat = None
g.name = None
g.mcdata = False
g.pos = False
g.dimension = None
g.item_lock = False
g.inv = {}
g.objects = {}
g.mobs = {}
g.players = {}
g.player_names = {}
g.window = None
g.job = None
g.correction_count = 0
g.holding = 0
g.afk = False
g.health = None
g.food = None
g.hunger = None
g.food_saturation = None
g.saturation = None
g.health_update_received = False
g.health_updated_at = None
g.dead = False
g.sand_origin = None
g.reload_lock = threading.RLock()
_item_icon_cache = Path(__file__).resolve().parent / 'web_interface' / 'item_cache'
_item_icon_locks = {}
_item_icon_locks_guard = threading.Lock()
def _api_plain(value, depth=0):
"""Convert item component values into bounded JSON-safe structures."""
if depth > 12:
return '[nested data omitted]'
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _api_plain(item, depth + 1)
for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_api_plain(item, depth + 1) for item in value]
if (type(value).__name__ in ('Slot', 'ItemStackData') and
all(hasattr(value, key) for key in
('present', 'item_id', 'item_count'))):
return {
'present': value.present,
'item_id': value.item_id,
'item_count': value.item_count,
'components': _api_plain(getattr(value, 'components', {}), depth + 1),
'components_unknown': bool(getattr(value, 'components_unknown', False)),
}
if hasattr(value, 'value'):
return _api_plain(value.value, depth + 1)
if hasattr(value, 'items'):
try:
return {str(key): _api_plain(item, depth + 1)
for key, item in value.items()}
except Exception:
pass
return str(value)
def _api_json_default(value):
"""Serialize live protocol objects without invoking fragile __repr__ code."""
from minecraft.networking.packets import Packet
if isinstance(value, Packet):
fields = {key: item for key, item in vars(value).items()
if key != 'context'}
return {'__type__': type(value).__name__,
'packet_name': getattr(value, 'packet_name', None),
'fields': fields}
if (type(value).__name__ in ('Slot', 'ItemStackData') and
all(hasattr(value, key) for key in
('present', 'item_id', 'item_count'))):
return _api_plain(value)
if all(hasattr(value, key) for key in ('x', 'y', 'z')):
return {'x': value.x, 'y': value.y, 'z': value.z}
if isinstance(value, (bytes, bytearray)):
return value.hex()
try:
return str(value)
except Exception:
return object.__repr__(value)
@app.route('/api/global')
def api_global():
snapshot = dict(g)
job = g.get('job')
job_state = getattr(job, 'state', None) if job else None
snapshot['job_state'] = getattr(job_state, '__name__', 'idle')
chunks = g.get('chunks')
if chunks is not None and hasattr(chunks, 'chunks'):
snapshot['chunk_state'] = {
'loaded': len(chunks.chunks),
'center': chunks.center,
'radius': chunks.radius,
'dimension': chunks.dimension,
}
response = app.response_class(
response=json.dumps(snapshot, default=_api_json_default),
status=200,
mimetype='application/json'
)
return response
@app.route('/api/items')
def api_items():
from mosfet.info.items import ITEM_NAMES
data = {str(item_id): name for item_id, name in ITEM_NAMES.items()}
return app.response_class(
response=json.dumps(data), status=200, mimetype='application/json')
@app.route('/api/inventory/drop', methods=['POST'])
def api_inventory_drop():
origin = request.headers.get('Origin')
if origin:
from urllib.parse import urlparse
if urlparse(origin).netloc.lower() != request.host.lower():
return jsonify(ok=False, error='cross-origin inventory actions are blocked'), 403
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify(ok=False, error='JSON body must be an object'), 400
try:
slot = int(payload.get('slot'))
except (TypeError, ValueError):
return jsonify(ok=False, error='slot must be an integer'), 400
action = payload.get('action')
if action not in ('one', 'stack', 'matching'):
return jsonify(ok=False, error='unknown inventory action'), 400
if not g.get('connected') or not g.get('game'):
return jsonify(ok=False, error='bot is not connected'), 503
with g.reload_lock:
item = g.inv.get(slot)
if slot < 5 or slot > 45 or not item or not getattr(item, 'present', False):
return jsonify(ok=False, error='that inventory slot is empty or protected'), 409
if action == 'matching':
count = g.game.start_dump(item.item_id)
if count is None:
return jsonify(ok=False, error='another inventory action is running'), 409
if not count:
return jsonify(ok=False, error='no matching stacks to drop'), 409
return jsonify(ok=True, action=action, stacks=count)
if not g.game.drop_inventory_slot(
slot, whole_stack=(action == 'stack')):
return jsonify(ok=False, error='another inventory action is running or the menu is open'), 409
return jsonify(ok=True, action=action, slot=slot)
def _item_texture_urls(name):
root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures'
yield root + '/item/{}.png'.format(name)
yield root + '/block/{}.png'.format(name)
simplified = re.sub(
r'_(slab|stairs|wall|fence_gate|fence|door|trapdoor|button|'
r'pressure_plate|sign|hanging_sign|carpet|banner|bed|candle)$',
'', name)
if simplified != name:
yield root + '/block/{}.png'.format(simplified)
yield root + '/item/{}.png'.format(simplified)
def _fetch_item_texture(name):
for url in _item_texture_urls(name):
request = urllib.request.Request(
url, headers={'User-Agent': 'curl/8.0 Mosfet/26.3'})
try:
with urllib.request.urlopen(request, timeout=4) as response:
image = response.read(2 * 1024 * 1024 + 1)
if len(image) <= 2 * 1024 * 1024 and image.startswith(b'\x89PNG\r\n\x1a\n'):
return image
except (urllib.error.URLError, TimeoutError, OSError):
continue
return None
@app.route('/api/item-icon/<name>')
def api_item_icon(name):
from mosfet.info.items import ITEM_NAMES
if not re.fullmatch(r'[a-z0-9_]+', name):
abort(404)
if name not in set(ITEM_NAMES.values()):
abort(404)
with _item_icon_locks_guard:
lock = _item_icon_locks.setdefault(name, threading.Lock())
_item_icon_cache.mkdir(parents=True, exist_ok=True)
cached = _item_icon_cache / (name + '.png')
with lock:
if not cached.is_file():
image = _fetch_item_texture(name)
if image is None:
abort(404)
temporary = cached.with_suffix('.tmp')
temporary.write_bytes(image)
temporary.replace(cached)
return send_file(cached, mimetype='image/png', max_age=86400)
@app.route('/')
def root():
return app.send_static_file('index.html')
def main():
"""Run the 26.3 client with in-place development hot reload.
Application modules are refreshed while the current socket and player
session remain alive. Connection/bootstrap and packet-schema edits are
reported as deferred because their listeners cannot safely be replaced
without reconnecting.
"""
g.running = True
changes = queue.Queue()
observer = Observer()
source_root = Path(__file__).resolve().parent / 'mosfet'
def enqueue_change(event):
if event.is_directory:
return
path = getattr(event, 'dest_path', None) if event.event_type == 'moved' else event.src_path
if not path or Path(path).suffix != '.py' or '__pycache__' in path:
return
changes.put(str(Path(path).resolve()))
def module_name(path):
source = Path(path)
try:
relative = source.relative_to(source_root)
except ValueError:
return None
parts = list(relative.with_suffix('').parts)
if parts and parts[-1] == '__init__':
parts.pop()
return '.'.join(('mosfet', *parts))
def reload_changed(paths):
paths = sorted(set(path for path in paths if Path(path).is_file()))
if not paths:
return
# Don't execute a half-written editor save. If any file is syntactically
# invalid, keep the currently running code untouched.
try:
for path in paths:
source = Path(path)
compile(source.read_text(encoding='utf-8'), str(source), 'exec')
except (OSError, SyntaxError, UnicodeError):
print('[hot reload] waiting for a valid save', flush=True)
return
deferred = []
modules = []
for path in paths:
name = module_name(path)
if not name:
continue
if name in ('mosfet.connect', 'mosfet') or name == 'mosfet.monkey_patch':
deferred.append(name)
else:
modules.append(name)
if deferred:
print('[hot reload] {} changed; connection/bootstrap changes are '
'deferred until the next start (no disconnect performed)'.format(
', '.join(sorted(set(deferred)))), flush=True)
if not modules:
return
if not g.get('connected') or not g.get('commands'):
deadline = time.monotonic() + 15.0
while (g.running and time.monotonic() < deadline and
(not g.get('connected') or not g.get('commands'))):
time.sleep(0.1)
if not g.get('connected') or not g.get('commands'):
print('[hot reload] runtime not ready; change left for next save',
flush=True)
return
managers_changed = bool(
{'mosfet.protocol.managers', 'mosfet.protocol.types'} & set(modules))
core_modules = {
'mosfet.bot', 'mosfet.commands', 'mosfet.game', 'mosfet.job',
'mosfet.path', 'mosfet.print_help', 'mosfet.utils',
'mosfet.vector', 'mosfet.world', 'mosfet.info.blocks',
'mosfet.info.items', 'mosfet.info.mcdata', 'mosfet.info.mobs',
}
# bot.py reloads the legacy gameplay modules and job state classes as
# one coherent group. Reload protocol adapters first so rebinding sees
# their latest definitions.
direct_modules = [name for name in modules
if name not in core_modules and
not name.startswith('mosfet.jobs.')]
if managers_changed and 'mosfet.protocol.managers' not in direct_modules:
direct_modules.append('mosfet.protocol.managers')
priority = {
'mosfet.protocol.types': 0,
'mosfet.protocol.packets': 1,
'mosfet.protocol.managers': 2,
'mosfet.protocol.modern': 3,
}
direct_modules.sort(key=lambda name: (priority.get(name, 10), name))
connection = g.connection
packet_lock = getattr(connection, 'packet_listener_lock', None)
try:
if packet_lock is None:
with g.reload_lock:
apply_reload(direct_modules, managers_changed)
else:
# Match packet dispatch's lock order (listener lock, then the
# runtime lock) so callbacks cannot race listener replacement.
with packet_lock:
with g.reload_lock:
apply_reload(direct_modules, managers_changed)
except Exception:
traceback.print_exc()
print('[hot reload] failed; connection left running', flush=True)
def apply_reload(direct_modules, managers_changed):
for name in direct_modules:
module = sys.modules.get(name)
if module is not None:
importlib.reload(module)
bot_module = sys.modules.get('mosfet.bot')
if bot_module is None:
bot_module = importlib.import_module('mosfet.bot')
bot_module = importlib.reload(bot_module)
bot_module.refresh_runtime(global_state,
reload_managers=managers_changed)
print('[hot reload] refreshed {} (session preserved)'.format(
', '.join(direct_modules) if direct_modules else 'bot runtime'),
flush=True)
def reload_worker():
while True:
path = changes.get()
if path is None:
return
batch = {path}
stopping = False
# Coalesce the burst of modified/moved events from editor saves.
while True:
try:
candidate = changes.get(timeout=0.4)
except queue.Empty:
break
if candidate is None:
stopping = True
break
batch.add(candidate)
time.sleep(0.15)
reload_changed(batch)
if stopping:
return
event_handler = FileSystemEventHandler()
event_handler.on_modified = enqueue_change
event_handler.on_created = enqueue_change
event_handler.on_moved = enqueue_change
observer.schedule(event_handler, str(source_root), recursive=True)
observer.start()
worker = threading.Thread(target=reload_worker,
name='mosfet-hot-reloader', daemon=True)
worker.start()
try:
connect.main(global_state)
except KeyboardInterrupt:
pass
except BaseException:
traceback.print_exc()
finally:
g.running = False
changes.put(None)
observer.stop()
observer.join()
worker.join(timeout=2.0)
def run_api():
host = '0.0.0.0'
port = 3300
while True:
print('Trying to run web interface on port:', port)
print('If it works, go to http://localhost:' + str(port))
try:
app.run(host=host, port=port)
except OSError:
print()
print('Error: Port already taken.')
port += 1
if __name__ == '__main__':
threading.Thread(target=run_api, daemon=True).start()
main()