/ plugins / google_meet / cli.py
cli.py
  1  """CLI commands for the google_meet plugin.
  2  
  3  Wires ``hermes meet <subcommand>``:
  4    setup       — preflight playwright, chromium, auth file, print fixes
  5    auth        — open a browser to sign into Google, save storage state
  6    join <url>  — join a Meet URL synchronously (also callable from the agent)
  7    status      — print current bot state
  8    transcript  — print the transcript
  9    stop        — leave the current meeting
 10  """
 11  
 12  from __future__ import annotations
 13  
 14  import argparse
 15  import json
 16  import os
 17  import sys
 18  from pathlib import Path
 19  from typing import Optional
 20  
 21  from hermes_constants import get_hermes_home
 22  
 23  from plugins.google_meet import process_manager as pm
 24  from plugins.google_meet.meet_bot import _is_safe_meet_url
 25  
 26  
 27  def _auth_state_path() -> Path:
 28      return Path(get_hermes_home()) / "workspace" / "meetings" / "auth.json"
 29  
 30  
 31  # ---------------------------------------------------------------------------
 32  # argparse wiring
 33  # ---------------------------------------------------------------------------
 34  
 35  def register_cli(subparser: argparse.ArgumentParser) -> None:
 36      """Build the ``hermes meet`` argparse tree.
 37  
 38      Called by :func:`_register_cli_commands` at plugin load time.
 39      """
 40      subs = subparser.add_subparsers(dest="meet_command")
 41  
 42      subs.add_parser("setup", help="Preflight: playwright, chromium, auth")
 43  
 44      inst_p = subs.add_parser(
 45          "install",
 46          help="Install prerequisites (pip deps, Chromium, platform audio tools)",
 47      )
 48      inst_p.add_argument(
 49          "--realtime", action="store_true",
 50          help="Also install realtime audio tools (pulseaudio-utils on Linux, BlackHole+ffmpeg on macOS). Uses sudo/brew, prompts before invoking either.",
 51      )
 52      inst_p.add_argument(
 53          "--yes", "-y", action="store_true",
 54          help="Answer yes to all prompts (use with care; will run sudo apt-get or brew without asking).",
 55      )
 56  
 57      subs.add_parser("auth", help="Sign in to Google and save session state")
 58  
 59      join_p = subs.add_parser("join", help="Join a Meet URL")
 60      join_p.add_argument("url", help="https://meet.google.com/...")
 61      join_p.add_argument("--guest-name", default="Hermes Agent")
 62      join_p.add_argument("--duration", default=None, help="e.g. 30m, 2h, 90s")
 63      join_p.add_argument("--headed", action="store_true", help="show browser")
 64      join_p.add_argument(
 65          "--mode", choices=("transcribe", "realtime"), default="transcribe",
 66          help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)"
 67      )
 68      join_p.add_argument(
 69          "--node", default=None,
 70          help="remote node name, or 'auto' to use the sole registered node"
 71      )
 72  
 73      subs.add_parser("status", help="Print current Meet bot state")
 74  
 75      tr_p = subs.add_parser("transcript", help="Print the scraped transcript")
 76      tr_p.add_argument("--last", type=int, default=None)
 77  
 78      say_p = subs.add_parser("say", help="Speak text in an active realtime meeting")
 79      say_p.add_argument("text", help="what to say")
 80      say_p.add_argument("--node", default=None)
 81  
 82      subs.add_parser("stop", help="Leave the current meeting")
 83  
 84      # v3: remote node host management.
 85      node_p = subs.add_parser(
 86          "node",
 87          help="Manage remote meet node hosts (run/list/approve/remove/status/ping)",
 88      )
 89      try:
 90          from plugins.google_meet.node.cli import register_cli as _register_node_cli
 91          _register_node_cli(node_p)
 92      except Exception as e:  # pragma: no cover — defensive
 93          # If the node module fails to import for any reason (optional dep
 94          # missing at import time etc.), leave the subparser present but
 95          # flag it. The argparse dispatch will surface a clear error.
 96          def _node_unavailable(args):
 97              print(f"hermes meet node: module unavailable ({e})")
 98              return 1
 99          node_p.set_defaults(func=_node_unavailable)
