Semver rename + single-call gateway
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+272
-4
@@ -17,7 +17,9 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -83,6 +85,45 @@ _kb_lock = threading.Lock()
|
|||||||
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
|
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
|
||||||
_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'}
|
_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RCON world observation helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rcon_query(cmd: str, host: str = '127.0.0.1', port: int = 25577,
|
||||||
|
password: str = 'REDACTED_RCON', timeout: float = 5.0) -> str:
|
||||||
|
"""Send a single RCON command and return the response text."""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
s.connect((host, int(port)))
|
||||||
|
|
||||||
|
def pkt(req_id: int, pkt_type: int, payload: str) -> bytes:
|
||||||
|
p = payload.encode('utf-8') + b'\x00\x00'
|
||||||
|
return struct.pack('<iii', len(p) + 8, req_id, pkt_type) + p
|
||||||
|
|
||||||
|
# Authenticate (type 3)
|
||||||
|
s.sendall(pkt(1, 3, password))
|
||||||
|
time.sleep(0.15)
|
||||||
|
s.recv(4096)
|
||||||
|
# Send command (type 2)
|
||||||
|
s.sendall(pkt(2, 2, cmd))
|
||||||
|
time.sleep(0.2)
|
||||||
|
r = s.recv(4096)
|
||||||
|
return r[12:-2].decode('utf-8', errors='replace')
|
||||||
|
except Exception as e:
|
||||||
|
return f'RCON error: {e}'
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _rcon_cfg() -> Dict[str, Any]:
|
||||||
|
"""Return RCON connection params from config."""
|
||||||
|
return {
|
||||||
|
'host': str(CFG.get('rcon_host', '127.0.0.1')),
|
||||||
|
'port': int(CFG.get('rcon_port', 25577)),
|
||||||
|
'password': str(CFG.get('rcon_password', 'REDACTED_RCON')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
COMMAND_PREFIXES_BY_MODE = {
|
COMMAND_PREFIXES_BY_MODE = {
|
||||||
'sudo': [
|
'sudo': [
|
||||||
@@ -651,6 +692,163 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]:
|
|||||||
return {'ok': False, 'error': str(e), 'results': []}
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# World observation tools (RCON-based)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ENTITY_TYPES_SCAN = [
|
||||||
|
'zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch',
|
||||||
|
'phantom', 'drowned', 'husk', 'stray', 'pillager', 'vindicator',
|
||||||
|
'cow', 'pig', 'sheep', 'chicken', 'horse', 'wolf', 'cat', 'villager',
|
||||||
|
'iron_golem', 'snow_golem', 'bee', 'fox', 'rabbit', 'squid',
|
||||||
|
'bat', 'parrot', 'turtle', 'dolphin', 'axolotl', 'goat', 'frog',
|
||||||
|
'allay', 'sniffer', 'camel', 'armadillo', 'breeze', 'bogged',
|
||||||
|
'item', 'experience_orb', 'armor_stand', 'minecart', 'boat', 'tnt',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pos(rcon_output: str) -> Optional[List[float]]:
|
||||||
|
"""Parse position from 'data get entity <p> Pos' RCON output."""
|
||||||
|
m = re.search(r'\[(-?[\d.]+)d,\s*(-?[\d.]+)d,\s*(-?[\d.]+)d\]', rcon_output)
|
||||||
|
if m:
|
||||||
|
return [float(m.group(1)), float(m.group(2)), float(m.group(3))]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tool_world_player_info(player: str) -> Dict[str, Any]:
|
||||||
|
"""Get player position, health, gamemode, and inventory summary."""
|
||||||
|
rc = _rcon_cfg()
|
||||||
|
try:
|
||||||
|
pos_raw = _rcon_query(f'data get entity {player} Pos', **rc)
|
||||||
|
pos = _parse_pos(pos_raw)
|
||||||
|
health_raw = _rcon_query(f'data get entity {player} Health', **rc)
|
||||||
|
health_m = re.search(r'([\d.]+)f', health_raw)
|
||||||
|
health = float(health_m.group(1)) if health_m else None
|
||||||
|
gm_raw = _rcon_query(f'data get entity {player} playerGameType', **rc)
|
||||||
|
gm_m = re.search(r'(\d+)', gm_raw)
|
||||||
|
gamemode_map = {0: 'survival', 1: 'creative', 2: 'adventure', 3: 'spectator'}
|
||||||
|
gamemode = gamemode_map.get(int(gm_m.group(1)), 'unknown') if gm_m else None
|
||||||
|
inv_raw = _rcon_query(f'data get entity {player} Inventory', **rc)
|
||||||
|
# Count inventory items (each item in the list is an entry)
|
||||||
|
inv_count = inv_raw.count('{') if 'entity data' in inv_raw.lower() else 0
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'results': [{
|
||||||
|
'player': player,
|
||||||
|
'position': {'x': pos[0], 'y': pos[1], 'z': pos[2]} if pos else None,
|
||||||
|
'health': health,
|
||||||
|
'max_health': 20.0,
|
||||||
|
'gamemode': gamemode,
|
||||||
|
'inventory_items': inv_count,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_world_nearby_entities(player: str, radius: int = 30) -> Dict[str, Any]:
|
||||||
|
"""Scan for entity types near a player within given radius."""
|
||||||
|
rc = _rcon_cfg()
|
||||||
|
radius = min(max(radius, 5), 60) # clamp to 5-60
|
||||||
|
try:
|
||||||
|
pos_raw = _rcon_query(f'data get entity {player} Pos', **rc)
|
||||||
|
pos = _parse_pos(pos_raw)
|
||||||
|
if not pos:
|
||||||
|
return {'ok': False, 'error': 'player not found or offline', 'results': []}
|
||||||
|
x, y, z = int(pos[0]), int(pos[1]), int(pos[2])
|
||||||
|
found = []
|
||||||
|
for etype in _ENTITY_TYPES_SCAN:
|
||||||
|
r = _rcon_query(
|
||||||
|
f'execute if entity @e[x={x},y={y},z={z},distance=..{radius},type=minecraft:{etype}]',
|
||||||
|
**rc
|
||||||
|
)
|
||||||
|
if 'passed' in r.lower():
|
||||||
|
count_m = re.search(r'Count:\s*(\d+)', r)
|
||||||
|
count = int(count_m.group(1)) if count_m else 1
|
||||||
|
found.append({'type': etype, 'count': count})
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'results': [{
|
||||||
|
'player': player,
|
||||||
|
'scan_center': {'x': x, 'y': y, 'z': z},
|
||||||
|
'radius': radius,
|
||||||
|
'entities': found,
|
||||||
|
'total': sum(e['count'] for e in found),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_world_check_block(x: int, y: int, z: int, block_type: str) -> Dict[str, Any]:
|
||||||
|
"""Check if a specific block type exists at coordinates."""
|
||||||
|
rc = _rcon_cfg()
|
||||||
|
try:
|
||||||
|
if not block_type.startswith('minecraft:'):
|
||||||
|
block_type = f'minecraft:{block_type}'
|
||||||
|
r = _rcon_query(f'execute if block {x} {y} {z} {block_type}', **rc)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'results': [{
|
||||||
|
'position': {'x': x, 'y': y, 'z': z},
|
||||||
|
'block_type': block_type,
|
||||||
|
'matches': 'passed' in r.lower(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_world_server_state() -> Dict[str, Any]:
|
||||||
|
"""Get server-level state: players, time, worldborder, difficulty."""
|
||||||
|
rc = _rcon_cfg()
|
||||||
|
try:
|
||||||
|
players_raw = _rcon_query('list', **rc)
|
||||||
|
time_raw = _rcon_query('time query daytime', **rc)
|
||||||
|
border_raw = _rcon_query('worldborder get', **rc)
|
||||||
|
diff_raw = _rcon_query('difficulty', **rc)
|
||||||
|
|
||||||
|
# Parse player list
|
||||||
|
players = []
|
||||||
|
m = re.search(r'online:\s*(.*)', players_raw)
|
||||||
|
if m and m.group(1).strip():
|
||||||
|
players = [p.strip() for p in m.group(1).split(',') if p.strip()]
|
||||||
|
count_m = re.search(r'(\d+) of a max of (\d+)', players_raw)
|
||||||
|
count = int(count_m.group(1)) if count_m else len(players)
|
||||||
|
max_players = int(count_m.group(2)) if count_m else 20
|
||||||
|
|
||||||
|
# Parse time
|
||||||
|
time_m = re.search(r'(\d+)', time_raw)
|
||||||
|
ticks = int(time_m.group(1)) if time_m else 0
|
||||||
|
# Convert to approximate in-game time (0=6:00, 6000=noon, 12000=18:00, 18000=midnight)
|
||||||
|
hours = ((ticks + 6000) % 24000) // 1000
|
||||||
|
is_night = ticks >= 13000 or ticks < 0
|
||||||
|
|
||||||
|
# Parse worldborder
|
||||||
|
border_m = re.search(r'(\d+)', border_raw)
|
||||||
|
border = int(border_m.group(1)) if border_m else None
|
||||||
|
|
||||||
|
# Parse difficulty
|
||||||
|
diff_m = re.search(r'difficulty is (\w+)', diff_raw)
|
||||||
|
difficulty = diff_m.group(1) if diff_m else 'unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'results': [{
|
||||||
|
'players_online': players,
|
||||||
|
'player_count': count,
|
||||||
|
'max_players': max_players,
|
||||||
|
'time_ticks': ticks,
|
||||||
|
'approx_hour': hours,
|
||||||
|
'is_night': is_night,
|
||||||
|
'worldborder_width': border,
|
||||||
|
'difficulty': difficulty,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e), 'results': []}
|
||||||
|
|
||||||
|
|
||||||
def tool_local_search(query: str) -> Dict[str, Any]:
|
def tool_local_search(query: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
rows = _kb_search(query, limit=5)
|
rows = _kb_search(query, limit=5)
|
||||||
@@ -673,6 +871,33 @@ def _tool_router(user_text: str, max_steps: int, mode: str, context: Dict[str, A
|
|||||||
if max_steps <= 0:
|
if max_steps <= 0:
|
||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
# --- World observation tools (RCON-based) ---
|
||||||
|
# In sudo mode, get player info for position-aware command generation
|
||||||
|
world_enabled = bool(CFG.get('world_observation_enabled', True))
|
||||||
|
if world_enabled and mode == 'sudo':
|
||||||
|
player = str((context or {}).get('player') or '').strip()
|
||||||
|
if not player:
|
||||||
|
# Try to extract player from server_state context
|
||||||
|
ss = (context or {}).get('server_state') or {}
|
||||||
|
players = ss.get('online_players') or []
|
||||||
|
if players:
|
||||||
|
player = players[0] if len(players) == 1 else ''
|
||||||
|
if player:
|
||||||
|
# Always get player info for sudo -- position is critical for build/fill/tp commands
|
||||||
|
calls.append({'tool': 'world.player_info', 'player': player})
|
||||||
|
# Scan nearby entities if the request involves mobs, entities, or environmental awareness
|
||||||
|
if any(k in text for k in [
|
||||||
|
'mob', 'monster', 'entity', 'creature', 'animal', 'kill', 'clear',
|
||||||
|
'around', 'nearby', 'surround', 'area', 'here', 'near me',
|
||||||
|
'spawn', 'summon', 'destroy', 'nuke', 'tnt', 'protect', 'safe',
|
||||||
|
]):
|
||||||
|
calls.append({'tool': 'world.nearby_entities', 'player': player, 'radius': 30})
|
||||||
|
|
||||||
|
# In god/god_system mode, get server state for contextual awareness
|
||||||
|
if world_enabled and mode in ('god', 'god_system'):
|
||||||
|
calls.append({'tool': 'world.server_state'})
|
||||||
|
|
||||||
|
# --- Knowledge tools ---
|
||||||
if mode == 'sudo':
|
if mode == 'sudo':
|
||||||
q = user_text
|
q = user_text
|
||||||
req = str((context or {}).get('request') or '').strip()
|
req = str((context or {}).get('request') or '').strip()
|
||||||
@@ -707,7 +932,10 @@ def _commands_prompt(mode: str) -> str:
|
|||||||
'You may output template workflow meta-commands: template search <query>, template pick <n> [name], template build <name>.\n'
|
'You may output template workflow meta-commands: template search <query>, template pick <n> [name], template build <name>.\n'
|
||||||
'For build/make/create requests, prefer the template workflow instead of raw block-by-block commands.\n'
|
'For build/make/create requests, prefer the template workflow instead of raw block-by-block commands.\n'
|
||||||
'If request is ambiguous or unsupported, choose a closest valid in-game workaround and keep scope bounded.\n'
|
'If request is ambiguous or unsupported, choose a closest valid in-game workaround and keep scope bounded.\n'
|
||||||
'If still unsafe/unknown, return empty commands.'
|
'If still unsafe/unknown, return empty commands.\n'
|
||||||
|
'WORLD STATE: If world.player_info or world.nearby_entities tool results are present, use the player\'s '
|
||||||
|
'actual coordinates for fill/setblock/tp commands instead of ~ ~ ~ relative coords when absolute positioning '
|
||||||
|
'is more reliable. Use nearby entity info to make contextually aware decisions.'
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode == 'god_system':
|
if mode == 'god_system':
|
||||||
@@ -827,6 +1055,7 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
|||||||
for c in calls:
|
for c in calls:
|
||||||
tool = c['tool']
|
tool = c['tool']
|
||||||
q = c.get('query', '')
|
q = c.get('query', '')
|
||||||
|
tool_input_desc = q # default description for trace
|
||||||
if tool == 'web.search':
|
if tool == 'web.search':
|
||||||
out = tool_web_search(q)
|
out = tool_web_search(q)
|
||||||
elif tool == 'minecraft.wiki_lookup':
|
elif tool == 'minecraft.wiki_lookup':
|
||||||
@@ -835,10 +1064,27 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
|||||||
out = tool_local_search(q)
|
out = tool_local_search(q)
|
||||||
elif tool == 'local.read':
|
elif tool == 'local.read':
|
||||||
out = tool_local_read(str(c.get('doc_id', '')), q)
|
out = tool_local_read(str(c.get('doc_id', '')), q)
|
||||||
|
elif tool == 'world.player_info':
|
||||||
|
p = str(c.get('player', ''))
|
||||||
|
tool_input_desc = p
|
||||||
|
out = tool_world_player_info(p)
|
||||||
|
elif tool == 'world.nearby_entities':
|
||||||
|
p = str(c.get('player', ''))
|
||||||
|
r = int(c.get('radius', 30))
|
||||||
|
tool_input_desc = f'{p} radius={r}'
|
||||||
|
out = tool_world_nearby_entities(p, r)
|
||||||
|
elif tool == 'world.check_block':
|
||||||
|
bx, by, bz = int(c.get('x', 0)), int(c.get('y', 0)), int(c.get('z', 0))
|
||||||
|
bt = str(c.get('block_type', ''))
|
||||||
|
tool_input_desc = f'{bx},{by},{bz} {bt}'
|
||||||
|
out = tool_world_check_block(bx, by, bz, bt)
|
||||||
|
elif tool == 'world.server_state':
|
||||||
|
tool_input_desc = 'server'
|
||||||
|
out = tool_world_server_state()
|
||||||
else:
|
else:
|
||||||
out = {'ok': False, 'error': 'unknown tool', 'results': []}
|
out = {'ok': False, 'error': 'unknown tool', 'results': []}
|
||||||
tool_trace.append({'tool': tool, 'input': q, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))})
|
tool_trace.append({'tool': tool, 'input': tool_input_desc, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))})
|
||||||
tool_results_block += f"\nTOOL {tool} query={q}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n"
|
tool_results_block += f"\nTOOL {tool} query={tool_input_desc}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n"
|
||||||
|
|
||||||
# localized retrieval hop: after index search, fetch one top document excerpt
|
# localized retrieval hop: after index search, fetch one top document excerpt
|
||||||
if tool == 'local.search' and out.get('ok') and out.get('results') and len(tool_trace) < max(0, min(req.max_tool_steps, 6)):
|
if tool == 'local.search' and out.get('ok') and out.get('results') and len(tool_trace) < max(0, min(req.max_tool_steps, 6)):
|
||||||
@@ -849,7 +1095,29 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
|
|||||||
tool_trace.append({'tool': 'local.read', 'input': doc_id, 'ok': read_out.get('ok', False), 'results_count': len(read_out.get('results', []))})
|
tool_trace.append({'tool': 'local.read', 'input': doc_id, 'ok': read_out.get('ok', False), 'results_count': len(read_out.get('results', []))})
|
||||||
tool_results_block += f"\nTOOL local.read doc_id={doc_id}\nRESULT={json.dumps(read_out, ensure_ascii=True)[:3000]}\n"
|
tool_results_block += f"\nTOOL local.read doc_id={doc_id}\nRESULT={json.dumps(read_out, ensure_ascii=True)[:3000]}\n"
|
||||||
|
|
||||||
# Commands call
|
# Single-call mode: one LLM call returns both commands and message
|
||||||
|
if CFG.get('single_call', False):
|
||||||
|
combined_prompt = _commands_prompt(session.mode)
|
||||||
|
if session.mode != 'sudo':
|
||||||
|
combined_prompt += '\n\nAlso include a "message" field with a dramatic in-character response.'
|
||||||
|
|
||||||
|
sc_messages = [
|
||||||
|
{'role': 'system', 'content': combined_prompt},
|
||||||
|
*session.messages[-12:],
|
||||||
|
{'role': 'user', 'content': user_blob + tool_results_block},
|
||||||
|
]
|
||||||
|
sc_raw = _ollama_chat(
|
||||||
|
CFG.get('command_model', CFG.get('model', 'mortdecai-v4')),
|
||||||
|
sc_messages,
|
||||||
|
fmt='json',
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=600,
|
||||||
|
)
|
||||||
|
sc_parsed = _parse_json(sc_raw)
|
||||||
|
commands = _sanitize_commands(sc_parsed.get('commands') or [], session.mode)
|
||||||
|
message = sc_parsed.get('message') or None
|
||||||
|
else:
|
||||||
|
# Two-call mode: separate command and message calls
|
||||||
cmd_messages = [
|
cmd_messages = [
|
||||||
{'role': 'system', 'content': _commands_prompt(session.mode)},
|
{'role': 'system', 'content': _commands_prompt(session.mode)},
|
||||||
*session.messages[-12:],
|
*session.messages[-12:],
|
||||||
|
|||||||
+18
-4
@@ -3687,11 +3687,25 @@ def _is_rcon_error(result: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_ERROR_CORRECTION_SYSTEM = (
|
_ERROR_CORRECTION_SYSTEM = (
|
||||||
"A Minecraft command failed. Analyze the error and return a corrected command.\n"
|
"A Minecraft 1.21 command failed with an RCON error. Your job: return a DIFFERENT, corrected command.\n"
|
||||||
|
"Do NOT return the same command. Analyze the error and fix the syntax.\n\n"
|
||||||
"Respond with JSON: {\"corrected\": \"the fixed command\"}\n"
|
"Respond with JSON: {\"corrected\": \"the fixed command\"}\n"
|
||||||
"The command must be a single complete string. Use minecraft: prefix for all items/effects.\n"
|
"If you cannot fix it, return {\"corrected\": \"\"}.\n\n"
|
||||||
"Use 1.21 syntax: enchantments use [enchantments={name:level}] NOT {Enchantments:[...]}.\n"
|
"Common fixes:\n"
|
||||||
"If you cannot fix it, return {\"corrected\": \"\"}."
|
"- 'Incorrect argument for command' → wrong arg order or missing subcommand\n"
|
||||||
|
" xp player 100 → xp add player 100 levels\n"
|
||||||
|
" gamemode player survival → gamemode survival player\n"
|
||||||
|
"- 'Unknown item' → missing minecraft: prefix or wrong item name\n"
|
||||||
|
" give player diamond → give player minecraft:diamond 1\n"
|
||||||
|
" bed → minecraft:white_bed, log → minecraft:oak_log\n"
|
||||||
|
"- 'Expected whitespace' → count before enchantments or bad syntax\n"
|
||||||
|
" give player sword 1[enchantments=...] → give player sword[enchantments=...] 1\n"
|
||||||
|
"- 'Unknown enchantment/effect' → wrong name\n"
|
||||||
|
" sharp → sharpness, prot → protection, speed_boost → speed\n"
|
||||||
|
"- Old NBT format → use 1.21 component syntax\n"
|
||||||
|
" {Enchantments:[...]} → [enchantments={name:level}]\n"
|
||||||
|
"- Missing 'give' subcommand for effects\n"
|
||||||
|
" effect player speed → effect give player minecraft:speed 30 1\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str:
|
def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user