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).
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""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)
|