"""Python parity for Wave 5-9 voice packs that were Rust-only: - wol Wake-on-LAN magic packet to a MAC stored in memory - banter pause / resume idle banter, fire-one - conversation summary + repeat from LLM history - ddg_answer DuckDuckGo Instant Answer Q&A (no API key) These piggyback on existing Python modules (memory_store, scheduler_store, the openai client kept in main.py) and degrade gracefully — e.g. ddg_answer opens a browser fallback when the API has no instant answer. Each `do_*` is callable with the standard `(action, voice)` signature so extensions.py can wire it identically to the Rust packs' yaml entries. """ import json import random import re import socket import struct import urllib.parse import urllib.request import webbrowser from typing import Callable, Optional, List import memory_store _speak: Optional[Callable[[str], None]] = None _history: Optional[List[dict]] = None # populated by main.py at startup def set_speak(fn: Callable[[str], None]) -> None: global _speak _speak = fn def set_history_ref(history_list: list) -> None: """main.py owns the live LLM `message_log`. Inject the reference here so conversation.summary/repeat can read it without re-importing main.""" global _history _history = history_list def _say(text: str) -> None: if _speak is not None: _speak(text) else: print(f"[wave59] {text}") # ─── Wake-on-LAN ──────────────────────────────────────────────────────────── def _normalise_mac(mac: str) -> Optional[str]: """Return the canonical 12-hex-digits form or None if malformed. Accepts AA:BB:CC:DD:EE:FF, AA-BB-..., AABB.CCDD.EEFF, AABBCCDDEEFF. """ cleaned = re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() if len(cleaned) != 12: return None return cleaned def _build_magic_packet(mac_hex: str) -> bytes: mac_bytes = bytes.fromhex(mac_hex) return b"\xff" * 6 + mac_bytes * 16 def _send_magic_packet(packet: bytes) -> bool: """Best-effort UDP broadcast on port 9. Returns True on send success.""" try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.sendto(packet, ("255.255.255.255", 9)) return True except OSError as e: print(f"[wol] send failed: {e}") return False def do_wol(action=None, voice=None, alias: str = "server") -> None: """Mirror of resources/commands/wol/wake.lua. Reads MAC from memory["wol_"] or env JARVIS_WOL_TARGET, normalises, and fires the magic packet. The alias is fixed to "server" here just like the Rust side — extending to multiple aliases is straightforward.""" mac = memory_store.recall(f"wol_{alias}") if not mac: import os mac = os.environ.get("JARVIS_WOL_TARGET", "") if not mac: _say(f"Сэр, MAC-адрес не задан. Скажи: запомни wol_{alias}, а потом адрес.") return cleaned = _normalise_mac(mac) if cleaned is None: _say(f"Сэр, MAC-адрес в памяти выглядит подозрительно: {mac}") return ok = _send_magic_packet(_build_magic_packet(cleaned)) if not ok: _say("Не получилось отправить пакет.") return pretty = "-".join(cleaned[i:i+2] for i in range(0, 12, 2)) _say(f"Пакет отправлен на {pretty}") # ─── Idle banter ──────────────────────────────────────────────────────────── # # Python doesn't ship a background idle-banter thread (the Rust one does). # These handlers expose just the user-facing actions so the same voice # phrases work on both sides. A real Python bg-thread would mirror # crates/jarvis-core/src/idle_banter.rs — left as future work, since the # Python edition is typically run alongside the Rust daemon anyway. _BANTER_PAUSED = False _BANTER_LINES = [ "Системы в норме, сэр. Всё под контролем.", "Я здесь, если что, сэр. Никуда не делся.", "Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.", "Сэр, статус: чашка кофе крайне рекомендована.", "К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.", "Жду команд, сэр. У меня есть весь день.", ] def do_banter_fire(action=None, voice=None) -> None: if _BANTER_PAUSED: _say("В этот раз без комментариев, сэр.") return _say(random.choice(_BANTER_LINES)) def do_banter_pause(action=None, voice=None) -> None: global _BANTER_PAUSED _BANTER_PAUSED = True _say("Молчу, сэр.") def do_banter_resume(action=None, voice=None) -> None: global _BANTER_PAUSED _BANTER_PAUSED = False _say("Хорошо, сэр. Возвращаюсь к комментариям.") # ─── Conversation summary / repeat ────────────────────────────────────────── def do_conversation_summary(action=None, voice=None) -> None: """LLM-driven recap of the last ~12 turns from main.py's message_log. Offline-safe: if GROQ_TOKEN missing or history empty, returns a graceful short message instead of attempting a network call. """ if _history is None or len(_history) <= 1: # _history[0] is the system prompt; need at least one real turn _say("Мы пока ещё ни о чём не говорили, сэр.") return # last 12 real turns (skip system at [0]) turns = _history[-12:] if len(_history) > 12 else _history[1:] if not turns: _say("Мы пока ещё ни о чём не говорили, сэр.") return import os token = os.environ.get("GROQ_TOKEN", "") if not token: # Offline fallback — speak the last user message last_user = next( (m for m in reversed(turns) if m.get("role") == "user"), None, ) if last_user: _say("Без сети, сэр. Последний раз вы спросили: " + last_user.get("content", "")[:200]) else: _say("Без сети, сэр.") return try: from openai import OpenAI client = OpenAI( api_key=token, base_url=os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1"), ) transcript_lines = [] for m in turns: who = "Я" if m.get("role") == "assistant" else "Пользователь" transcript_lines.append(who + ": " + (m.get("content") or "")) sys_msg = ( "Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое " "напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в " "стиле британского дворецкого. Не пересказывай дословно — сожми суть." ) resp = client.chat.completions.create( model=os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"), messages=[ {"role": "system", "content": sys_msg}, {"role": "user", "content": "\n".join(transcript_lines)}, ], max_tokens=200, temperature=0.4, ) text = (resp.choices[0].message.content or "").strip() if not text: _say("Не смог разобрать ответ.") return _say(text) except Exception as e: print(f"[conversation] LLM call failed: {e}") _say("LLM не отвечает.") def do_conversation_repeat(action=None, voice=None) -> None: if _history is None: _say("Я ещё ничего не говорил, сэр.") return last = next( (m for m in reversed(_history) if m.get("role") == "assistant"), None, ) if last is None: _say("Я ещё ничего не говорил, сэр.") return _say(last.get("content") or "") # ─── DuckDuckGo Instant Answer ────────────────────────────────────────────── _DDG_TRIGGERS = [ "что такое", "кто такой", "кто такая", "расскажи про", "расскажи о", "найди информацию о", "найди информацию про", "поищи в интернете", "поищи в сети", "погугли что такое", "what is", "who is", "tell me about", "look up", "search the web for", ] def _strip_ddg_trigger(phrase: str) -> str: p = (phrase or "").lower().strip() for t in _DDG_TRIGGERS: if p.startswith(t): return p[len(t):].strip() return p def do_ddg_answer(action=None, voice=None) -> None: query = _strip_ddg_trigger(voice or "") if not query: _say("Что искать?") return url = ("https://api.duckduckgo.com/?q=" + urllib.parse.quote(query, safe="") + "&format=json&no_html=1&skip_disambig=1") try: req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 J.A.R.V.I.S."}) with urllib.request.urlopen(req, timeout=8) as resp: data = json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError) as e: print(f"[ddg] fetch failed: {e}") _say("DDG не отвечает.") return answer = (data.get("AbstractText") or "").strip() if not answer: answer = (data.get("Answer") or "").strip() if not answer: answer = (data.get("Definition") or "").strip() if not answer: related = data.get("RelatedTopics") or [] for r in related: t = (r or {}).get("Text", "").strip() if t: answer = t break if not answer: fallback = "https://duckduckgo.com/?q=" + urllib.parse.quote(query, safe="") try: webbrowser.open(fallback) except OSError: pass _say(f"Ничего конкретного не нашёл, открываю поиск по запросу: {query}") return if len(answer) > 320: answer = answer[:300] + "…" _say(answer)