290 lines
11 KiB
Python
290 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)
|