feat: add voice idea command

This commit is contained in:
admin committed 2026-10-05 01:24:23 +02:00
1 parent 6ed2599a52
commit 95f9729261
7 files changed
+25 -16

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -1 +1 @@
0.1.0+13
0.1.0+14
+7 -7
View File
@@ -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)
+1 -1
View File
@@ -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)
+13 -4
View File
@@ -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):