844 lines
32 KiB
Python
844 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
|
||
"""Internal MoonWell bridge between mod-playerbots-ai and Yandex AI Studio."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from collections import defaultdict, deque
|
||
from http import HTTPStatus
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from typing import Any
|
||
|
||
|
||
LOG = logging.getLogger("playerbots-ai")
|
||
logging.basicConfig(
|
||
level=os.getenv("PLAYERBOTS_AI_LOG_LEVEL", "INFO").upper(),
|
||
format="%(asctime)s %(levelname)s %(message)s",
|
||
)
|
||
|
||
HOST = os.getenv("PLAYERBOTS_AI_HOST", "0.0.0.0")
|
||
PORT = int(os.getenv("PLAYERBOTS_AI_PORT", "8080"))
|
||
YANDEX_API_KEY = os.getenv("YANDEX_AI_API_KEY", "").strip()
|
||
YANDEX_FOLDER_ID = os.getenv("YANDEX_AI_FOLDER_ID", "").strip()
|
||
YANDEX_BASE_URL = os.getenv(
|
||
"YANDEX_AI_BASE_URL", "https://ai.api.cloud.yandex.net/v1"
|
||
).rstrip("/")
|
||
YANDEX_MODEL_URI = os.getenv("YANDEX_AI_MODEL_URI", "").strip()
|
||
MODEL_URI = YANDEX_MODEL_URI or (
|
||
f"gpt://{YANDEX_FOLDER_ID}/aliceai-llm-flash" if YANDEX_FOLDER_ID else ""
|
||
)
|
||
REQUEST_TIMEOUT = float(os.getenv("PLAYERBOTS_AI_REQUEST_TIMEOUT", "12"))
|
||
TEMPERATURE = float(os.getenv("PLAYERBOTS_AI_TEMPERATURE", "0.4"))
|
||
MAX_TOKENS = int(os.getenv("PLAYERBOTS_AI_MAX_TOKENS", "120"))
|
||
MAX_REPLY_CHARACTERS = int(os.getenv("PLAYERBOTS_AI_MAX_REPLY_CHARACTERS", "240"))
|
||
HISTORY_MESSAGES = max(0, int(os.getenv("PLAYERBOTS_AI_HISTORY_MESSAGES", "6")))
|
||
LOG_API_RESPONSES = os.getenv("PLAYERBOTS_AI_LOG_API_RESPONSES", "1").strip().lower() in {
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
}
|
||
COMMANDS_ENABLED = os.getenv("PLAYERBOTS_AI_COMMANDS_ENABLED", "1").strip().lower() in {
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
}
|
||
CLASSIFIER_CACHE_ENABLED = os.getenv(
|
||
"PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED", "1"
|
||
).strip().lower() in {"1", "true", "yes", "on"}
|
||
CLASSIFIER_CACHE_DB = os.getenv(
|
||
"PLAYERBOTS_AI_CLASSIFIER_CACHE_DB", "/data/classifier-cache.sqlite3"
|
||
)
|
||
CLASSIFIER_CACHE_TTL_SECONDS = max(
|
||
60, int(os.getenv("PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS", "604800"))
|
||
)
|
||
CLASSIFIER_CACHE_MAX_ENTRIES = max(
|
||
100, int(os.getenv("PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES", "10000"))
|
||
)
|
||
|
||
SAFE_EXACT_COMMANDS = {
|
||
"accept",
|
||
"accept *",
|
||
"add all loot",
|
||
"attack",
|
||
"attack rti target",
|
||
"autogear",
|
||
"buff",
|
||
"disperse disable",
|
||
"flee",
|
||
"focus heal ?",
|
||
"focus heal clear",
|
||
"focus heal none",
|
||
"focus heal unset",
|
||
"follow",
|
||
"give leader",
|
||
"glyphs",
|
||
"grind",
|
||
"help",
|
||
"home",
|
||
"inv",
|
||
"invite",
|
||
"items",
|
||
"leave",
|
||
"lfg",
|
||
"ll all",
|
||
"ll gray",
|
||
"ll normal",
|
||
"ll quest",
|
||
"ll skill",
|
||
"los",
|
||
"maintenance",
|
||
"open items",
|
||
"outfit ?",
|
||
"pet aggressive",
|
||
"pet attack",
|
||
"pet defensive",
|
||
"pet follow",
|
||
"pet passive",
|
||
"pet stance",
|
||
"pet stay",
|
||
"quests",
|
||
"quests all",
|
||
"quests completed",
|
||
"quests incompleted",
|
||
"ready",
|
||
"release",
|
||
"reset",
|
||
"reset botAI",
|
||
"revive",
|
||
"roll",
|
||
"rti",
|
||
"rtsc",
|
||
"rtsc cancel",
|
||
"rtsc go save",
|
||
"rtsc toggle",
|
||
"runaway",
|
||
"spells",
|
||
"ss reset",
|
||
"stats",
|
||
"stay",
|
||
"summon",
|
||
"t",
|
||
"talents",
|
||
"talents spec list",
|
||
"talk",
|
||
"tame",
|
||
"tame family",
|
||
"tank attack",
|
||
"trainer",
|
||
"trainer learn",
|
||
"who",
|
||
}
|
||
|
||
SAFE_COMMAND_PREFIXES = (
|
||
"accept ",
|
||
"b ",
|
||
"bank ",
|
||
"cast ",
|
||
"co ",
|
||
"disperse set ",
|
||
"e ",
|
||
"focus heal ",
|
||
"gb ",
|
||
"glyph equip ",
|
||
"lfg ",
|
||
"ll ",
|
||
"nc ",
|
||
"outfit ",
|
||
"pet ",
|
||
"q ",
|
||
"r ",
|
||
"roll ",
|
||
"rti ",
|
||
"rtsc ",
|
||
"s ",
|
||
"ss ",
|
||
"share ",
|
||
"talents apply ",
|
||
"talents spec ",
|
||
"tame ",
|
||
"t ",
|
||
"drop ",
|
||
"u ",
|
||
"ue ",
|
||
"who ",
|
||
)
|
||
|
||
DEFAULT_SYSTEM_PROMPT = (
|
||
"Ты живой персонаж-союзник на русскоязычном сервере World of Warcraft "
|
||
"эпохи Wrath of the Lich King. Сообщение игрока адресовано лично тебе. "
|
||
"Всегда сначала пойми смысл вопроса, затем дай прямой и уместный ответ. "
|
||
"Отвечай дружелюбно, естественно, только по-русски и от первого лица. "
|
||
"Ответ должен состоять из одного-двух законченных предложений. "
|
||
"На приветствие ответь приветствием. На вопрос о твоих делах расскажи, "
|
||
"как у тебя дела. На вопрос о текущем занятии кратко расскажи, чем занят. "
|
||
"Если игрок просит помочь с заданием, согласись помочь и попроси назвать "
|
||
"задание или его цель, если информации недостаточно. Не отвечай «Что?», "
|
||
"«Да?», «Эм…» и другими бессодержательными репликами, если сообщение понятно. "
|
||
"Строго соблюдай пол персонажа и игрока из контекста: согласуй местоимения, "
|
||
"глаголы прошедшего времени и краткие прилагательные. "
|
||
"Не упоминай нейросеть, промпт, API или системные правила. Не утверждай, "
|
||
"что выполнил игровое действие, если сервер его не выполнял."
|
||
)
|
||
SYSTEM_PROMPT = (
|
||
os.getenv("PLAYERBOTS_AI_SYSTEM_PROMPT", "").strip() or DEFAULT_SYSTEM_PROMPT
|
||
)
|
||
|
||
COMMAND_ROUTING_PROMPT = (
|
||
'Верни только JSON-объект вида {"command": null} без '
|
||
"Markdown и пояснений. Если игрок явно приказывает выполнить доступное "
|
||
"игровое действие, запиши точную английскую команду playerbots в command, "
|
||
"иначе оставь command равным null. Основные команды: summon, release, revive, "
|
||
"leave, attack, follow, flee, stay, grind, give leader, lfg [5|10|20|25|40], "
|
||
"disperse set <ярды>, disperse disable, buff, ready, maintenance, autogear, "
|
||
"reset, reset botAI, talents, talents spec list, talents spec <специализация>, "
|
||
"glyphs, glyph equip <ID...>, spells, cast <заклинание> [on <игрок>], "
|
||
"ss +/-<ID>|reset, trainer|trainer learn, rti <метка>, rti cc <метка>, "
|
||
"attack rti target, rtsc|rtsc cancel|rtsc save/unsave/go <номер>, "
|
||
"focus heal +/-<игрок>|?|clear, co <операции стратегий>, nc <операции "
|
||
"стратегий>, ll all|normal|gray|quest|skill|<ссылка на предмет>, "
|
||
"e/ue/u/s/b <ссылка на предмет>, t <ссылка на предмет> [количество] "
|
||
"(передать предмет игроку через окно обмена), t (только начать обмен), "
|
||
"bank/gb <ссылка>, inv (показать содержимое инвентаря), open items (открыть "
|
||
"предметы-контейнеры), roll [предмет], outfit ?, outfit <имя> "
|
||
"equip|replace|update|reset, quests|quests all|quests completed|quests "
|
||
"incompleted, q <ссылка на задание> (показать прогресс), accept "
|
||
"*|<ссылка на задание>, share <ссылка на задание>, r <ссылка на предмет "
|
||
"награды>, talk (поговорить с выбранным NPC и сдать готовое задание), "
|
||
"pet aggressive|passive|defensive|"
|
||
"stance|attack|follow|stay, tame, tame name/id/family/rename <значение>, "
|
||
"los, stats, home, who [профессия], help. Стратегии co/nc передавай ровно "
|
||
"как +strategy, -strategy или ~strategy; известные стратегии включают tank, "
|
||
"tank assist, dps, cc, assist, aoe, boost, threat, grind, heal, focus, "
|
||
"avoid aoe, save mana, healer dps, tank face, behind, pull, pull back, "
|
||
"mark rti, follow, food, pvp, loot, new rpg, bear, cat, caster, frost, fire, "
|
||
"bmight, bwisdom, bkings, bsanc, bdps, bspeed, bmana, imp, voidwalker, "
|
||
"succubus, felhunter, felguard, ss master, ss self, ss tank, ss healer, "
|
||
"moltencore, bwl, aq20, karazhan, gruulslair, magtheridon, ssc, voa, naxx, "
|
||
"wotlk-os, wotlk-eoe, ulduar, onyxia, icc. Сохраняй ссылки WoW, имена, "
|
||
"названия заклинаний и предметов из сообщения без изменений. Никогда не "
|
||
"создавай команды с точкой, playerbot/playerbots, account, debug, cdebug, "
|
||
"cheat, destroy, mail, sendmail, init или console. Команду drop <задание> "
|
||
"создавай только по явной просьбе отказаться, бросить или удалить конкретное "
|
||
"задание. Если это обычный "
|
||
"разговор, вопрос, приветствие или неоднозначное пожелание, command должен "
|
||
"быть null. Вопрос «можешь ли ты» сам по себе не является приказом. "
|
||
"Примеры точного преобразования: «иди за мной» → "
|
||
"follow; «стой здесь» → stay; «покажи инвентарь» → inv; «покажи "
|
||
"характеристики» → stats; «покажи заклинания» → spells; «включи режим "
|
||
"танка» → co +tank; «выключи АоЕ» → "
|
||
"co -aoe; «начни собирать добычу» → nc +loot; «перестань следовать» → "
|
||
"nc -follow; «сделай череп приоритетной целью» → rti skull; «назначь луну "
|
||
"целью контроля» → rti cc moon; «начни обмен» → t; «дай мне [предмет]» → "
|
||
"t [предмет]; «покажи все задания» → quests all; «покажи прогресс "
|
||
"[задания]» → q [задание]; «прими все задания» → accept *; «поделись "
|
||
"[заданием]» → share [задание]; «выбери [предмет] в награду» → r [предмет]; "
|
||
"«сдай готовое задание» → talk; «обслужи "
|
||
"экипировку и выучи доступные навыки» → maintenance."
|
||
)
|
||
|
||
CLASS_NAMES = {
|
||
1: "воин",
|
||
2: "паладин",
|
||
3: "охотник",
|
||
4: "разбойник",
|
||
5: "жрец",
|
||
6: "рыцарь смерти",
|
||
7: "шаман",
|
||
8: "маг",
|
||
9: "чернокнижник",
|
||
11: "друид",
|
||
}
|
||
|
||
RACE_NAMES = {
|
||
1: "человек",
|
||
2: "орк",
|
||
3: "дворф",
|
||
4: "ночной эльф",
|
||
5: "нежить",
|
||
6: "таурен",
|
||
7: "гном",
|
||
8: "тролль",
|
||
10: "эльф крови",
|
||
11: "дреней",
|
||
}
|
||
|
||
history: dict[str, deque[dict[str, str]]] = defaultdict(
|
||
lambda: deque(maxlen=HISTORY_MESSAGES)
|
||
)
|
||
history_lock = threading.Lock()
|
||
cache_stats_lock = threading.Lock()
|
||
classifier_cache_hits = 0
|
||
classifier_cache_misses = 0
|
||
classifier_cache_available = CLASSIFIER_CACHE_ENABLED
|
||
|
||
|
||
def configured() -> bool:
|
||
return bool(YANDEX_API_KEY and YANDEX_FOLDER_ID and MODEL_URI)
|
||
|
||
|
||
def initialize_classifier_cache() -> None:
|
||
global classifier_cache_available
|
||
if not CLASSIFIER_CACHE_ENABLED:
|
||
return
|
||
try:
|
||
directory = os.path.dirname(CLASSIFIER_CACHE_DB)
|
||
if directory:
|
||
os.makedirs(directory, exist_ok=True)
|
||
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
|
||
database.execute("PRAGMA journal_mode=WAL")
|
||
database.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS classifier_cache (
|
||
cache_key TEXT PRIMARY KEY,
|
||
command TEXT NOT NULL,
|
||
updated_at INTEGER NOT NULL
|
||
)
|
||
"""
|
||
)
|
||
except (OSError, sqlite3.Error) as error:
|
||
classifier_cache_available = False
|
||
LOG.error("classifier cache unavailable, continuing without it: %s", error)
|
||
|
||
|
||
def classifier_cache_key(payload: dict[str, Any]) -> str:
|
||
normalized = normalized_routing_text(payload)
|
||
prompt_version = hashlib.sha256(COMMAND_ROUTING_PROMPT.encode("utf-8")).hexdigest()[:16]
|
||
return f"{prompt_version}:{normalized}"
|
||
|
||
|
||
def get_cached_classifier_command(payload: dict[str, Any]) -> tuple[bool, str]:
|
||
global classifier_cache_hits, classifier_cache_misses
|
||
if not classifier_cache_available:
|
||
return False, ""
|
||
|
||
cache_key = classifier_cache_key(payload)
|
||
cutoff = int(time.time()) - CLASSIFIER_CACHE_TTL_SECONDS
|
||
try:
|
||
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
|
||
row = database.execute(
|
||
"SELECT command, updated_at FROM classifier_cache WHERE cache_key = ?",
|
||
(cache_key,),
|
||
).fetchone()
|
||
if row and row[1] < cutoff:
|
||
database.execute(
|
||
"DELETE FROM classifier_cache WHERE cache_key = ?", (cache_key,)
|
||
)
|
||
row = None
|
||
except sqlite3.Error as error:
|
||
LOG.error("classifier cache read failed, using API: %s", error)
|
||
return False, ""
|
||
|
||
with cache_stats_lock:
|
||
if row:
|
||
classifier_cache_hits += 1
|
||
else:
|
||
classifier_cache_misses += 1
|
||
if not row:
|
||
return False, ""
|
||
|
||
LOG.info(
|
||
"classifier cache hit: key=%s result=%s",
|
||
hashlib.sha256(cache_key.encode("utf-8")).hexdigest()[:12],
|
||
row[0] or "chat",
|
||
)
|
||
return True, row[0]
|
||
|
||
|
||
def cache_classifier_command(payload: dict[str, Any], command: str) -> None:
|
||
if not classifier_cache_available:
|
||
return
|
||
|
||
now = int(time.time())
|
||
cutoff = now - CLASSIFIER_CACHE_TTL_SECONDS
|
||
try:
|
||
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
|
||
database.execute(
|
||
"""
|
||
INSERT INTO classifier_cache(cache_key, command, updated_at)
|
||
VALUES (?, ?, ?)
|
||
ON CONFLICT(cache_key) DO UPDATE SET
|
||
command = excluded.command,
|
||
updated_at = excluded.updated_at
|
||
""",
|
||
(classifier_cache_key(payload), command, now),
|
||
)
|
||
database.execute(
|
||
"DELETE FROM classifier_cache WHERE updated_at < ?", (cutoff,)
|
||
)
|
||
count = database.execute(
|
||
"SELECT COUNT(*) FROM classifier_cache"
|
||
).fetchone()[0]
|
||
excess = count - CLASSIFIER_CACHE_MAX_ENTRIES
|
||
if excess > 0:
|
||
database.execute(
|
||
"""
|
||
DELETE FROM classifier_cache WHERE cache_key IN (
|
||
SELECT cache_key FROM classifier_cache
|
||
ORDER BY updated_at ASC LIMIT ?
|
||
)
|
||
""",
|
||
(excess,),
|
||
)
|
||
except sqlite3.Error as error:
|
||
LOG.error("classifier cache write failed, continuing without cache: %s", error)
|
||
|
||
|
||
def classifier_cache_entry_count() -> int:
|
||
if not classifier_cache_available or not os.path.exists(CLASSIFIER_CACHE_DB):
|
||
return 0
|
||
try:
|
||
with sqlite3.connect(CLASSIFIER_CACHE_DB, timeout=5) as database:
|
||
return database.execute(
|
||
"SELECT COUNT(*) FROM classifier_cache"
|
||
).fetchone()[0]
|
||
except sqlite3.Error:
|
||
return 0
|
||
|
||
|
||
def required_string(payload: dict[str, Any], key: str, max_length: int) -> str:
|
||
value = payload.get(key)
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise ValueError(f"{key} must be a non-empty string")
|
||
return value.strip()[:max_length]
|
||
|
||
|
||
def integer(payload: dict[str, Any], key: str, default: int = 0) -> int:
|
||
value = payload.get(key, default)
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
raise ValueError(f"{key} must be an integer")
|
||
return value
|
||
|
||
|
||
def clean_reply(text: str) -> str:
|
||
compact = " ".join(text.replace("\r", " ").replace("\n", " ").split())
|
||
if len(compact) > MAX_REPLY_CHARACTERS:
|
||
compact = compact[:MAX_REPLY_CHARACTERS].rstrip() + "…"
|
||
return compact
|
||
|
||
|
||
def validated_native_command(arguments: Any) -> str:
|
||
if isinstance(arguments, str):
|
||
try:
|
||
arguments = json.loads(arguments)
|
||
except json.JSONDecodeError:
|
||
return ""
|
||
if not isinstance(arguments, dict):
|
||
return ""
|
||
command = arguments.get("command")
|
||
if not isinstance(command, str):
|
||
return ""
|
||
command = command.strip()
|
||
if (
|
||
not command
|
||
or len(command) > 300
|
||
or "\\" in command
|
||
or any(ord(character) < 0x20 for character in command)
|
||
):
|
||
return ""
|
||
if command in SAFE_EXACT_COMMANDS:
|
||
return command
|
||
if any(
|
||
command.startswith(prefix) and len(command) > len(prefix)
|
||
for prefix in SAFE_COMMAND_PREFIXES
|
||
):
|
||
return command
|
||
return ""
|
||
|
||
|
||
def normalized_command_text(text: str) -> str:
|
||
folded = text.lower().replace("ё", "е")
|
||
return " ".join(
|
||
"".join(character if character.isalnum() else " " for character in folded).split()
|
||
)
|
||
|
||
|
||
def normalized_routing_text(payload: dict[str, Any]) -> str:
|
||
message = required_string(payload, "message", 1000)
|
||
bot_name = required_string(payload, "bot_name", 64)
|
||
message_without_name = re.sub(
|
||
re.escape(bot_name), " ", message, flags=re.IGNORECASE
|
||
)
|
||
return normalized_command_text(message_without_name)
|
||
|
||
|
||
def is_obvious_social_chat(payload: dict[str, Any]) -> bool:
|
||
text = normalized_routing_text(payload)
|
||
patterns = (
|
||
r"^(?:привет|здравствуй|здравствуйте|добрый день|добрый вечер)\b",
|
||
r"\bкак (?:у тебя )?дела\b",
|
||
r"\bкак (?:твое|твоё) настроение\b",
|
||
r"\bчто ты (?:делаешь|делаешь тут|сейчас делаешь)\b",
|
||
r"\b(?:расскажи|поведай) (?:о|про) себя\b",
|
||
r"\bкто ты\b",
|
||
)
|
||
return any(re.search(pattern, text) for pattern in patterns)
|
||
|
||
|
||
def deterministic_command(message: str) -> str:
|
||
text = normalized_command_text(message)
|
||
|
||
item_match = re.search(
|
||
r"(?:\|c[0-9a-fA-F]{8})?\|Hitem:[^|]+\|h\[[^]]+\]\|h(?:\|r)?",
|
||
message,
|
||
)
|
||
quest_match = re.search(
|
||
r"(?:\|c[0-9a-fA-F]{8})?\|Hquest:[^|]+\|h\[[^]]+\]\|h(?:\|r)?",
|
||
message,
|
||
)
|
||
item_link = item_match.group(0) if item_match else ""
|
||
quest_link = quest_match.group(0) if quest_match else ""
|
||
|
||
if item_link and re.search(
|
||
r"\b(?:выбери|возьми)\b.*\b(?:награду|награде|награды)\b"
|
||
r"|\b(?:награду|награде|награды)\b.*\b(?:выбери|возьми)\b",
|
||
text,
|
||
):
|
||
return f"r {item_link}"
|
||
if item_link and re.search(
|
||
r"\b(?:дай|передай|отдай|вручи|обменяй)\b", text
|
||
):
|
||
return f"t {item_link}"
|
||
|
||
if quest_link:
|
||
if re.search(r"\b(?:прими|возьми)\b", text):
|
||
return f"accept {quest_link}"
|
||
if re.search(r"\b(?:поделись|расшарь|передай)\b", text):
|
||
return f"share {quest_link}"
|
||
if re.search(r"\b(?:откажись|брось|удали)\b", text):
|
||
return f"drop {quest_link}"
|
||
if re.search(
|
||
r"\b(?:покажи|расскажи|проверь)\b.*\b(?:задание|задания|квест|квеста|прогресс)\b",
|
||
text,
|
||
):
|
||
return f"q {quest_link}"
|
||
|
||
patterns = (
|
||
(r"\b(?:следуй|иди|пойдем|пошли)\s+за\s+мной\b", "follow"),
|
||
(
|
||
r"\b(?:стой|останься|оставайся|жди|подожди)\s+"
|
||
r"(?:тут|здесь|на\s+месте)\b",
|
||
"stay",
|
||
),
|
||
(r"\bне\s+двигайся\b", "stay"),
|
||
(r"\b(?:атакуй|бей|ударь)\s+(?:мою\s+)?цель\b", "attack"),
|
||
(r"\b(?:отступай|убегай|беги\s+ко\s+мне)\b", "flee"),
|
||
(r"\bпригласи\s+меня\s+(?:в\s+)?(?:группу|пати|рейд)\b", "invite"),
|
||
(r"\b(?:выйди|покинь)\s+(?:из\s+)?(?:группу|группы|пати|рейд|рейда)\b", "leave"),
|
||
(r"\b(?:собери|подбери)\s+(?:всю\s+)?(?:добычу|лут)\b", "add all loot"),
|
||
(r"\b(?:воскресни|оживи)\b", "revive"),
|
||
(r"\b(?:освободи|выпусти)\s+дух\b", "release"),
|
||
(r"\b(?:телепортируйся|призовись)\s+ко\s+мне\b", "summon"),
|
||
(r"\b(?:бафни|усиль)\s+(?:меня|группу|пати|нас)\b", "buff"),
|
||
(
|
||
r"\b(?:покажи|открой)\s+(?:мне\s+)?"
|
||
r"(?:свой\s+|свои\s+)?(?:инвентарь|сумки|вещи)\b",
|
||
"inv",
|
||
),
|
||
(
|
||
r"\b(?:начни|открой|предложи)\s+(?:мне\s+)?обмен\b"
|
||
r"|\bдавай\s+обменяемся\b"
|
||
r"|\bдай\s+мне\s+(?:свои\s+)?предметы\b",
|
||
"t",
|
||
),
|
||
(
|
||
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?(?:все\s+)?"
|
||
r"(?:свои\s+)?(?:задания|квесты)\b"
|
||
r"|\bкакие\s+у\s+тебя\s+(?:задания|квесты)\b",
|
||
"quests all",
|
||
),
|
||
(
|
||
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?(?:завершенные|выполненные)\s+"
|
||
r"(?:задания|квесты)\b",
|
||
"quests completed",
|
||
),
|
||
(
|
||
r"\b(?:покажи|перечисли)\s+(?:мне\s+)?"
|
||
r"(?:незавершенные|невыполненные)\s+(?:задания|квесты)\b",
|
||
"quests incompleted",
|
||
),
|
||
(r"\b(?:прими|возьми)\s+все\s+(?:задания|квесты)\b", "accept *"),
|
||
(
|
||
r"\b(?:сдай|заверши)\s+(?:готовое\s+|выполненное\s+)?"
|
||
r"(?:задание|квест)\b",
|
||
"talk",
|
||
),
|
||
)
|
||
for pattern, command in patterns:
|
||
if re.search(pattern, text):
|
||
return command
|
||
return ""
|
||
|
||
|
||
def gender_context(value: int, subject: str) -> str:
|
||
if value == 0:
|
||
return (
|
||
f"{subject}: мужской пол; используй соответствующие мужские "
|
||
"местоимения и формы слов."
|
||
)
|
||
if value == 1:
|
||
return (
|
||
f"{subject}: женский пол; используй соответствующие женские "
|
||
"местоимения и формы слов."
|
||
)
|
||
return (
|
||
f"{subject}: пол неизвестен; избегай местоимений и форм слов, "
|
||
"указывающих на пол."
|
||
)
|
||
|
||
|
||
def command_acknowledgement(payload: dict[str, Any]) -> str:
|
||
bot_gender = integer(payload, "bot_gender", 2)
|
||
if bot_gender == 0:
|
||
return "Понял команду."
|
||
if bot_gender == 1:
|
||
return "Поняла команду."
|
||
return "Команда понята."
|
||
|
||
|
||
def build_messages(payload: dict[str, Any]) -> tuple[str, list[dict[str, str]]]:
|
||
bot_guid = required_string(payload, "bot_guid", 32)
|
||
player_guid = required_string(payload, "player_guid", 32)
|
||
bot_name = required_string(payload, "bot_name", 64)
|
||
player_name = required_string(payload, "player_name", 64)
|
||
message = required_string(payload, "message", 1000)
|
||
bot_class = CLASS_NAMES.get(integer(payload, "bot_class"), "приключенец")
|
||
bot_race = RACE_NAMES.get(integer(payload, "bot_race"), "неизвестная раса")
|
||
bot_gender = integer(payload, "bot_gender", 2)
|
||
player_gender = integer(payload, "player_gender", 2)
|
||
bot_level = integer(payload, "bot_level")
|
||
zone_id = integer(payload, "zone_id")
|
||
|
||
conversation_key = f"{bot_guid}:{player_guid}"
|
||
context = (
|
||
f"Ты — {bot_name}, {bot_race}, {bot_class}, уровень {bot_level}. "
|
||
f"{gender_context(bot_gender, 'Персонаж')} "
|
||
f"Игрока зовут {player_name}. {gender_context(player_gender, 'Игрок')} "
|
||
f"Текущая игровая зона имеет ID {zone_id}."
|
||
)
|
||
|
||
messages: list[dict[str, str]] = [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
]
|
||
messages.append({"role": "system", "content": context})
|
||
with history_lock:
|
||
messages.extend(list(history[conversation_key]))
|
||
messages.append({"role": "user", "content": message})
|
||
return conversation_key, messages
|
||
|
||
|
||
def request_yandex(
|
||
messages: list[dict[str, str]],
|
||
*,
|
||
purpose: str,
|
||
structured: bool,
|
||
temperature: float,
|
||
) -> str:
|
||
request_payload: dict[str, Any] = {
|
||
"model": MODEL_URI,
|
||
"messages": messages,
|
||
"temperature": temperature,
|
||
"max_tokens": MAX_TOKENS,
|
||
"stream": False,
|
||
}
|
||
if structured:
|
||
request_payload["response_format"] = {"type": "json_object"}
|
||
request_body = json.dumps(request_payload, ensure_ascii=False).encode("utf-8")
|
||
|
||
request = urllib.request.Request(
|
||
f"{YANDEX_BASE_URL}/chat/completions",
|
||
data=request_body,
|
||
method="POST",
|
||
headers={
|
||
"Authorization": f"Api-Key {YANDEX_API_KEY}",
|
||
"OpenAI-Project": YANDEX_FOLDER_ID,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
"User-Agent": "MoonWell-playerbots-ai/1.0",
|
||
},
|
||
)
|
||
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||
response_payload = json.load(response)
|
||
except urllib.error.HTTPError as error:
|
||
error_body = error.read(2048).decode("utf-8", errors="replace")
|
||
raise RuntimeError(f"Yandex API HTTP {error.code}: {error_body}") from error
|
||
except urllib.error.URLError as error:
|
||
raise RuntimeError(f"Yandex API connection failed: {error.reason}") from error
|
||
|
||
if LOG_API_RESPONSES:
|
||
LOG.info(
|
||
"Yandex API %s response: %s",
|
||
purpose,
|
||
json.dumps(response_payload, ensure_ascii=False, separators=(",", ":")),
|
||
)
|
||
|
||
try:
|
||
assistant_message = response_payload["choices"][0]["message"]
|
||
except (KeyError, IndexError, TypeError) as error:
|
||
raise RuntimeError("Yandex API response has no assistant message") from error
|
||
|
||
content = assistant_message.get("content")
|
||
if not isinstance(content, str):
|
||
raise RuntimeError("Yandex API returned non-text assistant content")
|
||
return content
|
||
|
||
|
||
def query_yandex(payload: dict[str, Any]) -> tuple[str, str]:
|
||
conversation_key, chat_messages = build_messages(payload)
|
||
command = ""
|
||
if COMMANDS_ENABLED:
|
||
command = deterministic_command(payload["message"])
|
||
if command:
|
||
return command_acknowledgement(payload), command
|
||
|
||
skip_classifier = is_obvious_social_chat(payload)
|
||
cache_hit, command = (False, "")
|
||
if not skip_classifier:
|
||
cache_hit, command = get_cached_classifier_command(payload)
|
||
if not skip_classifier and not cache_hit:
|
||
routing_content = request_yandex(
|
||
[
|
||
{"role": "system", "content": COMMAND_ROUTING_PROMPT},
|
||
{"role": "user", "content": payload["message"]},
|
||
],
|
||
purpose="classifier",
|
||
structured=True,
|
||
temperature=0.1,
|
||
)
|
||
try:
|
||
routed_response = json.loads(routing_content)
|
||
except json.JSONDecodeError as error:
|
||
raise RuntimeError("Yandex API returned invalid command JSON") from error
|
||
if not isinstance(routed_response, dict):
|
||
raise RuntimeError("Yandex API returned non-object command JSON")
|
||
command = validated_native_command(routed_response)
|
||
if routed_response.get("command") is not None and not command:
|
||
return "Эту команду я не могу выполнить.", ""
|
||
cache_classifier_command(payload, command)
|
||
if command:
|
||
return command_acknowledgement(payload), command
|
||
|
||
reply = clean_reply(
|
||
request_yandex(
|
||
chat_messages,
|
||
purpose="chat",
|
||
structured=False,
|
||
temperature=TEMPERATURE,
|
||
)
|
||
)
|
||
if not reply:
|
||
raise RuntimeError("Yandex API returned an empty chat reply")
|
||
|
||
with history_lock:
|
||
conversation = history[conversation_key]
|
||
conversation.append({"role": "user", "content": payload["message"]})
|
||
conversation.append({"role": "assistant", "content": reply})
|
||
return reply, ""
|
||
|
||
|
||
class RequestHandler(BaseHTTPRequestHandler):
|
||
server_version = "MoonWellPlayerbotsAI/1.0"
|
||
|
||
def log_message(self, format_string: str, *args: Any) -> None:
|
||
LOG.debug("%s - %s", self.client_address[0], format_string % args)
|
||
|
||
def send_text(self, status: HTTPStatus, text: str) -> None:
|
||
body = text.encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_GET(self) -> None:
|
||
if self.path != "/health":
|
||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||
return
|
||
self.send_json(
|
||
HTTPStatus.OK,
|
||
{
|
||
"status": "ok",
|
||
"configured": configured(),
|
||
"model": MODEL_URI or None,
|
||
"commands_enabled": COMMANDS_ENABLED,
|
||
"log_api_responses": LOG_API_RESPONSES,
|
||
"classifier_cache_enabled": CLASSIFIER_CACHE_ENABLED,
|
||
"classifier_cache_available": classifier_cache_available,
|
||
"classifier_cache_entries": classifier_cache_entry_count(),
|
||
"classifier_cache_hits": classifier_cache_hits,
|
||
"classifier_cache_misses": classifier_cache_misses,
|
||
},
|
||
)
|
||
|
||
def do_POST(self) -> None:
|
||
if self.path != "/v1/chat":
|
||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||
return
|
||
if not configured():
|
||
self.send_text(
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
"Yandex AI credentials are not configured",
|
||
)
|
||
return
|
||
|
||
try:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
except ValueError:
|
||
self.send_text(HTTPStatus.BAD_REQUEST, "invalid Content-Length")
|
||
return
|
||
if content_length <= 0 or content_length > 32768:
|
||
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "invalid request size")
|
||
return
|
||
|
||
try:
|
||
payload = json.loads(self.rfile.read(content_length))
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("request body must be an object")
|
||
reply, command = query_yandex(payload)
|
||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as error:
|
||
self.send_text(HTTPStatus.BAD_REQUEST, str(error))
|
||
return
|
||
except Exception as error:
|
||
LOG.error("LLM request failed: %s", error)
|
||
self.send_text(HTTPStatus.BAD_GATEWAY, "LLM provider request failed")
|
||
return
|
||
|
||
self.send_text(HTTPStatus.OK, f"PB-COMMAND:{command}\n{reply}")
|
||
|
||
|
||
def main() -> None:
|
||
initialize_classifier_cache()
|
||
LOG.info(
|
||
"starting bridge on %s:%d, configured=%s, model=%s",
|
||
HOST,
|
||
PORT,
|
||
configured(),
|
||
MODEL_URI or "<not configured>",
|
||
)
|
||
ThreadingHTTPServer((HOST, PORT), RequestHandler).serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|