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:
parent
4e62049a98
commit
2bef1d5ea7
4 changed files with 839 additions and 0 deletions
160
commands.yaml
160
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}
|
||||
|
|
|
|||
|
|
@ -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
40
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 = ''
|
||||
|
|
|
|||
613
wave1_handlers.py
Normal file
613
wave1_handlers.py
Normal 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) + ".")
|
||||
Loading…
Add table
Add a link
Reference in a new issue