feat: add voice idea command
This commit is contained in:
1 parent
6ed2599a52
commit
95f9729261
7 files changed
+25
-16
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+7
-7
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user