feat(plugins): google_meet \u2014 join, transcribe, speak, follow up (#16364)
* 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.
This commit is contained in:
266
tests/plugins/test_google_meet_audio.py
Normal file
266
tests/plugins/test_google_meet_audio.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Tests for plugins.google_meet.audio_bridge (v2).
|
||||
|
||||
Covers the platform gating and pactl / system_profiler plumbing
|
||||
without actually invoking those tools on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linux setup / teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _linux_pactl_result(stdout: str) -> MagicMock:
|
||||
"""Build a fake CompletedProcess-ish object for subprocess.run."""
|
||||
m = MagicMock()
|
||||
m.stdout = stdout
|
||||
m.stderr = ""
|
||||
m.returncode = 0
|
||||
return m
|
||||
|
||||
|
||||
def test_setup_linux_loads_null_sink_and_virtual_source():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
# First call = null-sink → module id 42
|
||||
# Second call = virtual-source → module id 43
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42\n")
|
||||
if "module-virtual-source" in argv:
|
||||
return _linux_pactl_result("43\n")
|
||||
raise AssertionError(f"unexpected pactl invocation: {argv}")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_fake_run):
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
# Two pactl load-module calls, in order.
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == "pactl" and calls[0][1] == "load-module"
|
||||
assert "module-null-sink" in calls[0]
|
||||
assert any(a.startswith("sink_name=hermes_meet_sink") for a in calls[0])
|
||||
assert calls[1][0] == "pactl" and calls[1][1] == "load-module"
|
||||
assert "module-virtual-source" in calls[1]
|
||||
assert any(a.startswith("source_name=hermes_meet_src") for a in calls[1])
|
||||
assert any("master=hermes_meet_sink.monitor" in a for a in calls[1])
|
||||
|
||||
# Dict shape.
|
||||
assert info["platform"] == "linux"
|
||||
assert info["device_name"] == "hermes_meet_src"
|
||||
assert info["write_target"] == "hermes_meet_sink"
|
||||
assert info["sample_rate"] == 48000
|
||||
assert info["channels"] == 2
|
||||
assert info["module_ids"] == [42, 43]
|
||||
|
||||
# Properties.
|
||||
assert br.device_name == "hermes_meet_src"
|
||||
assert br.write_target == "hermes_meet_sink"
|
||||
|
||||
|
||||
def test_teardown_linux_unloads_modules_in_reverse_order():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
def _setup_run(argv, **kwargs):
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42\n")
|
||||
return _linux_pactl_result("43\n")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_setup_run):
|
||||
br = AudioBridge()
|
||||
br.setup()
|
||||
|
||||
unload_calls: list[list[str]] = []
|
||||
|
||||
def _teardown_run(argv, **kwargs):
|
||||
unload_calls.append(list(argv))
|
||||
return _linux_pactl_result("")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_teardown_run):
|
||||
br.teardown()
|
||||
|
||||
# Two unload calls, in reverse order: 43 (virtual-source) then 42 (sink).
|
||||
assert [c[1] for c in unload_calls] == ["unload-module", "unload-module"]
|
||||
assert unload_calls[0][2] == "43"
|
||||
assert unload_calls[1][2] == "42"
|
||||
|
||||
# Second teardown is a no-op.
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
|
||||
br.teardown()
|
||||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_linux_parses_module_id_from_multi_line_output():
|
||||
"""Some pactl builds include trailing whitespace / notices."""
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42 \n")
|
||||
return _linux_pactl_result("43\n")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_fake_run):
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
assert info["module_ids"] == [42, 43]
|
||||
|
||||
|
||||
def test_setup_linux_pactl_missing_raises_clean_error():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=FileNotFoundError("pactl")):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="pactl"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# macOS setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BH_PRESENT = (
|
||||
"Audio:\n"
|
||||
" Devices:\n"
|
||||
" BlackHole 2ch:\n"
|
||||
" Manufacturer: Existential Audio\n"
|
||||
)
|
||||
|
||||
_BH_ABSENT = (
|
||||
"Audio:\n"
|
||||
" Devices:\n"
|
||||
" MacBook Pro Microphone:\n"
|
||||
" Default Input: Yes\n"
|
||||
)
|
||||
|
||||
|
||||
def test_setup_darwin_returns_blackhole_when_present():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
|
||||
return_value=_BH_PRESENT) as check:
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
check.assert_called_once()
|
||||
argv = check.call_args.args[0]
|
||||
assert argv[0] == "system_profiler"
|
||||
assert "SPAudioDataType" in argv
|
||||
|
||||
assert info["platform"] == "darwin"
|
||||
assert info["device_name"] == "BlackHole 2ch"
|
||||
assert info["write_target"] == "BlackHole 2ch"
|
||||
assert info["module_ids"] == []
|
||||
assert info["sample_rate"] == 48000
|
||||
assert info["channels"] == 2
|
||||
|
||||
# teardown is a no-op on darwin (no modules to unload).
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
|
||||
br.teardown()
|
||||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_darwin_raises_when_blackhole_missing():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
|
||||
return_value=_BH_ABSENT):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="BlackHole"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows / unsupported
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_windows_raises():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Windows"):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="not supported"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chrome_fake_audio_flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_linux():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"):
|
||||
flags = chrome_fake_audio_flags(
|
||||
{"platform": "linux", "device_name": "hermes_meet_src"}
|
||||
)
|
||||
assert "--use-fake-ui-for-media-stream" in flags
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_darwin():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"):
|
||||
flags = chrome_fake_audio_flags(
|
||||
{"platform": "darwin", "device_name": "BlackHole 2ch"}
|
||||
)
|
||||
assert "--use-fake-ui-for-media-stream" in flags
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_windows_raises():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Windows"):
|
||||
with pytest.raises(RuntimeError):
|
||||
chrome_fake_audio_flags({"platform": "windows"})
|
||||
|
||||
|
||||
def test_property_access_before_setup_raises():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = br.device_name
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = br.write_target
|
||||
675
tests/plugins/test_google_meet_node.py
Normal file
675
tests/plugins/test_google_meet_node.py
Normal file
@@ -0,0 +1,675 @@
|
||||
"""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"]
|
||||
814
tests/plugins/test_google_meet_plugin.py
Normal file
814
tests/plugins/test_google_meet_plugin.py
Normal file
@@ -0,0 +1,814 @@
|
||||
"""Tests for the google_meet plugin.
|
||||
|
||||
Covers the safety-gated pieces that don't require Playwright:
|
||||
|
||||
* URL regex — only ``https://meet.google.com/`` URLs pass
|
||||
* Meeting-id extraction from Meet URLs
|
||||
* Status / transcript writes round-trip through the file-backed state
|
||||
* Tool handlers return well-formed JSON under all branches
|
||||
* Process manager refuses unsafe URLs and clears stale state cleanly
|
||||
* ``_on_session_end`` hook is defensive (no-ops when no bot active)
|
||||
|
||||
Does NOT spawn a real Chromium — we mock ``subprocess.Popen`` where needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL safety gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_safe_meet_url_accepts_standard_meet_codes():
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||
|
||||
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij")
|
||||
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij?pli=1")
|
||||
assert _is_safe_meet_url("https://meet.google.com/new")
|
||||
assert _is_safe_meet_url("https://meet.google.com/lookup/ABC123")
|
||||
|
||||
|
||||
def test_is_safe_meet_url_rejects_non_meet_urls():
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||
|
||||
# wrong host
|
||||
assert not _is_safe_meet_url("https://evil.example.com/abc-defg-hij")
|
||||
# wrong scheme
|
||||
assert not _is_safe_meet_url("http://meet.google.com/abc-defg-hij")
|
||||
# malformed code
|
||||
assert not _is_safe_meet_url("https://meet.google.com/not-a-meet-code")
|
||||
# subdomain hijack attempts
|
||||
assert not _is_safe_meet_url("https://meet.google.com.evil.com/abc-defg-hij")
|
||||
assert not _is_safe_meet_url("https://notmeet.google.com/abc-defg-hij")
|
||||
# empty / wrong type
|
||||
assert not _is_safe_meet_url("")
|
||||
assert not _is_safe_meet_url(None) # type: ignore[arg-type]
|
||||
assert not _is_safe_meet_url(123) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_meeting_id_extraction():
|
||||
from plugins.google_meet.meet_bot import _meeting_id_from_url
|
||||
|
||||
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij") == "abc-defg-hij"
|
||||
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij?pli=1") == "abc-defg-hij"
|
||||
# fallback for codes we can't parse (e.g. /new before redirect)
|
||||
fallback = _meeting_id_from_url("https://meet.google.com/new")
|
||||
assert fallback.startswith("meet-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _BotState — transcript + status file round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_bot_state_dedupes_captions_and_flushes_status(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
out = tmp_path / "session"
|
||||
state = _BotState(out_dir=out, meeting_id="abc-defg-hij",
|
||||
url="https://meet.google.com/abc-defg-hij")
|
||||
|
||||
state.record_caption("Alice", "Hey everyone")
|
||||
state.record_caption("Alice", "Hey everyone") # dup — ignored
|
||||
state.record_caption("Bob", "Let's start")
|
||||
|
||||
transcript = (out / "transcript.txt").read_text()
|
||||
assert "Alice: Hey everyone" in transcript
|
||||
assert "Bob: Let's start" in transcript
|
||||
# dedup — Alice line appears exactly once
|
||||
assert transcript.count("Alice: Hey everyone") == 1
|
||||
|
||||
status = json.loads((out / "status.json").read_text())
|
||||
assert status["meetingId"] == "abc-defg-hij"
|
||||
assert status["transcriptLines"] == 2
|
||||
assert status["transcriptPath"].endswith("transcript.txt")
|
||||
|
||||
|
||||
def test_bot_state_ignores_blank_text(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
|
||||
url="https://meet.google.com/x-y-z")
|
||||
state.record_caption("Alice", "")
|
||||
state.record_caption("Alice", " ")
|
||||
state.record_caption("", "text but no speaker")
|
||||
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
assert status["transcriptLines"] == 1
|
||||
# blank-speaker falls back to "Unknown"
|
||||
assert "Unknown: text but no speaker" in (tmp_path / "s" / "transcript.txt").read_text()
|
||||
|
||||
|
||||
def test_parse_duration():
|
||||
from plugins.google_meet.meet_bot import _parse_duration
|
||||
|
||||
assert _parse_duration("30m") == 30 * 60
|
||||
assert _parse_duration("2h") == 2 * 3600
|
||||
assert _parse_duration("90s") == 90
|
||||
assert _parse_duration("90") == 90
|
||||
assert _parse_duration("") is None
|
||||
assert _parse_duration("bogus") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_manager — refuses unsafe URLs, manages active pointer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_start_refuses_unsafe_url():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
res = pm.start("https://evil.example.com/abc-defg-hij")
|
||||
assert res["ok"] is False
|
||||
assert "refusing" in res["error"]
|
||||
|
||||
|
||||
def test_status_reports_no_active_meeting():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
assert pm.status() == {"ok": False, "reason": "no active meeting"}
|
||||
assert pm.transcript() == {"ok": False, "reason": "no active meeting"}
|
||||
assert pm.stop() == {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
|
||||
def test_start_spawns_subprocess_and_writes_active_pointer(tmp_path):
|
||||
"""Verify start() wires env vars correctly and records the pid."""
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
captured_env = {}
|
||||
captured_argv = []
|
||||
|
||||
def _fake_popen(argv, **kwargs):
|
||||
captured_argv.extend(argv)
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
return _FakeProc(99999)
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen):
|
||||
# Also prevent pid liveness probe from stomping on our real pids
|
||||
with patch.object(pm, "_pid_alive", return_value=False):
|
||||
res = pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
guest_name="Test Bot",
|
||||
duration="15m",
|
||||
)
|
||||
|
||||
assert res["ok"] is True
|
||||
assert res["meeting_id"] == "abc-defg-hij"
|
||||
assert res["pid"] == 99999
|
||||
assert captured_env["HERMES_MEET_URL"] == "https://meet.google.com/abc-defg-hij"
|
||||
assert captured_env["HERMES_MEET_GUEST_NAME"] == "Test Bot"
|
||||
assert captured_env["HERMES_MEET_DURATION"] == "15m"
|
||||
# python -m plugins.google_meet.meet_bot
|
||||
assert any("plugins.google_meet.meet_bot" in a for a in captured_argv)
|
||||
|
||||
# .active.json points at the bot
|
||||
active = pm._read_active()
|
||||
assert active is not None
|
||||
assert active["pid"] == 99999
|
||||
assert active["meeting_id"] == "abc-defg-hij"
|
||||
|
||||
|
||||
def test_transcript_reads_last_n_lines(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
meeting_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
meeting_dir.mkdir(parents=True)
|
||||
(meeting_dir / "transcript.txt").write_text(
|
||||
"[10:00:00] Alice: one\n"
|
||||
"[10:00:01] Bob: two\n"
|
||||
"[10:00:02] Alice: three\n"
|
||||
)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(meeting_dir),
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0,
|
||||
})
|
||||
|
||||
res = pm.transcript(last=2)
|
||||
assert res["ok"] is True
|
||||
assert res["total"] == 3
|
||||
assert len(res["lines"]) == 2
|
||||
assert res["lines"][-1].endswith("Alice: three")
|
||||
|
||||
|
||||
def test_stop_signals_process_and_clears_pointer(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
pm._write_active({
|
||||
"pid": 11111, "meeting_id": "x-y-z",
|
||||
"out_dir": str(tmp_path / "x-y-z"),
|
||||
"url": "https://meet.google.com/x-y-z",
|
||||
"started_at": 0,
|
||||
})
|
||||
|
||||
alive_seq = iter([True, True, False]) # alive at first, gone after SIGTERM
|
||||
def _alive(pid):
|
||||
try:
|
||||
return next(alive_seq)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
sent = []
|
||||
def _kill(pid, sig):
|
||||
sent.append((pid, sig))
|
||||
|
||||
with patch.object(pm, "_pid_alive", side_effect=_alive), \
|
||||
patch.object(pm.os, "kill", side_effect=_kill), \
|
||||
patch.object(pm.time, "sleep", lambda _s: None):
|
||||
res = pm.stop()
|
||||
|
||||
assert res["ok"] is True
|
||||
assert (11111, signal.SIGTERM) in sent
|
||||
# .active.json cleared
|
||||
assert pm._read_active() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers — JSON shape + safety gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meet_join_handler_missing_url_returns_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({}))
|
||||
assert out["success"] is False
|
||||
assert "url is required" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_handler_respects_safety_gate():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True):
|
||||
out = json.loads(handle_meet_join({"url": "https://evil.example.com/foo"}))
|
||||
assert out["success"] is False
|
||||
assert "refusing" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_handler_returns_error_when_playwright_missing():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=False):
|
||||
out = json.loads(handle_meet_join({"url": "https://meet.google.com/abc-defg-hij"}))
|
||||
assert out["success"] is False
|
||||
assert "prerequisites missing" in out["error"]
|
||||
|
||||
|
||||
def test_meet_say_requires_text():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
|
||||
out = json.loads(handle_meet_say({}))
|
||||
assert out["success"] is False
|
||||
assert "text is required" in out["error"]
|
||||
|
||||
|
||||
def test_meet_say_no_active_meeting():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
|
||||
out = json.loads(handle_meet_say({"text": "hello everyone"}))
|
||||
assert out["success"] is False
|
||||
# Falls through to pm.enqueue_say which reports no active meeting.
|
||||
assert "no active meeting" in out.get("reason", "")
|
||||
|
||||
|
||||
def test_meet_status_and_transcript_no_active():
|
||||
from plugins.google_meet.tools import handle_meet_status, handle_meet_transcript
|
||||
|
||||
assert json.loads(handle_meet_status({}))["success"] is False
|
||||
assert json.loads(handle_meet_transcript({}))["success"] is False
|
||||
|
||||
|
||||
def test_meet_leave_no_active():
|
||||
from plugins.google_meet.tools import handle_meet_leave
|
||||
|
||||
out = json.loads(handle_meet_leave({}))
|
||||
assert out["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _on_session_end — defensive cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_on_session_end_noop_when_nothing_active():
|
||||
from plugins.google_meet import _on_session_end
|
||||
# Should not raise and should not call stop().
|
||||
with patch("plugins.google_meet.pm.stop") as stop_mock:
|
||||
_on_session_end()
|
||||
stop_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_on_session_end_stops_live_bot():
|
||||
from plugins.google_meet import _on_session_end
|
||||
from plugins.google_meet import pm
|
||||
|
||||
with patch.object(pm, "status", return_value={"ok": True, "alive": True}), \
|
||||
patch.object(pm, "stop") as stop_mock:
|
||||
_on_session_end()
|
||||
stop_mock.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin register() — platform gating + tool registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_register_refuses_on_windows():
|
||||
import plugins.google_meet as plugin
|
||||
|
||||
calls = {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
class _Ctx:
|
||||
def register_tool(self, **kw): calls["tools"].append(kw["name"])
|
||||
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
|
||||
def register_hook(self, name, fn): calls["hooks"].append(name)
|
||||
|
||||
with patch.object(plugin.platform, "system", return_value="Windows"):
|
||||
plugin.register(_Ctx())
|
||||
|
||||
assert calls == {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
|
||||
def test_register_wires_tools_cli_and_hook_on_linux():
|
||||
import plugins.google_meet as plugin
|
||||
|
||||
calls = {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
class _Ctx:
|
||||
def register_tool(self, **kw): calls["tools"].append(kw["name"])
|
||||
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
|
||||
def register_hook(self, name, fn): calls["hooks"].append(name)
|
||||
|
||||
with patch.object(plugin.platform, "system", return_value="Linux"):
|
||||
plugin.register(_Ctx())
|
||||
|
||||
assert set(calls["tools"]) == {
|
||||
"meet_join", "meet_status", "meet_transcript", "meet_leave", "meet_say",
|
||||
}
|
||||
assert calls["cli"] == ["meet"]
|
||||
assert calls["hooks"] == ["on_session_end"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2: process_manager.enqueue_say + realtime-mode passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_enqueue_say_requires_text():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
assert pm.enqueue_say("")["ok"] is False
|
||||
assert pm.enqueue_say(" ")["ok"] is False
|
||||
|
||||
|
||||
def test_enqueue_say_no_active_meeting():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
res = pm.enqueue_say("hi team")
|
||||
assert res["ok"] is False
|
||||
assert "no active meeting" in res["reason"]
|
||||
|
||||
|
||||
def test_enqueue_say_rejects_transcribe_mode(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
out_dir.mkdir(parents=True)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0, "mode": "transcribe",
|
||||
})
|
||||
res = pm.enqueue_say("hi team")
|
||||
assert res["ok"] is False
|
||||
assert "transcribe mode" in res["reason"]
|
||||
|
||||
|
||||
def test_enqueue_say_writes_jsonl_in_realtime_mode():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
out_dir.mkdir(parents=True)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0, "mode": "realtime",
|
||||
})
|
||||
res = pm.enqueue_say("hello everyone")
|
||||
assert res["ok"] is True
|
||||
assert "enqueued_id" in res
|
||||
|
||||
queue = out_dir / "say_queue.jsonl"
|
||||
assert queue.is_file()
|
||||
lines = [json.loads(ln) for ln in queue.read_text().splitlines() if ln.strip()]
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["text"] == "hello everyone"
|
||||
|
||||
|
||||
def test_start_passes_mode_into_active_record():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid): self.pid = pid
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", return_value=_FakeProc(12345)), \
|
||||
patch.object(pm, "_pid_alive", return_value=False):
|
||||
res = pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
mode="realtime",
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert res["mode"] == "realtime"
|
||||
assert pm._read_active()["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_start_realtime_env_vars_threaded_through():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid): self.pid = pid
|
||||
|
||||
captured_env = {}
|
||||
def _fake_popen(argv, **kwargs):
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
return _FakeProc(11111)
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen), \
|
||||
patch.object(pm, "_pid_alive", return_value=False):
|
||||
pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
mode="realtime",
|
||||
realtime_model="gpt-realtime",
|
||||
realtime_voice="alloy",
|
||||
realtime_instructions="Be brief.",
|
||||
realtime_api_key="sk-test",
|
||||
)
|
||||
assert captured_env["HERMES_MEET_MODE"] == "realtime"
|
||||
assert captured_env["HERMES_MEET_REALTIME_MODEL"] == "gpt-realtime"
|
||||
assert captured_env["HERMES_MEET_REALTIME_VOICE"] == "alloy"
|
||||
assert captured_env["HERMES_MEET_REALTIME_INSTRUCTIONS"] == "Be brief."
|
||||
assert captured_env["HERMES_MEET_REALTIME_KEY"] == "sk-test"
|
||||
|
||||
|
||||
def test_meet_join_accepts_realtime_mode():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True), \
|
||||
patch("plugins.google_meet.tools.pm.start", return_value={"ok": True, "meeting_id": "x-y-z"}) as start_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"mode": "realtime",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert start_mock.call_args.kwargs["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_meet_join_rejects_bad_mode():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"mode": "bogus",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "mode must be" in out["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3: NodeClient routing from tool handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meet_join_unknown_node_returns_clear_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "my-mac",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "no registered meet node" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_routes_to_registered_node():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
|
||||
return_value={"ok": True, "meeting_id": "a-b-c"}) as call_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "my-mac",
|
||||
"mode": "realtime",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "my-mac"
|
||||
assert call_mock.call_args.kwargs["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_meet_say_routes_to_node():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.say",
|
||||
return_value={"ok": True, "enqueued_id": "abc"}) as call_mock:
|
||||
out = json.loads(handle_meet_say({"text": "hello", "node": "my-mac"}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "my-mac"
|
||||
call_mock.assert_called_once_with("hello")
|
||||
|
||||
|
||||
def test_meet_join_auto_node_selects_sole_registered():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("only-one", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
|
||||
return_value={"ok": True}) as call_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "auto",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "only-one"
|
||||
assert call_mock.called
|
||||
|
||||
|
||||
def test_meet_join_auto_node_ambiguous_returns_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("a", "ws://1.2.3.4:18789", "tok")
|
||||
reg.add("b", "ws://5.6.7.8:18789", "tok")
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "auto",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "no registered meet node" in out["error"]
|
||||
|
||||
|
||||
def test_cli_register_includes_node_subcommand():
|
||||
"""`hermes meet` argparse tree includes the node subtree."""
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
# Parse a known-good node invocation to prove the subtree is wired.
|
||||
ns = parser.parse_args(["node", "list"])
|
||||
assert ns.meet_command == "node"
|
||||
assert ns.node_cmd == "list"
|
||||
|
||||
|
||||
def test_cli_join_accepts_mode_and_node_flags():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args([
|
||||
"join", "https://meet.google.com/abc-defg-hij",
|
||||
"--mode", "realtime", "--node", "my-mac",
|
||||
])
|
||||
assert ns.mode == "realtime"
|
||||
assert ns.node == "my-mac"
|
||||
|
||||
|
||||
def test_cli_say_subcommand_exists():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["say", "hello team", "--node", "my-mac"])
|
||||
assert ns.text == "hello team"
|
||||
assert ns.node == "my-mac"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2.1: new _BotState fields + status dict shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_bot_state_exposes_v2_telemetry_fields(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
|
||||
url="https://meet.google.com/x-y-z")
|
||||
# Defaults for the new fields.
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
for key in (
|
||||
"realtime", "realtimeReady", "realtimeDevice",
|
||||
"audioBytesOut", "lastAudioOutAt", "lastBargeInAt",
|
||||
"joinAttemptedAt", "leaveReason",
|
||||
):
|
||||
assert key in status, f"missing v2 telemetry key: {key}"
|
||||
assert status["realtime"] is False
|
||||
assert status["realtimeReady"] is False
|
||||
assert status["audioBytesOut"] == 0
|
||||
|
||||
# Setting them flushes them.
|
||||
state.set(realtime=True, realtime_ready=True, audio_bytes_out=1024,
|
||||
leave_reason="lobby_timeout")
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
assert status["realtime"] is True
|
||||
assert status["realtimeReady"] is True
|
||||
assert status["audioBytesOut"] == 1024
|
||||
assert status["leaveReason"] == "lobby_timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admission detection + barge-in helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_looks_like_human_speaker():
|
||||
from plugins.google_meet.meet_bot import _looks_like_human_speaker
|
||||
|
||||
# Blank, "unknown", "you", and the bot's own name → not human (no barge-in)
|
||||
for s in ("", " ", "Unknown", "unknown", "You", "you", "Hermes Agent", "hermes agent"):
|
||||
assert not _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} should NOT be human"
|
||||
# Real names → human (barge-in)
|
||||
for s in ("Alice", "Bob Lee", "@teknium"):
|
||||
assert _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} SHOULD be human"
|
||||
|
||||
|
||||
def test_detect_admission_returns_false_on_error():
|
||||
from plugins.google_meet.meet_bot import _detect_admission
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): raise RuntimeError("boom")
|
||||
|
||||
assert _detect_admission(_FakePage()) is False
|
||||
|
||||
|
||||
def test_detect_admission_true_when_probe_returns_true():
|
||||
from plugins.google_meet.meet_bot import _detect_admission
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): return True
|
||||
|
||||
assert _detect_admission(_FakePage()) is True
|
||||
|
||||
|
||||
def test_detect_denied_returns_false_on_error():
|
||||
from plugins.google_meet.meet_bot import _detect_denied
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): raise RuntimeError("boom")
|
||||
|
||||
assert _detect_denied(_FakePage()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Realtime session counters + cancel_response (barge-in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_realtime_session_cancel_response_when_disconnected():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
# No _ws yet — cancel should no-op and return False.
|
||||
assert sess.cancel_response() is False
|
||||
|
||||
|
||||
def test_realtime_session_cancel_response_sends_cancel_frame():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
sent = []
|
||||
|
||||
class _FakeWs:
|
||||
def send(self, msg): sent.append(msg)
|
||||
|
||||
sess._ws = _FakeWs()
|
||||
assert sess.cancel_response() is True
|
||||
assert len(sent) == 1
|
||||
import json as _j
|
||||
envelope = _j.loads(sent[0])
|
||||
assert envelope == {"type": "response.cancel"}
|
||||
|
||||
|
||||
def test_realtime_session_counters_initialized():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
assert sess.audio_bytes_out == 0
|
||||
assert sess.last_audio_out_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hermes meet install CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_install_subcommand_is_registered():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["install"])
|
||||
assert ns.meet_command == "install"
|
||||
assert ns.realtime is False
|
||||
assert ns.yes is False
|
||||
|
||||
|
||||
def test_cli_install_flags_parse():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["install", "--realtime", "--yes"])
|
||||
assert ns.realtime is True
|
||||
assert ns.yes is True
|
||||
|
||||
|
||||
def test_cmd_install_refuses_windows(capsys):
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
|
||||
with patch("plugins.google_meet.cli.platform" if False else "platform.system",
|
||||
return_value="Windows"):
|
||||
rc = _cmd_install(realtime=False, assume_yes=True)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Windows" in out
|
||||
|
||||
|
||||
def test_cmd_install_runs_pip_and_playwright(capsys):
|
||||
"""End-to-end wiring: pip + playwright install invoked, returncodes handled."""
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
import subprocess as _sp
|
||||
|
||||
calls = []
|
||||
class _FakeRes:
|
||||
def __init__(self, rc=0): self.returncode = rc
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
return _FakeRes(0)
|
||||
|
||||
with patch("platform.system", return_value="Linux"), \
|
||||
patch("subprocess.run", side_effect=_fake_run), \
|
||||
patch("shutil.which", return_value="/usr/bin/paplay"):
|
||||
rc = _cmd_install(realtime=False, assume_yes=True)
|
||||
assert rc == 0
|
||||
# First invocation: pip install
|
||||
pip_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "pip", "install"]]
|
||||
assert pip_cmds, f"no pip install run: {calls}"
|
||||
assert "playwright" in pip_cmds[0]
|
||||
assert "websockets" in pip_cmds[0]
|
||||
# Second: playwright install chromium
|
||||
pw_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "playwright", "install"]]
|
||||
assert pw_cmds, f"no playwright install run: {calls}"
|
||||
assert "chromium" in pw_cmds[0]
|
||||
|
||||
|
||||
def test_cmd_install_realtime_skips_when_deps_present(capsys):
|
||||
"""When paplay + pactl are already on PATH, no sudo call happens."""
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
|
||||
calls = []
|
||||
class _FakeRes:
|
||||
def __init__(self, rc=0): self.returncode = rc
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
return _FakeRes(0)
|
||||
|
||||
with patch("platform.system", return_value="Linux"), \
|
||||
patch("subprocess.run", side_effect=_fake_run), \
|
||||
patch("shutil.which", return_value="/usr/bin/paplay"):
|
||||
rc = _cmd_install(realtime=True, assume_yes=True)
|
||||
assert rc == 0
|
||||
# No sudo apt-get call — paplay was already on PATH.
|
||||
sudo_calls = [c for c in calls if c and c[0] == "sudo"]
|
||||
assert sudo_calls == [], f"unexpected sudo invocation: {sudo_calls}"
|
||||
out = capsys.readouterr().out
|
||||
assert "already installed" in out
|
||||
293
tests/plugins/test_google_meet_realtime.py
Normal file
293
tests/plugins/test_google_meet_realtime.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Tests for plugins.google_meet.realtime.openai_client (v2).
|
||||
|
||||
Uses a scripted fake WebSocket — no network, no API key required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeWS:
|
||||
"""Scripted WS: send() records frames, recv() pops a queue."""
|
||||
|
||||
def __init__(self, recv_frames: list):
|
||||
self.sent: list[dict] = []
|
||||
self._recv_q: list = list(recv_frames)
|
||||
self.closed = False
|
||||
|
||||
def send(self, payload):
|
||||
# Always accept str payloads — client encodes JSON with json.dumps.
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
payload = payload.decode()
|
||||
self.sent.append(json.loads(payload))
|
||||
|
||||
def recv(self, timeout=None): # noqa: ARG002
|
||||
if not self._recv_q:
|
||||
raise RuntimeError("fake ws: no more frames")
|
||||
frame = self._recv_q.pop(0)
|
||||
if isinstance(frame, dict):
|
||||
return json.dumps(frame)
|
||||
return frame
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _install_fake_websockets(monkeypatch, fake_ws):
|
||||
"""Install a fake ``websockets.sync.client`` module in sys.modules."""
|
||||
mod_websockets = types.ModuleType("websockets")
|
||||
mod_sync = types.ModuleType("websockets.sync")
|
||||
mod_sync_client = types.ModuleType("websockets.sync.client")
|
||||
|
||||
captured = {"url": None, "headers": None, "kwargs": None}
|
||||
|
||||
def _connect(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
captured["headers"] = (
|
||||
kwargs.get("additional_headers") or kwargs.get("extra_headers")
|
||||
)
|
||||
return fake_ws
|
||||
|
||||
mod_sync_client.connect = _connect
|
||||
mod_sync.client = mod_sync_client
|
||||
mod_websockets.sync = mod_sync
|
||||
|
||||
monkeypatch.setitem(sys.modules, "websockets", mod_websockets)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync", mod_sync)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync.client", mod_sync_client)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_sends_session_update_with_voice_and_instructions(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[])
|
||||
captured = _install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(
|
||||
api_key="sk-test",
|
||||
model="gpt-realtime",
|
||||
voice="verse",
|
||||
instructions="Be brief.",
|
||||
)
|
||||
sess.connect()
|
||||
|
||||
# Auth + beta headers set.
|
||||
assert captured["url"].startswith("wss://api.openai.com/v1/realtime")
|
||||
assert "model=gpt-realtime" in captured["url"]
|
||||
headers = captured["headers"] or []
|
||||
hdict = dict(headers)
|
||||
assert hdict.get("Authorization") == "Bearer sk-test"
|
||||
assert hdict.get("OpenAI-Beta") == "realtime=v1"
|
||||
|
||||
# First frame sent must be session.update with the right shape.
|
||||
assert len(ws.sent) == 1
|
||||
update = ws.sent[0]
|
||||
assert update["type"] == "session.update"
|
||||
s = update["session"]
|
||||
assert s["voice"] == "verse"
|
||||
assert s["instructions"] == "Be brief."
|
||||
assert set(s["modalities"]) == {"audio", "text"}
|
||||
assert s["output_audio_format"] == "pcm16"
|
||||
assert s["input_audio_format"] == "pcm16"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# speak()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_speak_sends_create_and_response_and_writes_audio(monkeypatch, tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
audio_bytes = b"\x01\x02\x03\x04PCM!"
|
||||
b64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
recv_frames = [
|
||||
{"type": "response.created"},
|
||||
{"type": "response.audio.delta", "delta": b64},
|
||||
{"type": "response.audio.delta", "delta": base64.b64encode(b"more").decode()},
|
||||
{"type": "response.done"},
|
||||
]
|
||||
ws = _FakeWS(recv_frames=recv_frames)
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sink = tmp_path / "out.pcm"
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=sink)
|
||||
sess.connect()
|
||||
result = sess.speak("Hello everyone.")
|
||||
|
||||
# Frames sent after session.update: conversation.item.create then response.create.
|
||||
types_sent = [f["type"] for f in ws.sent]
|
||||
assert types_sent == ["session.update", "conversation.item.create", "response.create"]
|
||||
|
||||
item = ws.sent[1]["item"]
|
||||
assert item["role"] == "user"
|
||||
assert item["content"][0]["type"] == "input_text"
|
||||
assert item["content"][0]["text"] == "Hello everyone."
|
||||
|
||||
resp = ws.sent[2]["response"]
|
||||
assert resp["modalities"] == ["audio"]
|
||||
|
||||
# Audio file got decoded + appended bytes.
|
||||
data = sink.read_bytes()
|
||||
assert data == audio_bytes + b"more"
|
||||
assert result["ok"] is True
|
||||
assert result["bytes_written"] == len(audio_bytes) + len(b"more")
|
||||
assert result["duration_ms"] >= 0.0
|
||||
|
||||
|
||||
def test_speak_raises_on_error_frame(monkeypatch, tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[
|
||||
{"type": "response.created"},
|
||||
{"type": "error", "error": {"message": "bad juju"}},
|
||||
])
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=tmp_path / "o.pcm")
|
||||
sess.connect()
|
||||
with pytest.raises(RuntimeError, match="bad juju"):
|
||||
sess.speak("hi")
|
||||
|
||||
|
||||
def test_speak_without_connect_raises(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
with pytest.raises(RuntimeError, match="connect"):
|
||||
sess.speak("hi")
|
||||
|
||||
|
||||
def test_close_is_idempotent_and_closes_ws(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[])
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
sess.connect()
|
||||
sess.close()
|
||||
assert ws.closed is True
|
||||
# Second close is a no-op.
|
||||
sess.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# websockets dependency missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_raises_clean_error_when_websockets_missing(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
# Make `import websockets.sync.client` fail.
|
||||
monkeypatch.setitem(sys.modules, "websockets", None)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync", None)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync.client", None)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
with pytest.raises(RuntimeError, match="pip install websockets"):
|
||||
sess.connect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RealtimeSpeaker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self):
|
||||
self.spoken: list[str] = []
|
||||
|
||||
def speak(self, text, timeout=30.0): # noqa: ARG002
|
||||
self.spoken.append(text)
|
||||
return {"ok": True, "bytes_written": len(text), "duration_ms": 1.0}
|
||||
|
||||
|
||||
def test_speaker_run_until_stopped_processes_queue(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "queue.jsonl"
|
||||
processed = tmp_path / "processed.jsonl"
|
||||
queue.write_text(
|
||||
json.dumps({"id": "a", "text": "hello one"}) + "\n"
|
||||
+ json.dumps({"id": "b", "text": "hello two"}) + "\n"
|
||||
)
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=processed)
|
||||
|
||||
# Stop once the queue is empty.
|
||||
def _stop():
|
||||
return queue.exists() and queue.read_text().strip() == ""
|
||||
|
||||
speaker.run_until_stopped(_stop, poll_interval=0.01)
|
||||
|
||||
assert stub.spoken == ["hello one", "hello two"]
|
||||
|
||||
# Processed file has both entries, in order.
|
||||
lines = [json.loads(l) for l in processed.read_text().splitlines() if l.strip()]
|
||||
assert [l["id"] for l in lines] == ["a", "b"]
|
||||
assert all(l["result"]["ok"] for l in lines)
|
||||
|
||||
# Queue is empty (possibly empty string) after processing.
|
||||
assert queue.read_text().strip() == ""
|
||||
|
||||
|
||||
def test_speaker_exits_immediately_when_stop_fn_true(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "q.jsonl"
|
||||
queue.write_text(json.dumps({"id": "x", "text": "never spoken"}) + "\n")
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue)
|
||||
speaker.run_until_stopped(lambda: True, poll_interval=0.01)
|
||||
assert stub.spoken == []
|
||||
|
||||
|
||||
def test_speaker_drops_line_without_processed_path_when_none(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "q.jsonl"
|
||||
queue.write_text(json.dumps({"id": "only", "text": "once"}) + "\n")
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=None)
|
||||
|
||||
def _stop():
|
||||
return queue.read_text().strip() == ""
|
||||
|
||||
speaker.run_until_stopped(_stop, poll_interval=0.01)
|
||||
assert stub.spoken == ["once"]
|
||||
assert queue.read_text().strip() == ""
|
||||
Reference in New Issue
Block a user