SHA256
chore: зафиксированы все текущие изменения проекта
This commit is contained in:
@@ -14,11 +14,20 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
DEFAULT_ALLOWED_PLAYERS = ",".join([
|
||||
"malvviiina:Милана",
|
||||
"zodiaktechnika32:Сергей",
|
||||
"oidasyda:Иван",
|
||||
"blackbyrd1:Ворон",
|
||||
"dimasol1:Дима",
|
||||
])
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
@@ -33,6 +42,21 @@ def normalize_username(value: str | None) -> str:
|
||||
return value.lower()
|
||||
|
||||
|
||||
def parse_allowed_players(raw: str) -> dict[str, str]:
|
||||
players: dict[str, str] = {}
|
||||
for item in (raw or "").split(","):
|
||||
part = item.strip()
|
||||
if not part:
|
||||
continue
|
||||
username_part, sep, name_part = part.partition(":")
|
||||
username = normalize_username(username_part)
|
||||
if not username:
|
||||
continue
|
||||
player_name = (name_part if sep else username_part).strip() or username
|
||||
players[username] = player_name
|
||||
return players
|
||||
|
||||
|
||||
def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
@@ -55,6 +79,27 @@ def read_env_file(path: Path) -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
class VoiceTranscriptionError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str,
|
||||
*,
|
||||
stage: str,
|
||||
retryable: bool = True,
|
||||
detail: str = "",
|
||||
):
|
||||
super().__init__(user_message)
|
||||
self.user_message = user_message
|
||||
self.stage = stage
|
||||
self.retryable = retryable
|
||||
self.detail = detail
|
||||
|
||||
def log_text(self) -> str:
|
||||
if self.detail and self.detail != self.user_message:
|
||||
return f"{self.user_message} stage={self.stage} retryable={self.retryable} detail={self.detail}"
|
||||
return f"{self.user_message} stage={self.stage} retryable={self.retryable}"
|
||||
|
||||
|
||||
class JsonLineStore:
|
||||
@staticmethod
|
||||
def load(path: Path) -> list[dict[str, Any]]:
|
||||
@@ -116,11 +161,39 @@ class TelegramApi:
|
||||
result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15)
|
||||
return result.get("result", [])
|
||||
|
||||
def send_message(self, chat_id: int, text: str, reply_to_message_id: int | None = None) -> None:
|
||||
def send_message(self, chat_id: int | str, text: str, reply_to_message_id: int | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||
if reply_to_message_id is not None:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
self.call("sendMessage", payload=payload, timeout=30)
|
||||
return self.call("sendMessage", payload=payload, timeout=30)
|
||||
|
||||
def send_voice(
|
||||
self,
|
||||
chat_id: int | str,
|
||||
voice: str,
|
||||
caption: str = "",
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "voice": voice}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to_message_id is not None:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
return self.call("sendVoice", payload=payload, timeout=60)
|
||||
|
||||
def send_audio(
|
||||
self,
|
||||
chat_id: int | str,
|
||||
audio: str,
|
||||
caption: str = "",
|
||||
reply_to_message_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "audio": audio}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to_message_id is not None:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
return self.call("sendAudio", payload=payload, timeout=60)
|
||||
|
||||
def delete_webhook(self) -> None:
|
||||
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
|
||||
@@ -134,10 +207,13 @@ class BotConfig:
|
||||
self.root_dir = root_dir
|
||||
self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN")
|
||||
self.allowed_username = normalize_username(env.get("ALLOWED_TELEGRAM_USERNAME", "AidarKC"))
|
||||
self.allowed_players = parse_allowed_players(env.get("ALLOWED_TELEGRAM_PLAYERS", DEFAULT_ALLOWED_PLAYERS))
|
||||
self.allowed_channel_username = normalize_username(env.get("ALLOWED_TELEGRAM_CHANNEL_USERNAME", "shine_writing"))
|
||||
self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot")
|
||||
self.openai_api_key = env.get("OPENAI_API_KEY", "").strip()
|
||||
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
|
||||
self.telegram_file_download_timeout_seconds = int(env.get("TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS", "300"))
|
||||
self.openai_transcribe_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900"))
|
||||
self.codex_bin = Path(env.get(
|
||||
"CODEX_BIN",
|
||||
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
||||
@@ -185,6 +261,20 @@ class ShinePyBotService:
|
||||
self.last_heartbeat_at: float = 0.0
|
||||
self.restart_requested = False
|
||||
|
||||
def _is_owner(self, username: str) -> bool:
|
||||
return normalize_username(username) == self.cfg.allowed_username
|
||||
|
||||
def _is_allowed_player(self, username: str) -> bool:
|
||||
return normalize_username(username) in self.cfg.allowed_players
|
||||
|
||||
def _is_allowed_user(self, username: str) -> bool:
|
||||
uname = normalize_username(username)
|
||||
return uname == self.cfg.allowed_username or uname in self.cfg.allowed_players
|
||||
|
||||
def _player_name(self, username: str) -> str:
|
||||
uname = normalize_username(username)
|
||||
return self.cfg.allowed_players.get(uname, uname)
|
||||
|
||||
def run(self) -> None:
|
||||
self._ensure_dirs()
|
||||
self._acquire_single_instance_lock()
|
||||
@@ -250,9 +340,16 @@ class ShinePyBotService:
|
||||
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
else:
|
||||
self.state = {}
|
||||
sessions = self.state.get("user_sessions")
|
||||
if not isinstance(sessions, dict):
|
||||
sessions = {}
|
||||
self.state["user_sessions"] = sessions
|
||||
if not self.state.get("current_history_file"):
|
||||
history_file = self._create_new_history_file("initial")
|
||||
history_file = self._create_new_history_file("initial", self.cfg.allowed_username)
|
||||
self.state["current_history_file"] = str(history_file)
|
||||
sessions[self.cfg.allowed_username] = {"current_history_file": str(history_file)}
|
||||
elif self.cfg.allowed_username not in sessions:
|
||||
sessions[self.cfg.allowed_username] = {"current_history_file": str(self.state["current_history_file"])}
|
||||
if not isinstance(self.state.get("next_job_number"), int):
|
||||
self.state["next_job_number"] = 1
|
||||
self.state["updated_at"] = now_iso()
|
||||
@@ -320,26 +417,70 @@ class ShinePyBotService:
|
||||
self._persist_state()
|
||||
|
||||
def _current_history_file(self) -> Path:
|
||||
return Path(self.state["current_history_file"])
|
||||
return self._current_history_file_for_user(self.cfg.allowed_username)
|
||||
|
||||
def _create_new_history_file(self, reason: str) -> Path:
|
||||
def _history_dirs_for_user(self, username: str) -> tuple[Path, Path]:
|
||||
uname = normalize_username(username) or "unknown"
|
||||
history_dir = self.history_dir / uname
|
||||
archive_dir = history_dir / "archive"
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
return history_dir, archive_dir
|
||||
|
||||
def _ensure_user_session(self, username: str) -> None:
|
||||
uname = normalize_username(username) or self.cfg.allowed_username
|
||||
sessions = self.state.setdefault("user_sessions", {})
|
||||
if not isinstance(sessions, dict):
|
||||
sessions = {}
|
||||
self.state["user_sessions"] = sessions
|
||||
session = sessions.get(uname)
|
||||
if isinstance(session, dict) and session.get("current_history_file"):
|
||||
return
|
||||
history_file = self._create_new_history_file("initial", uname)
|
||||
sessions[uname] = {"current_history_file": str(history_file)}
|
||||
if uname == self.cfg.allowed_username:
|
||||
self.state["current_history_file"] = str(history_file)
|
||||
self._persist_state()
|
||||
|
||||
def _current_history_file_for_user(self, username: str) -> Path:
|
||||
uname = normalize_username(username) or self.cfg.allowed_username
|
||||
self._ensure_user_session(uname)
|
||||
sessions = self.state.get("user_sessions") or {}
|
||||
session = sessions.get(uname) or {}
|
||||
return Path(session["current_history_file"])
|
||||
|
||||
def _create_new_history_file(self, reason: str, username: str) -> Path:
|
||||
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||
path = self.history_dir / f"{ts}_{rnd}.jsonl"
|
||||
JsonLineStore.append(path, {"ts": now_iso(), "type": "history_created", "reason": reason})
|
||||
history_dir, _ = self._history_dirs_for_user(username)
|
||||
path = history_dir / f"{ts}_{rnd}.jsonl"
|
||||
JsonLineStore.append(path, {
|
||||
"ts": now_iso(),
|
||||
"type": "history_created",
|
||||
"reason": reason,
|
||||
"username": normalize_username(username),
|
||||
})
|
||||
return path
|
||||
|
||||
def _rotate_history(self, reason: str, username: str) -> Path:
|
||||
current = self._current_history_file()
|
||||
uname = normalize_username(username) or self.cfg.allowed_username
|
||||
current = self._current_history_file_for_user(uname)
|
||||
_, archive_dir = self._history_dirs_for_user(uname)
|
||||
if current.exists():
|
||||
archived = self.history_archive_dir / current.name
|
||||
archived = archive_dir / current.name
|
||||
current.replace(archived)
|
||||
else:
|
||||
archived = self.history_archive_dir / "(empty)"
|
||||
new_file = self._create_new_history_file(reason)
|
||||
self.state["current_history_file"] = str(new_file)
|
||||
archived = archive_dir / "(empty)"
|
||||
new_file = self._create_new_history_file(reason, uname)
|
||||
sessions = self.state.setdefault("user_sessions", {})
|
||||
if not isinstance(sessions, dict):
|
||||
sessions = {}
|
||||
self.state["user_sessions"] = sessions
|
||||
sessions[uname] = {"current_history_file": str(new_file)}
|
||||
if uname == self.cfg.allowed_username:
|
||||
self.state["current_history_file"] = str(new_file)
|
||||
self._persist_state()
|
||||
self._append_history_event("history_rotated", {"reason": reason, "username": username, "archived": str(archived)})
|
||||
self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname)
|
||||
return archived
|
||||
|
||||
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
|
||||
@@ -347,10 +488,28 @@ class ShinePyBotService:
|
||||
row.update(payload)
|
||||
JsonLineStore.append(history_path, row)
|
||||
|
||||
def _append_history_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
history_path = self._current_history_file()
|
||||
def _append_history_event(self, event_type: str, payload: dict[str, Any], username: str | None = None) -> None:
|
||||
history_path = self._current_history_file_for_user(username or self.cfg.allowed_username)
|
||||
self._append_history(history_path, "system_event", {"event": event_type, **payload})
|
||||
|
||||
def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None:
|
||||
uname = normalize_username(username)
|
||||
sent = self.state.get("player_welcome_sent")
|
||||
if not isinstance(sent, dict):
|
||||
sent = {}
|
||||
self.state["player_welcome_sent"] = sent
|
||||
if sent.get(uname):
|
||||
return
|
||||
player_name = self._player_name(uname)
|
||||
text = (
|
||||
f"Привет, {player_name}.\n"
|
||||
"Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
|
||||
"Команда /new начинает новую сессию и архивирует текущую историю."
|
||||
)
|
||||
self._safe_send(chat_id, text, reply_to=message_id)
|
||||
sent[uname] = now_iso()
|
||||
self._persist_state()
|
||||
|
||||
def _resolve_chat_id(self, chat_id: int) -> int:
|
||||
migrations = self.state.get("chat_id_migrations")
|
||||
if not isinstance(migrations, dict):
|
||||
@@ -440,35 +599,39 @@ class ShinePyBotService:
|
||||
)
|
||||
if is_channel_post and not is_allowed_channel:
|
||||
return
|
||||
if chat_username and chat_username == self.cfg.allowed_channel_username:
|
||||
self._remember_public_report_chat(chat_id)
|
||||
|
||||
# Игнорируем системные сообщения о входе/выходе и смене заголовка/фото.
|
||||
if message.get("new_chat_members") or message.get("left_chat_member"):
|
||||
return
|
||||
if message.get("group_chat_created") or message.get("supergroup_chat_created") or message.get("channel_chat_created"):
|
||||
return
|
||||
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
history_path = self._current_history_file()
|
||||
if author_username != self.cfg.allowed_username:
|
||||
if is_channel_post or is_group_message:
|
||||
self._append_history(history_path, "chat_context_message", {
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"updateType": update_type,
|
||||
"chatType": chat_type,
|
||||
"chatUsername": chat_username,
|
||||
"chatTitle": chat_title,
|
||||
"username": username,
|
||||
"authorSignature": author_signature,
|
||||
"text": text,
|
||||
"hasVoice": bool(message.get("voice")),
|
||||
"hasAudio": bool(message.get("audio")),
|
||||
})
|
||||
if is_group_message:
|
||||
self._safe_send(chat_id, "Получил сообщение.", reply_to=message_id)
|
||||
actor_username = normalize_username(author_username)
|
||||
is_allowed = self._is_allowed_user(actor_username)
|
||||
is_private = chat_type == "private"
|
||||
if not is_allowed:
|
||||
if is_private:
|
||||
self._safe_send(chat_id, "Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.", reply_to=message_id)
|
||||
return
|
||||
if self._is_allowed_player(actor_username) and not is_private:
|
||||
return
|
||||
|
||||
self._ensure_user_session(actor_username)
|
||||
history_path = self._current_history_file_for_user(actor_username)
|
||||
if self._is_allowed_player(actor_username):
|
||||
self._send_player_welcome_once(chat_id, message_id, actor_username)
|
||||
|
||||
if not text:
|
||||
if message.get("voice"):
|
||||
self._enqueue_voice_job(
|
||||
chat_id,
|
||||
message_id,
|
||||
author_username,
|
||||
actor_username,
|
||||
message["voice"].get("file_id"),
|
||||
media_type="voice",
|
||||
update_type=update_type,
|
||||
chat_username=chat_username,
|
||||
chat_title=chat_title,
|
||||
@@ -480,8 +643,9 @@ class ShinePyBotService:
|
||||
self._enqueue_voice_job(
|
||||
chat_id,
|
||||
message_id,
|
||||
author_username,
|
||||
actor_username,
|
||||
message["audio"].get("file_id"),
|
||||
media_type="audio",
|
||||
update_type=update_type,
|
||||
chat_username=chat_username,
|
||||
chat_title=chat_title,
|
||||
@@ -493,7 +657,7 @@ class ShinePyBotService:
|
||||
return
|
||||
|
||||
if text.startswith("/"):
|
||||
self._handle_command(chat_id, message_id, author_username, text)
|
||||
self._handle_command(chat_id, message_id, actor_username, text)
|
||||
return
|
||||
|
||||
self._append_history(history_path, "incoming_text", {
|
||||
@@ -503,11 +667,11 @@ class ShinePyBotService:
|
||||
"chatType": chat_type,
|
||||
"chatUsername": chat_username,
|
||||
"chatTitle": chat_title,
|
||||
"username": author_username,
|
||||
"username": actor_username,
|
||||
"authorSignature": author_signature,
|
||||
"text": text,
|
||||
})
|
||||
job = self._build_job_base(chat_id, message_id, author_username, str(history_path))
|
||||
job = self._build_job_base(chat_id, message_id, actor_username, str(history_path))
|
||||
job["type"] = "text"
|
||||
job["text"] = text
|
||||
job["update_type"] = update_type
|
||||
@@ -515,6 +679,8 @@ class ShinePyBotService:
|
||||
job["chat_username"] = chat_username
|
||||
job["chat_title"] = chat_title
|
||||
job["author_signature"] = author_signature
|
||||
job["role"] = "owner" if self._is_owner(actor_username) else "player"
|
||||
job["player_name"] = self._player_name(actor_username) if job["role"] == "player" else ""
|
||||
with self.queue_lock:
|
||||
self.queue.append(job)
|
||||
self._persist_queue()
|
||||
@@ -527,6 +693,7 @@ class ShinePyBotService:
|
||||
username: str,
|
||||
file_id: str | None,
|
||||
*,
|
||||
media_type: str = "voice",
|
||||
update_type: str = "message",
|
||||
chat_username: str = "",
|
||||
chat_title: str = "",
|
||||
@@ -536,7 +703,7 @@ class ShinePyBotService:
|
||||
if not file_id:
|
||||
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
||||
return
|
||||
history_path = self._current_history_file()
|
||||
history_path = self._current_history_file_for_user(username)
|
||||
self._append_history(history_path, "incoming_voice", {
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
@@ -547,15 +714,19 @@ class ShinePyBotService:
|
||||
"username": username,
|
||||
"authorSignature": author_signature,
|
||||
"fileId": file_id,
|
||||
"mediaType": media_type,
|
||||
})
|
||||
job = self._build_job_base(chat_id, message_id, username, str(history_path))
|
||||
job["type"] = "voice"
|
||||
job["telegram_file_id"] = file_id
|
||||
job["telegram_media_type"] = media_type
|
||||
job["update_type"] = update_type
|
||||
job["chat_type"] = chat_type
|
||||
job["chat_username"] = chat_username
|
||||
job["chat_title"] = chat_title
|
||||
job["author_signature"] = author_signature
|
||||
job["role"] = "owner" if self._is_owner(username) else "player"
|
||||
job["player_name"] = self._player_name(username) if job["role"] == "player" else ""
|
||||
with self.queue_lock:
|
||||
self.queue.append(job)
|
||||
self._persist_queue()
|
||||
@@ -579,8 +750,11 @@ class ShinePyBotService:
|
||||
"chat_username": "",
|
||||
"chat_title": "",
|
||||
"author_signature": "",
|
||||
"role": "owner",
|
||||
"player_name": "",
|
||||
"text": "",
|
||||
"telegram_file_id": "",
|
||||
"telegram_media_type": "",
|
||||
"history_file": history_file,
|
||||
"attempts": 0,
|
||||
"retry_reason": "",
|
||||
@@ -592,8 +766,9 @@ class ShinePyBotService:
|
||||
|
||||
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
|
||||
lower = text.lower()
|
||||
is_owner = self._is_owner(username)
|
||||
if lower in ("/start", "/help"):
|
||||
self._safe_send(chat_id, self._help_text(), reply_to=message_id)
|
||||
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
|
||||
return
|
||||
if lower == "/status":
|
||||
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
|
||||
@@ -606,11 +781,14 @@ class ShinePyBotService:
|
||||
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
||||
return
|
||||
if lower in ("/restart_service", "/restart"):
|
||||
if not is_owner:
|
||||
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
|
||||
return
|
||||
self._append_history_event("restart_service_requested", {
|
||||
"chatId": chat_id,
|
||||
"messageId": message_id,
|
||||
"username": username,
|
||||
})
|
||||
}, username=username)
|
||||
self._safe_send(
|
||||
chat_id,
|
||||
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
|
||||
@@ -644,17 +822,19 @@ class ShinePyBotService:
|
||||
self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id)
|
||||
return
|
||||
|
||||
def _help_text(self) -> str:
|
||||
return (
|
||||
"Доступные команды:\n"
|
||||
"/status — активная задача и размер очереди\n"
|
||||
"/queue — список задач в очереди\n"
|
||||
"/stop — остановить текущую задачу\n"
|
||||
"/cancel <id|all> — удалить задачу по id (префикс) или все\n"
|
||||
"/new — архивировать историю и начать новую\n"
|
||||
"/restart_service — перезапустить сервис через systemd\n"
|
||||
"/help — эта справка"
|
||||
)
|
||||
def _help_text(self, *, is_owner: bool) -> str:
|
||||
lines = [
|
||||
"Доступные команды:",
|
||||
"/status — активная задача и размер очереди",
|
||||
"/queue — список задач в очереди",
|
||||
"/stop — остановить текущую задачу",
|
||||
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
||||
"/new — архивировать историю и начать новую",
|
||||
"/help — эта справка",
|
||||
]
|
||||
if is_owner:
|
||||
lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _status_text(self) -> str:
|
||||
with self.queue_lock:
|
||||
@@ -768,6 +948,7 @@ class ShinePyBotService:
|
||||
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
|
||||
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
||||
self._mark_job_done(job_id)
|
||||
self._send_private_job_public_report(job, answer)
|
||||
except Exception as e:
|
||||
if self.stop_current_job:
|
||||
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
|
||||
@@ -775,6 +956,15 @@ class ShinePyBotService:
|
||||
self._mark_job_removed(job_id)
|
||||
self.stop_current_job = False
|
||||
return
|
||||
if isinstance(e, VoiceTranscriptionError):
|
||||
self._append_history(history_path, "voice_transcription_failed", {
|
||||
"jobId": job_id,
|
||||
"jobNum": job_num,
|
||||
"stage": e.stage,
|
||||
"retryable": e.retryable,
|
||||
"error": e.user_message,
|
||||
"detail": e.detail,
|
||||
})
|
||||
self._handle_job_failure(job, e)
|
||||
|
||||
def _build_prompt(self, job: dict[str, Any]) -> str:
|
||||
@@ -782,6 +972,19 @@ class ShinePyBotService:
|
||||
retry_reason = (job.get("retry_reason") or "").strip()
|
||||
if retry_reason:
|
||||
retry_block = f"\n\nПометка retry: {retry_reason}"
|
||||
role = (job.get("role") or "owner").strip().lower()
|
||||
player_block = ""
|
||||
if role == "player":
|
||||
player_name = (job.get("player_name") or "").strip()
|
||||
player_dir = self.cfg.codex_workdir / "Players" / (job.get("username") or "")
|
||||
player_block = (
|
||||
"\n\nРежим игрока (обязательно):\n"
|
||||
f"- Пользователь: {player_name} (@{job.get('username')}).\n"
|
||||
f"- Рабочая папка игрока: {player_dir}\n"
|
||||
"- Код проекта не изменять.\n"
|
||||
"- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.\n"
|
||||
"- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока."
|
||||
)
|
||||
return (
|
||||
"Пришло сообщение в Telegram.\n"
|
||||
f"Тип: {job.get('type')}\n"
|
||||
@@ -794,7 +997,7 @@ class ShinePyBotService:
|
||||
f"{job.get('text')}\n\n"
|
||||
f"История диалога (JSONL): {job.get('history_file')}\n"
|
||||
f"Инструкции агента: {self.cfg.agent_instructions_file}\n"
|
||||
f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{retry_block}"
|
||||
f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}"
|
||||
)
|
||||
|
||||
def _run_codex(self, prompt: str, chat_id: int, message_id: int, job_id: str, job_num: Any) -> str:
|
||||
@@ -921,7 +1124,11 @@ class ShinePyBotService:
|
||||
chat_id = int(job["chat_id"])
|
||||
message_id = int(job["message_id"])
|
||||
error_text = str(err).strip() or err.__class__.__name__
|
||||
print(f"[py-bot] Ошибка job={job_id[:8]}: {error_text}", flush=True)
|
||||
user_error_text = self._user_error_text(err)
|
||||
retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable
|
||||
log_error_text = err.log_text() if isinstance(err, VoiceTranscriptionError) else error_text
|
||||
print(f"[py-bot] Ошибка job={job_id[:8]}: {log_error_text}", flush=True)
|
||||
print(traceback.format_exc(), flush=True)
|
||||
|
||||
with self.queue_lock:
|
||||
target = next((j for j in self.queue if j.get("id") == job_id), None)
|
||||
@@ -931,7 +1138,7 @@ class ShinePyBotService:
|
||||
target["attempts"] = attempts
|
||||
target["last_error"] = error_text[:1000]
|
||||
target["updated_at"] = now_iso()
|
||||
if attempts < self.cfg.max_retries:
|
||||
if retryable and attempts < self.cfg.max_retries:
|
||||
target["status"] = "pending"
|
||||
target["retry_reason"] = error_text[:200]
|
||||
self._persist_queue()
|
||||
@@ -942,9 +1149,19 @@ class ShinePyBotService:
|
||||
will_retry = False
|
||||
|
||||
if will_retry:
|
||||
self._safe_send(chat_id, f"Ошибка задачи #{job_num}, повтор: {attempts}/{self.cfg.max_retries}", reply_to=message_id)
|
||||
self._safe_send(
|
||||
chat_id,
|
||||
f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
else:
|
||||
self._safe_send(chat_id, f"Ошибка задачи #{job_num}. Лимит попыток исчерпан.", reply_to=message_id)
|
||||
self._safe_send(chat_id, f"{user_error_text}\nЗадача #{job_num} остановлена.", reply_to=message_id)
|
||||
|
||||
def _user_error_text(self, err: Exception) -> str:
|
||||
if isinstance(err, VoiceTranscriptionError):
|
||||
return f"Не удалось распознать голосовое: {err.user_message}"
|
||||
error_text = str(err).strip() or err.__class__.__name__
|
||||
return f"Ошибка выполнения задачи: {error_text}"
|
||||
|
||||
def _mark_job_done(self, job_id: str) -> None:
|
||||
with self.queue_lock:
|
||||
@@ -956,27 +1173,145 @@ class ShinePyBotService:
|
||||
self.queue = [j for j in self.queue if j.get("id") != job_id]
|
||||
self._persist_queue()
|
||||
|
||||
def _safe_send(self, chat_id: int, text: str, reply_to: int | None = None) -> None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
def _remember_public_report_chat(self, chat_id: int) -> None:
|
||||
if self.state.get("public_report_chat_id") == chat_id:
|
||||
return
|
||||
if len(text) > 3900:
|
||||
text = text[:3900] + "\n...[обрезано]"
|
||||
resolved_chat_id = self._resolve_chat_id(chat_id)
|
||||
resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
|
||||
self.state["public_report_chat_id"] = chat_id
|
||||
self.state["updated_at"] = now_iso()
|
||||
self._persist_state()
|
||||
|
||||
def _public_report_chat_id(self) -> int | str | None:
|
||||
chat_id = self.state.get("public_report_chat_id")
|
||||
if isinstance(chat_id, int):
|
||||
return self._resolve_chat_id(chat_id)
|
||||
if self.cfg.allowed_channel_username:
|
||||
return f"@{self.cfg.allowed_channel_username}"
|
||||
return None
|
||||
|
||||
def _send_private_job_public_report(self, job: dict[str, Any], answer: str) -> None:
|
||||
if job.get("chat_type") != "private":
|
||||
return
|
||||
report_chat_id = self._public_report_chat_id()
|
||||
if report_chat_id is None:
|
||||
return
|
||||
|
||||
job_num = job.get("num", "?")
|
||||
source_text = (job.get("text") or "").strip()
|
||||
if not source_text:
|
||||
source_text = "(пустой текст запроса)"
|
||||
role = (job.get("role") or "owner").strip().lower()
|
||||
author_label = "Айдар"
|
||||
if role == "player":
|
||||
player_name = (job.get("player_name") or "").strip() or job.get("username") or "Игрок"
|
||||
author_label = f"{player_name} (@{job.get('username')})"
|
||||
if job.get("type") == "voice":
|
||||
voice_file_id = (job.get("telegram_file_id") or "").strip()
|
||||
media_type = (job.get("telegram_media_type") or "voice").strip()
|
||||
request_caption = self._trim_telegram_caption(
|
||||
f"{author_label} сделал {media_type}-запрос, задача #{job_num}.\n\n"
|
||||
f"Распознанный текст:\n{source_text}"
|
||||
)
|
||||
request_message_id = None
|
||||
if voice_file_id:
|
||||
request_message_id = self._safe_send_telegram_file(
|
||||
report_chat_id,
|
||||
voice_file_id,
|
||||
media_type=media_type,
|
||||
caption=request_caption,
|
||||
)
|
||||
if request_message_id is None:
|
||||
request_message_id = self._safe_send(report_chat_id, request_caption)
|
||||
else:
|
||||
request_report = (
|
||||
f"{author_label} сделал запрос, задача #{job_num}.\n\n"
|
||||
f"{source_text}"
|
||||
)
|
||||
request_message_id = self._safe_send(report_chat_id, request_report)
|
||||
if request_message_id is None:
|
||||
return
|
||||
|
||||
answer_text = (answer or "").strip() or "(пустой ответ)"
|
||||
answer_chunks = split_long_text(f"Ответ на задачу #{job_num}:\n\n{answer_text}")
|
||||
for chunk in answer_chunks:
|
||||
self._safe_send(report_chat_id, chunk, reply_to=request_message_id)
|
||||
|
||||
@staticmethod
|
||||
def _trim_telegram_caption(text: str, limit: int = 1000) -> str:
|
||||
text = (text or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n...[обрезано]"
|
||||
|
||||
def _safe_send_telegram_file(
|
||||
self,
|
||||
chat_id: int | str,
|
||||
file_id: str,
|
||||
*,
|
||||
media_type: str = "voice",
|
||||
caption: str = "",
|
||||
reply_to: int | None = None,
|
||||
) -> int | None:
|
||||
file_id = (file_id or "").strip()
|
||||
if not file_id:
|
||||
return None
|
||||
caption = self._trim_telegram_caption(caption)
|
||||
resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
|
||||
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
|
||||
|
||||
def send(target_chat_id: int | str, target_reply_to: int | None) -> dict[str, Any]:
|
||||
if media_type == "audio":
|
||||
return self.telegram.send_audio(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
|
||||
return self.telegram.send_voice(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
|
||||
|
||||
try:
|
||||
self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
|
||||
sent = send(resolved_chat_id, resolved_reply_to)
|
||||
result = sent.get("result") or {}
|
||||
message_id = result.get("message_id")
|
||||
return message_id if isinstance(message_id, int) else None
|
||||
except Exception as e:
|
||||
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
|
||||
if migrate_to_chat_id is not None:
|
||||
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
|
||||
if isinstance(resolved_chat_id, int):
|
||||
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error")
|
||||
try:
|
||||
self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
|
||||
return
|
||||
sent = send(migrate_to_chat_id, None)
|
||||
result = sent.get("result") or {}
|
||||
message_id = result.get("message_id")
|
||||
return message_id if isinstance(message_id, int) else None
|
||||
except Exception as retry_error:
|
||||
print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True)
|
||||
return None
|
||||
print(f"[py-bot] sendFile error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
def _safe_send(self, chat_id: int | str, text: str, reply_to: int | None = None) -> int | None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > 3900:
|
||||
text = text[:3900] + "\n...[обрезано]"
|
||||
resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
|
||||
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
|
||||
try:
|
||||
sent = self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
|
||||
result = sent.get("result") or {}
|
||||
message_id = result.get("message_id")
|
||||
return message_id if isinstance(message_id, int) else None
|
||||
except Exception as e:
|
||||
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
|
||||
if migrate_to_chat_id is not None:
|
||||
if isinstance(resolved_chat_id, int):
|
||||
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
|
||||
try:
|
||||
sent = self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
|
||||
result = sent.get("result") or {}
|
||||
message_id = result.get("message_id")
|
||||
return message_id if isinstance(message_id, int) else None
|
||||
except Exception as retry_error:
|
||||
print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True)
|
||||
return
|
||||
return None
|
||||
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
||||
return None
|
||||
|
||||
def _schedule_self_restart(self) -> None:
|
||||
if self.restart_requested:
|
||||
@@ -992,26 +1327,94 @@ class ShinePyBotService:
|
||||
|
||||
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
|
||||
if not self.cfg.openai_api_key:
|
||||
raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice")
|
||||
raise VoiceTranscriptionError(
|
||||
"не настроен ключ OpenAI для распознавания.",
|
||||
stage="config",
|
||||
retryable=False,
|
||||
)
|
||||
file_id = (job.get("telegram_file_id") or "").strip()
|
||||
if not file_id:
|
||||
raise RuntimeError("Пустой telegram_file_id")
|
||||
raise VoiceTranscriptionError(
|
||||
"Telegram не передал идентификатор файла.",
|
||||
stage="telegram_file_id",
|
||||
retryable=False,
|
||||
)
|
||||
job_id = str(job.get("id") or "")[:8]
|
||||
job_num = job.get("num", "?")
|
||||
media_type = (job.get("telegram_media_type") or "voice").strip()
|
||||
started_at = time.time()
|
||||
print(f"[py-bot] transcribe start job={job_id} num={job_num} media={media_type}", flush=True)
|
||||
file_bytes, filename = self._download_telegram_file(file_id)
|
||||
print(
|
||||
f"[py-bot] transcribe downloaded job={job_id} filename={filename} size={len(file_bytes)} bytes",
|
||||
flush=True,
|
||||
)
|
||||
text = self._openai_transcribe(file_bytes, filename).strip()
|
||||
if not text:
|
||||
raise RuntimeError("Распознавание вернуло пустой текст")
|
||||
raise VoiceTranscriptionError(
|
||||
"сервис распознавания вернул пустой текст. Возможно, в записи нет слышимой речи или качество звука слишком низкое.",
|
||||
stage="empty_text",
|
||||
retryable=False,
|
||||
)
|
||||
elapsed = self._format_duration(int(time.time() - started_at))
|
||||
print(f"[py-bot] transcribe done job={job_id} chars={len(text)} elapsed={elapsed}", flush=True)
|
||||
return text
|
||||
|
||||
def _download_telegram_file(self, file_id: str) -> tuple[bytes, str]:
|
||||
result = self.telegram.call("getFile", {"file_id": file_id}, timeout=60)
|
||||
try:
|
||||
result = self.telegram.call("getFile", {"file_id": file_id}, timeout=120)
|
||||
except TimeoutError as e:
|
||||
raise VoiceTranscriptionError(
|
||||
"Telegram долго не отдавал информацию о файле.",
|
||||
stage="telegram_get_file_timeout",
|
||||
detail=str(e),
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise VoiceTranscriptionError(
|
||||
"не удалось получить информацию о файле из Telegram.",
|
||||
stage="telegram_get_file",
|
||||
detail=str(e),
|
||||
) from e
|
||||
info = result.get("result") or {}
|
||||
file_path = info.get("file_path")
|
||||
if not file_path:
|
||||
raise RuntimeError("Telegram getFile не вернул file_path")
|
||||
raise VoiceTranscriptionError(
|
||||
"Telegram не вернул путь к файлу.",
|
||||
stage="telegram_file_path",
|
||||
retryable=True,
|
||||
detail=json.dumps(info, ensure_ascii=False)[:1000],
|
||||
)
|
||||
file_url = f"https://api.telegram.org/file/bot{self.cfg.telegram_bot_token}/{file_path}"
|
||||
req = request.Request(file_url, method="GET")
|
||||
with request.urlopen(req, timeout=120) as resp:
|
||||
data = resp.read()
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.cfg.telegram_file_download_timeout_seconds) as resp:
|
||||
data = resp.read()
|
||||
except TimeoutError as e:
|
||||
raise VoiceTranscriptionError(
|
||||
f"Telegram не успел отдать аудиофайл за {self.cfg.telegram_file_download_timeout_seconds} секунд.",
|
||||
stage="telegram_download_timeout",
|
||||
detail=str(e),
|
||||
) from e
|
||||
except error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace")
|
||||
raise VoiceTranscriptionError(
|
||||
f"Telegram вернул ошибку HTTP {e.code} при скачивании аудио.",
|
||||
stage="telegram_download_http",
|
||||
retryable=e.code >= 500 or e.code == 429,
|
||||
detail=detail[:1000],
|
||||
) from e
|
||||
except error.URLError as e:
|
||||
raise VoiceTranscriptionError(
|
||||
"не удалось скачать аудиофайл из Telegram из-за сетевой ошибки.",
|
||||
stage="telegram_download_network",
|
||||
detail=str(e.reason),
|
||||
) from e
|
||||
if not data:
|
||||
raise VoiceTranscriptionError(
|
||||
"Telegram отдал пустой аудиофайл.",
|
||||
stage="telegram_download_empty",
|
||||
retryable=True,
|
||||
)
|
||||
original_name = Path(file_path).name or "audio.ogg"
|
||||
lower = original_name.lower()
|
||||
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
|
||||
@@ -1051,11 +1454,40 @@ class ShinePyBotService:
|
||||
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
try:
|
||||
with request.urlopen(req, timeout=240) as resp:
|
||||
with request.urlopen(req, timeout=self.cfg.openai_transcribe_timeout_seconds) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except TimeoutError as e:
|
||||
raise VoiceTranscriptionError(
|
||||
f"OpenAI не успел распознать аудио за {self.cfg.openai_transcribe_timeout_seconds} секунд.",
|
||||
stage="openai_transcribe_timeout",
|
||||
detail=str(e),
|
||||
) from e
|
||||
except error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"OpenAI transcribe HTTP {e.code}: {detail}") from e
|
||||
if e.code == 400:
|
||||
user_message = "OpenAI не принял аудиофайл для распознавания."
|
||||
elif e.code == 401:
|
||||
user_message = "OpenAI отклонил ключ API для распознавания."
|
||||
elif e.code == 413:
|
||||
user_message = "аудиофайл слишком большой для распознавания OpenAI."
|
||||
elif e.code == 429:
|
||||
user_message = "OpenAI временно ограничил распознавание из-за лимита запросов."
|
||||
elif e.code >= 500:
|
||||
user_message = "OpenAI временно не смог обработать распознавание."
|
||||
else:
|
||||
user_message = f"OpenAI вернул ошибку HTTP {e.code} при распознавании."
|
||||
raise VoiceTranscriptionError(
|
||||
user_message,
|
||||
stage="openai_transcribe_http",
|
||||
retryable=e.code == 429 or e.code >= 500,
|
||||
detail=detail[:1500],
|
||||
) from e
|
||||
except error.URLError as e:
|
||||
raise VoiceTranscriptionError(
|
||||
"не удалось отправить аудио в OpenAI из-за сетевой ошибки.",
|
||||
stage="openai_transcribe_network",
|
||||
detail=str(e.reason),
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _extract_codex_user_note(line: str) -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user