290 lines
10 KiB
Python
290 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
fulfillment.py — Sulkta shop order fulfillment daemon
|
||
|
||
Runs alongside the MC server inside the Docker container.
|
||
Polls the shop API for confirmed orders, delivers items via RCON,
|
||
marks orders fulfilled. Retries each poll cycle if the player is offline.
|
||
"""
|
||
|
||
import os, socket, struct, time, json, logging, signal, sys
|
||
import urllib.request, urllib.parse, urllib.error
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s [fulfillment] %(levelname)s %(message)s',
|
||
datefmt='%Y-%m-%d %H:%M:%S',
|
||
stream=sys.stdout,
|
||
)
|
||
log = logging.getLogger(__name__)
|
||
|
||
SHOP_API_URL = os.environ.get('SHOP_API_URL', '').rstrip('/')
|
||
SHOP_API_KEY = os.environ.get('SHOP_API_KEY', '')
|
||
RCON_HOST = os.environ.get('RCON_HOST', '127.0.0.1')
|
||
RCON_PORT = int(os.environ.get('RCON_PORT', '25575'))
|
||
RCON_PASSWORD = os.environ.get('RCON_PASSWORD', '')
|
||
POLL_INTERVAL = int(os.environ.get('FULFILLMENT_POLL_INTERVAL', '30'))
|
||
STARTUP_DELAY = int(os.environ.get('FULFILLMENT_STARTUP_DELAY', '90'))
|
||
|
||
# ── Item delivery definitions ─────────────────────────────────────────────────
|
||
# {player} is replaced with the Minecraft username at delivery time.
|
||
ITEM_COMMANDS = {
|
||
'starter-kit': [
|
||
'/give {player} minecraft:diamond_pickaxe 1',
|
||
'/give {player} minecraft:diamond_axe 1',
|
||
'/give {player} minecraft:diamond_shovel 1',
|
||
'/give {player} minecraft:diamond_sword 1',
|
||
'/give {player} minecraft:diamond_helmet 1',
|
||
'/give {player} minecraft:diamond_chestplate 1',
|
||
'/give {player} minecraft:diamond_leggings 1',
|
||
'/give {player} minecraft:diamond_boots 1',
|
||
'/give {player} minecraft:cooked_beef 64',
|
||
'/give {player} minecraft:iron_ingot 64',
|
||
'/give {player} minecraft:gold_ingot 32',
|
||
'/give {player} minecraft:diamond 16',
|
||
'/give {player} minecraft:torch 64',
|
||
],
|
||
'reactor-kit': [
|
||
# Shell — 32 casings covers a 3×3×4 exterior comfortably
|
||
'/give {player} mekanism:fission_reactor_casing 32',
|
||
# Optional glass windows
|
||
'/give {player} mekanism:fission_reactor_glass 8',
|
||
# Ports: 2× coolant in/out + 1× heated coolant out + 1× waste drain
|
||
'/give {player} mekanism:fission_reactor_port 4',
|
||
# Logic adapter to arm/disarm the reactor
|
||
'/give {player} mekanism:fission_reactor_logic_adapter 1',
|
||
# Fuel assembly column + control rods
|
||
'/give {player} mekanism:fission_fuel_assembly 4',
|
||
'/give {player} mekanism:control_rod_assembly 4',
|
||
# Uranium — raw ore to process into fissile fuel
|
||
'/give {player} mekanism:yellow_cake_uranium 64',
|
||
],
|
||
}
|
||
|
||
# ── Minimal RCON client ───────────────────────────────────────────────────────
|
||
|
||
class RCONError(Exception):
|
||
pass
|
||
|
||
class RCON:
|
||
AUTH = 3
|
||
AUTH_RESPONSE = 2
|
||
EXEC = 2
|
||
RESPONSE = 0
|
||
|
||
def __init__(self, host, port, password, timeout=10):
|
||
self.host = host
|
||
self.port = port
|
||
self.password = password
|
||
self.timeout = timeout
|
||
self._sock = None
|
||
self._req_id = 0
|
||
|
||
def connect(self):
|
||
self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||
self._sock.settimeout(self.timeout)
|
||
self._authenticate()
|
||
|
||
def close(self):
|
||
if self._sock:
|
||
try:
|
||
self._sock.close()
|
||
except Exception:
|
||
pass
|
||
self._sock = None
|
||
|
||
def __enter__(self):
|
||
self.connect()
|
||
return self
|
||
|
||
def __exit__(self, *_):
|
||
self.close()
|
||
|
||
def _send(self, type_, payload):
|
||
self._req_id += 1
|
||
body = payload.encode('utf-8') + b'\x00\x00'
|
||
pkt = struct.pack('<iii', len(body) + 8, self._req_id, type_) + body
|
||
self._sock.sendall(pkt)
|
||
return self._req_id
|
||
|
||
def _recv(self):
|
||
def read(n):
|
||
buf = b''
|
||
while len(buf) < n:
|
||
chunk = self._sock.recv(n - len(buf))
|
||
if not chunk:
|
||
raise RCONError('Connection closed by server')
|
||
buf += chunk
|
||
return buf
|
||
size = struct.unpack('<i', read(4))[0]
|
||
data = read(size)
|
||
req_id, type_ = struct.unpack('<ii', data[:8])
|
||
payload = data[8:-2].decode('utf-8', errors='replace')
|
||
return req_id, type_, payload
|
||
|
||
def _authenticate(self):
|
||
self._send(self.AUTH, self.password)
|
||
req_id, _, _ = self._recv()
|
||
if req_id == -1:
|
||
raise RCONError('RCON authentication failed — wrong password?')
|
||
|
||
def command(self, cmd):
|
||
self._send(self.EXEC, cmd)
|
||
_, _, payload = self._recv()
|
||
return payload.strip()
|
||
|
||
# ── API helpers ───────────────────────────────────────────────────────────────
|
||
|
||
def api_get(action, extra_params=''):
|
||
url = f'{SHOP_API_URL}/api.php?action={action}&key={urllib.parse.quote(SHOP_API_KEY)}'
|
||
if extra_params:
|
||
url += '&' + extra_params
|
||
req = urllib.request.Request(url, headers={'Accept': 'application/json', 'User-Agent': 'sulkta-fulfillment/1.0'})
|
||
with urllib.request.urlopen(req, timeout=15) as r:
|
||
return json.loads(r.read())
|
||
|
||
def api_post(action, data):
|
||
url = f'{SHOP_API_URL}/api.php?action={action}'
|
||
body = json.dumps({**data, 'key': SHOP_API_KEY}).encode()
|
||
req = urllib.request.Request(
|
||
url, data=body,
|
||
headers={'Content-Type': 'application/json', 'User-Agent': 'sulkta-fulfillment/1.0'},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=15) as r:
|
||
return json.loads(r.read())
|
||
|
||
# ── Fulfillment logic ─────────────────────────────────────────────────────────
|
||
|
||
def get_online_players(rcon):
|
||
"""Returns a set of currently online player names."""
|
||
resp = rcon.command('/list')
|
||
# Vanilla/NeoForge: "There are N of a max of M players online: name1, name2"
|
||
if ':' not in resp:
|
||
return set()
|
||
names_part = resp.split(':', 1)[1].strip()
|
||
if not names_part:
|
||
return set()
|
||
return {n.strip() for n in names_part.split(',') if n.strip()}
|
||
|
||
|
||
def deliver_order(rcon, order):
|
||
"""
|
||
Runs all RCON give commands for an order.
|
||
Returns (success: bool, message: str).
|
||
"""
|
||
item_id = order['item_id']
|
||
player = order['minecraft_username']
|
||
commands = ITEM_COMMANDS.get(item_id)
|
||
|
||
if not commands:
|
||
return False, f'No delivery definition for item_id={item_id}'
|
||
|
||
errors = []
|
||
for cmd in commands:
|
||
filled = cmd.replace('{player}', player)
|
||
try:
|
||
result = rcon.command(filled)
|
||
log.debug('RCON %-60s → %s', filled, result)
|
||
# MC returns "Given [item]×N to PlayerName" on success.
|
||
# Any 'error' or 'unknown' in the response is a problem.
|
||
if result.lower().startswith('unknown') or 'error' in result.lower():
|
||
errors.append(f'{filled}: {result}')
|
||
except Exception as e:
|
||
errors.append(f'{filled}: RCON error: {e}')
|
||
|
||
if errors:
|
||
log.warning('Delivery errors for order %s: %s', order['order_id'], errors)
|
||
return False, '; '.join(errors)
|
||
|
||
return True, f'Delivered {len(commands)} command(s) via RCON'
|
||
|
||
|
||
def process_orders():
|
||
if not SHOP_API_URL or not SHOP_API_KEY:
|
||
log.debug('SHOP_API_URL / SHOP_API_KEY not set — skipping poll')
|
||
return
|
||
if not RCON_PASSWORD:
|
||
log.debug('RCON_PASSWORD not set — skipping poll')
|
||
return
|
||
|
||
# Fetch pending orders from Rackham
|
||
try:
|
||
resp = api_get('pending_fulfillment')
|
||
orders = resp.get('orders', [])
|
||
except Exception as e:
|
||
log.warning('Failed to fetch pending orders: %s', e)
|
||
return
|
||
|
||
if not orders:
|
||
return
|
||
|
||
log.info('%d confirmed order(s) to process', len(orders))
|
||
|
||
# Open RCON connection
|
||
try:
|
||
rcon = RCON(RCON_HOST, RCON_PORT, RCON_PASSWORD)
|
||
rcon.connect()
|
||
except Exception as e:
|
||
log.warning('RCON connect failed (%s) — will retry next cycle', e)
|
||
return
|
||
|
||
try:
|
||
online = get_online_players(rcon)
|
||
if online:
|
||
log.info('Online players: %s', ', '.join(sorted(online)))
|
||
else:
|
||
log.info('No players currently online')
|
||
|
||
for order in orders:
|
||
player = order['minecraft_username']
|
||
if player not in online:
|
||
log.info('Order %s: %s is offline, will retry next cycle', order['order_id'], player)
|
||
continue
|
||
|
||
log.info('Delivering order %s (%s) to %s', order['order_id'], order['item_id'], player)
|
||
success, msg = deliver_order(rcon, order)
|
||
|
||
try:
|
||
api_post('mark_fulfilled', {
|
||
'order_id': order['order_id'],
|
||
'success': success,
|
||
'message': msg,
|
||
})
|
||
status = 'fulfilled' if success else 'failed'
|
||
log.info('Order %s → %s: %s', order['order_id'], status, msg)
|
||
except Exception as e:
|
||
log.warning('Failed to mark order %s: %s', order['order_id'], e)
|
||
|
||
finally:
|
||
rcon.close()
|
||
|
||
|
||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
log.info('Sulkta fulfillment daemon starting')
|
||
log.info('Shop API : %s', SHOP_API_URL or '(not configured — set SHOP_API_URL)')
|
||
log.info('RCON : %s:%d', RCON_HOST, RCON_PORT)
|
||
log.info('Poll : every %ds', POLL_INTERVAL)
|
||
|
||
if STARTUP_DELAY > 0:
|
||
log.info('Waiting %ds for MC server to start…', STARTUP_DELAY)
|
||
time.sleep(STARTUP_DELAY)
|
||
|
||
def shutdown(sig, frame):
|
||
log.info('Shutting down (signal %d)', sig)
|
||
sys.exit(0)
|
||
|
||
signal.signal(signal.SIGTERM, shutdown)
|
||
signal.signal(signal.SIGINT, shutdown)
|
||
|
||
while True:
|
||
try:
|
||
process_orders()
|
||
except Exception as e:
|
||
log.error('Unhandled error in poll cycle: %s', e, exc_info=True)
|
||
time.sleep(POLL_INTERVAL)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|