SHA256
Добавить кошелек блокчейна и озвучивание агента
This commit is contained in:
@@ -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 <id|all>", reply_to=message_id)
|
||||
@@ -830,6 +979,9 @@ class ShinePyBotService:
|
||||
"/stop — остановить текущую задачу",
|
||||
"/cancel <id|all> — удалить задачу по 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(
|
||||
|
||||
Reference in New Issue
Block a user