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:
Teknium
2026-04-27 06:22:25 -07:00
committed by GitHub
parent 8ed599dc05
commit df3c9593f8
21 changed files with 5751 additions and 0 deletions

View 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

View 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"]

View 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

View 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() == ""