100  
101      subparser.set_defaults(func=meet_command)
102  
103  
104  # ---------------------------------------------------------------------------
105  # Dispatch
106  # ---------------------------------------------------------------------------
107  
108  def meet_command(args: argparse.Namespace) -> int:
109      sub = getattr(args, "meet_command", None)
110      if not sub:
111          print("usage: hermes meet {setup,auth,join,status,transcript,say,stop,node}")
112          return 2
113      if sub == "setup":
114          return _cmd_setup()
115      if sub == "install":
116          return _cmd_install(
117              realtime=bool(getattr(args, "realtime", False)),
118              assume_yes=bool(getattr(args, "yes", False)),
119          )
120      if sub == "auth":
121          return _cmd_auth()
122      if sub == "join":
123          return _cmd_join(
124              url=args.url,
125              guest_name=args.guest_name,
126              duration=args.duration,
127              headed=args.headed,
128              mode=getattr(args, "mode", "transcribe"),
129              node=getattr(args, "node", None),
130          )
131      if sub == "status":
132          return _cmd_status()
133      if sub == "transcript":
134          return _cmd_transcript(last=args.last)
135      if sub == "say":
136          return _cmd_say(text=args.text, node=getattr(args, "node", None))
137      if sub == "stop":
138          return _cmd_stop()
139      if sub == "node":
140          # Dispatch was set by the node cli's register_cli; fall through to
141          # whatever its subparsers wired.
142          fn = getattr(args, "func", None)
143          if fn is None or fn is meet_command:
144              print("usage: hermes meet node {run,list,approve,remove,status,ping}")
145              return 2
146          return fn(args)
147      print(f"unknown subcommand: {sub}")
148      return 2
149  
150  
151  # ---------------------------------------------------------------------------
152  # Subcommand handlers
153  # ---------------------------------------------------------------------------
154  
155  def _cmd_setup() -> int:
156      import platform as _p
157  
158      print("google_meet preflight")
159      print("---------------------")
160  
161      system = _p.system()
162      system_ok = system in ("Linux", "Darwin")
163      print(f"  platform       : {system}  [{'ok' if system_ok else 'unsupported'}]")
164  
165      try:
166          import playwright  # noqa: F401
167          pw_ok = True
168          pw_msg = "installed"
169      except ImportError:
170          pw_ok = False
171          pw_msg = "NOT installed — run: pip install playwright"
172      print(f"  playwright     : {pw_msg}")
173  
174      chromium_ok = False
175      chromium_msg = "unknown"
176      if pw_ok:
177          try:
178              from playwright.sync_api import sync_playwright
179              with sync_playwright() as p:
180                  try:
181                      exe = p.chromium.executable_path
182                      if exe and Path(exe).exists():
183                          chromium_ok = True
184                          chromium_msg = f"ok ({exe})"
185                      else:
186                          chromium_msg = (
187                              "not installed — run: "
188                              "python -m playwright install chromium"
189                          )
190                  except Exception as e:
191                      chromium_msg = f"probe failed: {e}"
192          except Exception as e:
193              chromium_msg = f"probe failed: {e}"
194      print(f"  chromium       : {chromium_msg}")
195  
196      auth_path = _auth_state_path()
197      auth_ok = auth_path.is_file()
198      print(
199          "  google auth    : "
200          + (f"ok ({auth_path})" if auth_ok else "not saved — run: hermes meet auth")
201      )
202  
203      print()
204      all_ok = system_ok and pw_ok and chromium_ok
205      if all_ok:
206          print(
207              "ready. Join a meeting:  "
208              "hermes meet join https://meet.google.com/abc-defg-hij"
209          )
210      else:
211          print("not ready yet — fix the items above.")
212      return 0 if all_ok else 1
213  
214  
215  def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:
216      """Install the plugin's prerequisites.
217  
218      Always: pip install playwright + websockets, then
219      ``python -m playwright install chromium``.
220  
221      With ``--realtime``: also install the platform audio bridge deps.
222        Linux : ``sudo apt-get install -y pulseaudio-utils``
223        macOS : ``brew install blackhole-2ch ffmpeg``  (+ remind the user
224                to select BlackHole as the default input device manually)
225  
226      Prompts before every package-manager invocation unless ``--yes``.
227      Refuses to run on Windows.
228      """
229      import platform as _p
230      import shutil as _shutil
231      import subprocess as _sp
232  
233      system = _p.system()
234      if system not in ("Linux", "Darwin"):
235          print(f"google_meet install: {system} is not supported (linux/macos only)")
236          return 1
237  
238      def _confirm(prompt: str) -> bool:
239          if assume_yes:
240              return True
241          try:
242              ans = input(f"{prompt} [y/N] ").strip().lower()
243          except EOFError:
244              return False
245          return ans in ("y", "yes")
246  
247      print("google_meet install")
248      print("-------------------")
249  
250      # 1) pip deps — always safe, venv-scoped.
251      pip_pkgs = ["playwright", "websockets"]
252      print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")
253      try:
254          res = _sp.run(
255              [sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
256              check=False,
257          )
258          if res.returncode != 0:
259              print("  pip install failed")
260              return 1
261      except Exception as e:
262          print(f"  pip install failed: {e}")
263          return 1
264  
265      # 2) Playwright browsers — pulls chromium (~300MB first run).
266      print("\n[2/3] python -m playwright install chromium")
267      try:
268          res = _sp.run(
269              [sys.executable, "-m", "playwright", "install", "chromium"],
270              check=False,
271          )
272          if res.returncode != 0:
273              print("  playwright install failed (may already be installed)")
274      except Exception as e:
275          print(f"  playwright install failed: {e}")
276          return 1
277  
278      # 3) Platform audio deps for realtime mode.
279      if realtime:
280          print("\n[3/3] realtime audio deps")
281          if system == "Linux":
282              if _shutil.which("paplay") and _shutil.which("pactl"):
283                  print("  pulseaudio-utils already installed.")
284              else:
285                  if not _confirm(
286                      "  install pulseaudio-utils? this runs `sudo apt-get install -y pulseaudio-utils`"
287                  ):
288                      print("  skipped (you can run it manually later)")
289                  else:
290                      cmd = ["sudo", "apt-get", "install", "-y", "pulseaudio-utils"]
291                      print(f"  $ {' '.join(cmd)}")
292                      res = _sp.run(cmd, check=False)
293                      if res.returncode != 0:
294                          print("  apt install failed — install pulseaudio-utils manually")
295          elif system == "Darwin":
296              have_bh = False
297              try:
298                  out = _sp.check_output(["system_profiler", "SPAudioDataType"], text=True)
299                  have_bh = "BlackHole" in out
300              except Exception:
301                  pass
302              have_ffmpeg = bool(_shutil.which("ffmpeg"))
303              needs = []
304              if not have_bh:
305                  needs.append("blackhole-2ch")
306              if not have_ffmpeg:
307                  needs.append("ffmpeg")
308              if not needs:
309                  print("  BlackHole and ffmpeg already installed.")
310              elif not _shutil.which("brew"):
311                  print(
312                      "  missing: " + ", ".join(needs) + "\n"
313                      "  install Homebrew first (https://brew.sh) or install the packages manually."
314                  )
315              else:
316                  if not _confirm(f"  install via brew: {' '.join(needs)}?"):
317                      print("  skipped (you can run it manually later)")
318                  else:
319                      cmd = ["brew", "install", *needs]
320                      print(f"  $ {' '.join(cmd)}")
321                      res = _sp.run(cmd, check=False)
322                      if res.returncode != 0:
323                          print("  brew install failed — install them manually")
324              print(
325                  "\n  NOTE: macOS does not auto-route audio. Open\n"
326                  "    System Settings → Sound → Input\n"
327                  "  and select 'BlackHole 2ch' before starting a realtime meeting.\n"
328                  "  hermes will not switch your default input for you."
329              )
330      else:
331          print("\n[3/3] skipped (pass --realtime to install audio tooling too)")
332  
333      print("\ndone. verify with: hermes meet setup")
334      return 0
335  
336  
337  def _cmd_auth() -> int:
338      """Open a headed Chromium, let the user sign in, save storage_state."""
339      try:
340          from playwright.sync_api import sync_playwright
341      except ImportError:
342          print(
343              "playwright is not installed. run:\n"
344              "  pip install playwright && python -m playwright install chromium"
345          )
346          return 1
347  
348      path = _auth_state_path()
349      path.parent.mkdir(parents=True, exist_ok=True)
350  
351      print(f"opening Chromium — sign in to Google, then return here and press Enter.")
352      print(f"saving storage state to: {path}")
353      try:
354          with sync_playwright() as pw:
355              browser = pw.chromium.launch(headless=False)
356              context = browser.new_context()
357              page = context.new_page()
358              page.goto("https://accounts.google.com/", wait_until="domcontentloaded")
359              try:
360                  input("press Enter after you've signed in ... ")
361              except EOFError:
362                  pass
363              context.storage_state(path=str(path))
364              browser.close()
365      except Exception as e:
366          print(f"auth failed: {e}")
367          return 1
368      print("saved. you can now run: hermes meet join <url>")
369      return 0
370  
371  
372  def _cmd_join(
373      url: str,
374      *,
375      guest_name: str,
376      duration: Optional[str],
377      headed: bool,
378      mode: str = "transcribe",
379      node: Optional[str] = None,
380  ) -> int:
381      if not _is_safe_meet_url(url):
382          print(f"refusing: not a meet.google.com URL: {url}")
383          return 2
384      if node:
385          # Remote: go through NodeClient.
386          try:
387              from plugins.google_meet.node.registry import NodeRegistry
388              from plugins.google_meet.node.client import NodeClient
389          except ImportError as e:
390              print(f"node module unavailable: {e}")
391              return 1
392          reg = NodeRegistry()
393          entry = reg.resolve(node if node != "auto" else None)
394          if entry is None:
395              print(f"no registered node matches {node!r}")
396              return 1
397          client = NodeClient(url=entry["url"], token=entry["token"])
398          try:
399              res = client.start_bot(
400                  url=url, guest_name=guest_name, duration=duration,
401                  headed=headed, mode=mode,
402              )
403          except Exception as e:
404              print(f"remote start_bot failed: {e}")
405              return 1
406          print(json.dumps({"node": entry.get("name"), **res}, indent=2))
407          return 0 if res.get("ok") else 1
408  
409      auth = _auth_state_path()
410      res = pm.start(
411          url=url,
412          headed=headed,
413          guest_name=guest_name,
414          duration=duration,
415          auth_state=str(auth) if auth.is_file() else None,
416          mode=mode,
417      )
418      print(json.dumps(res, indent=2))
419      return 0 if res.get("ok") else 1
420  
421  
422  def _cmd_say(text: str, node: Optional[str] = None) -> int:
423      if not (text or "").strip():
424          print("refusing: empty text")
425          return 2
426      if node:
427          try:
428              from plugins.google_meet.node.registry import NodeRegistry
429              from plugins.google_meet.node.client import NodeClient
430          except ImportError as e:
431              print(f"node module unavailable: {e}")
432              return 1
433          reg = NodeRegistry()
434          entry = reg.resolve(node if node != "auto" else None)
435          if entry is None:
436              print(f"no registered node matches {node!r}")
437              return 1
438          client = NodeClient(url=entry["url"], token=entry["token"])
439          try:
440              res = client.say(text)
441          except Exception as e:
442              print(f"remote say failed: {e}")
443              return 1
444          print(json.dumps({"node": entry.get("name"), **res}, indent=2))
445          return 0 if res.get("ok") else 1
446  
447      res = pm.enqueue_say(text)
448      print(json.dumps(res, indent=2))
449      return 0 if res.get("ok") else 1
450  
451  
452  def _cmd_status() -> int:
453      res = pm.status()
454      print(json.dumps(res, indent=2))
455      return 0 if res.get("ok") else 1
456  
457  
458  def _cmd_transcript(last: Optional[int]) -> int:
459      res = pm.transcript(last=last)
460      if not res.get("ok"):
461          print(json.dumps(res, indent=2))
462          return 1
463      for ln in res.get("lines", []):
464          print(ln)
465      return 0
466  
467  
468  def _cmd_stop() -> int:
469      res = pm.stop(reason="hermes meet stop")
470      print(json.dumps(res, indent=2))
471      return 0 if res.get("ok") else 1
472  
473  
474  if __name__ == "__main__":  # pragma: no cover
475      parser = argparse.ArgumentParser(prog="hermes meet")
476      register_cli(parser)
477      ns = parser.parse_args()
478      sys.exit(meet_command(ns))