mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
* feat(plugins): google_meet — bundled plugin for join+transcribe Meet calls v1 shipping transcribe-only. Spawns headless Chromium via Playwright, joins an explicit https://meet.google.com/ URL, enables live captions, and scrapes them into a transcript file the agent can read across turns. The agent then has the meeting content in context and can do followup work (send recap, file issues, schedule followups) with its regular tools. Surface: - Tools: meet_join, meet_status, meet_transcript, meet_leave, meet_say (meet_say is a v1 stub — returns not-implemented; v2 will wire realtime duplex audio via OpenAI Realtime / Gemini Live + BlackHole / PulseAudio null-sink.) - CLI: hermes meet setup | auth | join | status | transcript | stop - Lifecycle: on_session_end auto-leaves any still-running bot. Safety: - URL regex rejects anything that isn't https://meet.google.com/... - No calendar scanning, no auto-dial, no auto-consent announcement. - Single active meeting per install; a second meet_join leaves the first. - Platform-gated to Linux + macOS (Windows audio routing for v2 untested). - Opt-in: standalone plugin, user must add 'google_meet' to plugins.enabled in config.yaml. Zero core changes. Plugin uses existing register_tool / register_cli_command / register_hook surfaces. 21 new unit tests cover the URL safety gate, transcript dedup + status round-trip, process-manager refusals/start/stop paths, tool-handler JSON shape under each branch, session-end cleanup, and platform-gated register(). * feat(plugins/google_meet): v2 realtime audio + v3 remote node host v2 \u2014 agent speaks in-meeting audio_bridge.py: PulseAudio null-sink (Linux) + BlackHole probe (macOS). On Linux we load pactl module-null-sink + module-virtual-source, track module ids for teardown; Chrome gets PULSE_SOURCE=<virt src> env so its fake mic reads what we write to the sink. macOS just probes BlackHole 2ch and returns its device name \u2014 the plugin refuses to switch the user's default audio input (that would surprise them). realtime/openai_client.py: sync WebSocket client for the OpenAI Realtime API. RealtimeSession.speak(text) sends conversation.item.create + response.create, accumulates response.audio.delta PCM bytes, appends them to a file. RealtimeSpeaker runs a JSONL-queue loop consuming meet_say calls. 'websockets' is an optional dep imported lazily. meet_bot.py: when HERMES_MEET_MODE=realtime, provisions AudioBridge, starts RealtimeSession + speaker thread, spawns paplay to pump PCM into the null-sink, then cleans everything up on SIGTERM. If any realtime setup step fails, falls back cleanly to transcribe mode with an error flagged in status.json. process_manager.enqueue_say(): writes a JSONL line to say_queue.jsonl; refuses when no active meeting or active meeting is transcribe-only. tools.meet_say: real implementation; requires active mode='realtime'. meet_join: adds mode='transcribe'|'realtime' param. v3 \u2014 remote node host node/protocol.py: JSON envelope (type, id, token, payload) + validate. node/registry.py: $HERMES_HOME/workspace/meetings/nodes.json, with resolve() auto-selecting the sole registered node when name is None. node/server.py: NodeServer \u2014 websockets.serve, bearer-token auth, dispatches start_bot/stop/status/transcript/say/ping onto the local process_manager. Token auto-generated + persisted on first run. node/client.py: NodeClient \u2014 short-lived sync WS per RPC, raises RuntimeError on error envelopes, clean API matching the server. node/cli.py: 'hermes meet node {run,list,approve,remove,status,ping}' subtree; wired into the main meet CLI by cli.py so 'hermes meet node' Just Works. tools.py: every meet_* tool accepts node='<name>'|'auto'; when set, routes through NodeClient to the remote bot instead of running locally. Unknown node \u2192 clear 'no registered meet node matches ...' error. cli.py: 'hermes meet join --node my-mac --mode realtime' and 'hermes meet say "..." --node my-mac' route to the node; 'hermes meet node approve <name> <url> <token>' registers one. Tests 21 v1 tests updated (meet_say is no longer a stub; active-record now carries mode). 20 new audio_bridge + realtime tests. 42 new node tests (protocol/registry/server/client/cli). 17 new v1/v2/v3 integration tests at the plugin level covering enqueue_say edge cases, env var passthrough, mode validation, node routing (known/unknown/auto/ambiguous), and argparse wiring for `hermes meet say` + `hermes meet node` + --mode/--node flags. Total: 100 plugin tests + 58 plugin-system tests = 158 passing. E2E verified on Linux with fresh HERMES_HOME: plugin loads, 5 tools register, on_session_end hook wires, 'hermes meet' CLI tree wires including the node subtree, NodeRegistry round-trips, meet_join routes correctly to NodeClient under node='my-mac' with mode='realtime', enqueue_say accepts realtime/rejects transcribe, argparse parses every new flag cleanly. Zero changes to core. All new code lives under plugins/google_meet/. * feat(plugins/google_meet): auto-install, admission detect, mac PCM pump, barge-in, richer status Ready-for-live-test follow-up on PR #16364. Five additions that matter for the first live run on a real Meet, in priority order: 1. hermes meet install [--realtime] [--yes] pip install playwright websockets + python -m playwright install chromium --realtime: installs platform audio deps (pulseaudio-utils on Linux via sudo apt, blackhole-2ch + ffmpeg on macOS via brew). Prompts before sudo/brew unless --yes. Refuses on Windows. Refuses to auto-flip the macOS default input — user still selects BlackHole in System Settings (deliberate; surprise audio rerouting is worse than a manual step). 2. Admission detection _detect_admission(page): Leave-button visible OR caption region attached OR participants list present → we're in-call. _detect_denied(page): 'You can\'t join this video call' / 'You were removed' / 'No one responded to your request' → bail out. HERMES_MEET_LOBBY_TIMEOUT (default 300s) caps how long we sit in the lobby before giving up. in_call stays False until admitted. Status surfaces leaveReason: duration_expired | lobby_timeout | denied | page_closed. 3. macOS PCM pump ffmpeg reads speaker.pcm (24kHz s16le mono) and writes to the BlackHole AVFoundation output via -f audiotoolbox -audio_device_index <N>. _mac_audio_device_index() probes ffmpeg -f avfoundation -list_devices true to resolve 'BlackHole 2ch' → numeric index. Falls back to index 0 on probe failure. Linux paplay pump unchanged. 4. Richer status dict _BotState now tracks realtime, realtimeReady, realtimeDevice, audioBytesOut, lastAudioOutAt, lastBargeInAt, joinAttemptedAt, leaveReason. RealtimeSession.audio_bytes_out / last_audio_out_at counters fold into the status file once a second so meet_status() can show the agent's voice activity in near-real-time. 5. Barge-in RealtimeSession.cancel_response() sends type='response.cancel' over the same WS (lock-guarded so it's safe to call from the caption thread while speak() is reading frames). Handles response.cancelled as a terminal frame type. _looks_like_human_speaker() gates triggers so the bot's own name, 'You', 'Unknown', and blanks don't self-cancel. Called from the caption drain loop: when a new caption arrives attributed to a real participant while rt.session exists, we fire cancel_response() and stamp lastBargeInAt. Tests: 20 new unit tests across _BotState telemetry, barge-in gating, admission/denied probe error handling, cancel_response with and without a connected WS, and `hermes meet install` CLI wiring (flag parsing + end-to-end subprocess.run verification + Linux-already-installed fast path). Total 171 passing across all google_meet test files + the plugin-system regression suite. E2E verified on Linux: plugin loads, all 5 tools register, `hermes meet install --realtime --yes` parses, fresh-bot status.json has every new telemetry key, cancel_response on a disconnected session returns False without raising, barge-in helper gates the bot's own name correctly. Still out of scope (for a future PR, not blocking live test): mic → Realtime duplex (the agent listening to meeting audio via WebRTC), node-host TLS/pairing UX, Windows audio, Meet create+Twilio. Docs updated: SKILL.md now lists the installer subcommand, lobby timeout, barge-in caveat, and the full status-dict reference table. README.md quick-start uses hermes meet install.
676 lines
21 KiB
Python
676 lines
21 KiB
Python
"""Tests for the google_meet node primitive.
|
|
|
|
Covers protocol helpers, the file-backed registry, the server's
|
|
token-and-dispatch machinery, a mocked client, and the CLI plumbing.
|
|
We never open a real socket — websockets.serve / websockets.sync.client
|
|
are fully mocked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_home(tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
yield hermes_home
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# protocol.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_protocol_encode_decode_roundtrip():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
msg = protocol.make_request("ping", "tok", {"x": 1}, req_id="abc")
|
|
raw = protocol.encode(msg)
|
|
out = protocol.decode(raw)
|
|
assert out == msg
|
|
assert out["type"] == "ping"
|
|
assert out["id"] == "abc"
|
|
assert out["token"] == "tok"
|
|
assert out["payload"] == {"x": 1}
|
|
|
|
|
|
def test_protocol_make_request_autogenerates_id():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
a = protocol.make_request("ping", "tok", {})
|
|
b = protocol.make_request("ping", "tok", {})
|
|
assert a["id"] != b["id"]
|
|
assert len(a["id"]) >= 16 # uuid4 hex
|
|
|
|
|
|
def test_protocol_make_request_rejects_bad_input():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
with pytest.raises(ValueError):
|
|
protocol.make_request("", "tok", {})
|
|
with pytest.raises(ValueError):
|
|
protocol.make_request("unknown_type", "tok", {})
|
|
with pytest.raises(ValueError):
|
|
protocol.make_request("ping", "tok", "not a dict") # type: ignore[arg-type]
|
|
|
|
|
|
def test_protocol_decode_raises_on_malformed():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
with pytest.raises(ValueError):
|
|
protocol.decode("not json at all")
|
|
with pytest.raises(ValueError):
|
|
protocol.decode("[]") # list, not object
|
|
with pytest.raises(ValueError):
|
|
protocol.decode(json.dumps({"id": "x"})) # missing type
|
|
with pytest.raises(ValueError):
|
|
protocol.decode(json.dumps({"type": "ping"})) # missing id
|
|
|
|
|
|
def test_protocol_validate_request_happy_path():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
msg = protocol.make_request("status", "secret", {})
|
|
ok, reason = protocol.validate_request(msg, "secret")
|
|
assert ok is True
|
|
assert reason == ""
|
|
|
|
|
|
def test_protocol_validate_request_rejects_bad_token():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
msg = protocol.make_request("status", "wrong", {})
|
|
ok, reason = protocol.validate_request(msg, "right")
|
|
assert ok is False
|
|
assert "token" in reason.lower()
|
|
|
|
|
|
def test_protocol_validate_request_rejects_unknown_type():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
raw = {"type": "nope", "id": "1", "token": "t", "payload": {}}
|
|
ok, reason = protocol.validate_request(raw, "t")
|
|
assert ok is False
|
|
assert "unknown" in reason.lower()
|
|
|
|
|
|
def test_protocol_validate_request_rejects_missing_id():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
raw = {"type": "ping", "token": "t", "payload": {}}
|
|
ok, reason = protocol.validate_request(raw, "t")
|
|
assert ok is False
|
|
assert "id" in reason.lower()
|
|
|
|
|
|
def test_protocol_validate_request_rejects_non_dict_payload():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
raw = {"type": "ping", "id": "1", "token": "t", "payload": "oops"}
|
|
ok, reason = protocol.validate_request(raw, "t")
|
|
assert ok is False
|
|
|
|
|
|
def test_protocol_error_envelope_shape():
|
|
from plugins.google_meet.node import protocol
|
|
|
|
err = protocol.make_error("abc", "nope")
|
|
assert err == {"type": "error", "id": "abc", "error": "nope"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# registry.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_registry_add_get_roundtrip_persists(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
p = tmp_path / "nodes.json"
|
|
r = NodeRegistry(path=p)
|
|
r.add("mac", "ws://mac.local:18789", "deadbeef")
|
|
|
|
# Second instance sees it.
|
|
r2 = NodeRegistry(path=p)
|
|
entry = r2.get("mac")
|
|
assert entry is not None
|
|
assert entry["name"] == "mac"
|
|
assert entry["url"] == "ws://mac.local:18789"
|
|
assert entry["token"] == "deadbeef"
|
|
assert "added_at" in entry
|
|
|
|
|
|
def test_registry_get_returns_none_when_missing(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
assert r.get("ghost") is None
|
|
|
|
|
|
def test_registry_remove(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
r.add("a", "ws://a", "t")
|
|
assert r.remove("a") is True
|
|
assert r.get("a") is None
|
|
assert r.remove("a") is False # idempotent
|
|
|
|
|
|
def test_registry_list_all_sorted(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
r.add("zeta", "ws://z", "t1")
|
|
r.add("alpha", "ws://a", "t2")
|
|
names = [n["name"] for n in r.list_all()]
|
|
assert names == ["alpha", "zeta"]
|
|
|
|
|
|
def test_registry_resolve_auto_picks_single(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
r.add("mac", "ws://mac", "t")
|
|
picked = r.resolve(None)
|
|
assert picked is not None
|
|
assert picked["name"] == "mac"
|
|
|
|
|
|
def test_registry_resolve_ambiguous_returns_none(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
r.add("a", "ws://a", "t")
|
|
r.add("b", "ws://b", "t")
|
|
assert r.resolve(None) is None
|
|
|
|
|
|
def test_registry_resolve_empty_returns_none(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
assert r.resolve(None) is None
|
|
|
|
|
|
def test_registry_resolve_by_name(tmp_path):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
r = NodeRegistry(path=tmp_path / "n.json")
|
|
r.add("a", "ws://a", "t")
|
|
r.add("b", "ws://b", "t")
|
|
picked = r.resolve("b")
|
|
assert picked is not None
|
|
assert picked["name"] == "b"
|
|
assert r.resolve("ghost") is None
|
|
|
|
|
|
def test_registry_defaults_to_hermes_home(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
# _isolate_home already set HERMES_HOME to tmp_path/.hermes; the
|
|
# registry default path must live inside that tree.
|
|
r = NodeRegistry()
|
|
r.add("x", "ws://x", "t")
|
|
expected = Path(tmp_path) / ".hermes" / "workspace" / "meetings" / "nodes.json"
|
|
assert expected.is_file()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# server.py — token + dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_server_ensure_token_generates_and_persists(tmp_path):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
|
|
p = tmp_path / "tok.json"
|
|
s1 = NodeServer(token_path=p)
|
|
t1 = s1.ensure_token()
|
|
assert isinstance(t1, str) and len(t1) == 32
|
|
|
|
# Reuse on a fresh instance.
|
|
s2 = NodeServer(token_path=p)
|
|
t2 = s2.ensure_token()
|
|
assert t1 == t2
|
|
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
assert data["token"] == t1
|
|
assert "generated_at" in data
|
|
|
|
|
|
def test_server_get_token_is_idempotent(tmp_path):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
assert s.get_token() == s.get_token()
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.new_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
|
|
|
|
|
|
def test_server_handle_request_rejects_bad_token(tmp_path):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
s.ensure_token()
|
|
bad = protocol.make_request("ping", "not-the-token", {})
|
|
resp = asyncio.run(s._handle_request(bad))
|
|
assert resp["type"] == "error"
|
|
assert "token" in resp["error"].lower()
|
|
|
|
|
|
def test_server_handle_request_ping(tmp_path):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json", display_name="node-x")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("ping", tok, {})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "pong"
|
|
assert resp["id"] == req["id"]
|
|
assert resp["payload"]["display_name"] == "node-x"
|
|
|
|
|
|
def test_server_handle_request_status_dispatches_to_pm(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
monkeypatch.setattr(pm, "status",
|
|
lambda: {"ok": True, "alive": True, "meetingId": "abc"})
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("status", tok, {})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert resp["id"] == req["id"]
|
|
assert resp["payload"] == {"ok": True, "alive": True, "meetingId": "abc"}
|
|
|
|
|
|
def test_server_handle_request_start_bot_dispatches(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
captured = {}
|
|
|
|
def fake_start(**kwargs):
|
|
captured.update(kwargs)
|
|
return {"ok": True, "pid": 42, "meeting_id": "abc-defg-hij"}
|
|
|
|
monkeypatch.setattr(pm, "start", fake_start)
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("start_bot", tok, {
|
|
"url": "https://meet.google.com/abc-defg-hij",
|
|
"guest_name": "Bot",
|
|
"duration": "30m",
|
|
})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert resp["payload"]["ok"] is True
|
|
assert captured["url"] == "https://meet.google.com/abc-defg-hij"
|
|
assert captured["guest_name"] == "Bot"
|
|
assert captured["duration"] == "30m"
|
|
|
|
|
|
def test_server_handle_request_start_bot_missing_url(tmp_path):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("start_bot", tok, {"guest_name": "x"})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "error"
|
|
assert "url" in resp["error"]
|
|
|
|
|
|
def test_server_handle_request_stop_dispatches(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
got = {}
|
|
|
|
def fake_stop(*, reason="requested"):
|
|
got["reason"] = reason
|
|
return {"ok": True, "reason": reason}
|
|
|
|
monkeypatch.setattr(pm, "stop", fake_stop)
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("stop", tok, {"reason": "user-cancel"})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert got["reason"] == "user-cancel"
|
|
|
|
|
|
def test_server_handle_request_transcript(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
got = {}
|
|
|
|
def fake_transcript(last=None):
|
|
got["last"] = last
|
|
return {"ok": True, "lines": ["a", "b"], "total": 2}
|
|
|
|
monkeypatch.setattr(pm, "transcript", fake_transcript)
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("transcript", tok, {"last": 5})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert resp["payload"]["lines"] == ["a", "b"]
|
|
assert got["last"] == 5
|
|
|
|
|
|
def test_server_handle_request_say_enqueues_when_active(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
out = tmp_path / "meet-out"
|
|
out.mkdir()
|
|
monkeypatch.setattr(pm, "_read_active",
|
|
lambda: {"pid": 1, "meeting_id": "m", "out_dir": str(out)})
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("say", tok, {"text": "hello"})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert resp["payload"]["ok"] is True
|
|
assert resp["payload"]["enqueued"] is True
|
|
q = (out / "say_queue.jsonl").read_text(encoding="utf-8").strip().splitlines()
|
|
assert len(q) == 1
|
|
assert json.loads(q[0])["text"] == "hello"
|
|
|
|
|
|
def test_server_handle_request_say_without_active_still_ok(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
monkeypatch.setattr(pm, "_read_active", lambda: None)
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("say", tok, {"text": "hi"})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "response"
|
|
assert resp["payload"]["ok"] is True
|
|
assert resp["payload"]["enqueued"] is False
|
|
|
|
|
|
def test_server_handle_request_wraps_pm_exceptions(tmp_path, monkeypatch):
|
|
from plugins.google_meet.node.server import NodeServer
|
|
from plugins.google_meet.node import protocol
|
|
from plugins.google_meet import process_manager as pm
|
|
|
|
def boom():
|
|
raise ValueError("kaboom")
|
|
|
|
monkeypatch.setattr(pm, "status", boom)
|
|
|
|
s = NodeServer(token_path=tmp_path / "t.json")
|
|
tok = s.ensure_token()
|
|
req = protocol.make_request("status", tok, {})
|
|
resp = asyncio.run(s._handle_request(req))
|
|
assert resp["type"] == "error"
|
|
assert "kaboom" in resp["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# client.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeWS:
|
|
"""Minimal context-manager stand-in for websockets.sync.client.connect."""
|
|
|
|
def __init__(self, reply_builder):
|
|
self._reply_builder = reply_builder
|
|
self.sent = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def send(self, raw):
|
|
self.sent.append(raw)
|
|
|
|
def recv(self, timeout=None):
|
|
return self._reply_builder(self.sent[-1])
|
|
|
|
|
|
def _install_fake_ws(monkeypatch, reply_builder):
|
|
fake_ws_holder = {}
|
|
|
|
def _connect(url, **kwargs):
|
|
ws = _FakeWS(reply_builder)
|
|
fake_ws_holder["ws"] = ws
|
|
fake_ws_holder["url"] = url
|
|
fake_ws_holder["kwargs"] = kwargs
|
|
return ws
|
|
|
|
# Patch the concrete import site inside client._rpc
|
|
import websockets.sync.client as wsc # type: ignore
|
|
monkeypatch.setattr(wsc, "connect", _connect)
|
|
return fake_ws_holder
|
|
|
|
|
|
def test_client_rpc_sends_correct_envelope_and_parses_response(monkeypatch):
|
|
from plugins.google_meet.node.client import NodeClient
|
|
from plugins.google_meet.node import protocol
|
|
|
|
def reply(raw_out):
|
|
req = protocol.decode(raw_out)
|
|
return protocol.encode(protocol.make_response(req["id"], {"ok": True, "echo": req["type"]}))
|
|
|
|
holder = _install_fake_ws(monkeypatch, reply)
|
|
|
|
c = NodeClient("ws://remote:1", "tok123")
|
|
out = c._rpc("ping", {"hello": 1})
|
|
assert out == {"ok": True, "echo": "ping"}
|
|
|
|
sent = json.loads(holder["ws"].sent[0])
|
|
assert sent["type"] == "ping"
|
|
assert sent["token"] == "tok123"
|
|
assert sent["payload"] == {"hello": 1}
|
|
assert sent["id"] # non-empty
|
|
assert holder["url"] == "ws://remote:1"
|
|
|
|
|
|
def test_client_rpc_raises_on_error_envelope(monkeypatch):
|
|
from plugins.google_meet.node.client import NodeClient
|
|
from plugins.google_meet.node import protocol
|
|
|
|
def reply(raw_out):
|
|
req = protocol.decode(raw_out)
|
|
return protocol.encode(protocol.make_error(req["id"], "nope"))
|
|
|
|
_install_fake_ws(monkeypatch, reply)
|
|
|
|
c = NodeClient("ws://x", "t")
|
|
with pytest.raises(RuntimeError, match="nope"):
|
|
c._rpc("ping", {})
|
|
|
|
|
|
def test_client_rpc_raises_on_id_mismatch(monkeypatch):
|
|
from plugins.google_meet.node.client import NodeClient
|
|
from plugins.google_meet.node import protocol
|
|
|
|
def reply(raw_out):
|
|
return protocol.encode(protocol.make_response("different-id", {"ok": True}))
|
|
|
|
_install_fake_ws(monkeypatch, reply)
|
|
|
|
c = NodeClient("ws://x", "t")
|
|
with pytest.raises(RuntimeError, match="mismatch"):
|
|
c._rpc("ping", {})
|
|
|
|
|
|
def test_client_convenience_methods_hit_correct_types(monkeypatch):
|
|
from plugins.google_meet.node.client import NodeClient
|
|
from plugins.google_meet.node import protocol
|
|
|
|
seen = []
|
|
|
|
def reply(raw_out):
|
|
req = protocol.decode(raw_out)
|
|
seen.append((req["type"], req["payload"]))
|
|
return protocol.encode(protocol.make_response(req["id"], {"ok": True}))
|
|
|
|
_install_fake_ws(monkeypatch, reply)
|
|
|
|
c = NodeClient("ws://x", "t")
|
|
c.start_bot("https://meet.google.com/a-b-c", guest_name="G", duration="10m")
|
|
c.stop()
|
|
c.status()
|
|
c.transcript(last=3)
|
|
c.say("hi")
|
|
c.ping()
|
|
|
|
types = [t for t, _ in seen]
|
|
assert types == ["start_bot", "stop", "status", "transcript", "say", "ping"]
|
|
# Check specific payload routing
|
|
assert seen[0][1]["url"] == "https://meet.google.com/a-b-c"
|
|
assert seen[0][1]["guest_name"] == "G"
|
|
assert seen[0][1]["duration"] == "10m"
|
|
assert seen[3][1]["last"] == 3
|
|
assert seen[4][1]["text"] == "hi"
|
|
|
|
|
|
def test_client_init_rejects_bad_args():
|
|
from plugins.google_meet.node.client import NodeClient
|
|
|
|
with pytest.raises(ValueError):
|
|
NodeClient("", "t")
|
|
with pytest.raises(ValueError):
|
|
NodeClient("ws://x", "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cli.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_parser():
|
|
from plugins.google_meet.node.cli import register_cli
|
|
|
|
parser = argparse.ArgumentParser(prog="meet-node-test")
|
|
register_cli(parser)
|
|
return parser
|
|
|
|
|
|
def test_cli_approve_list_remove(capsys):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
|
|
p = _build_parser()
|
|
|
|
args = p.parse_args(["approve", "mac", "ws://mac:1", "tok"])
|
|
rc = args.func(args)
|
|
assert rc == 0
|
|
assert NodeRegistry().get("mac") is not None
|
|
|
|
args = p.parse_args(["list"])
|
|
rc = args.func(args)
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "mac" in out
|
|
assert "ws://mac:1" in out
|
|
|
|
args = p.parse_args(["remove", "mac"])
|
|
rc = args.func(args)
|
|
assert rc == 0
|
|
assert NodeRegistry().get("mac") is None
|
|
|
|
|
|
def test_cli_list_empty(capsys):
|
|
p = _build_parser()
|
|
args = p.parse_args(["list"])
|
|
rc = args.func(args)
|
|
assert rc == 0
|
|
assert "no nodes" in capsys.readouterr().out
|
|
|
|
|
|
def test_cli_remove_missing_returns_nonzero():
|
|
p = _build_parser()
|
|
args = p.parse_args(["remove", "ghost"])
|
|
rc = args.func(args)
|
|
assert rc == 1
|
|
|
|
|
|
def test_cli_status_pings_via_node_client(capsys, monkeypatch):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
from plugins.google_meet.node import cli as node_cli
|
|
|
|
NodeRegistry().add("mac", "ws://mac:1", "tok")
|
|
|
|
class _FakeClient:
|
|
def __init__(self, url, token):
|
|
assert url == "ws://mac:1"
|
|
assert token == "tok"
|
|
|
|
def ping(self):
|
|
return {"type": "pong", "display_name": "hermes-meet-node"}
|
|
|
|
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
|
|
|
|
p = _build_parser()
|
|
args = p.parse_args(["status", "mac"])
|
|
rc = args.func(args)
|
|
assert rc == 0
|
|
out = capsys.readouterr().out.strip()
|
|
data = json.loads(out)
|
|
assert data["ok"] is True
|
|
assert data["node"] == "mac"
|
|
|
|
|
|
def test_cli_status_unknown_node_fails(capsys):
|
|
p = _build_parser()
|
|
args = p.parse_args(["status", "ghost"])
|
|
rc = args.func(args)
|
|
assert rc == 1
|
|
|
|
|
|
def test_cli_status_reports_client_error(capsys, monkeypatch):
|
|
from plugins.google_meet.node.registry import NodeRegistry
|
|
from plugins.google_meet.node import cli as node_cli
|
|
|
|
NodeRegistry().add("mac", "ws://mac:1", "tok")
|
|
|
|
class _FakeClient:
|
|
def __init__(self, url, token):
|
|
pass
|
|
|
|
def ping(self):
|
|
raise RuntimeError("connection refused")
|
|
|
|
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
|
|
|
|
p = _build_parser()
|
|
args = p.parse_args(["status", "mac"])
|
|
rc = args.func(args)
|
|
assert rc == 1
|
|
data = json.loads(capsys.readouterr().out.strip())
|
|
assert data["ok"] is False
|
|
assert "connection refused" in data["error"]
|