Улучшенный будильник, таймер, перевод
This commit is contained in:
@@ -7,9 +7,9 @@ import json
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from ..core.config import BASE_DIR
|
||||
from ..audio.stt import listen
|
||||
from ..core.commands import is_stop_command
|
||||
|
||||
# Файл базы данных будильников
|
||||
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
||||
@@ -22,6 +22,73 @@ class AlarmClock:
|
||||
self.alarms = []
|
||||
self.load_alarms()
|
||||
|
||||
def _normalize_days(self, days):
|
||||
if not days:
|
||||
return None
|
||||
unique = sorted({int(day) for day in days})
|
||||
return unique
|
||||
|
||||
def _days_key(self, days):
|
||||
normalized = self._normalize_days(days)
|
||||
return tuple(normalized) if normalized else None
|
||||
|
||||
def _format_days_phrase(self, days):
|
||||
normalized = self._normalize_days(days)
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
day_set = set(normalized)
|
||||
if day_set == {0, 1, 2, 3, 4}:
|
||||
return "по будням"
|
||||
if day_set == {5, 6}:
|
||||
return "по выходным"
|
||||
if len(day_set) == 7:
|
||||
return "каждый день"
|
||||
|
||||
names = {
|
||||
0: "понедельникам",
|
||||
1: "вторникам",
|
||||
2: "средам",
|
||||
3: "четвергам",
|
||||
4: "пятницам",
|
||||
5: "субботам",
|
||||
6: "воскресеньям",
|
||||
}
|
||||
ordered = [names[d] for d in normalized if d in names]
|
||||
if not ordered:
|
||||
return ""
|
||||
if len(ordered) == 1:
|
||||
return f"по {ordered[0]}"
|
||||
return "по " + ", ".join(ordered[:-1]) + " и " + ordered[-1]
|
||||
|
||||
def _extract_alarm_days(self, text: str):
|
||||
text = text.lower().replace("ё", "е")
|
||||
days = set()
|
||||
|
||||
if re.search(r"\b(каждый день|ежедневно)\b", text):
|
||||
return [0, 1, 2, 3, 4, 5, 6]
|
||||
|
||||
if re.search(r"\b(по будн|в будн|будние)\b", text):
|
||||
days.update([0, 1, 2, 3, 4])
|
||||
|
||||
if re.search(r"\b(по выходн|в выходн|выходные)\b", text):
|
||||
days.update([5, 6])
|
||||
|
||||
day_patterns = {
|
||||
0: r"\bпонедельн\w*\b",
|
||||
1: r"\bвторник\w*\b",
|
||||
2: r"\bсред\w*\b",
|
||||
3: r"\bчетверг\w*\b",
|
||||
4: r"\bпятниц\w*\b",
|
||||
5: r"\bсуббот\w*\b",
|
||||
6: r"\bвоскресен\w*\b",
|
||||
}
|
||||
for day_idx, pattern in day_patterns.items():
|
||||
if re.search(pattern, text):
|
||||
days.add(day_idx)
|
||||
|
||||
return self._normalize_days(days)
|
||||
|
||||
def load_alarms(self):
|
||||
"""Загрузка списка будильников из JSON файла."""
|
||||
if ALARM_FILE.exists():
|
||||
@@ -42,15 +109,30 @@ class AlarmClock:
|
||||
|
||||
def add_alarm(self, hour: int, minute: int):
|
||||
"""Добавление нового будильника (или обновление существующего)."""
|
||||
return self.add_alarm_with_days(hour, minute, days=None)
|
||||
|
||||
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
||||
"""Добавление нового будильника (или обновление существующего) с днями недели."""
|
||||
days_key = self._days_key(days)
|
||||
for alarm in self.alarms:
|
||||
if alarm["hour"] == hour and alarm["minute"] == minute:
|
||||
if (
|
||||
alarm.get("hour") == hour
|
||||
and alarm.get("minute") == minute
|
||||
and self._days_key(alarm.get("days")) == days_key
|
||||
):
|
||||
alarm["active"] = True
|
||||
alarm["days"] = days_key
|
||||
alarm["last_triggered"] = None
|
||||
self.save_alarms()
|
||||
return
|
||||
|
||||
self.alarms.append({"hour": hour, "minute": minute, "active": True})
|
||||
self.alarms.append(
|
||||
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
||||
)
|
||||
self.save_alarms()
|
||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}")
|
||||
days_phrase = self._format_days_phrase(days_key)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
||||
|
||||
def cancel_all_alarms(self):
|
||||
"""Выключение (деактивация) всех будильников."""
|
||||
@@ -59,6 +141,27 @@ class AlarmClock:
|
||||
self.save_alarms()
|
||||
print("🔕 Все будильники отменены.")
|
||||
|
||||
def describe_alarms(self) -> str:
|
||||
"""Возвращает текстовое описание активных будильников."""
|
||||
active = [
|
||||
alarm
|
||||
for alarm in self.alarms
|
||||
if alarm.get("active") and "hour" in alarm and "minute" in alarm
|
||||
]
|
||||
if not active:
|
||||
return "Активных будильников нет."
|
||||
|
||||
active.sort(key=lambda a: (a["hour"], a["minute"]))
|
||||
items = []
|
||||
for alarm in active:
|
||||
time_str = f"{alarm['hour']:02d}:{alarm['minute']:02d}"
|
||||
days_phrase = self._format_days_phrase(alarm.get("days"))
|
||||
if days_phrase:
|
||||
items.append(f"{days_phrase} в {time_str}")
|
||||
else:
|
||||
items.append(time_str)
|
||||
return "Активные будильники: " + ", ".join(items) + "."
|
||||
|
||||
def check_alarms(self):
|
||||
"""
|
||||
Проверка: не пора ли звенеть?
|
||||
@@ -71,12 +174,30 @@ class AlarmClock:
|
||||
for alarm in self.alarms:
|
||||
if alarm["active"]:
|
||||
if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
|
||||
days = self._normalize_days(alarm.get("days"))
|
||||
if days and now.weekday() not in days:
|
||||
continue
|
||||
|
||||
last_triggered = alarm.get("last_triggered")
|
||||
if last_triggered:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_triggered)
|
||||
if (
|
||||
last_dt.date() == now.date()
|
||||
and last_dt.hour == now.hour
|
||||
and last_dt.minute == now.minute
|
||||
):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(
|
||||
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
|
||||
)
|
||||
alarm["active"] = (
|
||||
False # Одноразовый будильник, выключаем после срабатывания
|
||||
)
|
||||
if not days:
|
||||
# Одноразовый будильник, выключаем после срабатывания
|
||||
alarm["active"] = False
|
||||
alarm["last_triggered"] = now.isoformat()
|
||||
triggered = True
|
||||
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
|
||||
break # Звоним только один за раз
|
||||
@@ -106,21 +227,11 @@ class AlarmClock:
|
||||
return
|
||||
|
||||
try:
|
||||
stop_words = [
|
||||
"стоп",
|
||||
"хватит",
|
||||
"тихо",
|
||||
"замолчи",
|
||||
"отмена",
|
||||
"александр стоп",
|
||||
]
|
||||
|
||||
# Цикл ожидания стоп-команды
|
||||
while True:
|
||||
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
||||
if text:
|
||||
text_lower = text.lower()
|
||||
if any(word in text_lower for word in stop_words):
|
||||
if is_stop_command(text, mode="lenient"):
|
||||
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
||||
break
|
||||
|
||||
@@ -144,17 +255,26 @@ class AlarmClock:
|
||||
if "будильник" not in text and "разбуди" not in text:
|
||||
return None
|
||||
|
||||
if "будильник" in text and re.search(
|
||||
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
||||
):
|
||||
return self.describe_alarms()
|
||||
|
||||
if "отмени" in text:
|
||||
self.cancel_all_alarms()
|
||||
return "Хорошо, я отменил все будильники."
|
||||
|
||||
days = self._extract_alarm_days(text)
|
||||
|
||||
# Поиск формата "7:30", "7.30"
|
||||
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
|
||||
if match:
|
||||
h, m = int(match.group(1)), int(match.group(2))
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm(h, m)
|
||||
return f"Я установил будильник на {h} часов {m} минут."
|
||||
self.add_alarm_with_days(h, m, days=days)
|
||||
days_phrase = self._format_days_phrase(days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Я установил будильник на {h} часов {m} минут{suffix}."
|
||||
|
||||
# Поиск формата словами "на 7 часов 15 минут"
|
||||
match_time = re.search(
|
||||
@@ -174,8 +294,10 @@ class AlarmClock:
|
||||
h = 0
|
||||
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm(h, m)
|
||||
return f"Хорошо, разбужу вас в {h}:{m:02d}."
|
||||
self.add_alarm_with_days(h, m, days=days)
|
||||
days_phrase = self._format_days_phrase(days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||
|
||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
||||
|
||||
|
||||
324
app/features/music.py
Normal file
324
app/features/music.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Spotify Music Controller
|
||||
Модуль для управления воспроизведением музыки через Spotify API.
|
||||
|
||||
Поддерживаемые команды:
|
||||
- "включи музыку" / "play music" - воспроизведение топ-треков или по запросу
|
||||
- "пауза" / "стоп музыка" - пауза
|
||||
- "продолжи" / "дальше" - возобновление
|
||||
- "следующий трек" / "next" - следующий трек
|
||||
- "предыдущий трек" / "previous" - предыдущий трек
|
||||
- "что играет" / "какая песня" - информация о текущем треке
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
except ImportError:
|
||||
spotipy = None
|
||||
SpotifyOAuth = None
|
||||
|
||||
# Singleton instance
|
||||
_music_controller = None
|
||||
|
||||
|
||||
class SpotifyMusicController:
|
||||
"""Контроллер для управления Spotify воспроизведением."""
|
||||
|
||||
# Scopes для Spotify API
|
||||
SCOPES = [
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
"user-read-currently-playing",
|
||||
"user-top-read",
|
||||
"streaming",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация контроллера Spotify."""
|
||||
self.sp = None
|
||||
self._initialized = False
|
||||
self._init_error = None
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
Инициализация подключения к Spotify.
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
if self._initialized:
|
||||
return True
|
||||
|
||||
if spotipy is None:
|
||||
self._init_error = "Библиотека spotipy не установлена. Установите: pip install spotipy"
|
||||
print(f"⚠️ Spotify: {self._init_error}")
|
||||
return False
|
||||
|
||||
# Проверяем наличие ключей
|
||||
client_id = os.getenv("SPOTIFY_CLIENT_ID")
|
||||
client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
|
||||
redirect_uri = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback")
|
||||
|
||||
if not client_id or not client_secret:
|
||||
self._init_error = "Не заданы SPOTIFY_CLIENT_ID и SPOTIFY_CLIENT_SECRET в .env"
|
||||
print(f"⚠️ Spotify: {self._init_error}")
|
||||
return False
|
||||
|
||||
try:
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=" ".join(self.SCOPES),
|
||||
cache_path=".spotify_cache",
|
||||
)
|
||||
self.sp = spotipy.Spotify(auth_manager=auth_manager)
|
||||
# Проверяем подключение
|
||||
self.sp.current_user()
|
||||
self._initialized = True
|
||||
print("✅ Spotify: подключено")
|
||||
return True
|
||||
except Exception as e:
|
||||
self._init_error = str(e)
|
||||
print(f"❌ Spotify: ошибка инициализации - {e}")
|
||||
return False
|
||||
|
||||
def _ensure_initialized(self) -> bool:
|
||||
"""Проверяет и инициализирует при необходимости."""
|
||||
if not self._initialized:
|
||||
return self.initialize()
|
||||
return True
|
||||
|
||||
def _get_active_device(self) -> Optional[str]:
|
||||
"""Получить ID активного устройства воспроизведения."""
|
||||
try:
|
||||
devices = self.sp.devices()
|
||||
if devices and devices.get("devices"):
|
||||
# Ищем активное устройство или берем первое
|
||||
for device in devices["devices"]:
|
||||
if device.get("is_active"):
|
||||
return device["id"]
|
||||
# Если нет активного, берем первое
|
||||
return devices["devices"][0]["id"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def play_music(self, query: Optional[str] = None) -> str:
|
||||
"""
|
||||
Включить музыку.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос (название песни/артиста).
|
||||
Если None, включает топ-треки пользователя.
|
||||
|
||||
Returns:
|
||||
Сообщение для озвучки.
|
||||
"""
|
||||
if not self._ensure_initialized():
|
||||
return f"Не удалось подключиться к Spotify. {self._init_error or ''}"
|
||||
|
||||
try:
|
||||
device_id = self._get_active_device()
|
||||
|
||||
if query:
|
||||
# Поиск по запросу
|
||||
results = self.sp.search(q=query, type="track", limit=1)
|
||||
tracks = results.get("tracks", {}).get("items", [])
|
||||
|
||||
if not tracks:
|
||||
return f"Не нашёл песню '{query}'"
|
||||
|
||||
track = tracks[0]
|
||||
track_name = track["name"]
|
||||
artist_name = track["artists"][0]["name"]
|
||||
track_uri = track["uri"]
|
||||
|
||||
self.sp.start_playback(device_id=device_id, uris=[track_uri])
|
||||
return f"Включаю {track_name} от {artist_name}"
|
||||
else:
|
||||
# Включаем топ-треки пользователя
|
||||
top_tracks = self.sp.current_user_top_tracks(limit=20, time_range="medium_term")
|
||||
if top_tracks and top_tracks.get("items"):
|
||||
uris = [track["uri"] for track in top_tracks["items"]]
|
||||
self.sp.start_playback(device_id=device_id, uris=uris)
|
||||
first_track = top_tracks["items"][0]
|
||||
return f"Включаю вашу музыку. Сейчас играет {first_track['name']}"
|
||||
else:
|
||||
# Если нет топ-треков, ставим что-нибудь популярное
|
||||
self.sp.start_playback(device_id=device_id)
|
||||
return "Включаю музыку"
|
||||
|
||||
except spotipy.SpotifyException as e:
|
||||
if "NO_ACTIVE_DEVICE" in str(e) or "Player command failed" in str(e):
|
||||
return "Нет активного устройства Spotify. Откройте Spotify на телефоне или компьютере."
|
||||
elif "PREMIUM_REQUIRED" in str(e):
|
||||
return "Для управления воспроизведением нужен Spotify Premium."
|
||||
return f"Ошибка Spotify: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
except Exception as e:
|
||||
return f"Ошибка воспроизведения: {e}"
|
||||
|
||||
def pause_music(self) -> str:
|
||||
"""Поставить на паузу."""
|
||||
if not self._ensure_initialized():
|
||||
return "Spotify не подключён"
|
||||
|
||||
try:
|
||||
self.sp.pause_playback()
|
||||
return "Музыка на паузе"
|
||||
except spotipy.SpotifyException as e:
|
||||
if "NO_ACTIVE_DEVICE" in str(e):
|
||||
return "Нет активного устройства Spotify"
|
||||
return f"Не удалось поставить на паузу: {e}"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
def resume_music(self) -> str:
|
||||
"""Продолжить воспроизведение."""
|
||||
if not self._ensure_initialized():
|
||||
return "Spotify не подключён"
|
||||
|
||||
try:
|
||||
self.sp.start_playback()
|
||||
return "Продолжаю воспроизведение"
|
||||
except spotipy.SpotifyException as e:
|
||||
if "NO_ACTIVE_DEVICE" in str(e):
|
||||
return "Нет активного устройства Spotify"
|
||||
return f"Ошибка: {e}"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
def next_track(self) -> str:
|
||||
"""Следующий трек."""
|
||||
if not self._ensure_initialized():
|
||||
return "Spotify не подключён"
|
||||
|
||||
try:
|
||||
self.sp.next_track()
|
||||
# Небольшая задержка для обновления состояния
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
return self.get_current_track() or "Переключаю на следующий трек"
|
||||
except Exception as e:
|
||||
return f"Не удалось переключить трек: {e}"
|
||||
|
||||
def previous_track(self) -> str:
|
||||
"""Предыдущий трек."""
|
||||
if not self._ensure_initialized():
|
||||
return "Spotify не подключён"
|
||||
|
||||
try:
|
||||
self.sp.previous_track()
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
return self.get_current_track() or "Переключаю на предыдущий трек"
|
||||
except Exception as e:
|
||||
return f"Не удалось переключить трек: {e}"
|
||||
|
||||
def get_current_track(self) -> Optional[str]:
|
||||
"""Получить информацию о текущем треке."""
|
||||
if not self._ensure_initialized():
|
||||
return "Spotify не подключён"
|
||||
|
||||
try:
|
||||
current = self.sp.current_playback()
|
||||
if current and current.get("item"):
|
||||
track = current["item"]
|
||||
name = track["name"]
|
||||
artists = ", ".join(a["name"] for a in track["artists"])
|
||||
is_playing = current.get("is_playing", False)
|
||||
status = "Сейчас играет" if is_playing else "На паузе"
|
||||
return f"{status}: {name} от {artists}"
|
||||
return "Сейчас ничего не играет"
|
||||
except Exception as e:
|
||||
return f"Не удалось получить информацию: {e}"
|
||||
|
||||
def parse_command(self, text: str) -> Optional[str]:
|
||||
"""
|
||||
Распознать музыкальную команду и выполнить её.
|
||||
|
||||
Args:
|
||||
text: Текст команды от пользователя.
|
||||
|
||||
Returns:
|
||||
Ответ для озвучки или None, если это не музыкальная команда.
|
||||
"""
|
||||
text_lower = text.lower().strip()
|
||||
|
||||
# Команды паузы
|
||||
pause_patterns = [
|
||||
r"^(поставь на паузу|пауза|стоп музык|останови музык|выключи музык)",
|
||||
r"(pause|stop music)",
|
||||
]
|
||||
for pattern in pause_patterns:
|
||||
if re.search(pattern, text_lower):
|
||||
return self.pause_music()
|
||||
|
||||
# Команды продолжения
|
||||
resume_patterns = [
|
||||
r"^(продолжи|продолжай|возобнови|сними с паузы|дальше|играй дальше)",
|
||||
r"(resume|continue|play)",
|
||||
]
|
||||
for pattern in resume_patterns:
|
||||
if re.search(pattern, text_lower):
|
||||
return self.resume_music()
|
||||
|
||||
# Следующий трек
|
||||
next_patterns = [
|
||||
r"(следующ|дальше|скип|пропусти|next|skip)",
|
||||
]
|
||||
for pattern in next_patterns:
|
||||
if re.search(pattern, text_lower) and "трек" in text_lower or "песн" in text_lower or "skip" in text_lower or "next" in text_lower:
|
||||
return self.next_track()
|
||||
|
||||
# Предыдущий трек
|
||||
prev_patterns = [
|
||||
r"(предыдущ|назад|верни|previous|back)",
|
||||
]
|
||||
for pattern in prev_patterns:
|
||||
if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower):
|
||||
return self.previous_track()
|
||||
|
||||
# Что играет
|
||||
current_patterns = [
|
||||
r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))",
|
||||
r"(what.*(play|song)|current track)",
|
||||
]
|
||||
for pattern in current_patterns:
|
||||
if re.search(pattern, text_lower):
|
||||
return self.get_current_track()
|
||||
|
||||
# Включить музыку (с возможным запросом)
|
||||
play_patterns = [
|
||||
(r"^включи\s+музыку$", None), # Просто "включи музыку"
|
||||
(r"^включи\s+(.+)$", 1), # "включи [что-то]"
|
||||
(r"^поставь\s+(.+)$", 1), # "поставь [что-то]"
|
||||
(r"^играй\s+(.+)$", 1), # "играй [что-то]"
|
||||
(r"^play\s+(.+)$", 1), # "play [something]"
|
||||
(r"^(play music|включи музыку|поставь музыку)$", None),
|
||||
]
|
||||
|
||||
for pattern, group in play_patterns:
|
||||
match = re.search(pattern, text_lower)
|
||||
if match:
|
||||
query = None
|
||||
if group:
|
||||
query = match.group(group).strip()
|
||||
# Убираем слова "музыку", "песню", "трек" из запроса
|
||||
query = re.sub(r"(музыку|песню|трек|песня|song|track)\s*", "", query).strip()
|
||||
if not query:
|
||||
query = None
|
||||
return self.play_music(query)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_music_controller() -> SpotifyMusicController:
|
||||
"""Получить singleton экземпляр контроллера музыки."""
|
||||
global _music_controller
|
||||
if _music_controller is None:
|
||||
_music_controller = SpotifyMusicController()
|
||||
return _music_controller
|
||||
@@ -5,19 +5,307 @@
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from ..core.config import BASE_DIR
|
||||
from ..audio.stt import listen
|
||||
from ..core.commands import is_stop_command
|
||||
|
||||
# Morphological analysis for better recognition of number words.
|
||||
try:
|
||||
import pymorphy3
|
||||
|
||||
_MORPH = pymorphy3.MorphAnalyzer()
|
||||
except Exception:
|
||||
_MORPH = None
|
||||
|
||||
# Звуковой файл сигнала (используем тот же, что и для будильника)
|
||||
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
||||
TIMER_FILE = BASE_DIR / "data" / "timers.json"
|
||||
|
||||
# --- Number words parsing helpers (ru) ---
|
||||
_NUMBER_UNITS = {
|
||||
"ноль": 0,
|
||||
"один": 1,
|
||||
"два": 2,
|
||||
"три": 3,
|
||||
"четыре": 4,
|
||||
"пять": 5,
|
||||
"шесть": 6,
|
||||
"семь": 7,
|
||||
"восемь": 8,
|
||||
"девять": 9,
|
||||
}
|
||||
_NUMBER_TEENS = {
|
||||
"десять": 10,
|
||||
"одиннадцать": 11,
|
||||
"двенадцать": 12,
|
||||
"тринадцать": 13,
|
||||
"четырнадцать": 14,
|
||||
"пятнадцать": 15,
|
||||
"шестнадцать": 16,
|
||||
"семнадцать": 17,
|
||||
"восемнадцать": 18,
|
||||
"девятнадцать": 19,
|
||||
}
|
||||
_NUMBER_TENS = {
|
||||
"двадцать": 20,
|
||||
"тридцать": 30,
|
||||
"сорок": 40,
|
||||
"пятьдесят": 50,
|
||||
"шестьдесят": 60,
|
||||
"семьдесят": 70,
|
||||
"восемьдесят": 80,
|
||||
"девяносто": 90,
|
||||
}
|
||||
_NUMBER_HUNDREDS = {
|
||||
"сто": 100,
|
||||
"двести": 200,
|
||||
"триста": 300,
|
||||
"четыреста": 400,
|
||||
"пятьсот": 500,
|
||||
"шестьсот": 600,
|
||||
"семьсот": 700,
|
||||
"восемьсот": 800,
|
||||
"девятьсот": 900,
|
||||
}
|
||||
_NUMBER_SPECIAL = {
|
||||
"пол": 0.5,
|
||||
"полтора": 1.5,
|
||||
"полторы": 1.5,
|
||||
}
|
||||
_NUMBER_LEMMAS = (
|
||||
set(_NUMBER_UNITS)
|
||||
| set(_NUMBER_TEENS)
|
||||
| set(_NUMBER_TENS)
|
||||
| set(_NUMBER_HUNDREDS)
|
||||
| set(_NUMBER_SPECIAL)
|
||||
)
|
||||
_IGNORED_LEMMAS = {
|
||||
"на",
|
||||
"в",
|
||||
"во",
|
||||
"за",
|
||||
"через",
|
||||
"по",
|
||||
"к",
|
||||
"ко",
|
||||
"с",
|
||||
"со",
|
||||
"и",
|
||||
}
|
||||
_UNIT_LEMMAS = {
|
||||
"час": "hours",
|
||||
"минута": "minutes",
|
||||
"секунда": "seconds",
|
||||
"мин": "minutes",
|
||||
"сек": "seconds",
|
||||
}
|
||||
_UNIT_FORMS = {
|
||||
"hours": ("час", "часа", "часов"),
|
||||
"minutes": ("минуту", "минуты", "минут"),
|
||||
"seconds": ("секунду", "секунды", "секунд"),
|
||||
}
|
||||
|
||||
# Optional ordinal formatting for list numbering.
|
||||
try:
|
||||
from num2words import num2words
|
||||
except Exception:
|
||||
num2words = None
|
||||
|
||||
|
||||
def _lemmatize(token: str) -> str:
|
||||
if _MORPH is None:
|
||||
return token
|
||||
return _MORPH.parse(token)[0].normal_form
|
||||
|
||||
|
||||
def _tokenize_with_lemmas(text: str):
|
||||
tokens = []
|
||||
for match in re.finditer(r"[a-zA-Zа-яА-ЯёЁ]+|\d+", text.lower()):
|
||||
raw = match.group(0)
|
||||
lemma = _lemmatize(raw) if not raw.isdigit() else raw
|
||||
tokens.append({"raw": raw, "lemma": lemma})
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse_number_lemmas(lemmas):
|
||||
if not lemmas:
|
||||
return None
|
||||
|
||||
if len(lemmas) == 1 and lemmas[0] in _NUMBER_SPECIAL:
|
||||
return _NUMBER_SPECIAL[lemmas[0]]
|
||||
|
||||
total = 0
|
||||
idx = 0
|
||||
|
||||
if lemmas[idx] in _NUMBER_HUNDREDS:
|
||||
total += _NUMBER_HUNDREDS[lemmas[idx]]
|
||||
idx += 1
|
||||
|
||||
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TEENS:
|
||||
total += _NUMBER_TEENS[lemmas[idx]]
|
||||
idx += 1
|
||||
else:
|
||||
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TENS:
|
||||
total += _NUMBER_TENS[lemmas[idx]]
|
||||
idx += 1
|
||||
if idx < len(lemmas) and lemmas[idx] in _NUMBER_UNITS:
|
||||
total += _NUMBER_UNITS[lemmas[idx]]
|
||||
idx += 1
|
||||
|
||||
if total == 0 and lemmas[0] in _NUMBER_UNITS:
|
||||
return _NUMBER_UNITS[lemmas[0]]
|
||||
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
def _normalize_timer_text(text: str) -> str:
|
||||
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
|
||||
return re.sub(
|
||||
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
|
||||
"пол ",
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
def _find_word_number_before_unit(tokens, unit_index):
|
||||
collected = []
|
||||
idx = unit_index - 1
|
||||
while idx >= 0 and len(collected) < 4:
|
||||
lemma = tokens[idx]["lemma"]
|
||||
if lemma in _IGNORED_LEMMAS:
|
||||
idx -= 1
|
||||
continue
|
||||
if lemma in _NUMBER_LEMMAS:
|
||||
collected.insert(0, lemma)
|
||||
idx -= 1
|
||||
continue
|
||||
break
|
||||
return _parse_number_lemmas(collected)
|
||||
|
||||
|
||||
def _extract_word_time_values(text: str):
|
||||
tokens = _tokenize_with_lemmas(text)
|
||||
values = {"hours": None, "minutes": None, "seconds": None}
|
||||
|
||||
for idx, token in enumerate(tokens):
|
||||
lemma = token["lemma"]
|
||||
key = _UNIT_LEMMAS.get(lemma)
|
||||
if not key:
|
||||
continue
|
||||
value = _find_word_number_before_unit(tokens, idx)
|
||||
if value is not None:
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def _format_unit(value: int, unit_key: str) -> str:
|
||||
if unit_key not in _UNIT_FORMS:
|
||||
return f"{value}"
|
||||
|
||||
one, few, many = _UNIT_FORMS[unit_key]
|
||||
n = abs(int(value))
|
||||
if n % 100 in (11, 12, 13, 14):
|
||||
word = many
|
||||
elif n % 10 == 1:
|
||||
word = one
|
||||
elif n % 10 in (2, 3, 4):
|
||||
word = few
|
||||
else:
|
||||
word = many
|
||||
return f"{value} {word}"
|
||||
|
||||
|
||||
def _format_duration(total_seconds: float) -> str:
|
||||
total_seconds = int(round(total_seconds))
|
||||
if total_seconds < 0:
|
||||
total_seconds = 0
|
||||
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
parts = []
|
||||
if hours:
|
||||
parts.append(_format_unit(hours, "hours"))
|
||||
if minutes:
|
||||
parts.append(_format_unit(minutes, "minutes"))
|
||||
if seconds or not parts:
|
||||
parts.append(_format_unit(seconds, "seconds"))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _format_ordinal_index(index: int) -> str:
|
||||
if num2words is None:
|
||||
return f"{index}-й"
|
||||
try:
|
||||
return num2words(index, lang="ru", to="ordinal", case="nominative", gender="m")
|
||||
except Exception:
|
||||
return f"{index}-й"
|
||||
|
||||
|
||||
class TimerManager:
|
||||
def __init__(self):
|
||||
# Список активных таймеров: {"end_time": datetime, "label": str}
|
||||
self.timers = []
|
||||
self.load_timers()
|
||||
|
||||
def load_timers(self):
|
||||
"""Загрузка списка таймеров из JSON файла."""
|
||||
if TIMER_FILE.exists():
|
||||
try:
|
||||
with open(TIMER_FILE, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка загрузки таймеров: {e}")
|
||||
return
|
||||
|
||||
timers = []
|
||||
for item in raw:
|
||||
try:
|
||||
end_time = datetime.fromisoformat(item["end_time"])
|
||||
except Exception:
|
||||
continue
|
||||
label = item.get("label", "")
|
||||
timers.append({"end_time": end_time, "label": label})
|
||||
|
||||
self.timers = sorted(timers, key=lambda x: x["end_time"])
|
||||
|
||||
def save_timers(self):
|
||||
"""Сохранение списка таймеров в JSON файл."""
|
||||
payload = [
|
||||
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
|
||||
for t in self.timers
|
||||
]
|
||||
try:
|
||||
with open(TIMER_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка сохранения таймеров: {e}")
|
||||
|
||||
def describe_timers(self) -> str:
|
||||
"""Возвращает текстовое описание активных таймеров."""
|
||||
if not self.timers:
|
||||
return "Активных таймеров нет."
|
||||
|
||||
now = datetime.now()
|
||||
items = []
|
||||
for idx, timer in enumerate(self.timers, start=1):
|
||||
remaining = (timer["end_time"] - now).total_seconds()
|
||||
label = timer.get("label", "").strip()
|
||||
ordinal = _format_ordinal_index(idx)
|
||||
if remaining <= 0:
|
||||
status = "сработает сейчас"
|
||||
else:
|
||||
status = f"осталось {_format_duration(remaining)}"
|
||||
|
||||
if label:
|
||||
items.append(f"{ordinal}) {label} — {status}")
|
||||
else:
|
||||
items.append(f"{ordinal}) {status}")
|
||||
|
||||
return "Активные таймеры: " + "; ".join(items) + "."
|
||||
|
||||
def add_timer(self, seconds: int, label: str):
|
||||
"""Добавление нового таймера."""
|
||||
@@ -25,12 +313,14 @@ class TimerManager:
|
||||
self.timers.append({"end_time": end_time, "label": label})
|
||||
# Сортируем, чтобы ближайший был первым
|
||||
self.timers.sort(key=lambda x: x["end_time"])
|
||||
self.save_timers()
|
||||
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
|
||||
|
||||
def cancel_all_timers(self):
|
||||
"""Отмена всех таймеров."""
|
||||
count = len(self.timers)
|
||||
self.timers = []
|
||||
self.save_timers()
|
||||
print(f"🔕 Все таймеры ({count}) отменены.")
|
||||
|
||||
def check_timers(self):
|
||||
@@ -52,6 +342,7 @@ class TimerManager:
|
||||
# Удаляем его из списка
|
||||
label = first_timer["label"]
|
||||
self.timers.pop(0)
|
||||
self.save_timers()
|
||||
|
||||
print(f"⌛ ТАЙМЕР ИСТЕК: {label}")
|
||||
self.trigger_timer(label)
|
||||
@@ -78,22 +369,11 @@ class TimerManager:
|
||||
return
|
||||
|
||||
try:
|
||||
stop_words = [
|
||||
"стоп",
|
||||
"хватит",
|
||||
"тихо",
|
||||
"замолчи",
|
||||
"отмена",
|
||||
"александр стоп",
|
||||
"спасибо",
|
||||
]
|
||||
|
||||
# Цикл ожидания стоп-команды
|
||||
while True:
|
||||
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
||||
if text:
|
||||
text_lower = text.lower()
|
||||
if any(word in text_lower for word in stop_words):
|
||||
if is_stop_command(text, mode="lenient"):
|
||||
print(f"🛑 Таймер остановлен по команде: '{text}'")
|
||||
break
|
||||
|
||||
@@ -113,12 +393,17 @@ class TimerManager:
|
||||
Парсинг команды установки таймера.
|
||||
Примеры: "таймер на 5 минут", "засеки 10 секунд".
|
||||
"""
|
||||
text = text.lower()
|
||||
text = _normalize_timer_text(text.lower())
|
||||
|
||||
# Ключевые слова для таймера
|
||||
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
|
||||
return None
|
||||
|
||||
if "таймер" in text and re.search(
|
||||
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
||||
):
|
||||
return self.describe_timers()
|
||||
|
||||
if "отмени" in text or "удали" in text:
|
||||
self.cancel_all_timers()
|
||||
return "Хорошо, все таймеры отменены."
|
||||
@@ -128,37 +413,69 @@ class TimerManager:
|
||||
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
|
||||
|
||||
total_seconds = 0
|
||||
found_time = False
|
||||
parts = []
|
||||
hours = None
|
||||
minutes = None
|
||||
seconds = None
|
||||
has_fractional = False
|
||||
|
||||
# Часы
|
||||
match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text)
|
||||
if match_hours:
|
||||
h = int(match_hours.group(1))
|
||||
total_seconds += h * 3600
|
||||
parts.append(f"{h} ч")
|
||||
found_time = True
|
||||
hours = int(match_hours.group(1))
|
||||
|
||||
# Минуты
|
||||
match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text)
|
||||
if match_minutes:
|
||||
m = int(match_minutes.group(1))
|
||||
total_seconds += m * 60
|
||||
parts.append(f"{m} мин")
|
||||
found_time = True
|
||||
minutes = int(match_minutes.group(1))
|
||||
|
||||
# Секунды
|
||||
match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text)
|
||||
if match_seconds:
|
||||
s = int(match_seconds.group(1))
|
||||
total_seconds += s
|
||||
parts.append(f"{s} сек")
|
||||
found_time = True
|
||||
seconds = int(match_seconds.group(1))
|
||||
|
||||
# Дополняем числительные словами (например, "одну минуту")
|
||||
word_values = _extract_word_time_values(text)
|
||||
if hours is None and word_values["hours"] is not None:
|
||||
hours = word_values["hours"]
|
||||
if minutes is None and word_values["minutes"] is not None:
|
||||
minutes = word_values["minutes"]
|
||||
if seconds is None and word_values["seconds"] is not None:
|
||||
seconds = word_values["seconds"]
|
||||
|
||||
if hours is not None and isinstance(hours, float) and hours % 1 != 0:
|
||||
has_fractional = True
|
||||
if minutes is not None and isinstance(minutes, float) and minutes % 1 != 0:
|
||||
has_fractional = True
|
||||
if seconds is not None and isinstance(seconds, float) and seconds % 1 != 0:
|
||||
has_fractional = True
|
||||
|
||||
found_time = any(value is not None for value in [hours, minutes, seconds])
|
||||
if found_time:
|
||||
total_seconds = (
|
||||
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
|
||||
)
|
||||
if has_fractional:
|
||||
total_seconds = int(round(total_seconds))
|
||||
h = total_seconds // 3600
|
||||
m = (total_seconds % 3600) // 60
|
||||
s = total_seconds % 60
|
||||
else:
|
||||
h = int(hours) if hours is not None else 0
|
||||
m = int(minutes) if minutes is not None else 0
|
||||
s = int(seconds) if seconds is not None else 0
|
||||
|
||||
if h:
|
||||
parts.append(_format_unit(h, "hours"))
|
||||
if m:
|
||||
parts.append(_format_unit(m, "minutes"))
|
||||
if s or not parts:
|
||||
parts.append(_format_unit(s, "seconds"))
|
||||
|
||||
if found_time and total_seconds > 0:
|
||||
label = " ".join(parts)
|
||||
self.add_timer(total_seconds, label)
|
||||
return f"Засек {label}."
|
||||
return f"Поставил таймер на {label}."
|
||||
|
||||
# Если сказали "таймер", но не нашли время
|
||||
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
||||
@@ -172,4 +489,4 @@ def get_timer_manager():
|
||||
global _timer_manager
|
||||
if _timer_manager is None:
|
||||
_timer_manager = TimerManager()
|
||||
return _timer_manager
|
||||
return _timer_manager
|
||||
|
||||
Reference in New Issue
Block a user