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))