Mirrors all 10 packs from rust commit c4b2261 to python.
wave1_handlers.py (new, ~480 lines)
Self-contained module with 20 handlers + DPAPI helpers + http/ps wrappers.
Imports memory_store for state-backed packs (vault, clip, habit, rss, weather).
Coverage:
do_magic_8ball — 15 random answers
do_github_list_issues — gh issue list (uses memory[github.repo])
do_github_my_issues — gh issue list --assignee @me
do_weather_tomorrow — Open-Meteo + ipinfo auto-detect
do_weather_week — 7-day forecast min/max range
do_rss_add/read/list — RSS subscriptions + parse <title> tags
do_ics_create — write .ics, open via os.startfile
do_backup_export — zipfile of long_term_memory.json + schedule + macros
+ profiles/ + active_profile + llm_backend + settings
(searches script_dir + %APPDATA%/Jarvis)
do_clip_save/list/restore — 20-slot rolling clipboard history in memory
do_notif_missed/clear — enumerate memory[notif.*] / purge
do_vault_save/get/list — DPAPI encrypt via PowerShell, clears clipboard
after 30 sec via threading.Thread
do_habit_checkin/streak — date-keyed memory state, consecutive-day counter
extensions.py: +20 proxy functions + wave1_handlers import + set_speak wiring.
main.py: +20 dispatch entries.
commands.yaml: +20 entries (154 → 174).
Tests: ast.parse passes for all .py files. 42 pytest still green.
613 lines
23 KiB
Python
613 lines
23 KiB
Python
"""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'<title[^>]*>([^<]+)</title>', 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) + ".")
|