diff --git a/commands.yaml b/commands.yaml index dc99f0a..2daba39 100644 --- a/commands.yaml +++ b/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} diff --git a/extensions.py b/extensions.py index ba0ed9d..da3a8ab 100644 --- a/extensions.py +++ b/extensions.py @@ -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) diff --git a/main.py b/main.py index d15fe41..e03a185 100644 --- a/main.py +++ b/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 = '' diff --git a/wave1_handlers.py b/wave1_handlers.py new file mode 100644 index 0000000..9c56af2 --- /dev/null +++ b/wave1_handlers.py @@ -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'