diff --git a/commands.yaml b/commands.yaml index dc99f0a..2daba39 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1335,3 +1335,163 @@ daily_quote: - вдохнови меня - цитата action: {type: daily_quote} + +magic_8ball: + phrases: + - ответь да или нет + - магический шар + - восьмой шар + - скажи да или нет + - что скажешь + - предсказание + action: {type: magic_8ball} + +gh_list_issues: + phrases: + - какие issues + - какие задачи + - открытые issues + - список issues + - что в issues + - тикеты + action: {type: gh_list_issues} + +gh_my_issues: + phrases: + - мои issues + - что мне назначено + action: {type: gh_my_issues} + +weather_tomorrow: + phrases: + - погода завтра + - что с погодой завтра + - прогноз на завтра + action: {type: weather_tomorrow} + +weather_week: + phrases: + - прогноз на неделю + - погода на неделю + - недельный прогноз + action: {type: weather_week} + +rss_add: + phrases: + - добавь rss + - подпишись на + - запомни ленту + - добавь ленту + action: {type: rss_add} + +rss_read: + phrases: + - что в ленте + - новости из ленты + - почитай rss + - новости из rss + - новые статьи + action: {type: rss_read} + +rss_list: + phrases: + - какие у меня rss + - список лент + - мои подписки + action: {type: rss_list} + +ics_create: + phrases: + - добавь встречу + - создай событие + - создай встречу + - новая встреча + action: {type: ics_create} + +backup_export: + phrases: + - сделай бекап + - экспортируй настройки + - сохрани мои настройки + - выгрузи настройки + action: {type: backup_export} + +clip_save: + phrases: + - запиши буфер + - сохрани буфер + - запомни буфер + - запомни что в буфере + action: {type: clip_save} + +clip_list: + phrases: + - что я копировал + - история буфера + - последние буферы + - что было в буфере + action: {type: clip_list} + +clip_restore: + phrases: + - верни первый буфер + - верни второй буфер + - верни третий буфер + - восстанови буфер + - вставь старый буфер + action: {type: clip_restore} + +notif_missed: + phrases: + - что я пропустил + - что было пока меня не было + - какие уведомления + - пропущенные + action: {type: notif_missed} + +notif_clear: + phrases: + - очисти уведомления + - забудь уведомления + - сбрось пропущенное + action: {type: notif_clear} + +vault_save: + phrases: + - сохрани пароль от + - запомни пароль от + - пароль для + action: {type: vault_save} + +vault_get: + phrases: + - пароль от + - дай пароль + - покажи пароль от + - достань пароль + action: {type: vault_get} + +vault_list: + phrases: + - какие у меня пароли + - список паролей + - что в хранилище паролей + action: {type: vault_list} + +habit_checkin: + phrases: + - отметь привычку + - выполнил привычку + - я попил воды + - я размялся + - я отдохнул глазам + action: {type: habit_checkin} + +habit_streak: + phrases: + - сколько дней подряд + - мои привычки + - статистика привычек + - сколько дней пью воду + - трекинг привычек + action: {type: habit_streak} diff --git a/extensions.py b/extensions.py index ba0ed9d..da3a8ab 100644 --- a/extensions.py +++ b/extensions.py @@ -27,6 +27,7 @@ import scheduler_store import vision_handler import dev_handlers import llm_backend +import wave1_handlers _speak_fn = print _set_clipboard_fn = None @@ -39,6 +40,7 @@ def set_speak(fn): scheduler_store.set_speak(fn) vision_handler.set_speak(fn) dev_handlers.set_speak(fn) + wave1_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1093,3 +1095,27 @@ def do_github_list_prs(action, voice): def do_github_summarize_pr(action, voice): dev_handlers.do_github_summarize_pr(action, voice) + + +# ── Wave 1 proxies (to wave1_handlers) ────────────────────────────────────── + +def do_magic_8ball(a, v): wave1_handlers.do_magic_8ball(a, v) +def do_gh_list_issues(a, v): wave1_handlers.do_github_list_issues(a, v) +def do_gh_my_issues(a, v): wave1_handlers.do_github_my_issues(a, v) +def do_weather_tomorrow(a, v): wave1_handlers.do_weather_tomorrow(a, v) +def do_weather_week(a, v): wave1_handlers.do_weather_week(a, v) +def do_rss_add(a, v): wave1_handlers.do_rss_add(a, v) +def do_rss_read(a, v): wave1_handlers.do_rss_read(a, v) +def do_rss_list(a, v): wave1_handlers.do_rss_list(a, v) +def do_ics_create(a, v): wave1_handlers.do_ics_create(a, v) +def do_backup_export(a, v): wave1_handlers.do_backup_export(a, v) +def do_clip_save(a, v): wave1_handlers.do_clip_save(a, v) +def do_clip_list(a, v): wave1_handlers.do_clip_list(a, v) +def do_clip_restore(a, v): wave1_handlers.do_clip_restore(a, v) +def do_notif_missed(a, v): wave1_handlers.do_notif_missed(a, v) +def do_notif_clear(a, v): wave1_handlers.do_notif_clear(a, v) +def do_vault_save(a, v): wave1_handlers.do_vault_save(a, v) +def do_vault_get(a, v): wave1_handlers.do_vault_get(a, v) +def do_vault_list(a, v): wave1_handlers.do_vault_list(a, v) +def do_habit_checkin(a, v): wave1_handlers.do_habit_checkin(a, v) +def do_habit_streak(a, v): wave1_handlers.do_habit_streak(a, v) diff --git a/main.py b/main.py index d15fe41..e03a185 100644 --- a/main.py +++ b/main.py @@ -1636,6 +1636,46 @@ def run_action(action): extensions.do_world_clock(action, _current_voice or '') elif t == 'daily_quote': extensions.do_daily_quote(action, _current_voice or '') + elif t == 'magic_8ball': + extensions.do_magic_8ball(action, _current_voice or '') + elif t == 'gh_list_issues': + extensions.do_gh_list_issues(action, _current_voice or '') + elif t == 'gh_my_issues': + extensions.do_gh_my_issues(action, _current_voice or '') + elif t == 'weather_tomorrow': + extensions.do_weather_tomorrow(action, _current_voice or '') + elif t == 'weather_week': + extensions.do_weather_week(action, _current_voice or '') + elif t == 'rss_add': + extensions.do_rss_add(action, _current_voice or '') + elif t == 'rss_read': + extensions.do_rss_read(action, _current_voice or '') + elif t == 'rss_list': + extensions.do_rss_list(action, _current_voice or '') + elif t == 'ics_create': + extensions.do_ics_create(action, _current_voice or '') + elif t == 'backup_export': + extensions.do_backup_export(action, _current_voice or '') + elif t == 'clip_save': + extensions.do_clip_save(action, _current_voice or '') + elif t == 'clip_list': + extensions.do_clip_list(action, _current_voice or '') + elif t == 'clip_restore': + extensions.do_clip_restore(action, _current_voice or '') + elif t == 'notif_missed': + extensions.do_notif_missed(action, _current_voice or '') + elif t == 'notif_clear': + extensions.do_notif_clear(action, _current_voice or '') + elif t == 'vault_save': + extensions.do_vault_save(action, _current_voice or '') + elif t == 'vault_get': + extensions.do_vault_get(action, _current_voice or '') + elif t == 'vault_list': + extensions.do_vault_list(action, _current_voice or '') + elif t == 'habit_checkin': + extensions.do_habit_checkin(action, _current_voice or '') + elif t == 'habit_streak': + extensions.do_habit_streak(action, _current_voice or '') _current_voice: str = '' diff --git a/wave1_handlers.py b/wave1_handlers.py new file mode 100644 index 0000000..9c56af2 --- /dev/null +++ b/wave1_handlers.py @@ -0,0 +1,613 @@ +"""Wave 1 handler module — port of 10 rust packs from c4b2261. + +magic_8ball, github_issues, weather_extended, rss_reader, ics_event, +backup, clip_history, notif_queue, password_vault, habit_streaks. + +Imported by extensions.py + dispatched from main.py. +""" + +import base64 +import datetime as dt +import json as _json +import os +import random +import re +import subprocess +import urllib.parse +import urllib.request +import zipfile + +import memory_store + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[wave1] speak: {exc}") + + +def _ps(cmd, timeout=15): + try: + res = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, (res.stdout or '').strip(), (res.stderr or '') + except Exception as exc: + return False, '', str(exc) + + +def _gh(*args, timeout=15): + try: + res = subprocess.run( + ['gh', *args], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, res.stdout, res.stderr + except Exception as exc: + return False, '', str(exc) + + +def _http_json(url, timeout=10): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return _json.loads(r.read().decode()) + except Exception as exc: + print(f"[wave1] http: {exc}") + return None + + +# ── magic_8ball ──────────────────────────────────────────────────────────── + +_8BALL = [ + "Без сомнения, сэр.", "Однозначно да.", "Можете на это рассчитывать.", + "Скорее всего да.", "Перспективы хорошие.", "Знаки указывают на да.", + "Пока неясно, попробуйте позже.", "Сосредоточьтесь и спросите снова.", + "Не могу предсказать сейчас.", "Лучше не говорить.", + "Не рассчитывайте на это.", "Мой ответ — нет.", "Источники говорят нет.", + "Перспективы не очень.", "Очень сомнительно.", +] + + +def do_magic_8ball(action, voice): + _speak(random.choice(_8BALL)) + + +# ── github_issues ───────────────────────────────────────────────────────── + +def do_github_list_issues(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий: текущий репо owner/repo.") + return + ok, out, _ = _gh('issue', 'list', '--repo', repo, '--state', 'open', + '--json', 'number,title,labels', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Открытых issues нет.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"{len(items)} открытых issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +def do_github_my_issues(action, voice): + ok, out, _ = _gh('issue', 'list', '--assignee', '@me', '--state', 'open', + '--json', 'number,title,repository', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Тебе ничего не назначено.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"На вас {len(items)} issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +# ── weather_extended ────────────────────────────────────────────────────── + +def _weather_location(): + lat = memory_store.recall("weather.lat") + lon = memory_store.recall("weather.lon") + city = memory_store.recall("weather.city") + if lat and lon: + return lat, lon, city or "вашем городе" + ip = _http_json("https://ipinfo.io/json", timeout=5) + if not ip or not ip.get('loc'): + return None, None, None + parts = ip['loc'].split(',') + if len(parts) != 2: + return None, None, None + lat, lon = parts[0], parts[1] + city = ip.get('city', 'ваш город') + memory_store.remember("weather.lat", lat) + memory_store.remember("weather.lon", lon) + memory_store.remember("weather.city", city) + return lat, lon, city + + +def _weather_code_ru(code): + if code == 0: return "ясно" + if code <= 3: return "переменная облачность" + if 51 <= code <= 67: return "дождь" + if 71 <= code <= 77: return "снег" + if code >= 95: return "гроза" + return "переменно" + + +def do_weather_tomorrow(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code" + f"&timezone=auto&forecast_days=2") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + d = data['daily'] + if len(d.get('temperature_2m_max', [])) < 2: + _speak("Прогноз неполный.") + return + tmax = d['temperature_2m_max'][1] + tmin = d['temperature_2m_min'][1] + rain = d.get('precipitation_sum', [0, 0])[1] or 0 + code = d.get('weather_code', [0, 0])[1] or 0 + line = f"В {city} завтра {_weather_code_ru(code)}. От {round(tmin)} до {round(tmax)} градусов." + if rain > 5: + line += " Возможны осадки." + _speak(line) + + +def do_weather_week(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + maxes = data['daily'].get('temperature_2m_max', []) + mins = data['daily'].get('temperature_2m_min', []) + if not maxes: + _speak("Прогноз неполный.") + return + wk_max = max(maxes) + wk_min = min(mins) + _speak(f"Неделя в {city}: от {round(wk_min)} до {round(wk_max)} градусов.") + + +# ── rss_reader ──────────────────────────────────────────────────────────── + +def do_rss_add(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('добавь rss', 'подпишись на', 'запомни ленту', 'добавь ленту'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + m = re.search(r'(https?://\S+)', body) + if not m: + _speak("Не нашёл URL.") + return + url = m.group(1) + domain = url.split('://', 1)[1].split('/')[0] + memory_store.remember(f"rss.{domain}", url) + _speak(f"Лента {domain} добавлена.") + + +def do_rss_read(action, voice): + feed_url = None + feed_name = None + for rec in memory_store.all_facts(): + if rec['key'].startswith('rss.'): + feed_url = rec['value'] + feed_name = rec['key'][4:] + break + if not feed_url: + _speak("Лент не добавлено. Скажите 'добавь rss и URL'.") + return + try: + with urllib.request.urlopen(feed_url, timeout=10) as r: + body = r.read().decode('utf-8', errors='replace') + except Exception as exc: + print(f"[wave1] rss fetch: {exc}") + _speak("Не получилось загрузить ленту.") + return + titles = re.findall(r']*>([^<]+)', body) + if len(titles) < 2: + _speak("В ленте нет заголовков.") + return + sample = titles[1:4] # skip channel title + _speak(f"Из {feed_name}. " + ". ".join(sample) + ".") + + +def do_rss_list(action, voice): + feeds = [r['key'][4:] for r in memory_store.all_facts() if r['key'].startswith('rss.')] + if not feeds: + _speak("Лент пока нет.") + return + _speak("Подписан на: " + ", ".join(feeds) + ".") + + +# ── ics_event ───────────────────────────────────────────────────────────── + +def do_ics_create(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('добавь встречу', 'создай событие', 'создай встречу', + 'запиши встречу', 'новая встреча'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body: + _speak("Опишите встречу.") + return + + # Find time HH:MM or "в HH" + m = re.search(r'(\d{1,2})[:\-\s](\d{2})', body) + if m: + hh, mm = int(m.group(1)), int(m.group(2)) + else: + m = re.search(r'в\s+(\d{1,2})', body) + hh = int(m.group(1)) if m else 9 + mm = 0 + hh = max(0, min(23, hh)) + mm = max(0, min(59, mm)) + + today = dt.date.today() + low2 = body.lower() + if 'сегодня' in low2: + target = today + elif 'послезавтра' in low2: + target = today + dt.timedelta(days=2) + else: + target = today + dt.timedelta(days=1) + + summary = re.sub(r'\d{1,2}[:\-\s]\d{2}', '', body) + summary = re.sub(r'в\s+\d{1,2}', '', summary) + summary = re.sub(r'(сегодня|послезавтра|завтра)', '', summary, flags=re.IGNORECASE) + summary = summary.strip(' ,.:') or "Встреча" + + now = dt.datetime.now() + now_stamp = now.strftime('%Y%m%dT%H%M%S') + dt_start = f"{target.strftime('%Y%m%d')}T{hh:02d}{mm:02d}00" + end_hh = (hh + 1) % 24 + dt_end = f"{target.strftime('%Y%m%d')}T{end_hh:02d}{mm:02d}00" + uid = f"jarvis-{now_stamp}-{random.randint(1000, 9999)}" + + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//J.A.R.V.I.S.//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\nDTSTAMP:{now_stamp}\r\n" + f"DTSTART:{dt_start}\r\nDTEND:{dt_end}\r\n" + f"SUMMARY:{summary}\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + + userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public') + out_dir = os.path.join(userprofile, 'Documents', 'jarvis-events') + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"event-{now_stamp}.ics") + try: + with open(out_path, 'w', encoding='utf-8') as f: + f.write(ics) + except OSError as exc: + print(f"[wave1] ics write: {exc}") + _speak("Не получилось создать файл.") + return + + # Open via default handler. + os.startfile(out_path) + _speak(f"Встреча создана: {summary} в {hh:02d}:{mm:02d}.") + + +# ── backup ──────────────────────────────────────────────────────────────── + +def do_backup_export(action, voice): + cfg_dir = memory_store._PATH and os.path.dirname(memory_store._PATH) or \ + os.path.dirname(os.path.abspath(__file__)) + appdata = os.environ.get('APPDATA', '') + candidates = [] + # Search in script-relative + APPDATA/Jarvis + for base in (cfg_dir, os.path.join(appdata, 'Jarvis') if appdata else None): + if not base or not os.path.isdir(base): + continue + for fname in ('long_term_memory.json', 'schedule.json', 'macros.json', + 'active_profile.txt', 'llm_backend.txt', 'settings.json'): + p = os.path.join(base, fname) + if os.path.isfile(p): + candidates.append(p) + prof_dir = os.path.join(base, 'profiles') + if os.path.isdir(prof_dir): + for f in os.listdir(prof_dir): + p = os.path.join(prof_dir, f) + if os.path.isfile(p): + candidates.append(p) + if not candidates: + _speak("Нечего бекапить.") + return + + userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public') + stamp = dt.datetime.now().strftime('%Y%m%d-%H%M%S') + out_zip = os.path.join(userprofile, 'Documents', f'jarvis-backup-{stamp}.zip') + try: + with zipfile.ZipFile(out_zip, 'w', zipfile.ZIP_DEFLATED) as zf: + for src in candidates: + zf.write(src, os.path.basename(src)) + except OSError as exc: + print(f"[wave1] backup: {exc}") + _speak("Не получилось создать бекап.") + return + _speak(f"Бекап сохранён, {len(candidates)} файлов.") + + +# ── clip_history ────────────────────────────────────────────────────────── + +def _get_clipboard(): + """Read current clipboard via PowerShell.""" + ok, out, _ = _ps("Get-Clipboard -Raw", timeout=5) + return out if ok else '' + + +def _set_clipboard(text): + """Write to clipboard via PowerShell. None/empty → clears.""" + try: + subprocess.run( + ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'], + input=(text or ''), text=True, encoding='utf-8', timeout=10, check=False, + ) + except Exception as exc: + print(f"[wave1] clipboard set: {exc}") + + +def do_clip_save(action, voice): + content = _get_clipboard() + if not content: + _speak("Буфер пуст.") + return + # Shift + for i in range(19, 0, -1): + prev = memory_store.recall(f"clip.{i - 1}") + if prev: + memory_store.remember(f"clip.{i}", prev) + trimmed = content if len(content) <= 2000 else content[:2000] + "..." + memory_store.remember("clip.0", trimmed) + preview = trimmed[:40].replace('\n', ' ').replace('\r', ' ') + _speak(f"Запомнил: {preview}.") + + +def do_clip_list(action, voice): + count = 0 + previews = [] + for i in range(20): + val = memory_store.recall(f"clip.{i}") + if val: + count += 1 + if len(previews) < 3: + p = val[:30].replace('\n', ' ').replace('\r', ' ') + previews.append(p) + if count == 0: + _speak("История буфера пуста.") + return + _speak("В истории буфера: " + " | ".join(previews) + f". Всего {count} записей.") + + +def do_clip_restore(action, voice): + low = (voice or '').lower() + idx = 0 + if 'втор' in low: idx = 1 + elif 'трет' in low: idx = 2 + elif 'четверт' in low: idx = 3 + elif 'пят' in low: idx = 4 + m = re.search(r'(\d+)', low) + if m: + idx = int(m.group(1)) - 1 + content = memory_store.recall(f"clip.{idx}") + if not content: + _speak("В истории нет такой записи.") + return + _set_clipboard(content) + preview = content[:30].replace('\n', ' ') + _speak(f"Восстановил: {preview}.") + + +# ── notif_queue ─────────────────────────────────────────────────────────── + +def do_notif_missed(action, voice): + notifs = [] + for rec in memory_store.all_facts(): + if rec['key'].startswith('notif.'): + notifs.append((rec.get('last_used_at', 0), rec['value'])) + if not notifs: + _speak("Ничего не пропустил.") + return + notifs.sort(reverse=True) + sample = notifs[:5] + _speak(f"Пропущено {len(notifs)} уведомлений. " + ". ".join(n[1] for n in sample) + ".") + + +def do_notif_clear(action, voice): + count = 0 + for rec in memory_store.all_facts(): + if rec['key'].startswith('notif.'): + memory_store.forget(rec['key']) + count += 1 + _speak("Уведомлений и так нет." if count == 0 else f"Очистил {count} уведомлений.") + + +# ── password_vault ──────────────────────────────────────────────────────── + +def _dpapi_encrypt(plaintext): + escaped = plaintext.replace("'", "''") + ps = ( + "Add-Type -AssemblyName System.Security;" + f"$bytes = [System.Text.Encoding]::UTF8.GetBytes('{escaped}');" + "$enc = [System.Security.Cryptography.ProtectedData]::Protect(" + "$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);" + "Write-Output ([Convert]::ToBase64String($enc))" + ) + ok, out, _ = _ps(ps, timeout=10) + return out if ok else None + + +def _dpapi_decrypt(b64): + ps = ( + "Add-Type -AssemblyName System.Security;" + f"$bytes = [Convert]::FromBase64String('{b64}');" + "try {" + " $dec = [System.Security.Cryptography.ProtectedData]::Unprotect(" + " $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);" + " [System.Text.Encoding]::UTF8.GetString($dec) | Set-Clipboard;" + " Write-Output 'OK' } catch { Write-Output 'ERR' }" + ) + ok, out, _ = _ps(ps, timeout=10) + return ok and out == 'OK' + + +def _vault_strip_trigger(voice): + low = (voice or '').lower() + name = voice or '' + for trig in ('покажи пароль от', 'достань пароль от', 'пароль от', + 'сохрани пароль от', 'запомни пароль от', 'дай пароль', + 'пароль для', 'password for', 'get password', + 'save password for', 'remember password for'): + idx = low.find(trig) + if idx >= 0: + # find same trigger in original-case voice + name = name[idx + len(trig):].strip(' ,.:') + break + return name.strip() + + +def do_vault_save(action, voice): + name = _vault_strip_trigger(voice) + if not name: + _speak("Имя сервиса не указано.") + return + plaintext = _get_clipboard() + if not plaintext: + _speak("Скопируйте пароль в буфер сначала.") + return + b64 = _dpapi_encrypt(plaintext) + if not b64: + _speak("Не получилось зашифровать.") + return + memory_store.remember(f"vault.{name}", b64) + _set_clipboard("") + _speak(f"Сохранил пароль от {name}.") + + +def do_vault_get(action, voice): + name = _vault_strip_trigger(voice) + if not name: + _speak("От чего пароль?") + return + b64 = memory_store.recall(f"vault.{name}") + if not b64: + _speak(f"Пароля для {name} нет.") + return + if not _dpapi_decrypt(b64): + _speak("Не получилось расшифровать.") + return + # Clear clipboard 30 seconds later via background thread. + import threading + def _clear(): + import time + time.sleep(30) + _set_clipboard("") + threading.Thread(target=_clear, daemon=True).start() + _speak(f"Пароль от {name} в буфере на 30 секунд.") + + +def do_vault_list(action, voice): + names = [r['key'][6:] for r in memory_store.all_facts() if r['key'].startswith('vault.')] + if not names: + _speak("В хранилище паролей пусто.") + return + _speak(f"Паролей сохранено {len(names)}: " + ", ".join(names) + ".") + + +# ── habit_streaks ───────────────────────────────────────────────────────── + +def _habit_from_voice(voice): + low = (voice or '').lower() + if 'вод' in low: return 'water' + if 'размял' in low or 'зарядк' in low: return 'stretch' + if 'глаз' in low: return 'eyes' + # Strip leading trigger and use first word + for trig in ('отметь привычку', 'выполнил привычку', 'выполнил', 'отметь', + 'check in habit'): + idx = low.find(trig) + if idx >= 0: + body = (voice or '')[idx + len(trig):].strip(' ,.:').strip() + return body or 'general' + return 'general' + + +def do_habit_checkin(action, voice): + habit = _habit_from_voice(voice) + today = dt.date.today().strftime('%Y-%m-%d') + memory_store.remember(f"habit_streak.{habit}.{today}", "1") + _speak(f"Отметил {habit} за сегодня.") + + +def do_habit_streak(action, voice): + by_habit = {} + for rec in memory_store.all_facts(): + m = re.match(r'^habit_streak\.([^.]+)\.(.+)$', rec['key']) + if m: + habit, date = m.group(1), m.group(2) + by_habit.setdefault(habit, set()).add(date) + if not by_habit: + _speak("Привычек ещё не отмечено. Скажи 'выполнил привычку' после события.") + return + today = dt.date.today() + results = [] + for habit, dates in by_habit.items(): + streak = 0 + for d in range(31): + day = (today - dt.timedelta(days=d)).strftime('%Y-%m-%d') + if day in dates: + streak += 1 + else: + break + results.append((habit, streak)) + results.sort(key=lambda x: -x[1]) + sample = results[:3] + lines = [] + for habit, streak in sample: + if streak == 0: + lines.append(f"{habit} прервана") + else: + unit = 'день' if streak == 1 else ('дня' if streak < 5 else 'дней') + lines.append(f"{habit} {streak} {unit}") + _speak("Стрики: " + ", ".join(lines) + ".")