From 95f9729261d992782a477b44f6341d06a631ee06 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 5 Oct 2026 01:24:23 +0200 Subject: [PATCH] feat: add voice idea command --- Dockerfile | 2 +- IDEA.MD | 2 +- README.md | 2 +- VERSION | 2 +- apinex_client.py | 14 +++++++------- bot.py | 2 +- tests/test_writer_features.py | 17 +++++++++++++---- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9639ed6..af246ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng ffmpeg \ +RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng lame \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/IDEA.MD b/IDEA.MD index 142e083..03554a5 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -53,7 +53,7 @@ Telegram-бот, который по запросу пользователя г - [x] APInex JSON-вызовы и ошибки разбора журналируются в SQLite без системного промпта и image base64; срок хранения — 30 дней - [x] `/clear` полностью очищает данные текущего пользователя: ToV, скриншоты, историю постов, историю идей и настройки; API-логи сохраняются - [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов -- [x] `/idea_voice` синтезирует идею в OGG/Opus голосовое локально через `espeak-ng` + `ffmpeg`; runtime image включает оба инструмента +- [x] `/idea_voice` озвучивает одну идею локальным русским TTS (`espeak-ng` + `lame`) и отправляет MP3 как voice message; Docker runtime включает оба инструмента - [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей - [x] `/clear` очищает историю пользовательских данных и оставляет API-логи - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя diff --git a/README.md b/README.md index ded82b1..1a182ee 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The bot prepares social-media posts only when the user explicitly asks. It trans - APInex call and parsing logs live in SQLite at `/app/data/social_writer.sqlite3` for 30 days. They store user input/output JSON and error details; system prompt text is omitted in favor of a prompt name and SHA-256. Inline image base64 and binary audio are omitted. Treat log contents as private user data. - `/clear` полностью очищает данные текущего пользователя: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки; API-логи сохраняются. - `/idea` и русские запросы на идеи возвращают одну идею одним сообщением и сохраняют её для исключения повторов. -- `/idea_voice` генерирует одну идею и отправляет её голосовым сообщением; Docker image устанавливает `espeak-ng` и `ffmpeg` для локального OGG/Opus TTS. +- `/idea_voice` генерирует одну идею и отправляет её голосовым MP3-сообщением; Docker image включает `espeak-ng` и `lame` для локального русского TTS. - User-facing service text is always Russian; generated post variants follow the requested post language. Verbatim technical error detail may remain in its original language. - LLM model env defaults are `gemini-3.8-flash`; STT model defaults to APInex `nova-2`. - Per-user Tone of Voice lives in SQLite at `/app/data/social_writer.sqlite3`; persistent Coolify storage must be mounted at `/app/data`. diff --git a/VERSION b/VERSION index 08925d2..2026767 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+13 +0.1.0+14 diff --git a/apinex_client.py b/apinex_client.py index 2814ce4..1a0a21d 100644 --- a/apinex_client.py +++ b/apinex_client.py @@ -414,14 +414,14 @@ def _trim_history_posts(posts: list[str], char_limit: int = POST_HISTORY_CHAR_LI async def synthesize_idea_voice(text: str, user_id: int | None = None) -> bytes: - """Generate Russian OGG/Opus speech locally with espeak-ng and ffmpeg.""" + """Generate Russian MP3 speech locally; Telegram accepts MP3 in voice messages.""" espeak = shutil.which("espeak-ng") or shutil.which("espeak") - ffmpeg = shutil.which("ffmpeg") - if not espeak or not ffmpeg: - raise RuntimeError("Локальная озвучка не настроена: отсутствует espeak-ng или ffmpeg") + lame = shutil.which("lame") + if not espeak or not lame: + raise RuntimeError("Локальная озвучка не настроена: отсутствует espeak-ng или lame") with tempfile.TemporaryDirectory() as temp_dir: wav_path = os.path.join(temp_dir, "idea.wav") - ogg_path = os.path.join(temp_dir, "idea.ogg") + mp3_path = os.path.join(temp_dir, "idea.mp3") await asyncio.to_thread( subprocess.run, [espeak, "-v", "ru", "-s", "155", "-w", wav_path, text[:1200]], @@ -429,10 +429,10 @@ async def synthesize_idea_voice(text: str, user_id: int | None = None) -> bytes: ) await asyncio.to_thread( subprocess.run, - [ffmpeg, "-y", "-loglevel", "error", "-i", wav_path, "-c:a", "libopus", "-b:a", "48k", ogg_path], + [lame, "--silent", "-b", "64", wav_path, mp3_path], check=True, capture_output=True, timeout=30, ) - with open(ogg_path, "rb") as audio_file: + with open(mp3_path, "rb") as audio_file: return await asyncio.to_thread(audio_file.read) diff --git a/bot.py b/bot.py index 8bbe1ee..e7aee45 100644 --- a/bot.py +++ b/bot.py @@ -167,7 +167,7 @@ async def help_command(message: Message) -> None: async def _send_idea_voice(message: Message, idea: dict[str, str], user_id: int) -> None: text = f"{idea['title']}. {idea['angle']}. Зачем: {idea['why']}" audio = await synthesize_idea_voice(text, user_id=user_id) - await message.answer_voice(BufferedInputFile(audio, filename="idea.ogg")) + await message.answer_voice(BufferedInputFile(audio, filename="idea.mp3")) await asyncio.to_thread(save_idea, user_id, idea) diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 8080d50..d383fc7 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -211,12 +211,21 @@ class ApinexTests(unittest.IsolatedAsyncioTestCase): message.answer_voice.assert_awaited_once() voice_file = message.answer_voice.await_args.args[0] - self.assertEqual(voice_file.filename, "idea.ogg") + self.assertEqual(voice_file.filename, "idea.mp3") save_idea.assert_called_once_with(91, idea) - async def test_synthesize_idea_voice_returns_ogg_opus_audio(self): - audio = await apinex_client.synthesize_idea_voice("Идея для поста о кофе") - self.assertTrue(audio.startswith(b"OggS")) + async def test_synthesize_idea_voice_returns_mp3_audio(self): + def fake_run(command, **kwargs): + output_path = command[-1] if command[0].endswith("lame") else command[command.index("-w") + 1] + with open(output_path, "wb") as output_file: + output_file.write(b"ID3" + b"x" * 1200) + + with ( + patch.object(apinex_client.shutil, "which", side_effect=["/usr/bin/espeak-ng", "/usr/bin/lame"]), + patch.object(apinex_client.subprocess, "run", side_effect=fake_run), + ): + audio = await apinex_client.synthesize_idea_voice("Идея для поста о кофе") + self.assertTrue(audio.startswith(b"ID3")) self.assertGreater(len(audio), 1000) async def test_idea_generation_returns_topics_only_and_uses_history(self):