meet_bot.py
meet_bot.pyBrowse 18 files
5,749 tokens
22,527 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Headless Google Meet bot — Playwright + live-caption scraping.2 3Standalone subprocess spawned by ``process_manager.py``; configured via ``HERMES_MEET_*`` env,4status + transcript written under ``$HERMES_MEET_OUT_DIR`` (filesystem is the only IPC).5No WebRTC audio parsing: Meet's live captions are watched via a MutationObserver — lossy and6English-biased, but deterministic (no STT billing) and stable thanks to the ARIA role.7Debug: ``HERMES_MEET_URL=... HERMES_MEET_OUT_DIR=/tmp/x HERMES_MEET_HEADED=1 \\8 python -m plugins.google_meet.meet_bot``9"""10 11from __future__ import annotations12 13import os14import re15import shutil16import signal17import subprocess18import sys19import threading20import time21from pathlib import Path22from types import SimpleNamespace23from typing import Optional24 25from utils import atomic_json_write26 27# Short three-segment code, a lookup URL, or /new. Anything else is rejected.28MEET_URL_RE = re.compile(29 r"^https://meet\.google\.com/([a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,}|lookup/[^/?#]+|new)"30 r"(?:[/?#].*)?$")31 32_FFMPEG_MISSING = "ffmpeg not found — install via `brew install ffmpeg` for realtime on macOS"33 34 35def _is_safe_meet_url(url: str) -> bool:36 """True if *url* is a Google Meet URL we're willing to navigate to."""37 return isinstance(url, str) and bool(MEET_URL_RE.match(url.strip()))38 39 40def _meeting_id_from_url(url: str) -> str:41 """3-segment meeting code, or a timestamped id for ``/lookup/...`` and ``/new``."""42 m = re.search(r"meet\.google\.com/([a-z0-9]{3,}-[a-z0-9]{3,}-[a-z0-9]{3,})", url or "")43 return m.group(1) if m else f"meet-{int(time.time())}"44 45 46def _quiet(fn, *args, **kwargs):47 """Call *fn*, swallowing any exception (best-effort teardown steps)."""48 try:49 return fn(*args, **kwargs)50 except Exception:51 return None52 53 54# status.json keys in file order → _BotState attribute + initial value.55_STATUS_FIELDS = (56 ("meetingId", "meeting_id", None), ("url", "url", None), ("inCall", "in_call", False),57 ("captioning", "captioning", False), ("captionsEnabledAttempted", "captions_enabled_attempted", False),58 ("lobbyWaiting", "lobby_waiting", False), ("joinAttemptedAt", "join_attempted_at", None),59 ("joinedAt", "joined_at", None), ("lastCaptionAt", "last_caption_at", None),60 ("transcriptLines", "transcript_lines", 0), ("transcriptPath", "transcript_path", None),61 ("error", "error", None), ("exited", "exited", False), ("pid", None, None),62 # realtime telemetry63 ("realtime", "realtime", False), ("realtimeReady", "realtime_ready", False),64 ("realtimeDevice", "realtime_device", None), ("audioBytesOut", "audio_bytes_out", 0),65 ("lastAudioOutAt", "last_audio_out_at", None), ("lastBargeInAt", "last_barge_in_at", None),66 ("leaveReason", "leave_reason", None))67 68 69class _BotState:70 """Single-process mutable state, flushed to ``status.json`` on each change."""71 72 def __init__(self, out_dir: Path, meeting_id: str, url: str):73 self.__dict__.update({attr: default for _, attr, default in _STATUS_FIELDS if attr})74 self.__dict__.update(out_dir=out_dir, meeting_id=meeting_id, url=url, _seen=set(), # seen "speaker|text"75 transcript_path=out_dir / "transcript.txt", status_path=out_dir / "status.json")76 out_dir.mkdir(parents=True, exist_ok=True)77 self._flush()78 79 def record_caption(self, speaker: str, text: str) -> None:80 """Append a caption line unless this exact (speaker, text) was already seen."""81 speaker, text = (speaker or "").strip() or "Unknown", (text or "").strip()82 key = f"{speaker}|{text}"83 if not text or key in self._seen:84 return85 self._seen.add(key)86 self.transcript_lines += 187 self.last_caption_at = time.time()88 ts = time.strftime("%H:%M:%S", time.localtime(self.last_caption_at))89 with self.transcript_path.open("a", encoding="utf-8") as f:90 f.write(f"[{ts}] {speaker}: {text}\n")91 self._flush()92 93 def _flush(self) -> None:94 data = {key: getattr(self, attr) if attr else None for key, attr, _ in _STATUS_FIELDS}95 data.update(transcriptPath=str(self.transcript_path), pid=os.getpid()) # keeps table key order96 atomic_json_write(self.status_path, data)97 98 def set(self, **kwargs) -> None:99 self.__dict__.update(kwargs)100 self._flush()101 102 103# JS injected into the Meet tab: MutationObserver on the caption container104# collects {speaker, text}; ``window.__hermesMeetDrain()`` pulls new entries.105_CAPTION_OBSERVER_JS = r"""106(() => {107 if (window.__hermesMeetInstalled) return;108 window.__hermesMeetInstalled = true;109 window.__hermesMeetQueue = [];110 111 const captionSelector = '[role="region"][aria-label*="aption" i], ' +112 'div[jsname="YSxPC"], ' + // legacy113 'div[jsname="tgaKEf"]'; // current (Apr 2026)114 115 function pushEntry(speaker, text) {116 if (!text || !text.trim()) return;117 window.__hermesMeetQueue.push({118 ts: Date.now(),119 speaker: (speaker || '').trim(),120 text: text.trim(),121 });122 }123 124 function scan(root) {125 // Meet captions render as rows of speaker label + text block. Selectors126 // vary across Meet rewrites; try a few shapes and fall back to raw text.127 const rows = root.querySelectorAll('div[jsname="dsyhDe"], div.CNusmb, div.TBMuR');128 if (rows.length) {129 rows.forEach((row) => {130 const spkEl = row.querySelector('div.KcIKyf, div.zs7s8d, span[jsname="YSxPC"]');131 const txtEl = row.querySelector('div.bh44bd, span[jsname="tgaKEf"], div.iTTPOb');132 pushEntry(spkEl ? spkEl.innerText : '', txtEl ? txtEl.innerText : row.innerText);133 });134 return;135 }136 // Fallback: treat the whole region's innerText as one anonymous line.137 pushEntry('', (root.innerText || '').split('\n').filter(Boolean).pop());138 }139 140 function attach() {141 const el = document.querySelector(captionSelector);142 if (!el) return false;143 new MutationObserver(() => scan(el)).observe(el, { childList: true, subtree: true, characterData: true });144 scan(el);145 return true;146 }147 148 // Retry on interval — the caption region only appears after captions are149 // enabled and someone speaks.150 if (!attach()) {151 const iv = setInterval(() => { if (attach()) clearInterval(iv); }, 1500);152 }153 154 window.__hermesMeetDrain = () => {155 const out = window.__hermesMeetQueue.slice();156 window.__hermesMeetQueue = [];157 return out;158 };159})();160"""161 162# Best-effort caption toggle: Meet binds it to the ``c`` key; click targeting is too brittle.163_ENABLE_CAPTIONS_JS = (164 "(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', "165 "{ key: 'c', code: 'KeyC', keyCode: 67, which: 67, bubbles: true })); return true; })();")166 167_LEAVE_CALL_JS = (168 "() => { const b = document.querySelector('button[aria-label*=\"eave call\"]');"169 " if (b) b.click(); }")170 171# True once past the lobby: leave button, caption region (once our observer is installed)172# or participant list visible.173_ADMISSION_PROBE_JS = r"""174 (() => {175 if (document.querySelector('button[aria-label*="eave call" i]')) return true;176 if (window.__hermesMeetInstalled && document.querySelector(177 '[role="region"][aria-label*="aption" i], div[jsname="YSxPC"], div[jsname="tgaKEf"]')) return true;178 return !!document.querySelector('[aria-label*="articipants" i]');179 })();180 """181 182# English only — what Meet shows when the host denies or removes a guest.183_DENIED_PROBE_JS = r"""184 (() => {185 const text = document.body ? document.body.innerText || '' : '';186 return /You can't join this video call|You were removed from the meeting|No one responded to your request to join/i.test(text);187 })();188 """189 190 191def _probe(page, js: str) -> bool:192 """Evaluate a boolean JS probe; conservative — False on any error."""193 return bool(_quiet(page.evaluate, js))194 195 196def _visible(locator):197 """``locator.first`` if it exists and is visible, else None (swallows Playwright errors)."""198 return _quiet(lambda: locator.first if locator.first.count() and locator.first.is_visible() else None)199 200 201def _start_pcm_pump(rt: dict, bridge_info: dict, pcm_path: Path, state: "_BotState") -> None:202 """Stream the growing ``speaker.pcm`` (24kHz s16le mono) into the device Chrome's fake mic reads."""203 bridge_info = bridge_info or {}204 platform_tag = bridge_info.get("platform")205 target = bridge_info.get("write_target")206 if platform_tag == "linux":207 cmd = ["paplay", "--raw", "--rate=24000", "--format=s16le", "--channels=1",208 f"--device={target or 'hermes_meet_sink'}", str(pcm_path)]209 missing = "paplay not found — install pulseaudio-utils for realtime on Linux"210 elif platform_tag == "darwin":211 # User must have BlackHole as default input; ffmpeg targets it by audiotoolbox index.212 if not shutil.which("ffmpeg"):213 state.set(error=_FFMPEG_MISSING)214 return215 cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-re",216 "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", str(pcm_path), "-f", "audiotoolbox",217 "-audio_device_index", _mac_audio_device_index(target or "BlackHole 2ch"), "-"]218 missing = _FFMPEG_MISSING219 else:220 return221 try:222 rt["pcm_pump"] = subprocess.Popen(223 cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)224 except FileNotFoundError:225 state.set(error=missing)226 except Exception as e:227 if platform_tag != "darwin":228 raise229 state.set(error=f"macOS pcm pump failed to start: {e}")230 231 232def _start_realtime_speaker(rt: dict, cfg: "_BotConfig", stop_flag: dict, state: "_BotState") -> None:233 """Wire up the OpenAI Realtime session, the say-queue speaker thread and the PCM pump."""234 pcm_path, queue_path = cfg.out_dir / "speaker.pcm", cfg.out_dir / "say_queue.jsonl"235 pcm_path.write_bytes(b"") # clean sink file per session236 queue_path.touch() # so the speaker poller doesn't error on first iteration237 phase = "import"238 try:239 from plugins.google_meet.realtime.openai_client import RealtimeSession, RealtimeSpeaker240 phase = "connect"241 session = RealtimeSession(242 api_key=cfg.realtime_api_key, model=cfg.realtime_model, voice=cfg.realtime_voice,243 instructions=cfg.realtime_instructions, audio_sink_path=pcm_path, sample_rate=24000)244 session.connect()245 except Exception as e:246 state.set(error=f"realtime {phase} failed: {e}")247 return248 rt["session"] = session249 speaker = RealtimeSpeaker(session=session, queue_path=queue_path,250 processed_path=cfg.out_dir / "say_processed.jsonl")251 252 def _speaker_loop():253 try:254 speaker.run_until_stopped(lambda: stop_flag.get("stop", False))255 except Exception as e:256 state.set(error=f"realtime speaker crashed: {e}")257 258 rt["speaker_thread"] = threading.Thread(target=_speaker_loop, name="meet-speaker", daemon=True)259 rt["speaker_thread"].start()260 _start_pcm_pump(rt, rt["bridge_info"], pcm_path, state)261 state.set(realtime_ready=True)262 263 264def _mac_audio_device_index(device_name: str) -> str:265 """ffmpeg ``-audio_device_index`` for *device_name* (case-insensitive; ``"0"`` if not found).266 ffmpeg prints the avfoundation device table on stderr as ``[N] Name``."""267 out = _quiet(subprocess.run, ["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],268 capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=10)269 needle = device_name.strip().lower()270 for line in (out.stderr if out else "").splitlines():271 m = re.search(r"\[(\d+)\]\s+(.+)$", line)272 if m and m.group(2).strip().lower() == needle:273 return m.group(1)274 return "0"275 276 277def _setup_realtime(rt: dict, api_key: str, state: _BotState) -> None:278 """Provision the virtual audio bridge; on any failure fall back to transcribe mode."""279 if not api_key:280 state.set(error="realtime mode requested but no API key in HERMES_MEET_REALTIME_KEY/OPENAI_API_KEY — falling back to transcribe")281 rt["enabled"] = False282 return283 try:284 from plugins.google_meet.audio_bridge import AudioBridge285 rt["bridge"] = AudioBridge()286 rt["bridge_info"] = rt["bridge"].setup()287 state.set(realtime=True, realtime_device=rt["bridge_info"].get("device_name"))288 except Exception as e:289 state.set(error=f"audio bridge setup failed: {e} — falling back to transcribe")290 rt["enabled"] = False291 292 293def _teardown_realtime(rt: dict) -> None:294 if rt.get("pcm_pump"):295 _quiet(rt["pcm_pump"].terminate)296 _quiet(rt["pcm_pump"].wait, timeout=3)297 for key, method, kw in (("speaker_thread", "join", {"timeout": 5.0}), ("session", "close", {}),298 ("bridge", "teardown", {})):299 if rt[key] is not None:300 _quiet(getattr(rt[key], method), **kw)301 302 303_BotConfig = SimpleNamespace # everything the bot reads from ``HERMES_MEET_*`` env vars304 305 306def _config_from_env() -> _BotConfig:307 env = os.environ.get308 out_raw = env("HERMES_MEET_OUT_DIR", "").strip()309 return _BotConfig(310 url=env("HERMES_MEET_URL", "").strip(),311 out_dir=Path(out_raw) if out_raw else None,312 headed=env("HERMES_MEET_HEADED", "").lower() in {"1", "true", "yes"},313 auth_state=env("HERMES_MEET_AUTH_STATE", "").strip(),314 guest_name=env("HERMES_MEET_GUEST_NAME", "Hermes Agent"),315 duration_s=_parse_duration(env("HERMES_MEET_DURATION", "")),316 realtime=env("HERMES_MEET_MODE", "transcribe").strip().lower() == "realtime",317 # HERMES_MEET_REALTIME_KEY is resolved by process_manager.start() via the parent's318 # profile secret scope; OPENAI_API_KEY only serves standalone `python -m` runs.319 realtime_api_key=env("HERMES_MEET_REALTIME_KEY") or env("OPENAI_API_KEY", ""),320 realtime_model=env("HERMES_MEET_REALTIME_MODEL", "gpt-realtime"),321 realtime_voice=env("HERMES_MEET_REALTIME_VOICE", "alloy"),322 realtime_instructions=env("HERMES_MEET_REALTIME_INSTRUCTIONS", ""),323 lobby_timeout=float(env("HERMES_MEET_LOBBY_TIMEOUT", "300")))324 325 326def _join(page, cfg: _BotConfig, state: _BotState) -> None:327 """Fill the guest-name field and click 'Join now' / 'Ask to join' (the latter → lobby_waiting)."""328 name_box = _visible(page.locator('input[aria-label*="name" i]'))329 if name_box is not None:330 _quiet(name_box.fill, cfg.guest_name, timeout=2_000)331 for label in ("Join now", "Ask to join"):332 btn = _visible(page.get_by_role("button", name=label, exact=False))333 if btn is not None and _quiet(lambda: (btn.click(timeout=3_000), True)):334 if label == "Ask to join":335 state.set(lobby_waiting=True)336 break337 338 339def _drain_loop(page, cfg: _BotConfig, state: _BotState, rt: dict, stop_flag: dict) -> None:340 """Admission + caption drain loop until SIGTERM, duration expiry, lobby timeout/denial or page loss.341 Sets ``leave_reason`` for every exit but SIGTERM; triggers barge-in; mirrors realtime counters."""342 deadline = (time.time() + cfg.duration_s) if cfg.duration_s else None343 lobby_deadline = time.time() + cfg.lobby_timeout344 last_admission_check = 0.0345 while not stop_flag["stop"]:346 now = time.time()347 if deadline and now > deadline:348 state.set(leave_reason="duration_expired")349 return350 if not state.in_call and (now - last_admission_check) > 3.0:351 last_admission_check = now352 if _probe(page, _ADMISSION_PROBE_JS):353 state.set(in_call=True, lobby_waiting=False, joined_at=now)354 elif now > lobby_deadline:355 waited = int(lobby_deadline - state.join_attempted_at) if state.join_attempted_at else 0356 state.set(error=f"lobby timeout — host never admitted the bot within {waited}s",357 leave_reason="lobby_timeout")358 return359 elif _probe(page, _DENIED_PROBE_JS):360 state.set(error="host denied admission", leave_reason="denied")361 return362 try:363 queued = page.evaluate("window.__hermesMeetDrain && window.__hermesMeetDrain()")364 for entry in (e for e in (queued if isinstance(queued, list) else ()) if isinstance(e, dict)):365 speaker = str(entry.get("speaker", ""))366 state.record_caption(speaker=speaker, text=str(entry.get("text", "")))367 # Barge-in: a real human spoke while we may be generating audio.368 if (rt["session"] is not None and _looks_like_human_speaker(speaker, cfg.guest_name)369 and _quiet(rt["session"].cancel_response)):370 state.set(last_barge_in_at=now)371 except Exception:372 if page.is_closed(): # Meet reloaded or we got booted — exit rather than spin373 state.set(leave_reason="page_closed")374 return375 if rt["session"] is not None:376 state.set(audio_bytes_out=rt["session"].audio_bytes_out,377 last_audio_out_at=rt["session"].last_audio_out_at)378 time.sleep(1.0)379 380 381_CONTEXT_ARGS = {382 "viewport": {"width": 1280, "height": 800},383 "user_agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "384 "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),385 "permissions": ["microphone", "camera"]}386 387 388def run_bot() -> int:389 cfg = _config_from_env()390 if not _is_safe_meet_url(cfg.url):391 sys.stderr.write("google_meet bot: refusing to launch — HERMES_MEET_URL must be a "392 "meet.google.com URL. got: %r\n" % cfg.url)393 return 2394 if cfg.out_dir is None:395 sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n")396 return 2397 state = _BotState(out_dir=cfg.out_dir, meeting_id=_meeting_id_from_url(cfg.url), url=cfg.url)398 # SIGTERM sets a flag (not an exception) so the Playwright teardown below still runs399 # and ``meet_leave`` gets a finalized transcript.400 stop_flag = {"stop": False}401 for sig in (signal.SIGTERM, signal.SIGINT):402 signal.signal(sig, lambda _sig, _frame: stop_flag.__setitem__("stop", True))403 # Realtime resources in one dict so teardown works however we exit.404 rt = dict(enabled=cfg.realtime, bridge=None, bridge_info=None, session=None, speaker_thread=None)405 if rt["enabled"]:406 _setup_realtime(rt, cfg.realtime_api_key, state)407 try:408 from playwright.sync_api import sync_playwright409 except ImportError as e:410 state.set(error=f"playwright not installed: {e}", exited=True)411 sys.stderr.write("google_meet bot: playwright is not installed. Run "412 "`pip install playwright && python -m playwright install chromium`\n")413 if rt["bridge"]:414 rt["bridge"].teardown()415 return 3416 chrome_args = ["--use-fake-ui-for-media-stream", "--disable-blink-features=AutomationControlled"]417 if not rt["enabled"]:418 chrome_args.insert(1, "--use-fake-device-for-media-stream") # silent fake mic419 elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux":420 # Playwright's launch() takes no env: set PULSE_SOURCE on ourselves so Chrome inherits it.421 os.environ["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")422 context_args = dict(_CONTEXT_ARGS)423 if cfg.auth_state and Path(cfg.auth_state).is_file():424 context_args["storage_state"] = cfg.auth_state425 try:426 with sync_playwright() as pw:427 browser = pw.chromium.launch(headless=not cfg.headed, args=chrome_args)428 context = browser.new_context(**context_args)429 page = context.new_page()430 try:431 page.goto(cfg.url, wait_until="domcontentloaded", timeout=30_000)432 except Exception as e:433 state.set(error=f"navigate failed: {e}", exited=True)434 return 4435 _join(page, cfg, state)436 if _quiet(page.evaluate, _ENABLE_CAPTIONS_JS):437 state.set(captions_enabled_attempted=True)438 try:439 page.evaluate(_CAPTION_OBSERVER_JS)440 except Exception as e:441 state.set(error=f"caption observer install failed: {e}")442 # in_call stays False until admission is confirmed by the drain loop.443 state.set(captioning=True, join_attempted_at=time.time())444 if rt["enabled"]:445 _start_realtime_speaker(rt, cfg, stop_flag, state)446 _drain_loop(page, cfg, state, rt, stop_flag)447 _quiet(page.evaluate, _LEAVE_CALL_JS)448 context.close()449 browser.close()450 _teardown_realtime(rt)451 state.set(in_call=False, captioning=False, exited=True)452 return 0453 except Exception as e:454 state.set(error=f"unhandled: {e}", exited=True)455 return 1456 457 458def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool:459 """Whether a caption's speaker is probably a human rather than our own echo (Meet attributes460 our fake-mic audio to the bot's name; blank/unknown speakers are ambiguous — no barge-in)."""461 return bool(speaker and speaker.strip()) and (462 speaker.strip().lower() not in {"unknown", "you", bot_guest_name.strip().lower()})463 464 465_DURATION_UNITS = {"h": 3600.0, "m": 60.0, "s": 1.0}466 467 468def _parse_duration(raw: str) -> Optional[float]:469 """Parse ``30m`` / ``2h`` / ``90`` (seconds) → float seconds, or None."""470 if not raw:471 return None472 raw = raw.strip().lower()473 mult = _DURATION_UNITS.get(raw[-1:])474 try:475 return float(raw[:-1]) * mult if mult else float(raw)476 except ValueError:477 return None478 479 480if __name__ == "__main__": # pragma: no cover — subprocess entry point481 sys.exit(run_bot())482 483 484# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----485# Names external plugins imported from this module before the Sep 2026 decomposition.486# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).487# The whole block is removed by reverting the commit that added it.488import json # noqa: F401,E402489 490SAY_PCM_FILENAME = "speaker.pcm"491 492SAY_QUEUE_FILENAME = "say_queue.jsonl"493# ---- END PLUGIN-COMPAT ----494