mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
* feat(plugins): google_meet — bundled plugin for join+transcribe Meet calls v1 shipping transcribe-only. Spawns headless Chromium via Playwright, joins an explicit https://meet.google.com/ URL, enables live captions, and scrapes them into a transcript file the agent can read across turns. The agent then has the meeting content in context and can do followup work (send recap, file issues, schedule followups) with its regular tools. Surface: - Tools: meet_join, meet_status, meet_transcript, meet_leave, meet_say (meet_say is a v1 stub — returns not-implemented; v2 will wire realtime duplex audio via OpenAI Realtime / Gemini Live + BlackHole / PulseAudio null-sink.) - CLI: hermes meet setup | auth | join | status | transcript | stop - Lifecycle: on_session_end auto-leaves any still-running bot. Safety: - URL regex rejects anything that isn't https://meet.google.com/... - No calendar scanning, no auto-dial, no auto-consent announcement. - Single active meeting per install; a second meet_join leaves the first. - Platform-gated to Linux + macOS (Windows audio routing for v2 untested). - Opt-in: standalone plugin, user must add 'google_meet' to plugins.enabled in config.yaml. Zero core changes. Plugin uses existing register_tool / register_cli_command / register_hook surfaces. 21 new unit tests cover the URL safety gate, transcript dedup + status round-trip, process-manager refusals/start/stop paths, tool-handler JSON shape under each branch, session-end cleanup, and platform-gated register(). * feat(plugins/google_meet): v2 realtime audio + v3 remote node host v2 \u2014 agent speaks in-meeting audio_bridge.py: PulseAudio null-sink (Linux) + BlackHole probe (macOS). On Linux we load pactl module-null-sink + module-virtual-source, track module ids for teardown; Chrome gets PULSE_SOURCE=<virt src> env so its fake mic reads what we write to the sink. macOS just probes BlackHole 2ch and returns its device name \u2014 the plugin refuses to switch the user's default audio input (that would surprise them). realtime/openai_client.py: sync WebSocket client for the OpenAI Realtime API. RealtimeSession.speak(text) sends conversation.item.create + response.create, accumulates response.audio.delta PCM bytes, appends them to a file. RealtimeSpeaker runs a JSONL-queue loop consuming meet_say calls. 'websockets' is an optional dep imported lazily. meet_bot.py: when HERMES_MEET_MODE=realtime, provisions AudioBridge, starts RealtimeSession + speaker thread, spawns paplay to pump PCM into the null-sink, then cleans everything up on SIGTERM. If any realtime setup step fails, falls back cleanly to transcribe mode with an error flagged in status.json. process_manager.enqueue_say(): writes a JSONL line to say_queue.jsonl; refuses when no active meeting or active meeting is transcribe-only. tools.meet_say: real implementation; requires active mode='realtime'. meet_join: adds mode='transcribe'|'realtime' param. v3 \u2014 remote node host node/protocol.py: JSON envelope (type, id, token, payload) + validate. node/registry.py: $HERMES_HOME/workspace/meetings/nodes.json, with resolve() auto-selecting the sole registered node when name is None. node/server.py: NodeServer \u2014 websockets.serve, bearer-token auth, dispatches start_bot/stop/status/transcript/say/ping onto the local process_manager. Token auto-generated + persisted on first run. node/client.py: NodeClient \u2014 short-lived sync WS per RPC, raises RuntimeError on error envelopes, clean API matching the server. node/cli.py: 'hermes meet node {run,list,approve,remove,status,ping}' subtree; wired into the main meet CLI by cli.py so 'hermes meet node' Just Works. tools.py: every meet_* tool accepts node='<name>'|'auto'; when set, routes through NodeClient to the remote bot instead of running locally. Unknown node \u2192 clear 'no registered meet node matches ...' error. cli.py: 'hermes meet join --node my-mac --mode realtime' and 'hermes meet say "..." --node my-mac' route to the node; 'hermes meet node approve <name> <url> <token>' registers one. Tests 21 v1 tests updated (meet_say is no longer a stub; active-record now carries mode). 20 new audio_bridge + realtime tests. 42 new node tests (protocol/registry/server/client/cli). 17 new v1/v2/v3 integration tests at the plugin level covering enqueue_say edge cases, env var passthrough, mode validation, node routing (known/unknown/auto/ambiguous), and argparse wiring for `hermes meet say` + `hermes meet node` + --mode/--node flags. Total: 100 plugin tests + 58 plugin-system tests = 158 passing. E2E verified on Linux with fresh HERMES_HOME: plugin loads, 5 tools register, on_session_end hook wires, 'hermes meet' CLI tree wires including the node subtree, NodeRegistry round-trips, meet_join routes correctly to NodeClient under node='my-mac' with mode='realtime', enqueue_say accepts realtime/rejects transcribe, argparse parses every new flag cleanly. Zero changes to core. All new code lives under plugins/google_meet/. * feat(plugins/google_meet): auto-install, admission detect, mac PCM pump, barge-in, richer status Ready-for-live-test follow-up on PR #16364. Five additions that matter for the first live run on a real Meet, in priority order: 1. hermes meet install [--realtime] [--yes] pip install playwright websockets + python -m playwright install chromium --realtime: installs platform audio deps (pulseaudio-utils on Linux via sudo apt, blackhole-2ch + ffmpeg on macOS via brew). Prompts before sudo/brew unless --yes. Refuses on Windows. Refuses to auto-flip the macOS default input — user still selects BlackHole in System Settings (deliberate; surprise audio rerouting is worse than a manual step). 2. Admission detection _detect_admission(page): Leave-button visible OR caption region attached OR participants list present → we're in-call. _detect_denied(page): 'You can\'t join this video call' / 'You were removed' / 'No one responded to your request' → bail out. HERMES_MEET_LOBBY_TIMEOUT (default 300s) caps how long we sit in the lobby before giving up. in_call stays False until admitted. Status surfaces leaveReason: duration_expired | lobby_timeout | denied | page_closed. 3. macOS PCM pump ffmpeg reads speaker.pcm (24kHz s16le mono) and writes to the BlackHole AVFoundation output via -f audiotoolbox -audio_device_index <N>. _mac_audio_device_index() probes ffmpeg -f avfoundation -list_devices true to resolve 'BlackHole 2ch' → numeric index. Falls back to index 0 on probe failure. Linux paplay pump unchanged. 4. Richer status dict _BotState now tracks realtime, realtimeReady, realtimeDevice, audioBytesOut, lastAudioOutAt, lastBargeInAt, joinAttemptedAt, leaveReason. RealtimeSession.audio_bytes_out / last_audio_out_at counters fold into the status file once a second so meet_status() can show the agent's voice activity in near-real-time. 5. Barge-in RealtimeSession.cancel_response() sends type='response.cancel' over the same WS (lock-guarded so it's safe to call from the caption thread while speak() is reading frames). Handles response.cancelled as a terminal frame type. _looks_like_human_speaker() gates triggers so the bot's own name, 'You', 'Unknown', and blanks don't self-cancel. Called from the caption drain loop: when a new caption arrives attributed to a real participant while rt.session exists, we fire cancel_response() and stamp lastBargeInAt. Tests: 20 new unit tests across _BotState telemetry, barge-in gating, admission/denied probe error handling, cancel_response with and without a connected WS, and `hermes meet install` CLI wiring (flag parsing + end-to-end subprocess.run verification + Linux-already-installed fast path). Total 171 passing across all google_meet test files + the plugin-system regression suite. E2E verified on Linux: plugin loads, all 5 tools register, `hermes meet install --realtime --yes` parses, fresh-bot status.json has every new telemetry key, cancel_response on a disconnected session returns False without raising, barge-in helper gates the bot's own name correctly. Still out of scope (for a future PR, not blocking live test): mic → Realtime duplex (the agent listening to meeting audio via WebRTC), node-host TLS/pairing UX, Windows audio, Meet create+Twilio. Docs updated: SKILL.md now lists the installer subcommand, lobby timeout, barge-in caveat, and the full status-dict reference table. README.md quick-start uses hermes meet install.
815 lines
28 KiB
Python
815 lines
28 KiB
Python
"""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
|