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