Добавить кошелек блокчейна и озвучивание агента

This commit is contained in:
AidarKC
2026-05-29 23:48:44 +04:00
parent 775b655aac
commit 3a5856c7f0
19 changed files with 1301 additions and 28 deletions
+300 -7
View File
@@ -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(