chore: зафиксированы все текущие изменения проекта

This commit is contained in:
AidarKC
2026-05-25 23:46:54 +03:00
parent 8c5de781ea
commit 8941582d54
53 changed files with 2303 additions and 106 deletions
+3
View File
@@ -1,9 +1,12 @@
TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=replace_me
ALLOWED_TELEGRAM_USERNAME=AidarKC
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
BOT_USERNAME=aidar_su_bot
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
CODEX_TIMEOUT_SECONDS=900
+21 -7
View File
@@ -15,19 +15,33 @@
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
@@ -35,7 +49,7 @@
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`.
- Команда Telegram `/restart_service` алиас `/restart`) доступна только Айдару.
## Правила ответа
- Пиши содержательно и коротко.
+25
View File
@@ -0,0 +1,25 @@
# AGENTS
## Назначение
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
## Связанные инструкции
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
## Планы и задачи
- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`.
- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`.
- Горизонты планов:
- `near/` - ближайшие планы;
- `medium/` - среднесрочные планы;
- `far/` - дальнее будущее.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
- Не начинать реализацию задач из `Future_Features` без явной команды пользователя.
## Проверка после изменений
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
@@ -0,0 +1,26 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```
+8 -5
View File
@@ -2,6 +2,7 @@
Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`;
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно;
@@ -9,8 +10,7 @@
- вызывает Codex CLI и отправляет ответ в Telegram;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
@@ -20,8 +20,8 @@
- `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update;
- `data/history/*.jsonl` — активные истории;
- `data/history/archive/*.jsonl` — архив историй после `/new`.
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
## Локальный запуск
1. Скопировать пример:
@@ -29,7 +29,10 @@
2. Заполнить секреты в `.env`.
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
@@ -59,4 +62,4 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
+510 -78
View File
@@ -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: