File size: 8,107 Bytes
674d56a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | """Agent-facing tools for the google_meet plugin.
meet_join β join a Meet URL (locally, or on a remote node via node=<name>)
meet_status β bot liveness + transcript progress
meet_transcript β read the transcript (optional last-N)
meet_leave β signal the bot to leave cleanly
meet_say β speak text through the realtime bridge (mode='realtime' only)
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from plugins.google_meet import process_manager as pm
def check_meet_requirements() -> bool:
"""True when the plugin can run LOCALLY: Linux/macOS + importable ``playwright``.
Remote-node operation only needs ``websockets``; handlers relax this gate when a node is addressed."""
import importlib.util
import platform as _p
return (_p.system().lower() in {"linux", "darwin"}
and importlib.util.find_spec("playwright") is not None)
def resolve_node(node: str):
"""``(NodeClient, node_name)`` for *node* (``'auto'`` = the sole registered node), or ``(None, None)``."""
from plugins.google_meet.node.registry import NodeRegistry
from plugins.google_meet.node.client import NodeClient
entry = NodeRegistry().resolve(node if node != "auto" else None)
if entry is None:
return None, None
return NodeClient(url=entry["url"], token=entry["token"]), entry.get("name")
_NODE_PROP = {"type": "string"}
def _str(description: str) -> Dict[str, Any]:
return {"type": "string", "description": description}
def _schema(name: str, description: str, properties: Dict[str, Any], required=None) -> Dict[str, Any]:
params: Dict[str, Any] = {"type": "object", "properties": properties}
if required:
params["required"] = required
params["additionalProperties"] = False
return {"name": name, "description": description, "parameters": params}
MEET_JOIN_SCHEMA = _schema(
"meet_join",
"Join a Google Meet call and start scraping live captions into a transcript file. Only "
"meet.google.com URLs are accepted; no calendar scanning, no auto-dial. Spawns a headless "
"Chromium subprocess that runs in parallel with the agent loop β returns immediately. Poll "
"with meet_status and read captions with meet_transcript. Reminder to the agent: you "
"should announce yourself in the meeting (there is no automatic consent announcement).",
{"url": _str("Full https://meet.google.com/... URL. Required."),
"mode": {"type": "string", "enum": ["transcribe", "realtime"],
"description": ("transcribe (default): listen-only, scrape captions. "
"realtime: also enable agent speech via meet_say "
"(requires OpenAI Realtime key + platform audio bridge).")},
"guest_name": _str("Display name to use when joining as guest. Defaults to 'Hermes Agent'."),
"duration": _str("Optional max duration before auto-leave (e.g. '30m', "
"'2h', '90s'). Omit to stay until meet_leave is called."),
"headed": {"type": "boolean",
"description": "Run Chromium headed instead of headless (debug only). Default false."},
"node": _str("Name of a registered remote node to run the bot on (useful when the gateway "
"runs on a headless Linux box but the user's Chrome with a signed-in Google "
"profile lives on their Mac). Pass 'auto' to use the single registered node. "
"Default: run locally. Nodes are approved via `hermes meet node approve`.")},
required=["url"])
MEET_STATUS_SCHEMA = _schema(
"meet_status",
"Report the current Meet session state β whether the bot is alive, has joined, is sitting "
"in the lobby, number of transcript lines captured, and last-caption timestamp.",
{"node": _NODE_PROP})
MEET_TRANSCRIPT_SCHEMA = _schema(
"meet_transcript",
"Read the scraped transcript for the active Meet session. Returns "
"full transcript unless 'last' is set, in which case returns the last N lines only.",
{"last": {"type": "integer",
"description": ("Optional: return only the last N caption lines. Useful "
"for polling during a meeting without re-reading the whole transcript."),
"minimum": 1},
"node": _NODE_PROP})
MEET_LEAVE_SCHEMA = _schema(
"meet_leave",
"Leave the active Meet call cleanly, stop caption scraping, and finalize the transcript "
"file. Safe to call when no meeting is active β returns ok=false with a reason.",
{"node": _NODE_PROP})
MEET_SAY_SCHEMA = _schema(
"meet_say",
"Speak text into the active Meet call. Requires the active meeting to have been joined "
"with mode='realtime'. The text is queued to the bot's OpenAI Realtime session; the "
"generated audio is streamed into Chrome's fake microphone via a virtual audio device "
"(PulseAudio null-sink on Linux, BlackHole on macOS). Returns immediately β the actual "
"speech lags by a couple of seconds.",
{"text": _str("Text to speak."), "node": _NODE_PROP},
required=["text"])
def _json(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
def _err(msg: str, **extra) -> str:
return _json({"success": False, "error": msg, **extra})
def _dispatch(node: Optional[str], op: str, remote, local) -> str:
"""Run *remote(client)* on the addressed node, else *local()*; wrap as a tool result."""
if not node:
res = local()
return _json({"success": bool(res.get("ok")), **res})
client, node_name = resolve_node(node)
if client is None:
return _err(f"no registered meet node matches {node!r} β "
"run `hermes meet node approve <name> <url> <token>` first")
try:
res = remote(client)
except Exception as e:
return _err(f"remote node {op} failed: {e}", node=node_name)
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
def handle_meet_join(args: Dict[str, Any], **_kw) -> str:
url = (args.get("url") or "").strip()
if not url:
return _err("url is required")
mode = (args.get("mode") or "transcribe").strip().lower()
if mode not in {"transcribe", "realtime"}:
return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})")
common: Dict[str, Any] = dict(
url=url, guest_name=str(args.get("guest_name") or "Hermes Agent"),
duration=str(args.get("duration")) if args.get("duration") else None,
headed=bool(args.get("headed", False)), mode=mode)
def _local():
if not check_meet_requirements():
return {"ok": False, "error": (
"google_meet plugin prerequisites missing β install with "
"`pip install playwright && python -m playwright install "
"chromium`. Plugin is supported on Linux and macOS only.")}
return pm.start(**common)
return _dispatch(args.get("node"), "start_bot", lambda c: c.start_bot(**common), _local)
def handle_meet_status(args: Dict[str, Any], **_kw) -> str:
return _dispatch(args.get("node"), "status", lambda c: c.status(), pm.status)
def handle_meet_transcript(args: Dict[str, Any], **_kw) -> str:
try:
last = int(args["last"]) if args.get("last") is not None else None
except (TypeError, ValueError):
last = None
if last is not None and last < 1:
last = None
return _dispatch(args.get("node"), "transcript", lambda c: c.transcript(last=last),
lambda: pm.transcript(last=last))
def handle_meet_leave(args: Dict[str, Any], **_kw) -> str:
return _dispatch(args.get("node"), "stop", lambda c: c.stop(),
lambda: pm.stop(reason="agent called meet_leave"))
def handle_meet_say(args: Dict[str, Any], **_kw) -> str:
text = (args.get("text") or "").strip()
if not text:
return _err("text is required")
return _dispatch(args.get("node"), "say", lambda c: c.say(text), lambda: pm.enqueue_say(text))
|