J.A.R.V.I.S-py/wave59_handlers.py
Bossiara13 633850d0f7
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Failing after 5s
Python CI / ruff (push) Failing after 5s
Python CI / pytest (windows-latest) (push) Has been cancelled
feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests
New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs:

- wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell
  shellout (unlike the Rust pack). Reads MAC from memory_store under
  'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance
  (colons / dashes / dots / bare) as the Rust normaliser.

- banter: pause/resume state + fire-one. Python edition doesn't run a
  background banter thread (the Rust daemon already does), so this is
  just the user-facing surface — same voice phrases work.

- conversation: do_conversation_summary calls Groq with last 12 turns
  from main.py's `message_log`. set_history_ref() injects the live list
  reference at startup so the handler reads up-to-date history without
  a circular import. Offline-safe: GROQ_TOKEN missing → speak the last
  user message instead. do_conversation_repeat re-speaks the last
  assistant turn.

- ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks
  AbstractText → Answer → Definition → RelatedTopics[0]. Opens
  duckduckgo.com search fallback when nothing instant.

extensions.py / main.py / commands.yaml: 7 new dispatch entries +
proxy fns. commands.yaml now at 207 entries.

tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation,
magic packet shape, WOL error paths, banter state machine, DDG trigger
stripping, conversation history handling. Network calls deliberately
NOT exercised here — they belong to integration tests.

Tests: 104 → 115 (+11).
2026-05-24 23:12:04 +03:00

289 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_<alias>"] 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)