Улучшенный будильник, таймер, перевод

This commit is contained in:
2026-02-01 19:59:18 +03:00
parent 49dbaad122
commit d0b12009b3
15 changed files with 1013 additions and 105 deletions

View File

@@ -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"\етверг\w*\b",
4: r"\bпятниц\w*\b",
5: r"\bсуббот\w*\b",
6: r"\оскресен\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
View 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

View File

@@ -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