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