diff --git a/Dev_Docs/API/01_User_Registration_API.md b/Dev_Docs/API/01_User_Registration_API.md index 7801ce5..51d5b59 100644 --- a/Dev_Docs/API/01_User_Registration_API.md +++ b/Dev_Docs/API/01_User_Registration_API.md @@ -99,11 +99,24 @@ "blockchainName": "anya-001", "solanaKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY", - "deviceKey": "BASE64_32_PUBLIC_KEY" + "deviceKey": "BASE64_32_PUBLIC_KEY", + "serverLastGlobalNumber": 128, + "serverLastGlobalHash": "4f...ab", + "serverBlockchainSizeBytes": 45212, + "serverBlockchainSizeLimitBytes": 100000, + "serverBlocksCount": 129 } } ``` +Дополнительные серверные поля в `GetUser`: + +- `serverLastGlobalNumber` — номер последнего блока в пользовательском блокчейне на сервере; +- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа); +- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах; +- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах; +- `serverBlocksCount` — количество блоков в пользовательском блокчейне на сервере; + ### Успешный ответ: пользователя нет ```json diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 8bd4b4c..0033b48 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -13,7 +13,7 @@ | Операция | Раздел документации | Кратко | | --- | --- | --- | | `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) | -| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя | +| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | diff --git a/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md b/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md new file mode 100644 index 0000000..06596de --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md @@ -0,0 +1,43 @@ +# Кошелёк: лимит/закрепление блокчейна Сияния + +- статус: `pending` + +## Кратко что сделано + +- На экране `Кошелёк -> Блокчейн Сияния` добавлены 2 слоя данных: + - фактическое состояние цепочки на сервере (`кол-во блоков`, `размер`, `крайний блок`, `hash`, `размер крайнего блока`); + - закреплённое состояние в Solana PDA (`лимит`, `использовано`, `остаток`, `крайний блок`, `hash`). +- Добавлены действия: + - `Закрепить в Solana` — обновляет PDA до текущего состояния серверной цепочки; + - `Увеличить лимит` — увеличивает `paid_limit_bytes` в PDA с учётом цены из economy PDA. +- Если `rootKey`/`blockchainKey` не сохранены локально, экран запрашивает пароль, восстанавливает ключи через стандартную derivation-логику и предлагает сохранить их в зашифрованный контейнер. + +## Что проверять вручную + +1. Открыть `Кошелёк -> Блокчейн Сияния` под авторизованным пользователем. +2. Проверить, что в блоке "Фактическое состояние на сервере" отображаются: + - число блоков; + - размер цепочки; + - номер/хэш крайнего блока; + - размер крайнего блока. +3. Проверить, что в блоке "Закреплено в Solana" отображаются: + - лимит; + - израсходовано; + - остаток; + - номер/хэш крайнего закреплённого блока. +4. Нажать `Закрепить в Solana` и убедиться, что: + - приходит успешная транзакция; + - после обновления Solana-показатели подтягиваются до серверных (или максимально близко по актуальному состоянию). +5. Нажать `Увеличить лимит`, ввести значение кратное шагу, подтвердить списание и проверить: + - лимит увеличился; + - отображение цены/списания соответствует economy PDA. +6. Повторить пункты 4-5 в сценарии, когда `rootKey`/`blockchainKey` не сохранены, и проверить: + - появляется запрос пароля; + - после ввода пароля операции выполняются; + - предложение сохранить ключи показывается. + +## Ожидаемый результат + +- Экран корректно разделяет "фактическое состояние на сервере" и "закреплённое в Solana". +- Обе операции (`Закрепить в Solana`, `Увеличить лимит`) выполняются без ошибок при валидных данных. +- Восстановление ключей через пароль работает, а без нужных ключей операция не выполняется молча. diff --git a/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md b/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md new file mode 100644 index 0000000..b85cc6b --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md @@ -0,0 +1,29 @@ +# Озвучивание ответов агента + +## Что сделано + +В локальный Telegram-бот-сервис агента-кодера добавлены персональные настройки озвучивания финальных ответов: + +- `/voice_on` включает озвучивание для текущего Telegram-пользователя; +- `/voice_off` выключает озвучивание для текущего Telegram-пользователя; +- `/voice_status` показывает текущее состояние; +- если озвучивание включено, после текстового финального ответа сервис генерирует voice-файл через OpenAI TTS и отправляет его в Telegram; +- длинные ответы делятся на несколько фрагментов озвучки. + +## Что проверять + +1. Перезапустить `shine-agent-bot-coder`. +2. Отправить `/voice_status` и убедиться, что по умолчанию озвучивание выключено. +3. Отправить `/voice_on`. +4. Дать простую задачу агенту и проверить, что пришёл полный текстовый ответ и voice-файл с тем же ответом. +5. Отправить `/voice_off`. +6. Дать ещё одну простую задачу и проверить, что приходит только текст. +7. При возможности проверить второго whitelist-пользователя: его настройка должна быть независимой. + +## Ожидаемый результат + +Настройка хранится персонально по username и сохраняется после перезапуска сервиса. При включённой настройке Telegram получает текстовый ответ и дополнительное voice-сообщение с озвучкой. При выключенной настройке поведение остаётся прежним. + +## Статус + +pending diff --git a/SHiNE-agent-bot-coder/.env.example b/SHiNE-agent-bot-coder/.env.example index 34aafdf..b4b4811 100644 --- a/SHiNE-agent-bot-coder/.env.example +++ b/SHiNE-agent-bot-coder/.env.example @@ -7,6 +7,11 @@ BOT_USERNAME=aidar_su_bot OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +OPENAI_TTS_RESPONSE_FORMAT=opus +OPENAI_TTS_TIMEOUT_SECONDS=180 +OPENAI_TTS_CHUNK_CHARS=3500 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 diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index 6bc7319..6f84feb 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -32,6 +32,8 @@ - Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. - После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать. - Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. +- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`. +- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Промежуточные статусы и публичный отчёт в `@shine_writing` не озвучивать. ## Планы и отложенные фичи - Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index c00349f..d06bd36 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -8,6 +8,7 @@ - обрабатывает задачи строго последовательно; - поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); - вызывает Codex CLI и отправляет ответ в Telegram; +- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS; - при рестарте восстанавливает незавершённые задачи; - отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи; - принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`; @@ -33,6 +34,11 @@ - `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 секунд. + - `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`. + - `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`. + - `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`. + - `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд. + - `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов. 3. Запуск: - `python3 SHiNE-agent-bot-coder/py_bot_service.py` @@ -62,4 +68,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - `/stop` — остановить текущую задачу. - `/cancel ` — удалить задачу по id/префиксу или очистить очередь. - `/new` — архивировать текущую историю и начать новый диалог. +- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя. +- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. +- `/voice_status` — показать состояние озвучивания для текущего пользователя. - `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново. diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index dee95c8..4964067 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -64,6 +64,38 @@ def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] +def split_text_for_tts(text: str, chunk_size: int) -> list[str]: + text = (text or "").strip() + if not text: + return [] + chunks: list[str] = [] + current = "" + paragraphs = re.split(r"\n\s*\n", text) + for paragraph in paragraphs: + paragraph = paragraph.strip() + if not paragraph: + continue + if len(paragraph) > chunk_size: + if current: + chunks.append(current) + current = "" + for i in range(0, len(paragraph), chunk_size): + part = paragraph[i:i + chunk_size].strip() + if part: + chunks.append(part) + continue + candidate = paragraph if not current else f"{current}\n\n{paragraph}" + if len(candidate) <= chunk_size: + current = candidate + else: + if current: + chunks.append(current) + current = paragraph + if current: + chunks.append(current) + return chunks + + def read_env_file(path: Path) -> dict[str, str]: result: dict[str, str] = {} if not path.exists(): @@ -100,6 +132,10 @@ class VoiceTranscriptionError(RuntimeError): return f"{self.user_message} stage={self.stage} retryable={self.retryable}" +class VoiceReplyError(RuntimeError): + pass + + class JsonLineStore: @staticmethod def load(path: Path) -> list[dict[str, Any]]: @@ -154,6 +190,53 @@ class TelegramApi: raise RuntimeError(f"Telegram API error: {result}") return result + def call_multipart( + self, + method: str, + fields: dict[str, Any], + files: dict[str, tuple[str, bytes, str]], + timeout: int = 120, + ) -> dict[str, Any]: + boundary = "----shine-tg-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) + body = bytearray() + for name, value in fields.items(): + if value is None: + continue + body.extend( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + ).encode("utf-8") + ) + for name, (filename, data, mime) in files.items(): + body.extend( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' + f"Content-Type: {mime}\r\n\r\n" + ).encode("utf-8") + ) + body.extend(data) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode("utf-8")) + + req = request.Request(self.base + method, data=bytes(body), method="POST") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + try: + with request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except error.HTTPError as e: + body_text = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Telegram HTTP {e.code}: {body_text}") from e + except Exception as e: + raise RuntimeError(f"Telegram multipart request failed: {e}") from e + + result = json.loads(raw) + if not result.get("ok"): + raise RuntimeError(f"Telegram API error: {result}") + return result + def get_updates(self, offset: int | None, timeout_sec: int) -> list[dict[str, Any]]: payload: dict[str, Any] = {"timeout": timeout_sec, "allowed_updates": ["message", "channel_post"]} if offset is not None: @@ -195,6 +278,26 @@ class TelegramApi: payload["reply_to_message_id"] = reply_to_message_id return self.call("sendAudio", payload=payload, timeout=60) + def send_voice_upload( + self, + chat_id: int | str, + voice_bytes: bytes, + filename: str, + caption: str = "", + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + fields: dict[str, Any] = {"chat_id": chat_id} + if caption: + fields["caption"] = caption + if reply_to_message_id is not None: + fields["reply_to_message_id"] = reply_to_message_id + return self.call_multipart( + "sendVoice", + fields=fields, + files={"voice": (filename, voice_bytes, "audio/ogg")}, + timeout=180, + ) + def delete_webhook(self) -> None: self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30) @@ -214,6 +317,11 @@ class BotConfig: 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.openai_tts_model = env.get("OPENAI_TTS_MODEL", "gpt-4o-mini-tts") + self.openai_tts_voice = env.get("OPENAI_TTS_VOICE", "alloy") + self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") + self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) + self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) self.codex_bin = Path(env.get( "CODEX_BIN", "/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl" @@ -344,6 +452,10 @@ class ShinePyBotService: if not isinstance(sessions, dict): sessions = {} self.state["user_sessions"] = sessions + user_settings = self.state.get("user_settings") + if not isinstance(user_settings, dict): + user_settings = {} + self.state["user_settings"] = user_settings if not self.state.get("current_history_file"): history_file = self._create_new_history_file("initial", self.cfg.allowed_username) self.state["current_history_file"] = str(history_file) @@ -433,6 +545,7 @@ class ShinePyBotService: if not isinstance(sessions, dict): sessions = {} self.state["user_sessions"] = sessions + self._user_settings(uname) session = sessions.get(uname) if isinstance(session, dict) and session.get("current_history_file"): return @@ -483,6 +596,27 @@ class ShinePyBotService: self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname) return archived + def _user_settings(self, username: str) -> dict[str, Any]: + uname = normalize_username(username) or self.cfg.allowed_username + settings = self.state.get("user_settings") + if not isinstance(settings, dict): + settings = {} + self.state["user_settings"] = settings + user_settings = settings.get(uname) + if not isinstance(user_settings, dict): + user_settings = {} + settings[uname] = user_settings + if not isinstance(user_settings.get("voice_replies_enabled"), bool): + user_settings["voice_replies_enabled"] = False + return user_settings + + def _voice_replies_enabled(self, username: str) -> bool: + return bool(self._user_settings(username).get("voice_replies_enabled")) + + def _set_voice_replies_enabled(self, username: str, enabled: bool) -> None: + self._user_settings(username)["voice_replies_enabled"] = enabled + self._persist_state() + def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: row = {"ts": now_iso(), "type": event_type} row.update(payload) @@ -766,21 +900,36 @@ class ShinePyBotService: def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None: lower = text.lower() + command = lower.split(maxsplit=1)[0].split("@", 1)[0] is_owner = self._is_owner(username) - if lower in ("/start", "/help"): + if command in ("/start", "/help"): self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) return - if lower == "/status": + if command == "/status": self._safe_send(chat_id, self._status_text(), reply_to=message_id) return - if lower == "/queue": + if command == "/queue": self._safe_send(chat_id, self._queue_text(), reply_to=message_id) return - if lower == "/new": + if command == "/voice_on": + self._set_voice_replies_enabled(username, True) + self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Озвучивание финальных ответов включено для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_off": + self._set_voice_replies_enabled(username, False) + self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_status": + status = "включено" if self._voice_replies_enabled(username) else "выключено" + self._safe_send(chat_id, f"Озвучивание финальных ответов: {status}.", reply_to=message_id) + return + if command == "/new": archived = self._rotate_history("command_new", username) self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) return - if lower in ("/restart_service", "/restart"): + if command in ("/restart_service", "/restart"): if not is_owner: self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) return @@ -796,14 +945,14 @@ class ShinePyBotService: ) self._schedule_self_restart() return - if lower == "/stop": + if command == "/stop": stopped = self._cancel_active_job("stopped_by_user") if stopped: self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id) else: self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id) return - if lower.startswith("/cancel"): + if command == "/cancel": parts = text.split(maxsplit=1) if len(parts) < 2: self._safe_send(chat_id, "Использование: /cancel ", reply_to=message_id) @@ -830,6 +979,9 @@ class ShinePyBotService: "/stop — остановить текущую задачу", "/cancel — удалить задачу по id (префикс) или все", "/new — архивировать историю и начать новую", + "/voice_on — включить озвучивание финальных ответов", + "/voice_off — выключить озвучивание финальных ответов", + "/voice_status — показать состояние озвучивания", "/help — эта справка", ] if is_owner: @@ -945,6 +1097,8 @@ class ShinePyBotService: answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num) for chunk in split_long_text(answer): self._safe_send(chat_id, chunk, reply_to=message_id) + if self._voice_replies_enabled(job.get("username") or ""): + self._send_voice_reply_for_answer(chat_id, message_id, job_num, answer, history_path, job_id) 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) @@ -1284,6 +1438,51 @@ class ShinePyBotService: print(f"[py-bot] sendFile error: {e}", flush=True) return None + def _safe_send_voice_upload( + self, + chat_id: int | str, + voice_bytes: bytes, + filename: str, + *, + caption: str = "", + reply_to: int | None = None, + ) -> int | None: + if not voice_bytes: + 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]: + return self.telegram.send_voice_upload( + target_chat_id, + voice_bytes, + filename, + caption=caption, + reply_to_message_id=target_reply_to, + ) + + try: + 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: + if isinstance(resolved_chat_id, int): + self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_voice_upload_error") + try: + 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] sendVoiceUpload retry after migration error: {retry_error}", flush=True) + return None + print(f"[py-bot] sendVoiceUpload 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: @@ -1325,6 +1524,100 @@ class ShinePyBotService: threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() + def _send_voice_reply_for_answer( + self, + chat_id: int, + message_id: int, + job_num: Any, + answer: str, + history_path: Path, + job_id: str, + ) -> None: + if not self.cfg.openai_api_key: + note = "не настроен ключ OpenAI для озвучивания." + self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) + self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) + return + + chunks = split_text_for_tts(answer, self.cfg.openai_tts_chunk_chars) + if not chunks: + return + + sent_count = 0 + total = len(chunks) + print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total}", flush=True) + for index, chunk in enumerate(chunks, start=1): + try: + audio = self._openai_tts(chunk) + except VoiceReplyError as e: + self._append_history(history_path, "voice_reply_failed", { + "jobId": job_id, + "jobNum": job_num, + "part": index, + "parts": total, + "error": str(e), + }) + self._safe_send(chat_id, f"Не удалось озвучить ответ #{job_num}: {e}", reply_to=message_id) + return + caption = f"Озвучка ответа #{job_num}" + if total > 1: + caption += f", часть {index}/{total}" + message_sent = self._safe_send_voice_upload( + chat_id, + audio, + f"shine-answer-{job_num}-{index}.ogg", + caption=caption, + reply_to=message_id, + ) + if message_sent is None: + self._append_history(history_path, "voice_reply_failed", { + "jobId": job_id, + "jobNum": job_num, + "part": index, + "parts": total, + "error": "Telegram не принял voice-файл озвучки.", + }) + self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) + return + sent_count += 1 + self._append_history(history_path, "voice_reply_sent", {"jobId": job_id, "jobNum": job_num, "parts": sent_count}) + print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) + + def _openai_tts(self, text: str) -> bytes: + payload = { + "model": self.cfg.openai_tts_model, + "voice": self.cfg.openai_tts_voice, + "input": text, + "response_format": self.cfg.openai_tts_response_format, + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request("https://api.openai.com/v1/audio/speech", method="POST", data=data) + req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") + req.add_header("Content-Type", "application/json") + try: + with request.urlopen(req, timeout=self.cfg.openai_tts_timeout_seconds) as resp: + audio = resp.read() + except TimeoutError as e: + raise VoiceReplyError(f"OpenAI не успел сгенерировать речь за {self.cfg.openai_tts_timeout_seconds} секунд.") from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + if e.code == 401: + message = "OpenAI отклонил ключ API для озвучивания." + elif e.code == 429: + message = "OpenAI временно ограничил озвучивание из-за лимита запросов." + elif e.code >= 500: + message = "OpenAI временно не смог сгенерировать речь." + else: + message = f"OpenAI вернул ошибку HTTP {e.code} при озвучивании." + if detail: + message = f"{message} Детали: {detail[:500]}" + raise VoiceReplyError(message) from e + except error.URLError as e: + raise VoiceReplyError(f"не удалось отправить текст в OpenAI TTS из-за сетевой ошибки: {e.reason}") from e + if not audio: + raise VoiceReplyError("OpenAI вернул пустой аудиофайл.") + return audio + def _transcribe_voice_job(self, job: dict[str, Any]) -> str: if not self.cfg.openai_api_key: raise VoiceTranscriptionError( diff --git a/VERSION.properties b/VERSION.properties index 8f0bf56..02bda00 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.95 -server.version=1.2.89 +client.version=1.2.96 +server.version=1.2.90 diff --git a/shine-UI/index.html b/shine-UI/index.html index 8309c71..0ee93f1 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -6,7 +6,7 @@ Shine UI Demo +