feat: Wave 1 python parity — 20 new commands (154 → 174)

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.
This commit is contained in:
Bossiara13 2026-05-16 01:08:17 +03:00
parent 4e62049a98
commit 2bef1d5ea7
4 changed files with 839 additions and 0 deletions

View file

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

View file

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

40
main.py
View file

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

613
wave1_handlers.py Normal file
View file

@ -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'<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) + ".")