feat: add voice idea command
This commit is contained in:
1 parent
0d181b0c05
commit
6ed2599a52
9 files changed
+89
-6
No files matched your search
@@ -3,6 +3,9 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -53,6 +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] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей
|
||||
- [x] `/clear` очищает историю пользовательских данных и оставляет API-логи
|
||||
- [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
|
||||
|
||||
@@ -11,6 +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.
|
||||
- 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`.
|
||||
|
||||
@@ -5,6 +5,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -14,6 +17,7 @@ from user_store import save_api_call_log
|
||||
logger = logging.getLogger(__name__)
|
||||
APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt"
|
||||
APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions"
|
||||
TTS_NOT_CONFIGURED_MESSAGE = "Озвучка идей пока не настроена."
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"
|
||||
|
||||
POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса.
|
||||
@@ -409,6 +413,29 @@ def _trim_history_posts(posts: list[str], char_limit: int = POST_HISTORY_CHAR_LI
|
||||
return result
|
||||
|
||||
|
||||
async def synthesize_idea_voice(text: str, user_id: int | None = None) -> bytes:
|
||||
"""Generate Russian OGG/Opus speech locally with espeak-ng and ffmpeg."""
|
||||
espeak = shutil.which("espeak-ng") or shutil.which("espeak")
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not espeak or not ffmpeg:
|
||||
raise RuntimeError("Локальная озвучка не настроена: отсутствует espeak-ng или ffmpeg")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
wav_path = os.path.join(temp_dir, "idea.wav")
|
||||
ogg_path = os.path.join(temp_dir, "idea.ogg")
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[espeak, "-v", "ru", "-s", "155", "-w", wav_path, text[:1200]],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[ffmpeg, "-y", "-loglevel", "error", "-i", wav_path, "-c:a", "libopus", "-b:a", "48k", ogg_path],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
with open(ogg_path, "rb") as audio_file:
|
||||
return await asyncio.to_thread(audio_file.read)
|
||||
|
||||
|
||||
async def generate_ideas(
|
||||
request: str, tone_of_voice: str, history: list[str], idea_history: list[dict[str, str]], user_id: int | None = None
|
||||
) -> dict[str, str]:
|
||||
|
||||
@@ -12,9 +12,9 @@ from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TEST
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import BufferedInputFile, Message
|
||||
|
||||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
|
||||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, synthesize_idea_voice, transcribe_audio
|
||||
from help_message import HELP_RICH_HTML, rich_to_plain
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
@@ -164,11 +164,19 @@ async def help_command(message: Message) -> None:
|
||||
await message.answer(rich_to_plain(HELP_RICH_HTML))
|
||||
|
||||
|
||||
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 asyncio.to_thread(save_idea, user_id, idea)
|
||||
|
||||
|
||||
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
|
||||
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
|
||||
await asyncio.to_thread(save_idea, user_id, idea)
|
||||
|
||||
|
||||
|
||||
async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None:
|
||||
for post in posts:
|
||||
await message.answer(post)
|
||||
@@ -396,7 +404,21 @@ async def transcribe_voice(message: Message, bot: Bot) -> None:
|
||||
await report_error(message, "обработка голосового и генерация", exc)
|
||||
|
||||
|
||||
@router.message(F.text & F.text.startswith("/"))
|
||||
async def idea_voice_command(message: Message) -> None:
|
||||
if message.from_user is None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
try:
|
||||
async with typing_status(message.bot, message.chat.id):
|
||||
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
|
||||
history = await asyncio.to_thread(get_recent_post_history_text, message.from_user.id, POST_HISTORY_LIMIT, POST_HISTORY_CHAR_LIMIT)
|
||||
idea_history = await asyncio.to_thread(get_idea_history, message.from_user.id, 30)
|
||||
idea = await generate_ideas("предложи идею для поста", tone, history, idea_history, user_id=message.from_user.id)
|
||||
await _send_idea_voice(message, idea, message.from_user.id)
|
||||
except Exception as exc:
|
||||
await report_error(message, "озвучка идеи", exc)
|
||||
|
||||
|
||||
async def handle_command_text(message: Message) -> None:
|
||||
command = (message.text or "").strip().split(maxsplit=1)[0].split("@", 1)[0].casefold()
|
||||
if command == "/help":
|
||||
@@ -406,7 +428,9 @@ async def handle_command_text(message: Message) -> None:
|
||||
elif command == "/clear":
|
||||
await clear_command(message)
|
||||
elif command == "/idea":
|
||||
await _classify_and_respond(message, "предложи идеи для новых постов")
|
||||
await _classify_and_respond(message, "предложи идею для нового поста")
|
||||
elif command == "/idea_voice":
|
||||
await idea_voice_command(message)
|
||||
else:
|
||||
await message.answer("Неизвестная команда. Используй /help.")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ HELP_RICH_HTML = """<h1>Помощь по боту</h1>
|
||||
<h2>Попросить правку</h2>
|
||||
<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>
|
||||
<h2>Команды</h2>
|
||||
<p>Команда <code>/idea_voice</code> отправляет одну идею голосовым сообщением.</p>
|
||||
<p>Быстрые slash-команды только на английском: <code>/help</code> — эта инструкция; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста. Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>
|
||||
"""
|
||||
|
||||
|
||||
@@ -44,11 +44,12 @@ class HelpRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(bot.HELP_COMMANDS, {"help", "помощь"})
|
||||
from pathlib import Path
|
||||
source = Path(bot.__file__).read_text(encoding="utf-8")
|
||||
self.assertIn("@router.message(F.text & F.text.startswith(\"/\"))", source)
|
||||
self.assertIn("async def handle_command_text", source)
|
||||
self.assertIn("if command == \"/help\"", source)
|
||||
self.assertIn("elif command == \"/start\"", source)
|
||||
self.assertIn("elif command == \"/clear\"", source)
|
||||
self.assertIn("elif command == \"/idea\"", source)
|
||||
self.assertIn("elif command == \"/idea_voice\"", source)
|
||||
|
||||
def test_help_rich_message_contains_usage_scenarios(self):
|
||||
from help_message import HELP_RICH_HTML, rich_to_plain
|
||||
@@ -56,6 +57,7 @@ class HelpRoutingTests(unittest.TestCase):
|
||||
text = rich_to_plain(HELP_RICH_HTML)
|
||||
self.assertIn("/clear", text)
|
||||
self.assertIn("/idea", text)
|
||||
self.assertIn("/idea_voice", text)
|
||||
for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"):
|
||||
self.assertIn(term, text)
|
||||
|
||||
|
||||
@@ -195,6 +195,30 @@ class ApinexTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(captured["system_prompt"], apinex_client.TONE_PROFILE_SYSTEM_PROMPT)
|
||||
self.assertEqual(tone, "Разговорный стиль, короткие абзацы")
|
||||
|
||||
async def test_idea_voice_sends_one_audio_message_and_persists_idea_after_send(self):
|
||||
message = SimpleNamespace(answer_voice=AsyncMock())
|
||||
idea = {"title": "Заголовок", "angle": "Угол", "why": "Причина"}
|
||||
|
||||
async def run_in_thread(function, *args):
|
||||
return function(*args)
|
||||
|
||||
with (
|
||||
patch.object(bot, "synthesize_idea_voice", new=AsyncMock(return_value=b"OggS-audio")),
|
||||
patch.object(bot, "save_idea") as save_idea,
|
||||
patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread),
|
||||
):
|
||||
await bot._send_idea_voice(message, idea, 91)
|
||||
|
||||
message.answer_voice.assert_awaited_once()
|
||||
voice_file = message.answer_voice.await_args.args[0]
|
||||
self.assertEqual(voice_file.filename, "idea.ogg")
|
||||
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"))
|
||||
self.assertGreater(len(audio), 1000)
|
||||
|
||||
async def test_idea_generation_returns_topics_only_and_uses_history(self):
|
||||
captured = {}
|
||||
ideas_json = json.dumps({"idea": {"title": "Тема 0", "angle": "Угол 0", "why": "Польза 0"}}, ensure_ascii=False)
|
||||
|
||||
Reference in new issue
Block a user