feat: 🎙️ AI Voice & Video recognition enabled via Faster-Whisper. Fix ffprobe path issues in systemd.
This commit is contained in:
parent
1d5752dfd4
commit
827bc97e92
6 changed files with 100 additions and 87 deletions
|
|
@ -427,3 +427,19 @@ v2.0 🔮 Будущее
|
||||||
---
|
---
|
||||||
|
|
||||||
*Документ обновляется после каждой версии*
|
*Документ обновляется после каждой версии*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 v4.3 — AI Voice & Media (Whisper AI) — ЗАВЕРШЕНО (22.04.2026)
|
||||||
|
**Статус:** ✅ Завершено
|
||||||
|
|
||||||
|
**Функционал:**
|
||||||
|
- [x] Интеграция модели Faster-Whisper (small) для CPU
|
||||||
|
- [x] Авто-расшифровка голосовых сообщений (Voice)
|
||||||
|
- [x] Авто-расшифровка видео-сообщений (Video Notes / Кружочки)
|
||||||
|
- [x] Исправлена проблема с системными путями ffmpeg/ffprobe в systemd
|
||||||
|
- [x] Логирование расшифровок в базу данных `voice_recognition_logs`
|
||||||
|
|
||||||
|
**Планы на будущее:**
|
||||||
|
- [ ] ⏳ **Расшифровка для жильцов:** Разрешить верифицированным пользователям использовать расшифровку (с квотой, например, 3 сообщения в день).
|
||||||
|
- [ ] 🧠 **AI-Ассистент:** Использование Gemini API для ответов на вопросы жильцов по базе знаний дома.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Type=simple
|
||||||
User=matrixhasyou
|
User=matrixhasyou
|
||||||
Group=matrixhasyou
|
Group=matrixhasyou
|
||||||
WorkingDirectory=/home/matrixhasyou/domovoy_bot
|
WorkingDirectory=/home/matrixhasyou/domovoy_bot
|
||||||
Environment="PATH=/home/matrixhasyou/domovoy_bot/venv/bin"
|
Environment="PATH=/home/matrixhasyou/domovoy_bot/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
ExecStart=/home/matrixhasyou/domovoy_bot/venv/bin/python /home/matrixhasyou/domovoy_bot/main.py
|
ExecStart=/home/matrixhasyou/domovoy_bot/venv/bin/python /home/matrixhasyou/domovoy_bot/main.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,70 @@
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, ContentType
|
from aiogram.types import Message
|
||||||
from config import ADMIN_USER_ID
|
from config import ADMIN_USER_ID
|
||||||
from services.voice_service import VoiceService
|
from services.voice_service import VoiceService
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
# Initialize VoiceService
|
_voice_service = None
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
db_path = os.path.join(BASE_DIR, "database", "domovoy.db")
|
|
||||||
voice_service = VoiceService(db_path=db_path, model_size="small")
|
|
||||||
|
|
||||||
@router.message(F.content_type.in_({ContentType.VOICE, ContentType.VIDEO_NOTE}))
|
def get_voice_service():
|
||||||
|
global _voice_service
|
||||||
|
if _voice_service is None:
|
||||||
|
DB_PATH = "/home/matrixhasyou/domovoy_bot/database/domovoy.db"
|
||||||
|
_voice_service = VoiceService(db_path=DB_PATH, model_size="small")
|
||||||
|
return _voice_service
|
||||||
|
|
||||||
|
@router.message(F.voice | F.video_note)
|
||||||
async def handle_voice_message(message: Message, bot: Bot):
|
async def handle_voice_message(message: Message, bot: Bot):
|
||||||
# Только для владельца
|
if int(message.from_user.id) != int(ADMIN_USER_ID):
|
||||||
if message.from_user.id != ADMIN_USER_ID:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
msg_type = "VOICE" if message.voice else "VIDEO_NOTE"
|
status_msg = await message.reply("⏳ <i>Расшифровываю...</i>", parse_mode="HTML")
|
||||||
file_id = message.voice.file_id if message.voice else message.video_note.file_id
|
|
||||||
|
|
||||||
# Отправляем статус "печатает"
|
|
||||||
await bot.send_chat_action(message.chat.id, "typing")
|
await bot.send_chat_action(message.chat.id, "typing")
|
||||||
|
|
||||||
# Создаем папку для временных файлов если её нет
|
# Абсолютный путь к временной папке
|
||||||
temp_dir = os.path.join(BASE_DIR, "temp_voice")
|
temp_dir = Path("/home/matrixhasyou/domovoy_bot/temp_voice")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if message.voice:
|
||||||
|
file_id = message.voice.file_id
|
||||||
|
ext = "oga"
|
||||||
|
msg_type = "VOICE"
|
||||||
|
else:
|
||||||
|
file_id = message.video_note.file_id
|
||||||
|
ext = "mp4"
|
||||||
|
msg_type = "VIDEO_NOTE"
|
||||||
|
|
||||||
file = await bot.get_file(file_id)
|
file = await bot.get_file(file_id)
|
||||||
file_path = os.path.join(temp_dir, f"{file_id}.oga" if message.voice else f"{file_id}.mp4")
|
file_path = temp_dir / f"{file_id}.{ext}"
|
||||||
await bot.download_file(file.file_path, file_path)
|
|
||||||
|
|
||||||
# Распознавание
|
# Скачиваем
|
||||||
text = voice_service.transcribe(
|
await bot.download_file(file.file_path, str(file_path))
|
||||||
file_path=file_path,
|
|
||||||
user_id=message.from_user.id,
|
|
||||||
username=message.from_user.username or "Unknown",
|
|
||||||
msg_type=msg_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Удаляем временный файл
|
# Ждем секунду, чтобы ФС успела "переварить" файл
|
||||||
if os.path.exists(file_path): os.remove(file_path)
|
import asyncio
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"Файл {file_path} не был скачан!")
|
||||||
|
|
||||||
|
service = get_voice_service()
|
||||||
|
text = service.transcribe(str(file_path), message.from_user.id, message.from_user.username or "Owner", msg_type)
|
||||||
|
|
||||||
|
if file_path.exists(): file_path.unlink()
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
await message.reply(f"📝 <b>Распознанный текст:</b>\n\n{text}", parse_mode="HTML")
|
await status_msg.edit_text(f"📝 <b>РАСШИФРОВКА:</b>\n\n{text}", parse_mode="HTML")
|
||||||
else:
|
else:
|
||||||
await message.reply("🔇 Аудио сообщение пустое или не распознано.")
|
await status_msg.edit_text("🔇 Не удалось распознать текст.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Voice handler error: {e}")
|
logger.error(f"❌ Voice Error: {e}")
|
||||||
await message.reply(f"❌ Ошибка обработки: {e}")
|
await status_msg.edit_text(f"❌ Ошибка: {e}")
|
||||||
|
|
||||||
# Export router
|
|
||||||
voice_router = router
|
voice_router = router
|
||||||
|
|
|
||||||
65
main.py
65
main.py
|
|
@ -1,15 +1,4 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
|
||||||
🏠 Домовой Бот — Telegram-бот для чата многоквартирного дома
|
|
||||||
|
|
||||||
ВЕРСИЯ: v4.1 — VOICE-REBORN
|
|
||||||
ДАТА ФИКСАЦИИ: 21 апреля 2026 г.
|
|
||||||
СТАТУС: ✅ ОБНОВЛЕН
|
|
||||||
|
|
||||||
НОВОЕ В v4.1:
|
|
||||||
- ✅ Распознавание голосовых сообщений и видеокружков (Whisper Small)
|
|
||||||
- ✅ Логирование распознанного текста в БД
|
|
||||||
"""
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -21,18 +10,14 @@ BASE_DIR = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(BASE_DIR))
|
sys.path.insert(0, str(BASE_DIR))
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.filters import Command
|
|
||||||
from aiogram.types import Message
|
|
||||||
from aiogram.client.session.aiohttp import AiohttpSession
|
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
from config import (
|
from config import BOT_TOKEN, ADMIN_USER_ID
|
||||||
BOT_TOKEN, ADMIN_USER_ID, ADMIN_CHAT_ID, get_proxy_url,
|
|
||||||
USE_PROXY, PROXY_TYPE, PROXY_HOST, PROXY_PORT
|
|
||||||
)
|
|
||||||
from database.db import init_db
|
from database.db import init_db
|
||||||
|
|
||||||
|
# Импортируем роутеры
|
||||||
|
from handlers.voice import voice_router
|
||||||
from handlers import (
|
from handlers import (
|
||||||
admin_router, users_router, verification_router, chat_monitor_router,
|
admin_router, users_router, verification_router, chat_monitor_router,
|
||||||
security_router, apartments_router, antimat_router, phones_router,
|
security_router, apartments_router, antimat_router, phones_router,
|
||||||
|
|
@ -41,10 +26,8 @@ from handlers import (
|
||||||
toxicity_router, profile_history_router, achievements_router,
|
toxicity_router, profile_history_router, achievements_router,
|
||||||
thanks_router, events_router, initiative_group_router
|
thanks_router, events_router, initiative_group_router
|
||||||
)
|
)
|
||||||
from handlers.voice import voice_router
|
|
||||||
from handlers.mutual_aid import router as mutual_aid_router
|
from handlers.mutual_aid import router as mutual_aid_router
|
||||||
from services.scheduler import Scheduler
|
from services.scheduler import Scheduler
|
||||||
from services.web_server import WebServerService
|
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -55,7 +38,6 @@ logging.basicConfig(
|
||||||
logging.StreamHandler()
|
logging.StreamHandler()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def on_startup(bot: Bot):
|
async def on_startup(bot: Bot):
|
||||||
|
|
@ -63,33 +45,31 @@ async def on_startup(bot: Bot):
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
try:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>')
|
||||||
ADMIN_USER_ID,
|
except: pass
|
||||||
'✅ <b>LKM37 CORE v4.1 VOICE-REBORN АКТИВИРОВАН</b>\n\n'
|
|
||||||
'Модуль распознавания речи (Whisper Small) запущен.\n'
|
|
||||||
'Шли мне голосовухи — я их теперь "читаю"!',
|
|
||||||
parse_mode=ParseMode.HTML
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Ошибка уведомления админа: {e}')
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
from bot_instance import get_bot
|
from bot_instance import get_bot
|
||||||
bot = get_bot()
|
bot = get_bot()
|
||||||
dp = Dispatcher(storage=MemoryStorage())
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
|
||||||
# Регистрация роутеров
|
# КРИТИЧЕСКИ ВАЖНО: voice_router должен быть САМЫМ ПЕРВЫМ
|
||||||
routers = [
|
dp.include_router(voice_router)
|
||||||
admin_router, users_router, verification_router, chat_monitor_router,
|
|
||||||
security_router, apartments_router, antimat_router, phones_router,
|
# Регистрация остальных
|
||||||
polls_router, antispam_router, ratelimit_router, schedule_router,
|
dp.include_router(admin_router)
|
||||||
ads_router, payments_router, multicount_router, topics_router,
|
dp.include_router(users_router)
|
||||||
toxicity_router, profile_history_router, achievements_router,
|
|
||||||
thanks_router, events_router, initiative_group_router,
|
other_routers = [
|
||||||
mutual_aid_router, voice_router # Добавляем голосовой роутер
|
verification_router, chat_monitor_router, security_router,
|
||||||
|
apartments_router, antimat_router, phones_router, polls_router,
|
||||||
|
antispam_router, ratelimit_router, schedule_router, ads_router,
|
||||||
|
payments_router, multicount_router, topics_router, toxicity_router,
|
||||||
|
profile_history_router, achievements_router, thanks_router,
|
||||||
|
events_router, initiative_group_router, mutual_aid_router
|
||||||
]
|
]
|
||||||
|
|
||||||
for r in routers:
|
for r in other_routers:
|
||||||
dp.include_router(r)
|
dp.include_router(r)
|
||||||
|
|
||||||
from handlers.smart_broadcast import router as smart_broadcast_router
|
from handlers.smart_broadcast import router as smart_broadcast_router
|
||||||
|
|
@ -98,8 +78,7 @@ async def main():
|
||||||
dp.include_router(digest_router)
|
dp.include_router(digest_router)
|
||||||
|
|
||||||
dp.startup.register(on_startup)
|
dp.startup.register(on_startup)
|
||||||
|
logger.info('🤖 Бот запускается...')
|
||||||
logger.info('🤖 Ожидание команд субъектов...')
|
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,19 @@ from faster_whisper import WhisperModel
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ЖЕСТКИЙ ФИКС ПУТЕЙ К БИНАРНИКАМ
|
||||||
|
AudioSegment.converter = "/usr/bin/ffmpeg"
|
||||||
|
AudioSegment.ffprobe = "/usr/bin/ffprobe"
|
||||||
|
|
||||||
class VoiceService:
|
class VoiceService:
|
||||||
def __init__(self, db_path: str, model_size: str = "small"):
|
def __init__(self, db_path: str, model_size: str = "small"):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
# CPU-only optimization: int8 is fast on standard CPUs
|
logger.info(f"💾 Загрузка модели Whisper ({model_size})...")
|
||||||
self.model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
self.model = WhisperModel(model_size, device="cpu", compute_type="int8")
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
|
|
@ -33,28 +38,31 @@ class VoiceService:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def transcribe(self, file_path: str, user_id: int, username: str, msg_type: str) -> str:
|
def transcribe(self, file_path: str, user_id: int, username: str, msg_type: str) -> str:
|
||||||
try:
|
f_path = Path(file_path)
|
||||||
start_time = datetime.now()
|
if not f_path.exists():
|
||||||
|
raise FileNotFoundError(f"Файл не найден: {file_path}")
|
||||||
|
|
||||||
# Convert to wav if needed (Telegram uses .oga/.ogg)
|
try:
|
||||||
wav_path = file_path.rsplit('.', 1)[0] + ".wav"
|
logger.info(f"🔄 Конвертация {f_path.name}...")
|
||||||
audio = AudioSegment.from_file(file_path)
|
wav_path = str(f_path.with_suffix('.wav'))
|
||||||
|
|
||||||
|
# Конвертируем
|
||||||
|
audio = AudioSegment.from_file(str(f_path))
|
||||||
duration = int(len(audio) / 1000)
|
duration = int(len(audio) / 1000)
|
||||||
audio.export(wav_path, format="wav")
|
audio.export(wav_path, format="wav")
|
||||||
|
|
||||||
|
logger.info(f"🧠 Расшифровка ({duration}с)...")
|
||||||
segments, info = self.model.transcribe(wav_path, beam_size=5)
|
segments, info = self.model.transcribe(wav_path, beam_size=5)
|
||||||
text = " ".join([segment.text for segment in segments]).strip()
|
text = " ".join([segment.text for segment in segments]).strip()
|
||||||
|
|
||||||
# Clean up temp files
|
# Чистим за собой
|
||||||
if os.path.exists(wav_path): os.remove(wav_path)
|
if os.path.exists(wav_path): os.remove(wav_path)
|
||||||
|
|
||||||
# Log to DB
|
|
||||||
self._log_to_db(user_id, username, msg_type, duration, text)
|
self._log_to_db(user_id, username, msg_type, duration, text)
|
||||||
|
|
||||||
return text
|
return text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Transcription error: {e}")
|
logger.error(f"❌ Ошибка транскрибации: {e}", exc_info=True)
|
||||||
return f"[Ошибка распознавания]: {e}"
|
raise e
|
||||||
|
|
||||||
def _log_to_db(self, user_id, username, msg_type, duration, text):
|
def _log_to_db(self, user_id, username, msg_type, duration, text):
|
||||||
try:
|
try:
|
||||||
|
|
@ -68,5 +76,3 @@ class VoiceService:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DB Log error: {e}")
|
logger.error(f"DB Log error: {e}")
|
||||||
|
|
||||||
# Global instance will be initialized in main.py or handlers
|
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in a new issue