@openclaw/google-meet 2026.5.2 → 2026.5.3-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar-6EQiwLUb.js +126 -0
- package/dist/chrome-create-B0wV2zaj.js +963 -0
- package/dist/cli-CSyT9_N6.js +1377 -0
- package/dist/create-0ye_2zVk.js +106 -0
- package/dist/index.js +3509 -0
- package/dist/oauth-BJwzuzT-.js +141 -0
- package/package.json +13 -6
- package/google-meet.live.test.ts +0 -85
- package/index.create.test.ts +0 -595
- package/index.test.ts +0 -3616
- package/index.ts +0 -1170
- package/node-host.test.ts +0 -222
- package/src/agent-consult.ts +0 -70
- package/src/calendar.ts +0 -243
- package/src/cli.test.ts +0 -1013
- package/src/cli.ts +0 -2136
- package/src/config.ts +0 -509
- package/src/create.ts +0 -153
- package/src/drive.ts +0 -72
- package/src/google-api-errors.ts +0 -20
- package/src/meet.ts +0 -1024
- package/src/node-host.ts +0 -498
- package/src/oauth.test.ts +0 -71
- package/src/oauth.ts +0 -229
- package/src/realtime-node.ts +0 -271
- package/src/realtime.ts +0 -423
- package/src/runtime.ts +0 -804
- package/src/setup.ts +0 -276
- package/src/test-support/plugin-harness.ts +0 -225
- package/src/transports/chrome-browser-proxy.ts +0 -198
- package/src/transports/chrome-create.ts +0 -367
- package/src/transports/chrome.ts +0 -925
- package/src/transports/twilio.ts +0 -46
- package/src/transports/types.ts +0 -113
- package/src/voice-call-gateway.test.ts +0 -79
- package/src/voice-call-gateway.ts +0 -213
- package/tsconfig.json +0 -16
package/dist/index.js
ADDED
|
@@ -0,0 +1,3509 @@
|
|
|
1
|
+
import { a as isSameMeetUrlForReuse, c as resolveChromeNode, f as endGoogleMeetActiveConference, g as fetchLatestGoogleMeetConferenceRecord, h as fetchGoogleMeetSpace, i as callBrowserProxyOnNode, l as resolveChromeNodeInfo, m as fetchGoogleMeetAttendance, n as isGoogleMeetBrowserManualActionError, o as normalizeMeetUrlForReuse, p as fetchGoogleMeetArtifacts, r as asBrowserTabs, s as readBrowserTab, t as createMeetWithBrowserProxyOnNode, u as buildGoogleMeetPreflightReport } from "./chrome-create-B0wV2zaj.js";
|
|
2
|
+
import { n as findGoogleMeetCalendarEvent, r as listGoogleMeetCalendarEvents, t as buildGoogleMeetCalendarDayWindow } from "./calendar-6EQiwLUb.js";
|
|
3
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
4
|
+
import { ErrorCodes, GatewayClient, callGatewayFromCli, errorShape, startGatewayClientWhenEventLoopReady } from "openclaw/plugin-sdk/gateway-runtime";
|
|
5
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
6
|
+
import { normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
9
|
+
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, buildRealtimeVoiceAgentConsultWorkingResponse, consultRealtimeVoiceAgent, createRealtimeVoiceBridgeSession, resolveConfiguredRealtimeVoiceProvider, resolveRealtimeVoiceAgentConsultToolPolicy, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow } from "openclaw/plugin-sdk/realtime-voice";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
13
|
+
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
//#region extensions/google-meet/src/config.ts
|
|
18
|
+
const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND = [
|
|
19
|
+
"sox",
|
|
20
|
+
"-q",
|
|
21
|
+
"-t",
|
|
22
|
+
"coreaudio",
|
|
23
|
+
"BlackHole 2ch",
|
|
24
|
+
"-t",
|
|
25
|
+
"raw",
|
|
26
|
+
"-r",
|
|
27
|
+
"24000",
|
|
28
|
+
"-c",
|
|
29
|
+
"1",
|
|
30
|
+
"-e",
|
|
31
|
+
"signed-integer",
|
|
32
|
+
"-b",
|
|
33
|
+
"16",
|
|
34
|
+
"-L",
|
|
35
|
+
"-"
|
|
36
|
+
];
|
|
37
|
+
const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND = [
|
|
38
|
+
"sox",
|
|
39
|
+
"-q",
|
|
40
|
+
"-t",
|
|
41
|
+
"raw",
|
|
42
|
+
"-r",
|
|
43
|
+
"24000",
|
|
44
|
+
"-c",
|
|
45
|
+
"1",
|
|
46
|
+
"-e",
|
|
47
|
+
"signed-integer",
|
|
48
|
+
"-b",
|
|
49
|
+
"16",
|
|
50
|
+
"-L",
|
|
51
|
+
"-",
|
|
52
|
+
"-t",
|
|
53
|
+
"coreaudio",
|
|
54
|
+
"BlackHole 2ch"
|
|
55
|
+
];
|
|
56
|
+
const LEGACY_GOOGLE_MEET_AUDIO_INPUT_COMMAND = [
|
|
57
|
+
"rec",
|
|
58
|
+
"-q",
|
|
59
|
+
"-t",
|
|
60
|
+
"raw",
|
|
61
|
+
"-r",
|
|
62
|
+
"8000",
|
|
63
|
+
"-c",
|
|
64
|
+
"1",
|
|
65
|
+
"-e",
|
|
66
|
+
"mu-law",
|
|
67
|
+
"-b",
|
|
68
|
+
"8",
|
|
69
|
+
"-"
|
|
70
|
+
];
|
|
71
|
+
const LEGACY_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND = [
|
|
72
|
+
"play",
|
|
73
|
+
"-q",
|
|
74
|
+
"-t",
|
|
75
|
+
"raw",
|
|
76
|
+
"-r",
|
|
77
|
+
"8000",
|
|
78
|
+
"-c",
|
|
79
|
+
"1",
|
|
80
|
+
"-e",
|
|
81
|
+
"mu-law",
|
|
82
|
+
"-b",
|
|
83
|
+
"8",
|
|
84
|
+
"-"
|
|
85
|
+
];
|
|
86
|
+
const DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT = "pcm16-24khz";
|
|
87
|
+
const DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD = 650;
|
|
88
|
+
const DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD = 2500;
|
|
89
|
+
const DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS = 900;
|
|
90
|
+
const DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS = `You are joining a private Google Meet as an OpenClaw agent. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering.`;
|
|
91
|
+
const DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE = "Say exactly: I'm here and listening.";
|
|
92
|
+
const DEFAULT_GOOGLE_MEET_CONFIG = {
|
|
93
|
+
enabled: true,
|
|
94
|
+
defaults: {},
|
|
95
|
+
preview: { enrollmentAcknowledged: false },
|
|
96
|
+
defaultTransport: "chrome",
|
|
97
|
+
defaultMode: "realtime",
|
|
98
|
+
chrome: {
|
|
99
|
+
audioBackend: "blackhole-2ch",
|
|
100
|
+
audioFormat: DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT,
|
|
101
|
+
launch: true,
|
|
102
|
+
guestName: "OpenClaw Agent",
|
|
103
|
+
reuseExistingTab: true,
|
|
104
|
+
autoJoin: true,
|
|
105
|
+
joinTimeoutMs: 3e4,
|
|
106
|
+
waitForInCallMs: 2e4,
|
|
107
|
+
audioInputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND],
|
|
108
|
+
audioOutputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND],
|
|
109
|
+
bargeInRmsThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD,
|
|
110
|
+
bargeInPeakThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD,
|
|
111
|
+
bargeInCooldownMs: DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS
|
|
112
|
+
},
|
|
113
|
+
chromeNode: {},
|
|
114
|
+
twilio: {},
|
|
115
|
+
voiceCall: {
|
|
116
|
+
enabled: true,
|
|
117
|
+
requestTimeoutMs: 3e4,
|
|
118
|
+
dtmfDelayMs: 2500,
|
|
119
|
+
postDtmfSpeechDelayMs: 5e3
|
|
120
|
+
},
|
|
121
|
+
realtime: {
|
|
122
|
+
provider: "openai",
|
|
123
|
+
instructions: DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS,
|
|
124
|
+
introMessage: DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE,
|
|
125
|
+
toolPolicy: "safe-read-only",
|
|
126
|
+
providers: {}
|
|
127
|
+
},
|
|
128
|
+
oauth: {},
|
|
129
|
+
auth: { provider: "google-oauth" }
|
|
130
|
+
};
|
|
131
|
+
const GOOGLE_MEET_CLIENT_ID_KEYS = ["OPENCLAW_GOOGLE_MEET_CLIENT_ID", "GOOGLE_MEET_CLIENT_ID"];
|
|
132
|
+
const GOOGLE_MEET_CLIENT_SECRET_KEYS = ["OPENCLAW_GOOGLE_MEET_CLIENT_SECRET", "GOOGLE_MEET_CLIENT_SECRET"];
|
|
133
|
+
const GOOGLE_MEET_REFRESH_TOKEN_KEYS = ["OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN", "GOOGLE_MEET_REFRESH_TOKEN"];
|
|
134
|
+
const GOOGLE_MEET_ACCESS_TOKEN_KEYS = ["OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN", "GOOGLE_MEET_ACCESS_TOKEN"];
|
|
135
|
+
const GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS = ["OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT", "GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT"];
|
|
136
|
+
const GOOGLE_MEET_DEFAULT_MEETING_KEYS = ["OPENCLAW_GOOGLE_MEET_DEFAULT_MEETING", "GOOGLE_MEET_DEFAULT_MEETING"];
|
|
137
|
+
const GOOGLE_MEET_PREVIEW_ACK_KEYS = ["OPENCLAW_GOOGLE_MEET_PREVIEW_ACK", "GOOGLE_MEET_PREVIEW_ACK"];
|
|
138
|
+
function asRecord$3(value) {
|
|
139
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
140
|
+
}
|
|
141
|
+
function resolveBoolean(value, fallback) {
|
|
142
|
+
return typeof value === "boolean" ? value : fallback;
|
|
143
|
+
}
|
|
144
|
+
function resolveNumber(value, fallback) {
|
|
145
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
146
|
+
}
|
|
147
|
+
function resolveOptionalNumber(value) {
|
|
148
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
149
|
+
if (typeof value === "string" && value.trim()) {
|
|
150
|
+
const parsed = Number(value);
|
|
151
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function readEnvString(env, keys) {
|
|
155
|
+
for (const key of keys) {
|
|
156
|
+
const value = normalizeOptionalString(env[key]);
|
|
157
|
+
if (value) return value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function readEnvBoolean(env, keys) {
|
|
161
|
+
const normalized = normalizeOptionalLowercaseString(readEnvString(env, keys));
|
|
162
|
+
if (!normalized) return;
|
|
163
|
+
if ([
|
|
164
|
+
"1",
|
|
165
|
+
"true",
|
|
166
|
+
"yes",
|
|
167
|
+
"on"
|
|
168
|
+
].includes(normalized)) return true;
|
|
169
|
+
if ([
|
|
170
|
+
"0",
|
|
171
|
+
"false",
|
|
172
|
+
"no",
|
|
173
|
+
"off"
|
|
174
|
+
].includes(normalized)) return false;
|
|
175
|
+
}
|
|
176
|
+
function readEnvNumber(env, keys) {
|
|
177
|
+
return resolveOptionalNumber(readEnvString(env, keys));
|
|
178
|
+
}
|
|
179
|
+
function resolveStringArray(value) {
|
|
180
|
+
if (!Array.isArray(value)) return;
|
|
181
|
+
const normalized = value.map((entry) => normalizeOptionalString(entry)).filter((entry) => Boolean(entry));
|
|
182
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
183
|
+
}
|
|
184
|
+
function resolveProvidersConfig(value) {
|
|
185
|
+
const raw = asRecord$3(value);
|
|
186
|
+
const providers = {};
|
|
187
|
+
for (const [key, entry] of Object.entries(raw)) {
|
|
188
|
+
const providerId = normalizeOptionalLowercaseString(key);
|
|
189
|
+
if (!providerId) continue;
|
|
190
|
+
providers[providerId] = asRecord$3(entry);
|
|
191
|
+
}
|
|
192
|
+
return providers;
|
|
193
|
+
}
|
|
194
|
+
function resolveTransport$1(value, fallback) {
|
|
195
|
+
const normalized = normalizeOptionalLowercaseString(value);
|
|
196
|
+
return normalized === "chrome" || normalized === "chrome-node" || normalized === "twilio" ? normalized : fallback;
|
|
197
|
+
}
|
|
198
|
+
function resolveMode$1(value, fallback) {
|
|
199
|
+
const normalized = normalizeOptionalLowercaseString(value);
|
|
200
|
+
return normalized === "realtime" || normalized === "transcribe" ? normalized : fallback;
|
|
201
|
+
}
|
|
202
|
+
function resolveChromeAudioFormat(value) {
|
|
203
|
+
switch (normalizeOptionalString(value)?.toLowerCase().replaceAll("_", "-")) {
|
|
204
|
+
case "pcm16-24khz":
|
|
205
|
+
case "pcm16-24k":
|
|
206
|
+
case "pcm24":
|
|
207
|
+
case "pcm": return "pcm16-24khz";
|
|
208
|
+
case "g711-ulaw-8khz":
|
|
209
|
+
case "g711-ulaw-8k":
|
|
210
|
+
case "g711-ulaw":
|
|
211
|
+
case "mulaw":
|
|
212
|
+
case "mu-law": return "g711-ulaw-8khz";
|
|
213
|
+
default: return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function defaultAudioInputCommand(format) {
|
|
217
|
+
return format === "g711-ulaw-8khz" ? LEGACY_GOOGLE_MEET_AUDIO_INPUT_COMMAND : DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND;
|
|
218
|
+
}
|
|
219
|
+
function defaultAudioOutputCommand(format) {
|
|
220
|
+
return format === "g711-ulaw-8khz" ? LEGACY_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND : DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND;
|
|
221
|
+
}
|
|
222
|
+
function resolveGoogleMeetConfig(input) {
|
|
223
|
+
return resolveGoogleMeetConfigWithEnv(input);
|
|
224
|
+
}
|
|
225
|
+
function resolveGoogleMeetConfigWithEnv(input, env = process.env) {
|
|
226
|
+
const raw = asRecord$3(input);
|
|
227
|
+
const defaults = asRecord$3(raw.defaults);
|
|
228
|
+
const preview = asRecord$3(raw.preview);
|
|
229
|
+
const chrome = asRecord$3(raw.chrome);
|
|
230
|
+
const configuredAudioInputCommand = resolveStringArray(chrome.audioInputCommand);
|
|
231
|
+
const configuredAudioOutputCommand = resolveStringArray(chrome.audioOutputCommand);
|
|
232
|
+
const hasCustomAudioCommand = configuredAudioInputCommand !== void 0 || configuredAudioOutputCommand !== void 0;
|
|
233
|
+
const audioFormat = resolveChromeAudioFormat(chrome.audioFormat) ?? (hasCustomAudioCommand ? "g711-ulaw-8khz" : DEFAULT_GOOGLE_MEET_CONFIG.chrome.audioFormat);
|
|
234
|
+
const chromeNode = asRecord$3(raw.chromeNode);
|
|
235
|
+
const twilio = asRecord$3(raw.twilio);
|
|
236
|
+
const voiceCall = asRecord$3(raw.voiceCall);
|
|
237
|
+
const realtime = asRecord$3(raw.realtime);
|
|
238
|
+
const oauth = asRecord$3(raw.oauth);
|
|
239
|
+
const auth = asRecord$3(raw.auth);
|
|
240
|
+
return {
|
|
241
|
+
enabled: resolveBoolean(raw.enabled, DEFAULT_GOOGLE_MEET_CONFIG.enabled),
|
|
242
|
+
defaults: { meeting: normalizeOptionalString(defaults.meeting) ?? readEnvString(env, GOOGLE_MEET_DEFAULT_MEETING_KEYS) },
|
|
243
|
+
preview: { enrollmentAcknowledged: resolveBoolean(preview.enrollmentAcknowledged, readEnvBoolean(env, GOOGLE_MEET_PREVIEW_ACK_KEYS) ?? DEFAULT_GOOGLE_MEET_CONFIG.preview.enrollmentAcknowledged) },
|
|
244
|
+
defaultTransport: resolveTransport$1(raw.defaultTransport, DEFAULT_GOOGLE_MEET_CONFIG.defaultTransport),
|
|
245
|
+
defaultMode: resolveMode$1(raw.defaultMode, DEFAULT_GOOGLE_MEET_CONFIG.defaultMode),
|
|
246
|
+
chrome: {
|
|
247
|
+
audioBackend: "blackhole-2ch",
|
|
248
|
+
audioFormat,
|
|
249
|
+
launch: resolveBoolean(chrome.launch, DEFAULT_GOOGLE_MEET_CONFIG.chrome.launch),
|
|
250
|
+
browserProfile: normalizeOptionalString(chrome.browserProfile),
|
|
251
|
+
guestName: normalizeOptionalString(chrome.guestName) ?? DEFAULT_GOOGLE_MEET_CONFIG.chrome.guestName,
|
|
252
|
+
reuseExistingTab: resolveBoolean(chrome.reuseExistingTab, DEFAULT_GOOGLE_MEET_CONFIG.chrome.reuseExistingTab),
|
|
253
|
+
autoJoin: resolveBoolean(chrome.autoJoin, DEFAULT_GOOGLE_MEET_CONFIG.chrome.autoJoin),
|
|
254
|
+
joinTimeoutMs: resolveNumber(chrome.joinTimeoutMs, DEFAULT_GOOGLE_MEET_CONFIG.chrome.joinTimeoutMs),
|
|
255
|
+
waitForInCallMs: resolveNumber(chrome.waitForInCallMs, DEFAULT_GOOGLE_MEET_CONFIG.chrome.waitForInCallMs),
|
|
256
|
+
audioInputCommand: configuredAudioInputCommand ?? [...defaultAudioInputCommand(audioFormat)],
|
|
257
|
+
audioOutputCommand: configuredAudioOutputCommand ?? [...defaultAudioOutputCommand(audioFormat)],
|
|
258
|
+
bargeInInputCommand: resolveStringArray(chrome.bargeInInputCommand),
|
|
259
|
+
bargeInRmsThreshold: resolveNumber(chrome.bargeInRmsThreshold, DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInRmsThreshold),
|
|
260
|
+
bargeInPeakThreshold: resolveNumber(chrome.bargeInPeakThreshold, DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInPeakThreshold),
|
|
261
|
+
bargeInCooldownMs: resolveNumber(chrome.bargeInCooldownMs, DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInCooldownMs),
|
|
262
|
+
audioBridgeCommand: resolveStringArray(chrome.audioBridgeCommand),
|
|
263
|
+
audioBridgeHealthCommand: resolveStringArray(chrome.audioBridgeHealthCommand)
|
|
264
|
+
},
|
|
265
|
+
chromeNode: { node: normalizeOptionalString(chromeNode.node) },
|
|
266
|
+
twilio: {
|
|
267
|
+
defaultDialInNumber: normalizeOptionalString(twilio.defaultDialInNumber),
|
|
268
|
+
defaultPin: normalizeOptionalString(twilio.defaultPin),
|
|
269
|
+
defaultDtmfSequence: normalizeOptionalString(twilio.defaultDtmfSequence)
|
|
270
|
+
},
|
|
271
|
+
voiceCall: {
|
|
272
|
+
enabled: resolveBoolean(voiceCall.enabled, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.enabled),
|
|
273
|
+
gatewayUrl: normalizeOptionalString(voiceCall.gatewayUrl),
|
|
274
|
+
token: normalizeOptionalString(voiceCall.token),
|
|
275
|
+
requestTimeoutMs: resolveNumber(voiceCall.requestTimeoutMs, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.requestTimeoutMs),
|
|
276
|
+
dtmfDelayMs: resolveNumber(voiceCall.dtmfDelayMs, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.dtmfDelayMs),
|
|
277
|
+
postDtmfSpeechDelayMs: resolveNumber(voiceCall.postDtmfSpeechDelayMs, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.postDtmfSpeechDelayMs),
|
|
278
|
+
introMessage: normalizeOptionalString(voiceCall.introMessage)
|
|
279
|
+
},
|
|
280
|
+
realtime: {
|
|
281
|
+
provider: normalizeOptionalString(realtime.provider) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.provider,
|
|
282
|
+
model: normalizeOptionalString(realtime.model) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.model,
|
|
283
|
+
instructions: normalizeOptionalString(realtime.instructions) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.instructions,
|
|
284
|
+
introMessage: normalizeOptionalString(realtime.introMessage) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.introMessage,
|
|
285
|
+
agentId: normalizeOptionalString(realtime.agentId),
|
|
286
|
+
toolPolicy: resolveRealtimeVoiceAgentConsultToolPolicy(realtime.toolPolicy, DEFAULT_GOOGLE_MEET_CONFIG.realtime.toolPolicy),
|
|
287
|
+
providers: resolveProvidersConfig(realtime.providers)
|
|
288
|
+
},
|
|
289
|
+
oauth: {
|
|
290
|
+
clientId: normalizeOptionalString(oauth.clientId) ?? normalizeOptionalString(auth.clientId) ?? readEnvString(env, GOOGLE_MEET_CLIENT_ID_KEYS),
|
|
291
|
+
clientSecret: normalizeOptionalString(oauth.clientSecret) ?? normalizeOptionalString(auth.clientSecret) ?? readEnvString(env, GOOGLE_MEET_CLIENT_SECRET_KEYS),
|
|
292
|
+
refreshToken: normalizeOptionalString(oauth.refreshToken) ?? readEnvString(env, GOOGLE_MEET_REFRESH_TOKEN_KEYS),
|
|
293
|
+
accessToken: normalizeOptionalString(oauth.accessToken) ?? readEnvString(env, GOOGLE_MEET_ACCESS_TOKEN_KEYS),
|
|
294
|
+
expiresAt: resolveOptionalNumber(oauth.expiresAt) ?? readEnvNumber(env, GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS)
|
|
295
|
+
},
|
|
296
|
+
auth: {
|
|
297
|
+
provider: "google-oauth",
|
|
298
|
+
clientId: normalizeOptionalString(auth.clientId),
|
|
299
|
+
clientSecret: normalizeOptionalString(auth.clientSecret),
|
|
300
|
+
tokenPath: normalizeOptionalString(auth.tokenPath)
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region extensions/google-meet/src/agent-consult.ts
|
|
306
|
+
const GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME = REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME;
|
|
307
|
+
const GOOGLE_MEET_CONSULT_SYSTEM_PROMPT = [
|
|
308
|
+
"You are a behind-the-scenes consultant for a live meeting voice agent.",
|
|
309
|
+
"Prioritize a fast, speakable answer over exhaustive investigation.",
|
|
310
|
+
"For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
|
|
311
|
+
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
312
|
+
"Be accurate, brief, and speakable."
|
|
313
|
+
].join(" ");
|
|
314
|
+
function resolveGoogleMeetRealtimeTools(policy) {
|
|
315
|
+
return resolveRealtimeVoiceAgentConsultTools(policy);
|
|
316
|
+
}
|
|
317
|
+
function submitGoogleMeetConsultWorkingResponse(session, callId) {
|
|
318
|
+
if (!session.bridge.supportsToolResultContinuation) return;
|
|
319
|
+
session.submitToolResult(callId, buildRealtimeVoiceAgentConsultWorkingResponse("participant"), { willContinue: true });
|
|
320
|
+
}
|
|
321
|
+
async function consultOpenClawAgentForGoogleMeet(params) {
|
|
322
|
+
const agentId = normalizeAgentId(params.config.realtime.agentId);
|
|
323
|
+
const sessionKey = `agent:${agentId}:google-meet:${params.meetingSessionId}`;
|
|
324
|
+
return await consultRealtimeVoiceAgent({
|
|
325
|
+
cfg: params.fullConfig,
|
|
326
|
+
agentRuntime: params.runtime.agent,
|
|
327
|
+
logger: params.logger,
|
|
328
|
+
agentId,
|
|
329
|
+
sessionKey,
|
|
330
|
+
messageProvider: "google-meet",
|
|
331
|
+
lane: "google-meet",
|
|
332
|
+
runIdPrefix: `google-meet:${params.meetingSessionId}`,
|
|
333
|
+
args: params.args,
|
|
334
|
+
transcript: params.transcript,
|
|
335
|
+
surface: "a private Google Meet",
|
|
336
|
+
userLabel: "Participant",
|
|
337
|
+
assistantLabel: "Agent",
|
|
338
|
+
questionSourceLabel: "participant",
|
|
339
|
+
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(params.config.realtime.toolPolicy),
|
|
340
|
+
extraSystemPrompt: GOOGLE_MEET_CONSULT_SYSTEM_PROMPT
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region extensions/google-meet/src/realtime.ts
|
|
345
|
+
function splitCommand$1(argv) {
|
|
346
|
+
const [command, ...args] = argv;
|
|
347
|
+
if (!command) throw new Error("audio bridge command must not be empty");
|
|
348
|
+
return {
|
|
349
|
+
command,
|
|
350
|
+
args
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function readPcm16Stats(audio) {
|
|
354
|
+
let sumSquares = 0;
|
|
355
|
+
let peak = 0;
|
|
356
|
+
let samples = 0;
|
|
357
|
+
for (let offset = 0; offset + 1 < audio.byteLength; offset += 2) {
|
|
358
|
+
const sample = audio.readInt16LE(offset);
|
|
359
|
+
peak = Math.max(peak, Math.abs(sample));
|
|
360
|
+
sumSquares += sample * sample;
|
|
361
|
+
samples += 1;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
rms: samples > 0 ? Math.sqrt(sumSquares / samples) : 0,
|
|
365
|
+
peak
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function resolveGoogleMeetRealtimeAudioFormat(config) {
|
|
369
|
+
return config.chrome.audioFormat === "g711-ulaw-8khz" ? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ : REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ;
|
|
370
|
+
}
|
|
371
|
+
function resolveGoogleMeetRealtimeProvider(params) {
|
|
372
|
+
return resolveConfiguredRealtimeVoiceProvider({
|
|
373
|
+
configuredProviderId: params.config.realtime.provider,
|
|
374
|
+
providerConfigs: params.config.realtime.providers,
|
|
375
|
+
cfg: params.fullConfig,
|
|
376
|
+
providers: params.providers,
|
|
377
|
+
defaultModel: params.config.realtime.model,
|
|
378
|
+
noRegisteredProviderMessage: "No configured realtime voice provider registered"
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async function startCommandRealtimeAudioBridge(params) {
|
|
382
|
+
const input = splitCommand$1(params.inputCommand);
|
|
383
|
+
const output = splitCommand$1(params.outputCommand);
|
|
384
|
+
const spawnFn = params.spawn ?? ((command, args, options) => spawn(command, args, options));
|
|
385
|
+
const spawnOutputProcess = () => spawnFn(output.command, output.args, { stdio: [
|
|
386
|
+
"pipe",
|
|
387
|
+
"ignore",
|
|
388
|
+
"pipe"
|
|
389
|
+
] });
|
|
390
|
+
let outputProcess = spawnOutputProcess();
|
|
391
|
+
const inputProcess = spawnFn(input.command, input.args, { stdio: [
|
|
392
|
+
"ignore",
|
|
393
|
+
"pipe",
|
|
394
|
+
"pipe"
|
|
395
|
+
] });
|
|
396
|
+
let stopped = false;
|
|
397
|
+
let bridge = null;
|
|
398
|
+
let realtimeReady = false;
|
|
399
|
+
let lastInputAt;
|
|
400
|
+
let lastOutputAt;
|
|
401
|
+
let lastInputBytes = 0;
|
|
402
|
+
let lastOutputBytes = 0;
|
|
403
|
+
let lastClearAt;
|
|
404
|
+
let clearCount = 0;
|
|
405
|
+
let suppressedInputBytes = 0;
|
|
406
|
+
let lastSuppressedInputAt;
|
|
407
|
+
let suppressInputUntil = 0;
|
|
408
|
+
let lastOutputAtMs = 0;
|
|
409
|
+
let lastOutputPlayableUntilMs = 0;
|
|
410
|
+
let bargeInInputProcess;
|
|
411
|
+
const suppressInputForOutput = (audio) => {
|
|
412
|
+
const bytesPerMs = params.config.chrome.audioFormat === "g711-ulaw-8khz" ? 8 : 48;
|
|
413
|
+
const durationMs = Math.ceil(audio.byteLength / bytesPerMs);
|
|
414
|
+
const until = Date.now() + durationMs + 900;
|
|
415
|
+
suppressInputUntil = Math.max(suppressInputUntil, until);
|
|
416
|
+
lastOutputPlayableUntilMs = Math.max(lastOutputPlayableUntilMs, until);
|
|
417
|
+
};
|
|
418
|
+
const terminateProcess = (proc, signal = "SIGTERM") => {
|
|
419
|
+
if (proc.killed && signal !== "SIGKILL") return;
|
|
420
|
+
let exited = false;
|
|
421
|
+
proc.on("exit", () => {
|
|
422
|
+
exited = true;
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
proc.kill(signal);
|
|
426
|
+
} catch {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (signal === "SIGKILL") return;
|
|
430
|
+
setTimeout(() => {
|
|
431
|
+
if (!exited) try {
|
|
432
|
+
proc.kill("SIGKILL");
|
|
433
|
+
} catch {}
|
|
434
|
+
}, 1e3).unref?.();
|
|
435
|
+
};
|
|
436
|
+
const stop = async () => {
|
|
437
|
+
if (stopped) return;
|
|
438
|
+
stopped = true;
|
|
439
|
+
try {
|
|
440
|
+
bridge?.close();
|
|
441
|
+
} catch (error) {
|
|
442
|
+
params.logger.debug?.(`[google-meet] realtime voice bridge close ignored: ${formatErrorMessage(error)}`);
|
|
443
|
+
}
|
|
444
|
+
terminateProcess(inputProcess);
|
|
445
|
+
terminateProcess(outputProcess);
|
|
446
|
+
if (bargeInInputProcess) terminateProcess(bargeInInputProcess);
|
|
447
|
+
};
|
|
448
|
+
const fail = (label) => (error) => {
|
|
449
|
+
params.logger.warn(`[google-meet] ${label} failed: ${formatErrorMessage(error)}`);
|
|
450
|
+
stop();
|
|
451
|
+
};
|
|
452
|
+
const attachOutputProcessHandlers = (proc) => {
|
|
453
|
+
proc.on("error", (error) => {
|
|
454
|
+
if (proc !== outputProcess) return;
|
|
455
|
+
fail("audio output command")(error);
|
|
456
|
+
});
|
|
457
|
+
proc.on("exit", (code, signal) => {
|
|
458
|
+
if (proc !== outputProcess) return;
|
|
459
|
+
if (!stopped) {
|
|
460
|
+
params.logger.warn(`[google-meet] audio output command exited (${code ?? signal ?? "done"})`);
|
|
461
|
+
stop();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
proc.stderr?.on("data", (chunk) => {
|
|
465
|
+
params.logger.debug?.(`[google-meet] audio output: ${String(chunk).trim()}`);
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
const clearOutputPlayback = () => {
|
|
469
|
+
if (stopped) return;
|
|
470
|
+
const previousOutput = outputProcess;
|
|
471
|
+
outputProcess = spawnOutputProcess();
|
|
472
|
+
attachOutputProcessHandlers(outputProcess);
|
|
473
|
+
clearCount += 1;
|
|
474
|
+
lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
475
|
+
suppressInputUntil = 0;
|
|
476
|
+
lastOutputPlayableUntilMs = 0;
|
|
477
|
+
params.logger.debug?.(`[google-meet] cleared realtime audio output buffer by restarting playback command`);
|
|
478
|
+
terminateProcess(previousOutput, "SIGKILL");
|
|
479
|
+
};
|
|
480
|
+
const startHumanBargeInMonitor = () => {
|
|
481
|
+
const commandArgv = params.config.chrome.bargeInInputCommand;
|
|
482
|
+
if (!commandArgv) return;
|
|
483
|
+
const command = splitCommand$1(commandArgv);
|
|
484
|
+
let lastBargeInAt = 0;
|
|
485
|
+
bargeInInputProcess = spawnFn(command.command, command.args, { stdio: [
|
|
486
|
+
"ignore",
|
|
487
|
+
"pipe",
|
|
488
|
+
"pipe"
|
|
489
|
+
] });
|
|
490
|
+
bargeInInputProcess.stdout?.on("data", (chunk) => {
|
|
491
|
+
if (stopped || lastOutputAtMs === 0) return;
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
if (!(now <= Math.max(lastOutputPlayableUntilMs, suppressInputUntil)) && now - lastOutputAtMs > 1e3) return;
|
|
494
|
+
if (now - lastBargeInAt < params.config.chrome.bargeInCooldownMs) return;
|
|
495
|
+
const stats = readPcm16Stats(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
496
|
+
if (stats.rms < params.config.chrome.bargeInRmsThreshold && stats.peak < params.config.chrome.bargeInPeakThreshold) return;
|
|
497
|
+
lastBargeInAt = now;
|
|
498
|
+
suppressInputUntil = 0;
|
|
499
|
+
const beforeClearCount = clearCount;
|
|
500
|
+
bridge?.handleBargeIn({ audioPlaybackActive: true });
|
|
501
|
+
if (beforeClearCount === clearCount) clearOutputPlayback();
|
|
502
|
+
params.logger.debug?.(`[google-meet] human barge-in detected by local input (rms=${Math.round(stats.rms)}, peak=${stats.peak})`);
|
|
503
|
+
});
|
|
504
|
+
bargeInInputProcess.stderr?.on("data", (chunk) => {
|
|
505
|
+
params.logger.debug?.(`[google-meet] barge-in input: ${String(chunk).trim()}`);
|
|
506
|
+
});
|
|
507
|
+
bargeInInputProcess.on("error", (error) => {
|
|
508
|
+
params.logger.warn(`[google-meet] human barge-in input failed: ${formatErrorMessage(error)}`);
|
|
509
|
+
});
|
|
510
|
+
bargeInInputProcess.on("exit", (code, signal) => {
|
|
511
|
+
if (!stopped) params.logger.debug?.(`[google-meet] human barge-in input exited (${code ?? signal ?? "done"})`);
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
inputProcess.on("error", fail("audio input command"));
|
|
515
|
+
inputProcess.on("exit", (code, signal) => {
|
|
516
|
+
if (!stopped) {
|
|
517
|
+
params.logger.warn(`[google-meet] audio input command exited (${code ?? signal ?? "done"})`);
|
|
518
|
+
stop();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
attachOutputProcessHandlers(outputProcess);
|
|
522
|
+
inputProcess.stderr?.on("data", (chunk) => {
|
|
523
|
+
params.logger.debug?.(`[google-meet] audio input: ${String(chunk).trim()}`);
|
|
524
|
+
});
|
|
525
|
+
const resolved = resolveGoogleMeetRealtimeProvider({
|
|
526
|
+
config: params.config,
|
|
527
|
+
fullConfig: params.fullConfig,
|
|
528
|
+
providers: params.providers
|
|
529
|
+
});
|
|
530
|
+
const transcript = [];
|
|
531
|
+
bridge = createRealtimeVoiceBridgeSession({
|
|
532
|
+
provider: resolved.provider,
|
|
533
|
+
providerConfig: resolved.providerConfig,
|
|
534
|
+
audioFormat: resolveGoogleMeetRealtimeAudioFormat(params.config),
|
|
535
|
+
instructions: params.config.realtime.instructions,
|
|
536
|
+
initialGreetingInstructions: params.config.realtime.introMessage,
|
|
537
|
+
triggerGreetingOnReady: false,
|
|
538
|
+
markStrategy: "ack-immediately",
|
|
539
|
+
tools: resolveGoogleMeetRealtimeTools(params.config.realtime.toolPolicy),
|
|
540
|
+
audioSink: {
|
|
541
|
+
isOpen: () => !stopped,
|
|
542
|
+
sendAudio: (audio) => {
|
|
543
|
+
lastOutputAtMs = Date.now();
|
|
544
|
+
lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
545
|
+
lastOutputBytes += audio.byteLength;
|
|
546
|
+
suppressInputForOutput(audio);
|
|
547
|
+
outputProcess.stdin?.write(audio);
|
|
548
|
+
},
|
|
549
|
+
clearAudio: clearOutputPlayback
|
|
550
|
+
},
|
|
551
|
+
onTranscript: (role, text, isFinal) => {
|
|
552
|
+
if (isFinal) {
|
|
553
|
+
transcript.push({
|
|
554
|
+
role,
|
|
555
|
+
text
|
|
556
|
+
});
|
|
557
|
+
if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
|
|
558
|
+
params.logger.debug?.(`[google-meet] ${role}: ${text}`);
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
onToolCall: (event, session) => {
|
|
562
|
+
if (event.name !== GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME) {
|
|
563
|
+
session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" not available` });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
submitGoogleMeetConsultWorkingResponse(session, event.callId || event.itemId);
|
|
567
|
+
consultOpenClawAgentForGoogleMeet({
|
|
568
|
+
config: params.config,
|
|
569
|
+
fullConfig: params.fullConfig,
|
|
570
|
+
runtime: params.runtime,
|
|
571
|
+
logger: params.logger,
|
|
572
|
+
meetingSessionId: params.meetingSessionId,
|
|
573
|
+
args: event.args,
|
|
574
|
+
transcript
|
|
575
|
+
}).then((result) => {
|
|
576
|
+
session.submitToolResult(event.callId || event.itemId, result);
|
|
577
|
+
}).catch((error) => {
|
|
578
|
+
session.submitToolResult(event.callId || event.itemId, { error: formatErrorMessage(error) });
|
|
579
|
+
});
|
|
580
|
+
},
|
|
581
|
+
onError: fail("realtime voice bridge"),
|
|
582
|
+
onClose: (reason) => {
|
|
583
|
+
realtimeReady = false;
|
|
584
|
+
if (reason === "error") stop();
|
|
585
|
+
},
|
|
586
|
+
onReady: () => {
|
|
587
|
+
realtimeReady = true;
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
startHumanBargeInMonitor();
|
|
591
|
+
inputProcess.stdout?.on("data", (chunk) => {
|
|
592
|
+
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
593
|
+
if (!stopped && audio.byteLength > 0) {
|
|
594
|
+
if (Date.now() < suppressInputUntil) {
|
|
595
|
+
lastSuppressedInputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
596
|
+
suppressedInputBytes += audio.byteLength;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
600
|
+
lastInputBytes += audio.byteLength;
|
|
601
|
+
bridge?.sendAudio(Buffer.from(audio));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
await bridge.connect();
|
|
605
|
+
return {
|
|
606
|
+
providerId: resolved.provider.id,
|
|
607
|
+
inputCommand: params.inputCommand,
|
|
608
|
+
outputCommand: params.outputCommand,
|
|
609
|
+
speak: (instructions) => {
|
|
610
|
+
bridge?.triggerGreeting(instructions);
|
|
611
|
+
},
|
|
612
|
+
getHealth: () => ({
|
|
613
|
+
providerConnected: bridge?.bridge.isConnected() ?? false,
|
|
614
|
+
realtimeReady,
|
|
615
|
+
audioInputActive: lastInputBytes > 0,
|
|
616
|
+
audioOutputActive: lastOutputBytes > 0,
|
|
617
|
+
lastInputAt,
|
|
618
|
+
lastOutputAt,
|
|
619
|
+
lastSuppressedInputAt,
|
|
620
|
+
lastInputBytes,
|
|
621
|
+
lastOutputBytes,
|
|
622
|
+
suppressedInputBytes,
|
|
623
|
+
lastClearAt,
|
|
624
|
+
clearCount,
|
|
625
|
+
bridgeClosed: stopped
|
|
626
|
+
}),
|
|
627
|
+
stop
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region extensions/google-meet/src/realtime-node.ts
|
|
632
|
+
function asRecord$2(value) {
|
|
633
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
634
|
+
}
|
|
635
|
+
function readString$1(value) {
|
|
636
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
637
|
+
}
|
|
638
|
+
async function startNodeRealtimeAudioBridge(params) {
|
|
639
|
+
let stopped = false;
|
|
640
|
+
let bridge = null;
|
|
641
|
+
let realtimeReady = false;
|
|
642
|
+
let lastInputAt;
|
|
643
|
+
let lastOutputAt;
|
|
644
|
+
let lastClearAt;
|
|
645
|
+
let lastInputBytes = 0;
|
|
646
|
+
let lastOutputBytes = 0;
|
|
647
|
+
let consecutiveInputErrors = 0;
|
|
648
|
+
let lastInputError;
|
|
649
|
+
let clearCount = 0;
|
|
650
|
+
const resolved = resolveGoogleMeetRealtimeProvider({
|
|
651
|
+
config: params.config,
|
|
652
|
+
fullConfig: params.fullConfig,
|
|
653
|
+
providers: params.providers
|
|
654
|
+
});
|
|
655
|
+
const transcript = [];
|
|
656
|
+
const stop = async () => {
|
|
657
|
+
if (stopped) return;
|
|
658
|
+
stopped = true;
|
|
659
|
+
try {
|
|
660
|
+
bridge?.close();
|
|
661
|
+
} catch (error) {
|
|
662
|
+
params.logger.debug?.(`[google-meet] node realtime bridge close ignored: ${formatErrorMessage(error)}`);
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
await params.runtime.nodes.invoke({
|
|
666
|
+
nodeId: params.nodeId,
|
|
667
|
+
command: "googlemeet.chrome",
|
|
668
|
+
params: {
|
|
669
|
+
action: "stop",
|
|
670
|
+
bridgeId: params.bridgeId
|
|
671
|
+
},
|
|
672
|
+
timeoutMs: 5e3
|
|
673
|
+
});
|
|
674
|
+
} catch (error) {
|
|
675
|
+
params.logger.debug?.(`[google-meet] node audio bridge stop ignored: ${formatErrorMessage(error)}`);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
bridge = createRealtimeVoiceBridgeSession({
|
|
679
|
+
provider: resolved.provider,
|
|
680
|
+
providerConfig: resolved.providerConfig,
|
|
681
|
+
audioFormat: resolveGoogleMeetRealtimeAudioFormat(params.config),
|
|
682
|
+
instructions: params.config.realtime.instructions,
|
|
683
|
+
initialGreetingInstructions: params.config.realtime.introMessage,
|
|
684
|
+
triggerGreetingOnReady: false,
|
|
685
|
+
markStrategy: "ack-immediately",
|
|
686
|
+
tools: resolveGoogleMeetRealtimeTools(params.config.realtime.toolPolicy),
|
|
687
|
+
audioSink: {
|
|
688
|
+
isOpen: () => !stopped,
|
|
689
|
+
sendAudio: (audio) => {
|
|
690
|
+
lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
691
|
+
lastOutputBytes += audio.byteLength;
|
|
692
|
+
params.runtime.nodes.invoke({
|
|
693
|
+
nodeId: params.nodeId,
|
|
694
|
+
command: "googlemeet.chrome",
|
|
695
|
+
params: {
|
|
696
|
+
action: "pushAudio",
|
|
697
|
+
bridgeId: params.bridgeId,
|
|
698
|
+
base64: Buffer.from(audio).toString("base64")
|
|
699
|
+
},
|
|
700
|
+
timeoutMs: 5e3
|
|
701
|
+
}).catch((error) => {
|
|
702
|
+
params.logger.warn(`[google-meet] node audio output failed: ${formatErrorMessage(error)}`);
|
|
703
|
+
stop();
|
|
704
|
+
});
|
|
705
|
+
},
|
|
706
|
+
clearAudio: () => {
|
|
707
|
+
lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
708
|
+
clearCount += 1;
|
|
709
|
+
params.runtime.nodes.invoke({
|
|
710
|
+
nodeId: params.nodeId,
|
|
711
|
+
command: "googlemeet.chrome",
|
|
712
|
+
params: {
|
|
713
|
+
action: "clearAudio",
|
|
714
|
+
bridgeId: params.bridgeId
|
|
715
|
+
},
|
|
716
|
+
timeoutMs: 5e3
|
|
717
|
+
}).catch((error) => {
|
|
718
|
+
params.logger.warn(`[google-meet] node audio clear failed: ${formatErrorMessage(error)}`);
|
|
719
|
+
stop();
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
onTranscript: (role, text, isFinal) => {
|
|
724
|
+
if (isFinal) {
|
|
725
|
+
transcript.push({
|
|
726
|
+
role,
|
|
727
|
+
text
|
|
728
|
+
});
|
|
729
|
+
if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
|
|
730
|
+
params.logger.debug?.(`[google-meet] ${role}: ${text}`);
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
onToolCall: (event, session) => {
|
|
734
|
+
if (event.name !== GOOGLE_MEET_AGENT_CONSULT_TOOL_NAME) {
|
|
735
|
+
session.submitToolResult(event.callId || event.itemId, { error: `Tool "${event.name}" not available` });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
submitGoogleMeetConsultWorkingResponse(session, event.callId || event.itemId);
|
|
739
|
+
consultOpenClawAgentForGoogleMeet({
|
|
740
|
+
config: params.config,
|
|
741
|
+
fullConfig: params.fullConfig,
|
|
742
|
+
runtime: params.runtime,
|
|
743
|
+
logger: params.logger,
|
|
744
|
+
meetingSessionId: params.meetingSessionId,
|
|
745
|
+
args: event.args,
|
|
746
|
+
transcript
|
|
747
|
+
}).then((result) => {
|
|
748
|
+
session.submitToolResult(event.callId || event.itemId, result);
|
|
749
|
+
}).catch((error) => {
|
|
750
|
+
session.submitToolResult(event.callId || event.itemId, { error: formatErrorMessage(error) });
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
onError: (error) => {
|
|
754
|
+
params.logger.warn(`[google-meet] node realtime voice bridge failed: ${formatErrorMessage(error)}`);
|
|
755
|
+
stop();
|
|
756
|
+
},
|
|
757
|
+
onClose: (reason) => {
|
|
758
|
+
realtimeReady = false;
|
|
759
|
+
if (reason === "error") stop();
|
|
760
|
+
},
|
|
761
|
+
onReady: () => {
|
|
762
|
+
realtimeReady = true;
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
await bridge.connect();
|
|
766
|
+
(async () => {
|
|
767
|
+
for (;;) {
|
|
768
|
+
if (stopped) break;
|
|
769
|
+
try {
|
|
770
|
+
const raw = await params.runtime.nodes.invoke({
|
|
771
|
+
nodeId: params.nodeId,
|
|
772
|
+
command: "googlemeet.chrome",
|
|
773
|
+
params: {
|
|
774
|
+
action: "pullAudio",
|
|
775
|
+
bridgeId: params.bridgeId,
|
|
776
|
+
timeoutMs: 250
|
|
777
|
+
},
|
|
778
|
+
timeoutMs: 2e3
|
|
779
|
+
});
|
|
780
|
+
const result = asRecord$2(asRecord$2(raw).payload ?? raw);
|
|
781
|
+
consecutiveInputErrors = 0;
|
|
782
|
+
lastInputError = void 0;
|
|
783
|
+
const base64 = readString$1(result.base64);
|
|
784
|
+
if (base64) {
|
|
785
|
+
const audio = Buffer.from(base64, "base64");
|
|
786
|
+
lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
787
|
+
lastInputBytes += audio.byteLength;
|
|
788
|
+
bridge?.sendAudio(audio);
|
|
789
|
+
}
|
|
790
|
+
if (result.closed === true) await stop();
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (!stopped) {
|
|
793
|
+
const message = formatErrorMessage(error);
|
|
794
|
+
consecutiveInputErrors += 1;
|
|
795
|
+
lastInputError = message;
|
|
796
|
+
params.logger.warn(`[google-meet] node audio input failed (${consecutiveInputErrors}/5): ${message}`);
|
|
797
|
+
if (consecutiveInputErrors >= 5 || /unknown bridgeId|bridge is not open/i.test(message)) await stop();
|
|
798
|
+
else await new Promise((resolve) => setTimeout(resolve, 250));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
})();
|
|
803
|
+
return {
|
|
804
|
+
type: "node-command-pair",
|
|
805
|
+
providerId: resolved.provider.id,
|
|
806
|
+
nodeId: params.nodeId,
|
|
807
|
+
bridgeId: params.bridgeId,
|
|
808
|
+
speak: (instructions) => {
|
|
809
|
+
bridge?.triggerGreeting(instructions);
|
|
810
|
+
},
|
|
811
|
+
getHealth: () => ({
|
|
812
|
+
providerConnected: bridge?.bridge.isConnected() ?? false,
|
|
813
|
+
realtimeReady,
|
|
814
|
+
audioInputActive: lastInputBytes > 0,
|
|
815
|
+
audioOutputActive: lastOutputBytes > 0,
|
|
816
|
+
lastInputAt,
|
|
817
|
+
lastOutputAt,
|
|
818
|
+
lastClearAt,
|
|
819
|
+
lastInputBytes,
|
|
820
|
+
lastOutputBytes,
|
|
821
|
+
consecutiveInputErrors,
|
|
822
|
+
lastInputError,
|
|
823
|
+
clearCount,
|
|
824
|
+
bridgeClosed: stopped
|
|
825
|
+
}),
|
|
826
|
+
stop
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region extensions/google-meet/src/transports/chrome.ts
|
|
831
|
+
const GOOGLE_MEET_SYSTEM_PROFILER_COMMAND = "/usr/sbin/system_profiler";
|
|
832
|
+
const chromeTransportDeps = { callGatewayFromCli };
|
|
833
|
+
function outputMentionsBlackHole2ch(output) {
|
|
834
|
+
return /\bBlackHole\s+2ch\b/i.test(output);
|
|
835
|
+
}
|
|
836
|
+
async function assertBlackHole2chAvailable(params) {
|
|
837
|
+
if (process.platform !== "darwin") throw new Error("Chrome Meet transport with blackhole-2ch audio is currently macOS-only");
|
|
838
|
+
const result = await params.runtime.system.runCommandWithTimeout([GOOGLE_MEET_SYSTEM_PROFILER_COMMAND, "SPAudioDataType"], { timeoutMs: params.timeoutMs });
|
|
839
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
840
|
+
if (result.code !== 0 || !outputMentionsBlackHole2ch(output)) {
|
|
841
|
+
const hint = params.runtime.system.formatNativeDependencyHint?.({
|
|
842
|
+
packageName: "BlackHole 2ch",
|
|
843
|
+
downloadCommand: "brew install blackhole-2ch"
|
|
844
|
+
}) ?? "";
|
|
845
|
+
throw new Error([
|
|
846
|
+
"BlackHole 2ch audio device not found.",
|
|
847
|
+
"Install BlackHole 2ch and route Chrome input/output through the OpenClaw audio bridge.",
|
|
848
|
+
hint
|
|
849
|
+
].filter(Boolean).join(" "));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function launchChromeMeet(params) {
|
|
853
|
+
let audioBridge;
|
|
854
|
+
if (params.mode === "realtime") {
|
|
855
|
+
await assertBlackHole2chAvailable({
|
|
856
|
+
runtime: params.runtime,
|
|
857
|
+
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 1e4)
|
|
858
|
+
});
|
|
859
|
+
if (params.config.chrome.audioBridgeHealthCommand) {
|
|
860
|
+
const health = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeHealthCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
|
|
861
|
+
if (health.code !== 0) throw new Error(`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`);
|
|
862
|
+
}
|
|
863
|
+
if (params.config.chrome.audioBridgeCommand) {
|
|
864
|
+
const bridge = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
|
|
865
|
+
if (bridge.code !== 0) throw new Error(`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`);
|
|
866
|
+
audioBridge = { type: "external-command" };
|
|
867
|
+
} else {
|
|
868
|
+
if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) throw new Error("Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.");
|
|
869
|
+
audioBridge = {
|
|
870
|
+
type: "command-pair",
|
|
871
|
+
...await startCommandRealtimeAudioBridge({
|
|
872
|
+
config: params.config,
|
|
873
|
+
fullConfig: params.fullConfig,
|
|
874
|
+
runtime: params.runtime,
|
|
875
|
+
meetingSessionId: params.meetingSessionId,
|
|
876
|
+
inputCommand: params.config.chrome.audioInputCommand,
|
|
877
|
+
outputCommand: params.config.chrome.audioOutputCommand,
|
|
878
|
+
logger: params.logger
|
|
879
|
+
})
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (!params.config.chrome.launch) return {
|
|
884
|
+
launched: false,
|
|
885
|
+
audioBridge
|
|
886
|
+
};
|
|
887
|
+
let commandPairBridgeStopped = false;
|
|
888
|
+
const stopCommandPairBridge = async () => {
|
|
889
|
+
if (commandPairBridgeStopped) return;
|
|
890
|
+
commandPairBridgeStopped = true;
|
|
891
|
+
if (audioBridge?.type === "command-pair") await audioBridge.stop();
|
|
892
|
+
};
|
|
893
|
+
try {
|
|
894
|
+
return {
|
|
895
|
+
...await openMeetWithBrowserRequest({
|
|
896
|
+
callBrowser: callLocalBrowserRequest,
|
|
897
|
+
config: params.config,
|
|
898
|
+
mode: params.mode,
|
|
899
|
+
url: params.url
|
|
900
|
+
}),
|
|
901
|
+
audioBridge
|
|
902
|
+
};
|
|
903
|
+
} catch (error) {
|
|
904
|
+
await stopCommandPairBridge();
|
|
905
|
+
throw error;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function parseNodeStartResult(raw) {
|
|
909
|
+
const value = raw && typeof raw === "object" && "payload" in raw ? raw.payload : raw;
|
|
910
|
+
if (!value || typeof value !== "object") throw new Error("Google Meet node returned an invalid start result.");
|
|
911
|
+
return value;
|
|
912
|
+
}
|
|
913
|
+
function parseMeetBrowserStatus(result) {
|
|
914
|
+
const raw = (result && typeof result === "object" ? result : {}).result;
|
|
915
|
+
if (typeof raw !== "string" || !raw.trim()) return;
|
|
916
|
+
const parsed = JSON.parse(raw);
|
|
917
|
+
return {
|
|
918
|
+
inCall: parsed.inCall,
|
|
919
|
+
micMuted: parsed.micMuted,
|
|
920
|
+
lobbyWaiting: parsed.lobbyWaiting,
|
|
921
|
+
leaveReason: parsed.leaveReason,
|
|
922
|
+
captioning: parsed.captioning,
|
|
923
|
+
captionsEnabledAttempted: parsed.captionsEnabledAttempted,
|
|
924
|
+
transcriptLines: parsed.transcriptLines,
|
|
925
|
+
lastCaptionAt: parsed.lastCaptionAt,
|
|
926
|
+
lastCaptionSpeaker: parsed.lastCaptionSpeaker,
|
|
927
|
+
lastCaptionText: parsed.lastCaptionText,
|
|
928
|
+
recentTranscript: parsed.recentTranscript,
|
|
929
|
+
manualActionRequired: parsed.manualActionRequired,
|
|
930
|
+
manualActionReason: parsed.manualActionReason,
|
|
931
|
+
manualActionMessage: parsed.manualActionMessage,
|
|
932
|
+
browserUrl: parsed.url,
|
|
933
|
+
browserTitle: parsed.title,
|
|
934
|
+
status: "browser-control",
|
|
935
|
+
notes: Array.isArray(parsed.notes) ? parsed.notes.filter((note) => typeof note === "string") : void 0
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
async function callLocalBrowserRequest(params) {
|
|
939
|
+
return await chromeTransportDeps.callGatewayFromCli("browser.request", {
|
|
940
|
+
json: true,
|
|
941
|
+
timeout: String(params.timeoutMs + 5e3)
|
|
942
|
+
}, {
|
|
943
|
+
method: params.method,
|
|
944
|
+
path: params.path,
|
|
945
|
+
body: params.body,
|
|
946
|
+
timeoutMs: params.timeoutMs
|
|
947
|
+
}, { progress: false });
|
|
948
|
+
}
|
|
949
|
+
function mergeBrowserNotes(browser, notes) {
|
|
950
|
+
if (!browser || notes.length === 0) return browser;
|
|
951
|
+
return {
|
|
952
|
+
...browser,
|
|
953
|
+
notes: [...new Set([...browser.notes ?? [], ...notes])]
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function parsePermissionGrantNotes(result) {
|
|
957
|
+
const record = result && typeof result === "object" ? result : {};
|
|
958
|
+
const unsupportedPermissions = Array.isArray(record.unsupportedPermissions) ? record.unsupportedPermissions.filter((value) => typeof value === "string") : [];
|
|
959
|
+
const notes = ["Granted Meet microphone/camera permissions through browser control."];
|
|
960
|
+
if (unsupportedPermissions.includes("speakerSelection")) notes.push("Chrome did not accept the optional Meet speaker-selection permission.");
|
|
961
|
+
return notes;
|
|
962
|
+
}
|
|
963
|
+
async function grantMeetMediaPermissions(params) {
|
|
964
|
+
if (!params.allowMicrophone) return ["Observe-only mode skips Meet microphone/camera permission grants."];
|
|
965
|
+
try {
|
|
966
|
+
return parsePermissionGrantNotes(await params.callBrowser({
|
|
967
|
+
method: "POST",
|
|
968
|
+
path: "/permissions/grant",
|
|
969
|
+
body: {
|
|
970
|
+
origin: "https://meet.google.com",
|
|
971
|
+
permissions: ["audioCapture", "videoCapture"],
|
|
972
|
+
optionalPermissions: ["speakerSelection"],
|
|
973
|
+
timeoutMs: Math.min(params.timeoutMs, 5e3)
|
|
974
|
+
},
|
|
975
|
+
timeoutMs: Math.min(params.timeoutMs, 5e3)
|
|
976
|
+
}));
|
|
977
|
+
} catch (error) {
|
|
978
|
+
return [`Could not grant Meet media permissions automatically: ${error instanceof Error ? error.message : String(error)}`];
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function meetStatusScript(params) {
|
|
982
|
+
return `() => {
|
|
983
|
+
const text = (node) => (node?.innerText || node?.textContent || "").trim();
|
|
984
|
+
const allowMicrophone = ${JSON.stringify(params.allowMicrophone)};
|
|
985
|
+
const captureCaptions = ${JSON.stringify(params.captureCaptions)};
|
|
986
|
+
const buttons = [...document.querySelectorAll('button')];
|
|
987
|
+
const notes = [];
|
|
988
|
+
const findButton = (pattern) =>
|
|
989
|
+
buttons.find((button) => {
|
|
990
|
+
const label = [
|
|
991
|
+
button.getAttribute("aria-label"),
|
|
992
|
+
button.getAttribute("data-tooltip"),
|
|
993
|
+
text(button),
|
|
994
|
+
]
|
|
995
|
+
.filter(Boolean)
|
|
996
|
+
.join(" ");
|
|
997
|
+
return pattern.test(label) && !button.disabled;
|
|
998
|
+
});
|
|
999
|
+
const input = [...document.querySelectorAll('input')].find((el) =>
|
|
1000
|
+
/your name/i.test(el.getAttribute('aria-label') || el.placeholder || '')
|
|
1001
|
+
);
|
|
1002
|
+
if (${JSON.stringify(params.autoJoin)} && input && !input.value) {
|
|
1003
|
+
input.focus();
|
|
1004
|
+
input.value = ${JSON.stringify(params.guestName)};
|
|
1005
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1006
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1007
|
+
}
|
|
1008
|
+
const pageText = text(document.body).toLowerCase();
|
|
1009
|
+
const host = location.hostname.toLowerCase();
|
|
1010
|
+
const pageUrl = location.href;
|
|
1011
|
+
const permissionNeeded = /permission needed|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(pageText);
|
|
1012
|
+
const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
|
|
1013
|
+
if (!allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
|
|
1014
|
+
mic.click();
|
|
1015
|
+
notes.push("Muted Meet microphone for observe-only mode.");
|
|
1016
|
+
}
|
|
1017
|
+
const join = ${JSON.stringify(params.autoJoin)}
|
|
1018
|
+
? findButton(/join now|ask to join/i)
|
|
1019
|
+
: null;
|
|
1020
|
+
if (join) join.click();
|
|
1021
|
+
const microphoneChoice = findButton(/\\buse microphone\\b/i);
|
|
1022
|
+
const noMicrophoneChoice = findButton(/\\b(continue|join|use) without (microphone|mic)\\b|\\bnot now\\b/i);
|
|
1023
|
+
if (allowMicrophone && microphoneChoice) {
|
|
1024
|
+
microphoneChoice.click();
|
|
1025
|
+
notes.push("Accepted Meet microphone prompt with browser automation.");
|
|
1026
|
+
} else if (!allowMicrophone && noMicrophoneChoice) {
|
|
1027
|
+
noMicrophoneChoice.click();
|
|
1028
|
+
notes.push("Skipped Meet microphone prompt for observe-only mode.");
|
|
1029
|
+
}
|
|
1030
|
+
const inCall = buttons.some((button) => /leave call/i.test(button.getAttribute('aria-label') || text(button)));
|
|
1031
|
+
let captioning = false;
|
|
1032
|
+
let captionsEnabledAttempted = false;
|
|
1033
|
+
let transcriptLines = 0;
|
|
1034
|
+
let lastCaptionAt;
|
|
1035
|
+
let lastCaptionSpeaker;
|
|
1036
|
+
let lastCaptionText;
|
|
1037
|
+
let recentTranscript = [];
|
|
1038
|
+
const captionSelector = '[role="region"][aria-label*="aption" i], [aria-live="polite"][role="region"], div[aria-live="polite"]';
|
|
1039
|
+
const captionState = (() => {
|
|
1040
|
+
if (!captureCaptions) return undefined;
|
|
1041
|
+
const w = window;
|
|
1042
|
+
if (!inCall && !w.__openclawMeetCaptions) return undefined;
|
|
1043
|
+
if (!w.__openclawMeetCaptions) {
|
|
1044
|
+
w.__openclawMeetCaptions = {
|
|
1045
|
+
enabledAttempted: false,
|
|
1046
|
+
observerInstalled: false,
|
|
1047
|
+
lines: [],
|
|
1048
|
+
seen: {}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
return w.__openclawMeetCaptions;
|
|
1052
|
+
})();
|
|
1053
|
+
const recordCaption = (speaker, captionText) => {
|
|
1054
|
+
if (!captionState) return;
|
|
1055
|
+
const clean = String(captionText || "").replace(/\\s+/g, " ").trim();
|
|
1056
|
+
const cleanSpeaker = String(speaker || "").replace(/\\s+/g, " ").trim();
|
|
1057
|
+
if (!clean || clean.length < 2) return;
|
|
1058
|
+
if (/^(turn on captions|turn off captions|captions)$/i.test(clean)) return;
|
|
1059
|
+
const key = (cleanSpeaker + "\\n" + clean).toLowerCase();
|
|
1060
|
+
if (captionState.seen[key]) return;
|
|
1061
|
+
captionState.seen[key] = true;
|
|
1062
|
+
const entry = { at: new Date().toISOString(), speaker: cleanSpeaker || undefined, text: clean };
|
|
1063
|
+
captionState.lines.push(entry);
|
|
1064
|
+
if (captionState.lines.length > 50) captionState.lines.splice(0, captionState.lines.length - 50);
|
|
1065
|
+
};
|
|
1066
|
+
const scrapeCaptions = () => {
|
|
1067
|
+
if (!captionState) return;
|
|
1068
|
+
const regions = [...document.querySelectorAll(captionSelector)];
|
|
1069
|
+
for (const region of regions) {
|
|
1070
|
+
const raw = text(region);
|
|
1071
|
+
if (!raw) continue;
|
|
1072
|
+
const pieces = raw.split(/\\n+/).map((part) => part.trim()).filter(Boolean);
|
|
1073
|
+
if (pieces.length >= 2) {
|
|
1074
|
+
recordCaption(pieces[0], pieces.slice(1).join(" "));
|
|
1075
|
+
} else {
|
|
1076
|
+
recordCaption("", pieces[0] || raw);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
if (captionState) {
|
|
1081
|
+
if (inCall && !captionState.enabledAttempted) {
|
|
1082
|
+
const captionButton = findButton(/turn on captions|show captions|captions/i);
|
|
1083
|
+
const captionLabel = captionButton ? (captionButton.getAttribute("aria-label") || captionButton.getAttribute("data-tooltip") || text(captionButton)) : "";
|
|
1084
|
+
if (captionButton) {
|
|
1085
|
+
captionState.enabledAttempted = true;
|
|
1086
|
+
captionsEnabledAttempted = true;
|
|
1087
|
+
if (!/turn off captions|hide captions/i.test(captionLabel)) {
|
|
1088
|
+
captionButton.click();
|
|
1089
|
+
notes.push("Attempted to enable Meet captions for observe-only transcript health.");
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
} else if (captionState.enabledAttempted) {
|
|
1093
|
+
captionsEnabledAttempted = true;
|
|
1094
|
+
}
|
|
1095
|
+
if (inCall && !captionState.observerInstalled) {
|
|
1096
|
+
captionState.observerInstalled = true;
|
|
1097
|
+
new MutationObserver(scrapeCaptions).observe(document.body, {
|
|
1098
|
+
childList: true,
|
|
1099
|
+
subtree: true,
|
|
1100
|
+
characterData: true
|
|
1101
|
+
});
|
|
1102
|
+
notes.push("Installed Meet caption observer for observe-only transcript health.");
|
|
1103
|
+
}
|
|
1104
|
+
if (inCall) {
|
|
1105
|
+
scrapeCaptions();
|
|
1106
|
+
}
|
|
1107
|
+
const lines = Array.isArray(captionState.lines) ? captionState.lines : [];
|
|
1108
|
+
const last = lines[lines.length - 1];
|
|
1109
|
+
captioning = document.querySelector(captionSelector) !== null || lines.length > 0;
|
|
1110
|
+
transcriptLines = lines.length;
|
|
1111
|
+
lastCaptionAt = last?.at;
|
|
1112
|
+
lastCaptionSpeaker = last?.speaker;
|
|
1113
|
+
lastCaptionText = last?.text;
|
|
1114
|
+
recentTranscript = lines.slice(-5);
|
|
1115
|
+
}
|
|
1116
|
+
const lobbyWaiting = !inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText);
|
|
1117
|
+
const leaveReason = /you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended/i.test(pageText)
|
|
1118
|
+
? pageText.match(/you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended/i)?.[0]
|
|
1119
|
+
: undefined;
|
|
1120
|
+
let manualActionReason;
|
|
1121
|
+
let manualActionMessage;
|
|
1122
|
+
if (!inCall && (host === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)/i.test(pageText))) {
|
|
1123
|
+
manualActionReason = "google-login-required";
|
|
1124
|
+
manualActionMessage = "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.";
|
|
1125
|
+
} else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText)) {
|
|
1126
|
+
manualActionReason = "meet-admission-required";
|
|
1127
|
+
manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech.";
|
|
1128
|
+
} else if (permissionNeeded) {
|
|
1129
|
+
manualActionReason = "meet-permission-required";
|
|
1130
|
+
manualActionMessage = allowMicrophone
|
|
1131
|
+
? "Allow microphone/camera/speaker permissions for Meet in the OpenClaw browser profile, then retry."
|
|
1132
|
+
: "Join without microphone/camera permissions in the OpenClaw browser profile, then retry.";
|
|
1133
|
+
} else if (!inCall && (allowMicrophone ? !microphoneChoice : !noMicrophoneChoice) && /do you want people to hear you in the meeting/i.test(pageText)) {
|
|
1134
|
+
manualActionReason = "meet-audio-choice-required";
|
|
1135
|
+
manualActionMessage = allowMicrophone
|
|
1136
|
+
? "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry."
|
|
1137
|
+
: "Meet is showing the microphone choice. Choose the no-microphone option in the OpenClaw browser profile, then retry.";
|
|
1138
|
+
}
|
|
1139
|
+
return JSON.stringify({
|
|
1140
|
+
clickedJoin: Boolean(join),
|
|
1141
|
+
clickedMicrophoneChoice: Boolean(allowMicrophone && microphoneChoice),
|
|
1142
|
+
inCall,
|
|
1143
|
+
micMuted: mic ? /turn on microphone/i.test(mic.getAttribute('aria-label') || text(mic)) : undefined,
|
|
1144
|
+
lobbyWaiting,
|
|
1145
|
+
leaveReason,
|
|
1146
|
+
captioning,
|
|
1147
|
+
captionsEnabledAttempted,
|
|
1148
|
+
transcriptLines,
|
|
1149
|
+
lastCaptionAt,
|
|
1150
|
+
lastCaptionSpeaker,
|
|
1151
|
+
lastCaptionText,
|
|
1152
|
+
recentTranscript,
|
|
1153
|
+
manualActionRequired: Boolean(manualActionReason),
|
|
1154
|
+
manualActionReason,
|
|
1155
|
+
manualActionMessage,
|
|
1156
|
+
title: document.title,
|
|
1157
|
+
url: pageUrl,
|
|
1158
|
+
notes
|
|
1159
|
+
});
|
|
1160
|
+
}`;
|
|
1161
|
+
}
|
|
1162
|
+
async function openMeetWithBrowserProxy(params) {
|
|
1163
|
+
return await openMeetWithBrowserRequest({
|
|
1164
|
+
callBrowser: async (request) => await callBrowserProxyOnNode({
|
|
1165
|
+
runtime: params.runtime,
|
|
1166
|
+
nodeId: params.nodeId,
|
|
1167
|
+
method: request.method,
|
|
1168
|
+
path: request.path,
|
|
1169
|
+
body: request.body,
|
|
1170
|
+
timeoutMs: request.timeoutMs
|
|
1171
|
+
}),
|
|
1172
|
+
config: params.config,
|
|
1173
|
+
mode: params.mode,
|
|
1174
|
+
url: params.url
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
async function openMeetWithBrowserRequest(params) {
|
|
1178
|
+
if (!params.config.chrome.launch) return { launched: false };
|
|
1179
|
+
const timeoutMs = Math.max(1e3, params.config.chrome.joinTimeoutMs);
|
|
1180
|
+
let targetId;
|
|
1181
|
+
let tab;
|
|
1182
|
+
if (params.config.chrome.reuseExistingTab) {
|
|
1183
|
+
tab = asBrowserTabs(await params.callBrowser({
|
|
1184
|
+
method: "GET",
|
|
1185
|
+
path: "/tabs",
|
|
1186
|
+
timeoutMs: Math.min(timeoutMs, 5e3)
|
|
1187
|
+
})).find((entry) => isSameMeetUrlForReuse(entry.url, params.url));
|
|
1188
|
+
targetId = tab?.targetId;
|
|
1189
|
+
if (targetId) await params.callBrowser({
|
|
1190
|
+
method: "POST",
|
|
1191
|
+
path: "/tabs/focus",
|
|
1192
|
+
body: { targetId },
|
|
1193
|
+
timeoutMs: Math.min(timeoutMs, 5e3)
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (!targetId) {
|
|
1197
|
+
tab = readBrowserTab(await params.callBrowser({
|
|
1198
|
+
method: "POST",
|
|
1199
|
+
path: "/tabs/open",
|
|
1200
|
+
body: { url: params.url },
|
|
1201
|
+
timeoutMs
|
|
1202
|
+
}));
|
|
1203
|
+
targetId = tab?.targetId;
|
|
1204
|
+
}
|
|
1205
|
+
if (!targetId) return {
|
|
1206
|
+
launched: true,
|
|
1207
|
+
browser: {
|
|
1208
|
+
status: "browser-control",
|
|
1209
|
+
notes: ["Browser proxy opened Meet but did not return a targetId."],
|
|
1210
|
+
browserUrl: tab?.url,
|
|
1211
|
+
browserTitle: tab?.title
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
const permissionNotes = await grantMeetMediaPermissions({
|
|
1215
|
+
allowMicrophone: params.mode === "realtime",
|
|
1216
|
+
callBrowser: params.callBrowser,
|
|
1217
|
+
timeoutMs
|
|
1218
|
+
});
|
|
1219
|
+
const deadline = Date.now() + Math.max(0, params.config.chrome.waitForInCallMs);
|
|
1220
|
+
let browser = {
|
|
1221
|
+
status: "browser-control",
|
|
1222
|
+
browserUrl: tab?.url,
|
|
1223
|
+
browserTitle: tab?.title,
|
|
1224
|
+
notes: permissionNotes
|
|
1225
|
+
};
|
|
1226
|
+
do {
|
|
1227
|
+
try {
|
|
1228
|
+
browser = mergeBrowserNotes(parseMeetBrowserStatus(await params.callBrowser({
|
|
1229
|
+
method: "POST",
|
|
1230
|
+
path: "/act",
|
|
1231
|
+
body: {
|
|
1232
|
+
kind: "evaluate",
|
|
1233
|
+
targetId,
|
|
1234
|
+
fn: meetStatusScript({
|
|
1235
|
+
allowMicrophone: params.mode === "realtime",
|
|
1236
|
+
captureCaptions: params.mode === "transcribe",
|
|
1237
|
+
guestName: params.config.chrome.guestName,
|
|
1238
|
+
autoJoin: params.config.chrome.autoJoin
|
|
1239
|
+
})
|
|
1240
|
+
},
|
|
1241
|
+
timeoutMs: Math.min(timeoutMs, 1e4)
|
|
1242
|
+
})) ?? browser, permissionNotes);
|
|
1243
|
+
if (browser?.inCall === true) return {
|
|
1244
|
+
launched: true,
|
|
1245
|
+
browser
|
|
1246
|
+
};
|
|
1247
|
+
if (browser?.manualActionRequired === true) return {
|
|
1248
|
+
launched: true,
|
|
1249
|
+
browser
|
|
1250
|
+
};
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
browser = {
|
|
1253
|
+
...browser,
|
|
1254
|
+
inCall: false,
|
|
1255
|
+
manualActionRequired: true,
|
|
1256
|
+
manualActionReason: "browser-control-unavailable",
|
|
1257
|
+
manualActionMessage: "Open the OpenClaw browser profile, finish Google Meet login, admission, or permission prompts, then retry.",
|
|
1258
|
+
notes: [...permissionNotes, `Browser control could not inspect or auto-join Meet: ${error instanceof Error ? error.message : String(error)}`]
|
|
1259
|
+
};
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
if (Date.now() <= deadline) await new Promise((resolve) => setTimeout(resolve, 750));
|
|
1263
|
+
} while (Date.now() <= deadline);
|
|
1264
|
+
return {
|
|
1265
|
+
launched: true,
|
|
1266
|
+
browser
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function isRecoverableMeetTab(tab, url) {
|
|
1270
|
+
if (url) return isSameMeetUrlForReuse(tab.url, url);
|
|
1271
|
+
if (normalizeMeetUrlForReuse(tab.url)) return true;
|
|
1272
|
+
return (tab.url ?? "").startsWith("https://accounts.google.com/") && /sign in|google accounts|meet/i.test(tab.title ?? "");
|
|
1273
|
+
}
|
|
1274
|
+
async function inspectRecoverableMeetTab(params) {
|
|
1275
|
+
const allowMicrophone = params.mode !== "transcribe";
|
|
1276
|
+
await params.callBrowser({
|
|
1277
|
+
method: "POST",
|
|
1278
|
+
path: "/tabs/focus",
|
|
1279
|
+
body: { targetId: params.targetId },
|
|
1280
|
+
timeoutMs: Math.min(params.timeoutMs, 5e3)
|
|
1281
|
+
});
|
|
1282
|
+
const permissionNotes = await grantMeetMediaPermissions({
|
|
1283
|
+
allowMicrophone,
|
|
1284
|
+
callBrowser: params.callBrowser,
|
|
1285
|
+
timeoutMs: params.timeoutMs
|
|
1286
|
+
});
|
|
1287
|
+
const browser = mergeBrowserNotes(parseMeetBrowserStatus(await params.callBrowser({
|
|
1288
|
+
method: "POST",
|
|
1289
|
+
path: "/act",
|
|
1290
|
+
body: {
|
|
1291
|
+
kind: "evaluate",
|
|
1292
|
+
targetId: params.targetId,
|
|
1293
|
+
fn: meetStatusScript({
|
|
1294
|
+
allowMicrophone,
|
|
1295
|
+
captureCaptions: params.mode === "transcribe",
|
|
1296
|
+
guestName: params.config.chrome.guestName,
|
|
1297
|
+
autoJoin: false
|
|
1298
|
+
})
|
|
1299
|
+
},
|
|
1300
|
+
timeoutMs: Math.min(params.timeoutMs, 1e4)
|
|
1301
|
+
})) ?? {
|
|
1302
|
+
status: "browser-control",
|
|
1303
|
+
browserUrl: params.tab.url,
|
|
1304
|
+
browserTitle: params.tab.title
|
|
1305
|
+
}, permissionNotes);
|
|
1306
|
+
const manual = browser?.manualActionRequired ? browser.manualActionMessage || browser.manualActionReason : void 0;
|
|
1307
|
+
return {
|
|
1308
|
+
found: true,
|
|
1309
|
+
targetId: params.targetId,
|
|
1310
|
+
tab: params.tab,
|
|
1311
|
+
browser,
|
|
1312
|
+
message: manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused.")
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
async function recoverCurrentMeetTab(params) {
|
|
1316
|
+
const timeoutMs = Math.max(1e3, params.config.chrome.joinTimeoutMs);
|
|
1317
|
+
const tab = asBrowserTabs(await callLocalBrowserRequest({
|
|
1318
|
+
method: "GET",
|
|
1319
|
+
path: "/tabs",
|
|
1320
|
+
timeoutMs: Math.min(timeoutMs, 5e3)
|
|
1321
|
+
})).find((entry) => isRecoverableMeetTab(entry, params.url));
|
|
1322
|
+
const targetId = tab?.targetId;
|
|
1323
|
+
if (!tab || !targetId) return {
|
|
1324
|
+
transport: "chrome",
|
|
1325
|
+
found: false,
|
|
1326
|
+
tab,
|
|
1327
|
+
message: params.url ? `No existing Meet tab matched ${params.url}.` : "No existing Meet tab found in local Chrome."
|
|
1328
|
+
};
|
|
1329
|
+
return {
|
|
1330
|
+
transport: "chrome",
|
|
1331
|
+
...await inspectRecoverableMeetTab({
|
|
1332
|
+
callBrowser: callLocalBrowserRequest,
|
|
1333
|
+
config: params.config,
|
|
1334
|
+
mode: params.mode,
|
|
1335
|
+
timeoutMs,
|
|
1336
|
+
tab,
|
|
1337
|
+
targetId
|
|
1338
|
+
})
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
async function recoverCurrentMeetTabOnNode(params) {
|
|
1342
|
+
const nodeId = await resolveChromeNode({
|
|
1343
|
+
runtime: params.runtime,
|
|
1344
|
+
requestedNode: params.config.chromeNode.node
|
|
1345
|
+
});
|
|
1346
|
+
const timeoutMs = Math.max(1e3, params.config.chrome.joinTimeoutMs);
|
|
1347
|
+
const tab = asBrowserTabs(await callBrowserProxyOnNode({
|
|
1348
|
+
runtime: params.runtime,
|
|
1349
|
+
nodeId,
|
|
1350
|
+
method: "GET",
|
|
1351
|
+
path: "/tabs",
|
|
1352
|
+
timeoutMs: Math.min(timeoutMs, 5e3)
|
|
1353
|
+
})).find((entry) => isRecoverableMeetTab(entry, params.url));
|
|
1354
|
+
const targetId = tab?.targetId;
|
|
1355
|
+
if (!tab || !targetId) return {
|
|
1356
|
+
transport: "chrome-node",
|
|
1357
|
+
nodeId,
|
|
1358
|
+
found: false,
|
|
1359
|
+
tab,
|
|
1360
|
+
message: params.url ? `No existing Meet tab matched ${params.url}.` : "No existing Meet tab found on the selected Chrome node."
|
|
1361
|
+
};
|
|
1362
|
+
return {
|
|
1363
|
+
transport: "chrome-node",
|
|
1364
|
+
nodeId,
|
|
1365
|
+
...await inspectRecoverableMeetTab({
|
|
1366
|
+
callBrowser: async (request) => await callBrowserProxyOnNode({
|
|
1367
|
+
runtime: params.runtime,
|
|
1368
|
+
nodeId,
|
|
1369
|
+
method: request.method,
|
|
1370
|
+
path: request.path,
|
|
1371
|
+
body: request.body,
|
|
1372
|
+
timeoutMs: request.timeoutMs
|
|
1373
|
+
}),
|
|
1374
|
+
config: params.config,
|
|
1375
|
+
mode: params.mode,
|
|
1376
|
+
timeoutMs,
|
|
1377
|
+
tab,
|
|
1378
|
+
targetId
|
|
1379
|
+
})
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
async function launchChromeMeetOnNode(params) {
|
|
1383
|
+
const nodeId = await resolveChromeNode({
|
|
1384
|
+
runtime: params.runtime,
|
|
1385
|
+
requestedNode: params.config.chromeNode.node
|
|
1386
|
+
});
|
|
1387
|
+
try {
|
|
1388
|
+
await params.runtime.nodes.invoke({
|
|
1389
|
+
nodeId,
|
|
1390
|
+
command: "googlemeet.chrome",
|
|
1391
|
+
params: {
|
|
1392
|
+
action: "stopByUrl",
|
|
1393
|
+
url: params.url,
|
|
1394
|
+
mode: params.mode
|
|
1395
|
+
},
|
|
1396
|
+
timeoutMs: 5e3
|
|
1397
|
+
});
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
params.logger.debug?.(`[google-meet] node bridge cleanup before join ignored: ${error instanceof Error ? error.message : String(error)}`);
|
|
1400
|
+
}
|
|
1401
|
+
const browserControl = await openMeetWithBrowserProxy({
|
|
1402
|
+
runtime: params.runtime,
|
|
1403
|
+
nodeId,
|
|
1404
|
+
config: params.config,
|
|
1405
|
+
mode: params.mode,
|
|
1406
|
+
url: params.url
|
|
1407
|
+
});
|
|
1408
|
+
const result = parseNodeStartResult(await params.runtime.nodes.invoke({
|
|
1409
|
+
nodeId,
|
|
1410
|
+
command: "googlemeet.chrome",
|
|
1411
|
+
params: {
|
|
1412
|
+
action: "start",
|
|
1413
|
+
url: params.url,
|
|
1414
|
+
mode: params.mode,
|
|
1415
|
+
launch: false,
|
|
1416
|
+
browserProfile: params.config.chrome.browserProfile,
|
|
1417
|
+
joinTimeoutMs: params.config.chrome.joinTimeoutMs,
|
|
1418
|
+
audioInputCommand: params.config.chrome.audioInputCommand,
|
|
1419
|
+
audioOutputCommand: params.config.chrome.audioOutputCommand,
|
|
1420
|
+
audioBridgeCommand: params.config.chrome.audioBridgeCommand,
|
|
1421
|
+
audioBridgeHealthCommand: params.config.chrome.audioBridgeHealthCommand
|
|
1422
|
+
},
|
|
1423
|
+
timeoutMs: params.config.chrome.joinTimeoutMs + 5e3
|
|
1424
|
+
}));
|
|
1425
|
+
if (result.audioBridge?.type === "node-command-pair") {
|
|
1426
|
+
if (!result.bridgeId) throw new Error("Google Meet node did not return an audio bridge id.");
|
|
1427
|
+
const bridge = await startNodeRealtimeAudioBridge({
|
|
1428
|
+
config: params.config,
|
|
1429
|
+
fullConfig: params.fullConfig,
|
|
1430
|
+
runtime: params.runtime,
|
|
1431
|
+
meetingSessionId: params.meetingSessionId,
|
|
1432
|
+
nodeId,
|
|
1433
|
+
bridgeId: result.bridgeId,
|
|
1434
|
+
logger: params.logger
|
|
1435
|
+
});
|
|
1436
|
+
return {
|
|
1437
|
+
nodeId,
|
|
1438
|
+
launched: browserControl.launched || result.launched === true,
|
|
1439
|
+
audioBridge: bridge,
|
|
1440
|
+
browser: browserControl.browser ?? result.browser
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (result.audioBridge?.type === "external-command") return {
|
|
1444
|
+
nodeId,
|
|
1445
|
+
launched: browserControl.launched || result.launched === true,
|
|
1446
|
+
audioBridge: { type: "external-command" },
|
|
1447
|
+
browser: browserControl.browser ?? result.browser
|
|
1448
|
+
};
|
|
1449
|
+
return {
|
|
1450
|
+
nodeId,
|
|
1451
|
+
launched: browserControl.launched || result.launched === true,
|
|
1452
|
+
browser: browserControl.browser ?? result.browser
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region extensions/google-meet/src/node-host.ts
|
|
1457
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1458
|
+
function asRecord$1(value) {
|
|
1459
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1460
|
+
}
|
|
1461
|
+
function readString(value) {
|
|
1462
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1463
|
+
}
|
|
1464
|
+
function readStringArray(value) {
|
|
1465
|
+
if (!Array.isArray(value)) return;
|
|
1466
|
+
const result = value.filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
1467
|
+
return result.length > 0 ? result : void 0;
|
|
1468
|
+
}
|
|
1469
|
+
function readNumber(value, fallback) {
|
|
1470
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
1471
|
+
}
|
|
1472
|
+
function runCommandWithTimeout(argv, timeoutMs) {
|
|
1473
|
+
const [command, ...args] = argv;
|
|
1474
|
+
if (!command) throw new Error("command must not be empty");
|
|
1475
|
+
const result = spawnSync(command, args, {
|
|
1476
|
+
encoding: "utf8",
|
|
1477
|
+
timeout: timeoutMs
|
|
1478
|
+
});
|
|
1479
|
+
return {
|
|
1480
|
+
code: typeof result.status === "number" ? result.status : result.error ? 1 : 0,
|
|
1481
|
+
stdout: result.stdout ?? "",
|
|
1482
|
+
stderr: result.stderr ?? (result.error ? formatErrorMessage(result.error) : "")
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function assertBlackHoleAvailable(timeoutMs) {
|
|
1486
|
+
if (process.platform !== "darwin") throw new Error("Chrome Meet transport with blackhole-2ch audio is currently macOS-only");
|
|
1487
|
+
const result = runCommandWithTimeout([GOOGLE_MEET_SYSTEM_PROFILER_COMMAND, "SPAudioDataType"], timeoutMs);
|
|
1488
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
1489
|
+
if (result.code !== 0 || !outputMentionsBlackHole2ch(output)) throw new Error("BlackHole 2ch audio device not found on the node.");
|
|
1490
|
+
}
|
|
1491
|
+
function splitCommand(argv) {
|
|
1492
|
+
const [command, ...args] = argv;
|
|
1493
|
+
if (!command) throw new Error("audio command must not be empty");
|
|
1494
|
+
return {
|
|
1495
|
+
command,
|
|
1496
|
+
args
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
function wake(session) {
|
|
1500
|
+
const waiters = session.waiters.splice(0);
|
|
1501
|
+
for (const waiter of waiters) waiter();
|
|
1502
|
+
}
|
|
1503
|
+
function stopSession(session) {
|
|
1504
|
+
const wasClosed = session.closed;
|
|
1505
|
+
session.closed = true;
|
|
1506
|
+
session.closedAt ??= (/* @__PURE__ */ new Date()).toISOString();
|
|
1507
|
+
terminateChild(session.input);
|
|
1508
|
+
terminateChild(session.output);
|
|
1509
|
+
if (!wasClosed) wake(session);
|
|
1510
|
+
}
|
|
1511
|
+
function attachOutputProcessHandlers(session, outputProcess) {
|
|
1512
|
+
outputProcess.on("exit", () => {
|
|
1513
|
+
if (session.output === outputProcess) stopSession(session);
|
|
1514
|
+
});
|
|
1515
|
+
outputProcess.on("error", () => {
|
|
1516
|
+
if (session.output === outputProcess) stopSession(session);
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
function startOutputProcess(command) {
|
|
1520
|
+
return spawn(command.command, command.args, { stdio: [
|
|
1521
|
+
"pipe",
|
|
1522
|
+
"ignore",
|
|
1523
|
+
"pipe"
|
|
1524
|
+
] });
|
|
1525
|
+
}
|
|
1526
|
+
function startCommandPair(params) {
|
|
1527
|
+
const input = splitCommand(params.inputCommand);
|
|
1528
|
+
const output = splitCommand(params.outputCommand);
|
|
1529
|
+
const session = {
|
|
1530
|
+
id: `meet_node_${randomUUID()}`,
|
|
1531
|
+
url: params.url,
|
|
1532
|
+
mode: params.mode,
|
|
1533
|
+
outputCommand: output,
|
|
1534
|
+
chunks: [],
|
|
1535
|
+
waiters: [],
|
|
1536
|
+
closed: false,
|
|
1537
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1538
|
+
lastInputBytes: 0,
|
|
1539
|
+
lastOutputBytes: 0,
|
|
1540
|
+
clearCount: 0
|
|
1541
|
+
};
|
|
1542
|
+
const outputProcess = startOutputProcess(output);
|
|
1543
|
+
const inputProcess = spawn(input.command, input.args, { stdio: [
|
|
1544
|
+
"ignore",
|
|
1545
|
+
"pipe",
|
|
1546
|
+
"pipe"
|
|
1547
|
+
] });
|
|
1548
|
+
session.input = inputProcess;
|
|
1549
|
+
session.output = outputProcess;
|
|
1550
|
+
inputProcess.stdout?.on("data", (chunk) => {
|
|
1551
|
+
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1552
|
+
session.lastInputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1553
|
+
session.lastInputBytes += audio.byteLength;
|
|
1554
|
+
session.chunks.push(audio);
|
|
1555
|
+
if (session.chunks.length > 200) session.chunks.splice(0, session.chunks.length - 200);
|
|
1556
|
+
wake(session);
|
|
1557
|
+
});
|
|
1558
|
+
inputProcess.on("exit", () => stopSession(session));
|
|
1559
|
+
attachOutputProcessHandlers(session, outputProcess);
|
|
1560
|
+
inputProcess.on("error", () => stopSession(session));
|
|
1561
|
+
sessions.set(session.id, session);
|
|
1562
|
+
return session;
|
|
1563
|
+
}
|
|
1564
|
+
function terminateChild(child) {
|
|
1565
|
+
if (!child) return;
|
|
1566
|
+
let exited = child.exitCode !== null || child.signalCode !== null;
|
|
1567
|
+
child.once?.("exit", () => {
|
|
1568
|
+
exited = true;
|
|
1569
|
+
});
|
|
1570
|
+
try {
|
|
1571
|
+
child.kill("SIGTERM");
|
|
1572
|
+
} catch {}
|
|
1573
|
+
setTimeout(() => {
|
|
1574
|
+
if (exited) return;
|
|
1575
|
+
try {
|
|
1576
|
+
child.kill("SIGKILL");
|
|
1577
|
+
} catch {}
|
|
1578
|
+
}, 2e3).unref?.();
|
|
1579
|
+
}
|
|
1580
|
+
async function pullAudio(params) {
|
|
1581
|
+
const bridgeId = readString(params.bridgeId);
|
|
1582
|
+
if (!bridgeId) throw new Error("bridgeId required");
|
|
1583
|
+
const session = sessions.get(bridgeId);
|
|
1584
|
+
if (!session) throw new Error(`unknown bridgeId: ${bridgeId}`);
|
|
1585
|
+
const timeoutMs = Math.min(readNumber(params.timeoutMs, 250), 2e3);
|
|
1586
|
+
if (session.chunks.length === 0 && !session.closed) await Promise.race([setTimeout$1(timeoutMs), new Promise((resolve) => {
|
|
1587
|
+
session.waiters.push(resolve);
|
|
1588
|
+
})]);
|
|
1589
|
+
const chunk = session.chunks.shift();
|
|
1590
|
+
return {
|
|
1591
|
+
bridgeId,
|
|
1592
|
+
closed: session.closed,
|
|
1593
|
+
base64: chunk ? chunk.toString("base64") : void 0
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function pushAudio(params) {
|
|
1597
|
+
const bridgeId = readString(params.bridgeId);
|
|
1598
|
+
const base64 = readString(params.base64);
|
|
1599
|
+
if (!bridgeId || !base64) throw new Error("bridgeId and base64 required");
|
|
1600
|
+
const session = sessions.get(bridgeId);
|
|
1601
|
+
if (!session || session.closed) throw new Error(`bridge is not open: ${bridgeId}`);
|
|
1602
|
+
const audio = Buffer.from(base64, "base64");
|
|
1603
|
+
session.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1604
|
+
session.lastOutputBytes += audio.byteLength;
|
|
1605
|
+
session.output?.stdin?.write(audio);
|
|
1606
|
+
return {
|
|
1607
|
+
bridgeId,
|
|
1608
|
+
ok: true
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
function clearAudio(params) {
|
|
1612
|
+
const bridgeId = readString(params.bridgeId);
|
|
1613
|
+
if (!bridgeId) throw new Error("bridgeId required");
|
|
1614
|
+
const session = sessions.get(bridgeId);
|
|
1615
|
+
if (!session || session.closed) throw new Error(`bridge is not open: ${bridgeId}`);
|
|
1616
|
+
const previousOutput = session.output;
|
|
1617
|
+
const outputProcess = startOutputProcess(session.outputCommand);
|
|
1618
|
+
session.output = outputProcess;
|
|
1619
|
+
attachOutputProcessHandlers(session, outputProcess);
|
|
1620
|
+
session.clearCount += 1;
|
|
1621
|
+
session.lastClearAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1622
|
+
terminateChild(previousOutput);
|
|
1623
|
+
return {
|
|
1624
|
+
bridgeId,
|
|
1625
|
+
ok: true,
|
|
1626
|
+
clearCount: session.clearCount
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
function startChrome(params) {
|
|
1630
|
+
const url = readString(params.url);
|
|
1631
|
+
if (!url) throw new Error("url required");
|
|
1632
|
+
const timeoutMs = readNumber(params.joinTimeoutMs, 3e4);
|
|
1633
|
+
const mode = readString(params.mode);
|
|
1634
|
+
let bridgeId;
|
|
1635
|
+
let audioBridge;
|
|
1636
|
+
if (mode === "realtime") {
|
|
1637
|
+
assertBlackHoleAvailable(Math.min(timeoutMs, 1e4));
|
|
1638
|
+
const healthCommand = readStringArray(params.audioBridgeHealthCommand);
|
|
1639
|
+
if (healthCommand) {
|
|
1640
|
+
const health = runCommandWithTimeout(healthCommand, timeoutMs);
|
|
1641
|
+
if (health.code !== 0) throw new Error(`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`);
|
|
1642
|
+
}
|
|
1643
|
+
const bridgeCommand = readStringArray(params.audioBridgeCommand);
|
|
1644
|
+
if (bridgeCommand) {
|
|
1645
|
+
const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs);
|
|
1646
|
+
if (bridge.code !== 0) throw new Error(`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`);
|
|
1647
|
+
audioBridge = { type: "external-command" };
|
|
1648
|
+
} else {
|
|
1649
|
+
bridgeId = startCommandPair({
|
|
1650
|
+
inputCommand: readStringArray(params.audioInputCommand) ?? [...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND],
|
|
1651
|
+
outputCommand: readStringArray(params.audioOutputCommand) ?? [...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND],
|
|
1652
|
+
url,
|
|
1653
|
+
mode
|
|
1654
|
+
}).id;
|
|
1655
|
+
audioBridge = { type: "node-command-pair" };
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (params.launch !== false) {
|
|
1659
|
+
const argv = [
|
|
1660
|
+
"open",
|
|
1661
|
+
"-a",
|
|
1662
|
+
"Google Chrome"
|
|
1663
|
+
];
|
|
1664
|
+
const browserProfile = readString(params.browserProfile);
|
|
1665
|
+
if (browserProfile) argv.push("--args", `--profile-directory=${browserProfile}`);
|
|
1666
|
+
argv.push(url);
|
|
1667
|
+
const result = runCommandWithTimeout(argv, timeoutMs);
|
|
1668
|
+
if (result.code !== 0) {
|
|
1669
|
+
if (bridgeId) {
|
|
1670
|
+
const session = sessions.get(bridgeId);
|
|
1671
|
+
if (session) stopSession(session);
|
|
1672
|
+
}
|
|
1673
|
+
throw new Error(`failed to launch Chrome for Meet: ${result.stderr || result.stdout || result.code}`);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return {
|
|
1677
|
+
launched: params.launch !== false,
|
|
1678
|
+
bridgeId,
|
|
1679
|
+
audioBridge,
|
|
1680
|
+
browser: params.launch !== false ? {
|
|
1681
|
+
status: "chrome-opened",
|
|
1682
|
+
browserUrl: url,
|
|
1683
|
+
notes: ["Browser page control is handled by OpenClaw browser automation when using chrome-node."]
|
|
1684
|
+
} : void 0
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
function bridgeStatus(params) {
|
|
1688
|
+
const bridgeId = readString(params.bridgeId);
|
|
1689
|
+
const session = bridgeId ? sessions.get(bridgeId) : void 0;
|
|
1690
|
+
return { bridge: session ? {
|
|
1691
|
+
bridgeId,
|
|
1692
|
+
closed: session.closed,
|
|
1693
|
+
createdAt: session.createdAt,
|
|
1694
|
+
lastInputAt: session.lastInputAt,
|
|
1695
|
+
lastOutputAt: session.lastOutputAt,
|
|
1696
|
+
lastClearAt: session.lastClearAt,
|
|
1697
|
+
lastInputBytes: session.lastInputBytes,
|
|
1698
|
+
lastOutputBytes: session.lastOutputBytes,
|
|
1699
|
+
clearCount: session.clearCount,
|
|
1700
|
+
queuedInputChunks: session.chunks.length
|
|
1701
|
+
} : bridgeId ? {
|
|
1702
|
+
bridgeId,
|
|
1703
|
+
closed: true
|
|
1704
|
+
} : void 0 };
|
|
1705
|
+
}
|
|
1706
|
+
function normalizeMeetKey(value) {
|
|
1707
|
+
if (!value) return;
|
|
1708
|
+
try {
|
|
1709
|
+
const url = new URL(value);
|
|
1710
|
+
if (url.hostname.toLowerCase() !== "meet.google.com") return value;
|
|
1711
|
+
return /^\/([a-z]{3}-[a-z]{4}-[a-z]{3})(?:$|[/?#])/i.exec(url.pathname)?.[1]?.toLowerCase() ?? value;
|
|
1712
|
+
} catch {
|
|
1713
|
+
return value;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function summarizeSession(session) {
|
|
1717
|
+
return {
|
|
1718
|
+
bridgeId: session.id,
|
|
1719
|
+
url: session.url,
|
|
1720
|
+
mode: session.mode,
|
|
1721
|
+
closed: session.closed,
|
|
1722
|
+
createdAt: session.createdAt,
|
|
1723
|
+
closedAt: session.closedAt,
|
|
1724
|
+
lastInputAt: session.lastInputAt,
|
|
1725
|
+
lastOutputAt: session.lastOutputAt,
|
|
1726
|
+
lastInputBytes: session.lastInputBytes,
|
|
1727
|
+
lastOutputBytes: session.lastOutputBytes
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
function listSessions(params) {
|
|
1731
|
+
const urlKey = normalizeMeetKey(readString(params.url));
|
|
1732
|
+
const mode = readString(params.mode);
|
|
1733
|
+
return { bridges: [...sessions.values()].filter((session) => !session.closed).filter((session) => !urlKey || normalizeMeetKey(session.url) === urlKey).filter((session) => !mode || session.mode === mode).map(summarizeSession) };
|
|
1734
|
+
}
|
|
1735
|
+
function stopSessionsByUrl(params) {
|
|
1736
|
+
const urlKey = normalizeMeetKey(readString(params.url));
|
|
1737
|
+
if (!urlKey) throw new Error("url required");
|
|
1738
|
+
const mode = readString(params.mode);
|
|
1739
|
+
const exceptBridgeId = readString(params.exceptBridgeId);
|
|
1740
|
+
let stopped = 0;
|
|
1741
|
+
for (const [bridgeId, session] of sessions) {
|
|
1742
|
+
if (exceptBridgeId && bridgeId === exceptBridgeId) continue;
|
|
1743
|
+
if (normalizeMeetKey(session.url) !== urlKey) continue;
|
|
1744
|
+
if (mode && session.mode !== mode) continue;
|
|
1745
|
+
const wasClosed = session.closed;
|
|
1746
|
+
stopSession(session);
|
|
1747
|
+
sessions.delete(bridgeId);
|
|
1748
|
+
if (!wasClosed) stopped += 1;
|
|
1749
|
+
}
|
|
1750
|
+
return {
|
|
1751
|
+
ok: true,
|
|
1752
|
+
stopped
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
function stopChrome(params) {
|
|
1756
|
+
const bridgeId = readString(params.bridgeId);
|
|
1757
|
+
if (!bridgeId) return {
|
|
1758
|
+
ok: true,
|
|
1759
|
+
stopped: false
|
|
1760
|
+
};
|
|
1761
|
+
const session = sessions.get(bridgeId);
|
|
1762
|
+
if (!session) return {
|
|
1763
|
+
ok: true,
|
|
1764
|
+
stopped: false
|
|
1765
|
+
};
|
|
1766
|
+
stopSession(session);
|
|
1767
|
+
sessions.delete(bridgeId);
|
|
1768
|
+
return {
|
|
1769
|
+
ok: true,
|
|
1770
|
+
stopped: true
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
async function handleGoogleMeetNodeHostCommand(paramsJSON) {
|
|
1774
|
+
const params = asRecord$1(paramsJSON ? JSON.parse(paramsJSON) : {});
|
|
1775
|
+
const action = readString(params.action);
|
|
1776
|
+
let result;
|
|
1777
|
+
switch (action) {
|
|
1778
|
+
case "setup":
|
|
1779
|
+
assertBlackHoleAvailable(1e4);
|
|
1780
|
+
result = { ok: true };
|
|
1781
|
+
break;
|
|
1782
|
+
case "start":
|
|
1783
|
+
result = startChrome(params);
|
|
1784
|
+
break;
|
|
1785
|
+
case "status":
|
|
1786
|
+
result = bridgeStatus(params);
|
|
1787
|
+
break;
|
|
1788
|
+
case "list":
|
|
1789
|
+
result = listSessions(params);
|
|
1790
|
+
break;
|
|
1791
|
+
case "stopByUrl":
|
|
1792
|
+
result = stopSessionsByUrl(params);
|
|
1793
|
+
break;
|
|
1794
|
+
case "pullAudio":
|
|
1795
|
+
result = await pullAudio(params);
|
|
1796
|
+
break;
|
|
1797
|
+
case "pushAudio":
|
|
1798
|
+
result = pushAudio(params);
|
|
1799
|
+
break;
|
|
1800
|
+
case "clearAudio":
|
|
1801
|
+
result = clearAudio(params);
|
|
1802
|
+
break;
|
|
1803
|
+
case "stop":
|
|
1804
|
+
result = stopChrome(params);
|
|
1805
|
+
break;
|
|
1806
|
+
default: throw new Error("unsupported googlemeet.chrome action");
|
|
1807
|
+
}
|
|
1808
|
+
return JSON.stringify(result);
|
|
1809
|
+
}
|
|
1810
|
+
//#endregion
|
|
1811
|
+
//#region extensions/google-meet/src/setup.ts
|
|
1812
|
+
function resolveUserPath(input) {
|
|
1813
|
+
if (input === "~") return os.homedir();
|
|
1814
|
+
if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
|
|
1815
|
+
return input;
|
|
1816
|
+
}
|
|
1817
|
+
function isProviderUnreachableWebhookUrl(webhookUrl) {
|
|
1818
|
+
try {
|
|
1819
|
+
return isBlockedHostnameOrIp(new URL(webhookUrl).hostname);
|
|
1820
|
+
} catch {
|
|
1821
|
+
return false;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
function getVoiceCallWebhookExposureCheck(voiceCallConfig) {
|
|
1825
|
+
const publicUrl = normalizeOptionalString$1(voiceCallConfig.publicUrl);
|
|
1826
|
+
const tunnel = asRecord(voiceCallConfig.tunnel);
|
|
1827
|
+
const tailscale = asRecord(voiceCallConfig.tailscale);
|
|
1828
|
+
const tunnelProvider = normalizeOptionalString$1(tunnel.provider);
|
|
1829
|
+
const tailscaleMode = normalizeOptionalString$1(tailscale.mode);
|
|
1830
|
+
if (publicUrl) {
|
|
1831
|
+
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
|
|
1832
|
+
return {
|
|
1833
|
+
id: "twilio-voice-call-webhook",
|
|
1834
|
+
ok,
|
|
1835
|
+
message: ok ? `Voice-call public webhook URL configured: ${publicUrl}` : `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
if (tunnelProvider && tunnelProvider !== "none") return {
|
|
1839
|
+
id: "twilio-voice-call-webhook",
|
|
1840
|
+
ok: true,
|
|
1841
|
+
message: "Voice-call webhook exposure configured through tunnel"
|
|
1842
|
+
};
|
|
1843
|
+
if (tailscaleMode && tailscaleMode !== "off") return {
|
|
1844
|
+
id: "twilio-voice-call-webhook",
|
|
1845
|
+
ok: true,
|
|
1846
|
+
message: "Voice-call webhook exposure configured through Tailscale"
|
|
1847
|
+
};
|
|
1848
|
+
return {
|
|
1849
|
+
id: "twilio-voice-call-webhook",
|
|
1850
|
+
ok: false,
|
|
1851
|
+
message: "Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing"
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
function getGoogleMeetSetupStatus(config, options) {
|
|
1855
|
+
const checks = [];
|
|
1856
|
+
const env = options?.env ?? process.env;
|
|
1857
|
+
const fullConfig = asRecord(options?.fullConfig);
|
|
1858
|
+
const mode = options?.mode ?? config.defaultMode;
|
|
1859
|
+
const transport = options?.transport ?? config.defaultTransport;
|
|
1860
|
+
const needsChromeRealtimeAudio = mode === "realtime" && (transport === "chrome" || transport === "chrome-node");
|
|
1861
|
+
const pluginEntries = asRecord(asRecord(fullConfig.plugins).entries);
|
|
1862
|
+
const pluginAllow = asRecord(fullConfig.plugins).allow;
|
|
1863
|
+
const voiceCallEntry = asRecord(pluginEntries["voice-call"]);
|
|
1864
|
+
const voiceCallConfig = asRecord(voiceCallEntry.config);
|
|
1865
|
+
const voiceCallTwilioConfig = asRecord(voiceCallConfig.twilio);
|
|
1866
|
+
if (config.auth.tokenPath) {
|
|
1867
|
+
const tokenPath = resolveUserPath(config.auth.tokenPath);
|
|
1868
|
+
checks.push({
|
|
1869
|
+
id: "google-oauth-token",
|
|
1870
|
+
ok: fs.existsSync(tokenPath),
|
|
1871
|
+
message: fs.existsSync(tokenPath) ? "Google OAuth token file found" : `Google OAuth token file missing at ${config.auth.tokenPath}`
|
|
1872
|
+
});
|
|
1873
|
+
} else checks.push({
|
|
1874
|
+
id: "google-oauth-token",
|
|
1875
|
+
ok: true,
|
|
1876
|
+
message: "Google OAuth token path not configured; Chrome profile auth will be used"
|
|
1877
|
+
});
|
|
1878
|
+
checks.push({
|
|
1879
|
+
id: "chrome-profile",
|
|
1880
|
+
ok: true,
|
|
1881
|
+
message: config.chrome.browserProfile ? "Local Chrome uses the OpenClaw browser profile; chrome.browserProfile is passed to chrome-node hosts" : "Local Chrome uses the OpenClaw browser profile; configure browser.defaultProfile to choose another profile"
|
|
1882
|
+
});
|
|
1883
|
+
if (needsChromeRealtimeAudio) checks.push({
|
|
1884
|
+
id: "audio-bridge",
|
|
1885
|
+
ok: Boolean(config.chrome.audioBridgeCommand || config.chrome.audioInputCommand && config.chrome.audioOutputCommand),
|
|
1886
|
+
message: config.chrome.audioBridgeCommand ? "Chrome audio bridge command configured" : config.chrome.audioInputCommand && config.chrome.audioOutputCommand ? `Chrome command-pair realtime audio bridge configured (${config.chrome.audioFormat})` : "Chrome realtime audio bridge not configured"
|
|
1887
|
+
});
|
|
1888
|
+
else if (transport === "chrome" || transport === "chrome-node") checks.push({
|
|
1889
|
+
id: "audio-bridge",
|
|
1890
|
+
ok: true,
|
|
1891
|
+
message: "Chrome observe-only mode does not require a realtime audio bridge"
|
|
1892
|
+
});
|
|
1893
|
+
checks.push({
|
|
1894
|
+
id: "guest-join-defaults",
|
|
1895
|
+
ok: Boolean(config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab),
|
|
1896
|
+
message: config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab ? "Guest auto-join and tab reuse defaults are enabled" : "Set chrome.guestName, chrome.autoJoin, and chrome.reuseExistingTab for unattended guest joins"
|
|
1897
|
+
});
|
|
1898
|
+
checks.push({
|
|
1899
|
+
id: "chrome-node-target",
|
|
1900
|
+
ok: config.defaultTransport !== "chrome-node" || Boolean(config.chromeNode.node),
|
|
1901
|
+
message: config.defaultTransport === "chrome-node" && !config.chromeNode.node ? "chrome-node default should pin chromeNode.node when multiple nodes may be connected" : config.chromeNode.node ? `Chrome node pinned to ${config.chromeNode.node}` : "Chrome node not pinned; automatic selection works when exactly one capable node is connected"
|
|
1902
|
+
});
|
|
1903
|
+
if (needsChromeRealtimeAudio) checks.push({
|
|
1904
|
+
id: "intro-after-in-call",
|
|
1905
|
+
ok: config.chrome.waitForInCallMs > 0,
|
|
1906
|
+
message: config.chrome.waitForInCallMs > 0 ? `Realtime intro waits up to ${config.chrome.waitForInCallMs}ms for the Meet tab to be in-call` : "Set chrome.waitForInCallMs to delay realtime intro until the Meet tab is in-call"
|
|
1907
|
+
});
|
|
1908
|
+
if (transport === "twilio") {
|
|
1909
|
+
const hasRequestDialPlan = Boolean(options?.twilioDialInNumber);
|
|
1910
|
+
const hasDefaultDialPlan = Boolean(config.twilio.defaultDialInNumber);
|
|
1911
|
+
const hasDialPlan = hasRequestDialPlan || hasDefaultDialPlan;
|
|
1912
|
+
checks.push({
|
|
1913
|
+
id: "twilio-dial-plan",
|
|
1914
|
+
ok: hasDialPlan,
|
|
1915
|
+
message: hasRequestDialPlan ? "Twilio request includes a Meet dial-in number" : hasDefaultDialPlan ? "Twilio default Meet dial-in number is configured" : "Twilio joins require a Meet dial-in phone number; pass dialInNumber with optional pin/dtmfSequence or configure twilio.defaultDialInNumber"
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
if (config.voiceCall.enabled && (transport === "twilio" || Boolean(config.twilio.defaultDialInNumber) || Object.hasOwn(pluginEntries, "voice-call"))) {
|
|
1919
|
+
const voiceCallAllowed = !Array.isArray(pluginAllow) || pluginAllow.includes("voice-call");
|
|
1920
|
+
const voiceCallEnabled = voiceCallEntry.enabled !== false;
|
|
1921
|
+
checks.push({
|
|
1922
|
+
id: "twilio-voice-call-plugin",
|
|
1923
|
+
ok: voiceCallAllowed && voiceCallEnabled,
|
|
1924
|
+
message: voiceCallAllowed && voiceCallEnabled ? "Twilio transport can delegate dialing to the voice-call plugin" : "Enable plugins.entries.voice-call and include voice-call in plugins.allow for Twilio dialing"
|
|
1925
|
+
});
|
|
1926
|
+
if ((normalizeOptionalString$1(voiceCallConfig.provider) ?? "twilio") === "twilio") {
|
|
1927
|
+
const accountSid = normalizeOptionalString$1(voiceCallTwilioConfig.accountSid);
|
|
1928
|
+
const authToken = normalizeOptionalString$1(voiceCallTwilioConfig.authToken);
|
|
1929
|
+
const fromNumber = normalizeOptionalString$1(voiceCallConfig.fromNumber);
|
|
1930
|
+
const twilioReady = Boolean((accountSid || env.TWILIO_ACCOUNT_SID) && (authToken || env.TWILIO_AUTH_TOKEN) && (fromNumber || env.TWILIO_FROM_NUMBER));
|
|
1931
|
+
checks.push({
|
|
1932
|
+
id: "twilio-voice-call-credentials",
|
|
1933
|
+
ok: twilioReady,
|
|
1934
|
+
message: twilioReady ? "Twilio voice-call credentials are configured" : "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials"
|
|
1935
|
+
});
|
|
1936
|
+
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return {
|
|
1940
|
+
ok: checks.every((check) => check.ok),
|
|
1941
|
+
checks
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
function addGoogleMeetSetupCheck(status, check) {
|
|
1945
|
+
const checks = [...status.checks, check];
|
|
1946
|
+
return {
|
|
1947
|
+
ok: checks.every((item) => item.ok),
|
|
1948
|
+
checks
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
function asRecord(value) {
|
|
1952
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1953
|
+
}
|
|
1954
|
+
function normalizeOptionalString$1(value) {
|
|
1955
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1956
|
+
}
|
|
1957
|
+
//#endregion
|
|
1958
|
+
//#region extensions/google-meet/src/transports/twilio.ts
|
|
1959
|
+
const DTMF_PATTERN = /^[0-9*#wWpP,]+$/;
|
|
1960
|
+
function normalizeDialInNumber(value) {
|
|
1961
|
+
const normalized = normalizeOptionalString(value);
|
|
1962
|
+
if (!normalized) return;
|
|
1963
|
+
const compact = normalized.replace(/[()\s.-]/g, "");
|
|
1964
|
+
if (!/^\+?[0-9]{5,20}$/.test(compact)) throw new Error("dialInNumber must be a phone number");
|
|
1965
|
+
return compact;
|
|
1966
|
+
}
|
|
1967
|
+
function normalizeDtmfSequence(value) {
|
|
1968
|
+
const normalized = normalizeOptionalString(value);
|
|
1969
|
+
if (!normalized) return;
|
|
1970
|
+
const compact = normalized.replace(/\s+/g, "");
|
|
1971
|
+
if (!DTMF_PATTERN.test(compact)) throw new Error("dtmfSequence may only contain digits, *, #, comma, w, p");
|
|
1972
|
+
return compact;
|
|
1973
|
+
}
|
|
1974
|
+
function buildMeetDtmfSequence(params) {
|
|
1975
|
+
const explicit = normalizeDtmfSequence(params.dtmfSequence);
|
|
1976
|
+
if (explicit) return explicit;
|
|
1977
|
+
const pin = normalizeOptionalString(params.pin);
|
|
1978
|
+
if (!pin) return;
|
|
1979
|
+
const compactPin = pin.replace(/\s+/g, "");
|
|
1980
|
+
if (!/^[0-9]+#?$/.test(compactPin)) throw new Error("pin may only contain digits and an optional trailing #");
|
|
1981
|
+
return compactPin.endsWith("#") ? compactPin : `${compactPin}#`;
|
|
1982
|
+
}
|
|
1983
|
+
//#endregion
|
|
1984
|
+
//#region extensions/google-meet/src/voice-call-gateway.ts
|
|
1985
|
+
function sleep$1(ms) {
|
|
1986
|
+
if (ms <= 0) return Promise.resolve();
|
|
1987
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1988
|
+
}
|
|
1989
|
+
async function createConnectedGatewayClient(config) {
|
|
1990
|
+
let client;
|
|
1991
|
+
await new Promise((resolve, reject) => {
|
|
1992
|
+
const abortStart = new AbortController();
|
|
1993
|
+
const timer = setTimeout(() => {
|
|
1994
|
+
abortStart.abort();
|
|
1995
|
+
reject(/* @__PURE__ */ new Error("gateway connect timeout"));
|
|
1996
|
+
}, config.voiceCall.requestTimeoutMs);
|
|
1997
|
+
client = new GatewayClient({
|
|
1998
|
+
url: config.voiceCall.gatewayUrl,
|
|
1999
|
+
token: config.voiceCall.token,
|
|
2000
|
+
requestTimeoutMs: config.voiceCall.requestTimeoutMs,
|
|
2001
|
+
clientName: "cli",
|
|
2002
|
+
clientDisplayName: "Google Meet plugin",
|
|
2003
|
+
scopes: ["operator.write"],
|
|
2004
|
+
onHelloOk: () => {
|
|
2005
|
+
clearTimeout(timer);
|
|
2006
|
+
resolve();
|
|
2007
|
+
},
|
|
2008
|
+
onConnectError: (err) => {
|
|
2009
|
+
clearTimeout(timer);
|
|
2010
|
+
abortStart.abort();
|
|
2011
|
+
reject(err);
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
2014
|
+
startGatewayClientWhenEventLoopReady(client, {
|
|
2015
|
+
timeoutMs: config.voiceCall.requestTimeoutMs,
|
|
2016
|
+
signal: abortStart.signal
|
|
2017
|
+
}).then((readiness) => {
|
|
2018
|
+
if (!readiness.ready && !readiness.aborted) {
|
|
2019
|
+
clearTimeout(timer);
|
|
2020
|
+
reject(/* @__PURE__ */ new Error("gateway event loop readiness timeout"));
|
|
2021
|
+
}
|
|
2022
|
+
}).catch((err) => {
|
|
2023
|
+
clearTimeout(timer);
|
|
2024
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
2027
|
+
return client;
|
|
2028
|
+
}
|
|
2029
|
+
async function joinMeetViaVoiceCallGateway(params) {
|
|
2030
|
+
let client;
|
|
2031
|
+
try {
|
|
2032
|
+
client = await createConnectedGatewayClient(params.config);
|
|
2033
|
+
params.logger?.info(`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "post-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`);
|
|
2034
|
+
const start = await client.request("voicecall.start", {
|
|
2035
|
+
to: params.dialInNumber,
|
|
2036
|
+
mode: "conversation"
|
|
2037
|
+
}, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
|
|
2038
|
+
if (!start.callId) throw new Error(start.error || "voicecall.start did not return callId");
|
|
2039
|
+
params.logger?.info(`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`);
|
|
2040
|
+
let dtmfSent = false;
|
|
2041
|
+
if (params.dtmfSequence) {
|
|
2042
|
+
const delayMs = params.config.voiceCall.dtmfDelayMs;
|
|
2043
|
+
params.logger?.info(`[google-meet] Waiting ${delayMs}ms before sending Meet DTMF for callId=${start.callId}`);
|
|
2044
|
+
await sleep$1(delayMs);
|
|
2045
|
+
const dtmf = await client.request("voicecall.dtmf", {
|
|
2046
|
+
callId: start.callId,
|
|
2047
|
+
digits: params.dtmfSequence
|
|
2048
|
+
}, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
|
|
2049
|
+
if (dtmf.success === false) throw new Error(dtmf.error || "voicecall.dtmf failed");
|
|
2050
|
+
dtmfSent = true;
|
|
2051
|
+
params.logger?.info(`[google-meet] Meet DTMF sent after phone leg connected: callId=${start.callId} digits=${params.dtmfSequence.length}`);
|
|
2052
|
+
}
|
|
2053
|
+
let introSent = false;
|
|
2054
|
+
if (params.message) {
|
|
2055
|
+
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
|
|
2056
|
+
if (delayMs > 0) {
|
|
2057
|
+
params.logger?.info(`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`);
|
|
2058
|
+
await sleep$1(delayMs);
|
|
2059
|
+
}
|
|
2060
|
+
const spoken = await client.request("voicecall.speak", {
|
|
2061
|
+
callId: start.callId,
|
|
2062
|
+
message: params.message
|
|
2063
|
+
}, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
|
|
2064
|
+
if (spoken.success === false) throw new Error(spoken.error || "voicecall.speak failed");
|
|
2065
|
+
introSent = true;
|
|
2066
|
+
params.logger?.info(`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`);
|
|
2067
|
+
}
|
|
2068
|
+
return {
|
|
2069
|
+
callId: start.callId,
|
|
2070
|
+
dtmfSent,
|
|
2071
|
+
introSent
|
|
2072
|
+
};
|
|
2073
|
+
} finally {
|
|
2074
|
+
await client?.stopAndWait({ timeoutMs: 1e3 });
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
async function endMeetVoiceCallGatewayCall(params) {
|
|
2078
|
+
let client;
|
|
2079
|
+
try {
|
|
2080
|
+
client = await createConnectedGatewayClient(params.config);
|
|
2081
|
+
await client.request("voicecall.end", { callId: params.callId }, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
|
|
2082
|
+
} finally {
|
|
2083
|
+
await client?.stopAndWait({ timeoutMs: 1e3 });
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
async function speakMeetViaVoiceCallGateway(params) {
|
|
2087
|
+
let client;
|
|
2088
|
+
try {
|
|
2089
|
+
client = await createConnectedGatewayClient(params.config);
|
|
2090
|
+
const spoken = await client.request("voicecall.speak", {
|
|
2091
|
+
callId: params.callId,
|
|
2092
|
+
message: params.message
|
|
2093
|
+
}, { timeoutMs: params.config.voiceCall.requestTimeoutMs });
|
|
2094
|
+
if (spoken.success === false) throw new Error(spoken.error || "voicecall.speak failed");
|
|
2095
|
+
} finally {
|
|
2096
|
+
await client?.stopAndWait({ timeoutMs: 1e3 });
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
//#endregion
|
|
2100
|
+
//#region extensions/google-meet/src/runtime.ts
|
|
2101
|
+
function nowIso() {
|
|
2102
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
2103
|
+
}
|
|
2104
|
+
function normalizeMeetUrl(input) {
|
|
2105
|
+
const raw = normalizeOptionalString(input);
|
|
2106
|
+
if (!raw) throw new Error("url required");
|
|
2107
|
+
let url;
|
|
2108
|
+
try {
|
|
2109
|
+
url = new URL(raw);
|
|
2110
|
+
} catch {
|
|
2111
|
+
throw new Error("url must be a valid Google Meet URL");
|
|
2112
|
+
}
|
|
2113
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "meet.google.com") throw new Error("url must be an explicit https://meet.google.com/... URL");
|
|
2114
|
+
if (!/^\/[a-z]{3}-[a-z]{4}-[a-z]{3}(?:$|[/?#])/i.test(url.pathname)) throw new Error("url must include a Google Meet meeting code");
|
|
2115
|
+
return url.toString();
|
|
2116
|
+
}
|
|
2117
|
+
function resolveTransport(input, config) {
|
|
2118
|
+
return input ?? config.defaultTransport;
|
|
2119
|
+
}
|
|
2120
|
+
function resolveMode(input, config) {
|
|
2121
|
+
return input ?? config.defaultMode;
|
|
2122
|
+
}
|
|
2123
|
+
function hasRealtimeAudioOutputAdvanced(health, startOutputBytes) {
|
|
2124
|
+
return (health?.lastOutputBytes ?? 0) > startOutputBytes;
|
|
2125
|
+
}
|
|
2126
|
+
function transcriptCheckpoint(health) {
|
|
2127
|
+
return {
|
|
2128
|
+
lines: health?.transcriptLines ?? 0,
|
|
2129
|
+
lastCaptionAt: health?.lastCaptionAt,
|
|
2130
|
+
lastCaptionText: health?.lastCaptionText
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function hasTranscriptAdvanced(health, start) {
|
|
2134
|
+
if ((health?.transcriptLines ?? 0) > start.lines) return true;
|
|
2135
|
+
if (health?.lastCaptionAt && health.lastCaptionAt !== start.lastCaptionAt) return true;
|
|
2136
|
+
return Boolean(health?.lastCaptionText && health.lastCaptionText !== start.lastCaptionText);
|
|
2137
|
+
}
|
|
2138
|
+
function resolveProbeTimeoutMs(input, fallback) {
|
|
2139
|
+
if (input === void 0) return Math.min(Math.max(fallback, 1), 12e4);
|
|
2140
|
+
if (!Number.isFinite(input) || input <= 0) throw new Error("timeoutMs must be a positive number");
|
|
2141
|
+
return Math.min(Math.trunc(input), 12e4);
|
|
2142
|
+
}
|
|
2143
|
+
function sleep(ms) {
|
|
2144
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2145
|
+
}
|
|
2146
|
+
function isManagedChromeBrowserSession(session) {
|
|
2147
|
+
return Boolean((session.transport === "chrome" || session.transport === "chrome-node") && session.chrome && session.chrome.launched);
|
|
2148
|
+
}
|
|
2149
|
+
function evaluateSpeechReadiness(session) {
|
|
2150
|
+
if (session.mode !== "realtime" || !session.chrome) return { ready: true };
|
|
2151
|
+
if (!isManagedChromeBrowserSession(session)) {
|
|
2152
|
+
if (session.chrome.audioBridge) return { ready: true };
|
|
2153
|
+
return {
|
|
2154
|
+
ready: false,
|
|
2155
|
+
reason: "audio-bridge-unavailable",
|
|
2156
|
+
message: "Realtime speech requires an active Chrome audio bridge."
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
const health = session.chrome.health;
|
|
2160
|
+
if (health?.manualActionRequired) return {
|
|
2161
|
+
ready: false,
|
|
2162
|
+
reason: health.manualActionReason ?? "browser-unverified",
|
|
2163
|
+
message: health.manualActionMessage ?? "Resolve the Google Meet browser prompt before asking OpenClaw to speak."
|
|
2164
|
+
};
|
|
2165
|
+
if (health?.inCall === true) {
|
|
2166
|
+
if (session.chrome.audioBridge) return { ready: true };
|
|
2167
|
+
return {
|
|
2168
|
+
ready: false,
|
|
2169
|
+
reason: "audio-bridge-unavailable",
|
|
2170
|
+
message: "Realtime speech requires an active Chrome audio bridge."
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
if (health?.inCall === false) return {
|
|
2174
|
+
ready: false,
|
|
2175
|
+
reason: "not-in-call",
|
|
2176
|
+
message: "Google Meet has not reported that the browser participant is in the call."
|
|
2177
|
+
};
|
|
2178
|
+
return {
|
|
2179
|
+
ready: false,
|
|
2180
|
+
reason: "browser-unverified",
|
|
2181
|
+
message: "Google Meet browser state has not been verified yet."
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
function collectChromeAudioCommands(config) {
|
|
2185
|
+
const commands = config.chrome.audioBridgeCommand ? [config.chrome.audioBridgeCommand[0]] : [
|
|
2186
|
+
config.chrome.audioInputCommand?.[0],
|
|
2187
|
+
config.chrome.audioOutputCommand?.[0],
|
|
2188
|
+
config.chrome.bargeInInputCommand?.[0]
|
|
2189
|
+
];
|
|
2190
|
+
return [...new Set(commands.filter((value) => Boolean(value?.trim())))];
|
|
2191
|
+
}
|
|
2192
|
+
async function commandExists(runtime, command) {
|
|
2193
|
+
return (await runtime.system.runCommandWithTimeout([
|
|
2194
|
+
"/bin/sh",
|
|
2195
|
+
"-lc",
|
|
2196
|
+
"command -v \"$1\" >/dev/null 2>&1",
|
|
2197
|
+
"sh",
|
|
2198
|
+
command
|
|
2199
|
+
], { timeoutMs: 5e3 })).code === 0;
|
|
2200
|
+
}
|
|
2201
|
+
var GoogleMeetRuntime = class {
|
|
2202
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
2203
|
+
#sessionStops = /* @__PURE__ */ new Map();
|
|
2204
|
+
#sessionSpeakers = /* @__PURE__ */ new Map();
|
|
2205
|
+
#sessionHealth = /* @__PURE__ */ new Map();
|
|
2206
|
+
constructor(params) {
|
|
2207
|
+
this.params = params;
|
|
2208
|
+
}
|
|
2209
|
+
list() {
|
|
2210
|
+
this.#refreshHealth();
|
|
2211
|
+
return [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
2212
|
+
}
|
|
2213
|
+
async status(sessionId) {
|
|
2214
|
+
this.#refreshHealth(sessionId);
|
|
2215
|
+
if (!sessionId) {
|
|
2216
|
+
const sessions = [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
2217
|
+
await Promise.all(sessions.map((session) => this.#refreshCaptionHealthForSession(session)));
|
|
2218
|
+
return {
|
|
2219
|
+
found: true,
|
|
2220
|
+
sessions
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
const session = this.#sessions.get(sessionId);
|
|
2224
|
+
if (session) await this.#refreshCaptionHealthForSession(session);
|
|
2225
|
+
return session ? {
|
|
2226
|
+
found: true,
|
|
2227
|
+
session
|
|
2228
|
+
} : { found: false };
|
|
2229
|
+
}
|
|
2230
|
+
async setupStatus(options = {}) {
|
|
2231
|
+
const transport = resolveTransport(options.transport, this.params.config);
|
|
2232
|
+
const mode = resolveMode(options.mode, this.params.config);
|
|
2233
|
+
const twilioDialInNumber = transport === "twilio" ? normalizeDialInNumber(options.dialInNumber) : void 0;
|
|
2234
|
+
const shouldCheckChromeNode = transport === "chrome-node" || !options.transport && Boolean(this.params.config.chromeNode.node);
|
|
2235
|
+
let status = getGoogleMeetSetupStatus(this.params.config, {
|
|
2236
|
+
fullConfig: this.params.fullConfig,
|
|
2237
|
+
mode,
|
|
2238
|
+
transport,
|
|
2239
|
+
twilioDialInNumber
|
|
2240
|
+
});
|
|
2241
|
+
if (shouldCheckChromeNode) try {
|
|
2242
|
+
const node = await resolveChromeNodeInfo({
|
|
2243
|
+
runtime: this.params.runtime,
|
|
2244
|
+
requestedNode: this.params.config.chromeNode.node
|
|
2245
|
+
});
|
|
2246
|
+
const label = node.displayName ?? node.remoteIp ?? node.nodeId ?? "connected node";
|
|
2247
|
+
status = addGoogleMeetSetupCheck(status, {
|
|
2248
|
+
id: "chrome-node-connected",
|
|
2249
|
+
ok: true,
|
|
2250
|
+
message: `Connected Google Meet node ready: ${label}`
|
|
2251
|
+
});
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
status = addGoogleMeetSetupCheck(status, {
|
|
2254
|
+
id: "chrome-node-connected",
|
|
2255
|
+
ok: false,
|
|
2256
|
+
message: formatErrorMessage(error)
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
if (transport === "chrome" && mode === "realtime") {
|
|
2260
|
+
try {
|
|
2261
|
+
await assertBlackHole2chAvailable({
|
|
2262
|
+
runtime: this.params.runtime,
|
|
2263
|
+
timeoutMs: Math.min(this.params.config.chrome.joinTimeoutMs, 1e4)
|
|
2264
|
+
});
|
|
2265
|
+
status = addGoogleMeetSetupCheck(status, {
|
|
2266
|
+
id: "chrome-local-audio-device",
|
|
2267
|
+
ok: true,
|
|
2268
|
+
message: "BlackHole 2ch audio device found"
|
|
2269
|
+
});
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
status = addGoogleMeetSetupCheck(status, {
|
|
2272
|
+
id: "chrome-local-audio-device",
|
|
2273
|
+
ok: false,
|
|
2274
|
+
message: formatErrorMessage(error)
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
const commands = collectChromeAudioCommands(this.params.config);
|
|
2278
|
+
const missingCommands = [];
|
|
2279
|
+
for (const command of commands) try {
|
|
2280
|
+
if (!await commandExists(this.params.runtime, command)) missingCommands.push(command);
|
|
2281
|
+
} catch {
|
|
2282
|
+
missingCommands.push(command);
|
|
2283
|
+
}
|
|
2284
|
+
status = addGoogleMeetSetupCheck(status, {
|
|
2285
|
+
id: "chrome-local-audio-commands",
|
|
2286
|
+
ok: commands.length > 0 && missingCommands.length === 0,
|
|
2287
|
+
message: commands.length === 0 ? "Chrome realtime audio commands are not configured" : missingCommands.length === 0 ? `Chrome audio command${commands.length === 1 ? "" : "s"} available: ${commands.join(", ")}` : `Chrome audio command${missingCommands.length === 1 ? "" : "s"} missing: ${missingCommands.join(", ")}`
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
return status;
|
|
2291
|
+
}
|
|
2292
|
+
async createViaBrowser() {
|
|
2293
|
+
return createMeetWithBrowserProxyOnNode({
|
|
2294
|
+
runtime: this.params.runtime,
|
|
2295
|
+
config: this.params.config
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
async recoverCurrentTab(request = {}) {
|
|
2299
|
+
const transport = resolveTransport(request.transport, this.params.config);
|
|
2300
|
+
if (transport === "twilio") throw new Error("recover_current_tab only supports chrome or chrome-node transports");
|
|
2301
|
+
const url = request.url ? normalizeMeetUrl(request.url) : void 0;
|
|
2302
|
+
if (transport === "chrome-node") return recoverCurrentMeetTabOnNode({
|
|
2303
|
+
runtime: this.params.runtime,
|
|
2304
|
+
config: this.params.config,
|
|
2305
|
+
url
|
|
2306
|
+
});
|
|
2307
|
+
return recoverCurrentMeetTab({
|
|
2308
|
+
config: this.params.config,
|
|
2309
|
+
url
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
async join(request) {
|
|
2313
|
+
const url = normalizeMeetUrl(request.url);
|
|
2314
|
+
const transport = resolveTransport(request.transport, this.params.config);
|
|
2315
|
+
const mode = resolveMode(request.mode, this.params.config);
|
|
2316
|
+
const reusable = this.list().find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === mode);
|
|
2317
|
+
const speechInstructions = request.message ?? this.params.config.realtime.introMessage;
|
|
2318
|
+
if (reusable) {
|
|
2319
|
+
await this.#refreshBrowserHealthForChromeSession(reusable);
|
|
2320
|
+
reusable.notes = [...reusable.notes.filter((note) => note !== "Reused existing active Meet session."), "Reused existing active Meet session."];
|
|
2321
|
+
reusable.updatedAt = nowIso();
|
|
2322
|
+
return {
|
|
2323
|
+
session: reusable,
|
|
2324
|
+
spoken: mode === "realtime" && speechInstructions ? (await this.speak(reusable.id, speechInstructions)).spoken : false
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
const createdAt = nowIso();
|
|
2328
|
+
let delegatedTwilioSpoken = false;
|
|
2329
|
+
const session = {
|
|
2330
|
+
id: `meet_${randomUUID()}`,
|
|
2331
|
+
url,
|
|
2332
|
+
transport,
|
|
2333
|
+
mode,
|
|
2334
|
+
state: "active",
|
|
2335
|
+
createdAt,
|
|
2336
|
+
updatedAt: createdAt,
|
|
2337
|
+
participantIdentity: transport === "twilio" ? "Twilio phone participant" : transport === "chrome-node" ? "signed-in Google Chrome profile on a paired node" : "signed-in Google Chrome profile",
|
|
2338
|
+
realtime: {
|
|
2339
|
+
enabled: mode === "realtime",
|
|
2340
|
+
provider: this.params.config.realtime.provider,
|
|
2341
|
+
model: this.params.config.realtime.model,
|
|
2342
|
+
toolPolicy: this.params.config.realtime.toolPolicy
|
|
2343
|
+
},
|
|
2344
|
+
notes: []
|
|
2345
|
+
};
|
|
2346
|
+
try {
|
|
2347
|
+
if (transport === "chrome" || transport === "chrome-node") {
|
|
2348
|
+
const result = transport === "chrome-node" ? await launchChromeMeetOnNode({
|
|
2349
|
+
runtime: this.params.runtime,
|
|
2350
|
+
config: this.params.config,
|
|
2351
|
+
fullConfig: this.params.fullConfig,
|
|
2352
|
+
meetingSessionId: session.id,
|
|
2353
|
+
mode,
|
|
2354
|
+
url,
|
|
2355
|
+
logger: this.params.logger
|
|
2356
|
+
}) : await launchChromeMeet({
|
|
2357
|
+
runtime: this.params.runtime,
|
|
2358
|
+
config: this.params.config,
|
|
2359
|
+
fullConfig: this.params.fullConfig,
|
|
2360
|
+
meetingSessionId: session.id,
|
|
2361
|
+
mode,
|
|
2362
|
+
url,
|
|
2363
|
+
logger: this.params.logger
|
|
2364
|
+
});
|
|
2365
|
+
session.chrome = {
|
|
2366
|
+
audioBackend: this.params.config.chrome.audioBackend,
|
|
2367
|
+
launched: result.launched,
|
|
2368
|
+
nodeId: "nodeId" in result ? result.nodeId : void 0,
|
|
2369
|
+
browserProfile: this.params.config.chrome.browserProfile,
|
|
2370
|
+
audioBridge: result.audioBridge ? {
|
|
2371
|
+
type: result.audioBridge.type,
|
|
2372
|
+
provider: result.audioBridge.type === "command-pair" || result.audioBridge.type === "node-command-pair" ? result.audioBridge.providerId : void 0
|
|
2373
|
+
} : void 0,
|
|
2374
|
+
health: "browser" in result ? result.browser : void 0
|
|
2375
|
+
};
|
|
2376
|
+
if (result.audioBridge?.type === "command-pair" || result.audioBridge?.type === "node-command-pair") {
|
|
2377
|
+
this.#sessionStops.set(session.id, result.audioBridge.stop);
|
|
2378
|
+
this.#sessionSpeakers.set(session.id, result.audioBridge.speak);
|
|
2379
|
+
this.#sessionHealth.set(session.id, result.audioBridge.getHealth);
|
|
2380
|
+
}
|
|
2381
|
+
session.notes.push(result.audioBridge ? transport === "chrome-node" ? "Chrome node transport joins as the signed-in Google profile on the selected node and routes realtime audio through the node bridge." : "Chrome transport joins as the signed-in Google profile and routes realtime audio through the configured bridge." : mode === "realtime" ? "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing." : "Chrome transport joins as the signed-in Google profile without starting the realtime audio bridge.");
|
|
2382
|
+
this.#refreshSpeechReadiness(session);
|
|
2383
|
+
} else {
|
|
2384
|
+
const dialInNumber = normalizeDialInNumber(request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber);
|
|
2385
|
+
if (!dialInNumber) throw new Error("Twilio transport requires a Meet dial-in phone number. Google Meet URLs do not include dial-in details; pass dialInNumber with optional pin/dtmfSequence, configure twilio.defaultDialInNumber, or use chrome/chrome-node transport.");
|
|
2386
|
+
const dtmfSequence = buildMeetDtmfSequence({
|
|
2387
|
+
pin: request.pin ?? this.params.config.twilio.defaultPin,
|
|
2388
|
+
dtmfSequence: request.dtmfSequence ?? this.params.config.twilio.defaultDtmfSequence
|
|
2389
|
+
});
|
|
2390
|
+
const voiceCallResult = this.params.config.voiceCall.enabled ? await joinMeetViaVoiceCallGateway({
|
|
2391
|
+
config: this.params.config,
|
|
2392
|
+
dialInNumber,
|
|
2393
|
+
dtmfSequence,
|
|
2394
|
+
logger: this.params.logger,
|
|
2395
|
+
message: mode === "realtime" ? request.message ?? this.params.config.voiceCall.introMessage ?? this.params.config.realtime.introMessage : void 0
|
|
2396
|
+
}) : void 0;
|
|
2397
|
+
delegatedTwilioSpoken = Boolean(voiceCallResult?.introSent);
|
|
2398
|
+
session.twilio = {
|
|
2399
|
+
dialInNumber,
|
|
2400
|
+
pinProvided: Boolean(request.pin ?? this.params.config.twilio.defaultPin),
|
|
2401
|
+
dtmfSequence,
|
|
2402
|
+
voiceCallId: voiceCallResult?.callId,
|
|
2403
|
+
dtmfSent: voiceCallResult?.dtmfSent,
|
|
2404
|
+
introSent: voiceCallResult?.introSent
|
|
2405
|
+
};
|
|
2406
|
+
if (voiceCallResult?.callId) this.#sessionStops.set(session.id, async () => {
|
|
2407
|
+
await endMeetVoiceCallGatewayCall({
|
|
2408
|
+
config: this.params.config,
|
|
2409
|
+
callId: voiceCallResult.callId
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
session.notes.push(this.params.config.voiceCall.enabled ? dtmfSequence ? "Twilio transport delegated the phone leg to the voice-call plugin, then sent configured DTMF after connect before speaking." : "Twilio transport delegated the call to the voice-call plugin without configured DTMF." : "Twilio transport is an explicit dial plan; voice-call delegation is disabled.");
|
|
2413
|
+
}
|
|
2414
|
+
} catch (err) {
|
|
2415
|
+
this.params.logger.warn(`[google-meet] join failed: ${formatErrorMessage(err)}`);
|
|
2416
|
+
throw err;
|
|
2417
|
+
}
|
|
2418
|
+
this.#sessions.set(session.id, session);
|
|
2419
|
+
return {
|
|
2420
|
+
session,
|
|
2421
|
+
spoken: transport === "twilio" ? delegatedTwilioSpoken : mode === "realtime" && speechInstructions ? (await this.speak(session.id, speechInstructions)).spoken : false
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
async leave(sessionId) {
|
|
2425
|
+
const session = this.#sessions.get(sessionId);
|
|
2426
|
+
if (!session) return { found: false };
|
|
2427
|
+
const stop = this.#sessionStops.get(sessionId);
|
|
2428
|
+
if (stop) {
|
|
2429
|
+
this.#sessionStops.delete(sessionId);
|
|
2430
|
+
this.#sessionSpeakers.delete(sessionId);
|
|
2431
|
+
this.#sessionHealth.delete(sessionId);
|
|
2432
|
+
await stop();
|
|
2433
|
+
}
|
|
2434
|
+
session.state = "ended";
|
|
2435
|
+
session.updatedAt = nowIso();
|
|
2436
|
+
return {
|
|
2437
|
+
found: true,
|
|
2438
|
+
session
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
async speak(sessionId, instructions) {
|
|
2442
|
+
const session = this.#sessions.get(sessionId);
|
|
2443
|
+
if (!session) return {
|
|
2444
|
+
found: false,
|
|
2445
|
+
spoken: false
|
|
2446
|
+
};
|
|
2447
|
+
if (session.transport === "twilio" && session.twilio?.voiceCallId) {
|
|
2448
|
+
await speakMeetViaVoiceCallGateway({
|
|
2449
|
+
config: this.params.config,
|
|
2450
|
+
callId: session.twilio.voiceCallId,
|
|
2451
|
+
message: instructions || this.params.config.voiceCall.introMessage || this.params.config.realtime.introMessage || ""
|
|
2452
|
+
});
|
|
2453
|
+
session.twilio.introSent = true;
|
|
2454
|
+
session.updatedAt = nowIso();
|
|
2455
|
+
return {
|
|
2456
|
+
found: true,
|
|
2457
|
+
spoken: true,
|
|
2458
|
+
session
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
await this.#refreshBrowserHealthForChromeSession(session);
|
|
2462
|
+
const speak = this.#sessionSpeakers.get(sessionId);
|
|
2463
|
+
if (!speak || session.state !== "active") return {
|
|
2464
|
+
found: true,
|
|
2465
|
+
spoken: false,
|
|
2466
|
+
session
|
|
2467
|
+
};
|
|
2468
|
+
const readiness = this.#refreshSpeechReadiness(session);
|
|
2469
|
+
if (!readiness.ready) {
|
|
2470
|
+
const note = readiness.message ? `Realtime speech blocked: ${readiness.message}` : "Realtime speech blocked until Google Meet is ready.";
|
|
2471
|
+
session.notes = [...session.notes.filter((item) => item !== note), note];
|
|
2472
|
+
session.updatedAt = nowIso();
|
|
2473
|
+
return {
|
|
2474
|
+
found: true,
|
|
2475
|
+
spoken: false,
|
|
2476
|
+
session
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
speak(instructions || this.params.config.realtime.introMessage);
|
|
2480
|
+
session.updatedAt = nowIso();
|
|
2481
|
+
this.#refreshHealth(sessionId);
|
|
2482
|
+
return {
|
|
2483
|
+
found: true,
|
|
2484
|
+
spoken: true,
|
|
2485
|
+
session
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
async testSpeech(request) {
|
|
2489
|
+
if (request.mode === "transcribe") throw new Error("test_speech requires mode: realtime; use join mode: transcribe for observe-only sessions.");
|
|
2490
|
+
const url = normalizeMeetUrl(request.url);
|
|
2491
|
+
const transport = resolveTransport(request.transport, this.params.config);
|
|
2492
|
+
const beforeSessions = this.list();
|
|
2493
|
+
const before = new Set(beforeSessions.map((session) => session.id));
|
|
2494
|
+
const startOutputBytes = beforeSessions.find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === "realtime")?.chrome?.health?.lastOutputBytes ?? 0;
|
|
2495
|
+
const result = await this.join({
|
|
2496
|
+
...request,
|
|
2497
|
+
transport,
|
|
2498
|
+
url,
|
|
2499
|
+
mode: "realtime",
|
|
2500
|
+
message: request.message ?? "Say exactly: Google Meet speech test complete."
|
|
2501
|
+
});
|
|
2502
|
+
let health = result.session.chrome?.health;
|
|
2503
|
+
const shouldWaitForOutput = result.spoken === true && health?.manualActionRequired !== true && this.#sessionHealth.has(result.session.id);
|
|
2504
|
+
if (shouldWaitForOutput && !hasRealtimeAudioOutputAdvanced(health, startOutputBytes)) {
|
|
2505
|
+
const deadline = Date.now() + Math.min(this.params.config.chrome.joinTimeoutMs, 5e3);
|
|
2506
|
+
while (Date.now() < deadline) {
|
|
2507
|
+
await sleep(100);
|
|
2508
|
+
this.#refreshHealth(result.session.id);
|
|
2509
|
+
health = result.session.chrome?.health;
|
|
2510
|
+
if (hasRealtimeAudioOutputAdvanced(health, startOutputBytes)) break;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const speechOutputVerified = hasRealtimeAudioOutputAdvanced(health, startOutputBytes);
|
|
2514
|
+
return {
|
|
2515
|
+
createdSession: !before.has(result.session.id),
|
|
2516
|
+
inCall: health?.inCall,
|
|
2517
|
+
manualActionRequired: health?.manualActionRequired,
|
|
2518
|
+
manualActionReason: health?.manualActionReason,
|
|
2519
|
+
manualActionMessage: health?.manualActionMessage,
|
|
2520
|
+
spoken: result.spoken ?? false,
|
|
2521
|
+
speechOutputVerified,
|
|
2522
|
+
speechOutputTimedOut: shouldWaitForOutput && !speechOutputVerified,
|
|
2523
|
+
speechReady: health?.speechReady,
|
|
2524
|
+
speechBlockedReason: health?.speechBlockedReason,
|
|
2525
|
+
speechBlockedMessage: health?.speechBlockedMessage,
|
|
2526
|
+
audioOutputActive: health?.audioOutputActive,
|
|
2527
|
+
lastOutputBytes: health?.lastOutputBytes,
|
|
2528
|
+
session: result.session
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
async testListen(request) {
|
|
2532
|
+
if (request.mode === "realtime") throw new Error("test_listen requires mode: transcribe; use test_speech for realtime talk-back.");
|
|
2533
|
+
const url = normalizeMeetUrl(request.url);
|
|
2534
|
+
const transport = resolveTransport(request.transport, this.params.config);
|
|
2535
|
+
if (transport === "twilio") throw new Error("test_listen supports chrome or chrome-node transports");
|
|
2536
|
+
const beforeSessions = this.list();
|
|
2537
|
+
const before = new Set(beforeSessions.map((session) => session.id));
|
|
2538
|
+
const start = transcriptCheckpoint(beforeSessions.find((session) => session.state === "active" && isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === "transcribe")?.chrome?.health);
|
|
2539
|
+
const result = await this.join({
|
|
2540
|
+
...request,
|
|
2541
|
+
transport,
|
|
2542
|
+
url,
|
|
2543
|
+
mode: "transcribe",
|
|
2544
|
+
message: void 0
|
|
2545
|
+
});
|
|
2546
|
+
let health = result.session.chrome?.health;
|
|
2547
|
+
const timeoutMs = resolveProbeTimeoutMs(request.timeoutMs, this.params.config.chrome.joinTimeoutMs);
|
|
2548
|
+
const shouldWait = health?.manualActionRequired !== true && isManagedChromeBrowserSession(result.session);
|
|
2549
|
+
if (shouldWait && !hasTranscriptAdvanced(health, start)) {
|
|
2550
|
+
const deadline = Date.now() + timeoutMs;
|
|
2551
|
+
while (Date.now() < deadline) {
|
|
2552
|
+
await sleep(250);
|
|
2553
|
+
await this.#refreshCaptionHealthForSession(result.session);
|
|
2554
|
+
health = result.session.chrome?.health;
|
|
2555
|
+
if (health?.manualActionRequired || hasTranscriptAdvanced(health, start)) break;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
const listenVerified = hasTranscriptAdvanced(health, start);
|
|
2559
|
+
return {
|
|
2560
|
+
createdSession: !before.has(result.session.id),
|
|
2561
|
+
inCall: health?.inCall,
|
|
2562
|
+
manualActionRequired: health?.manualActionRequired,
|
|
2563
|
+
manualActionReason: health?.manualActionReason,
|
|
2564
|
+
manualActionMessage: health?.manualActionMessage,
|
|
2565
|
+
listenVerified,
|
|
2566
|
+
listenTimedOut: shouldWait && !listenVerified && health?.manualActionRequired !== true,
|
|
2567
|
+
captioning: health?.captioning,
|
|
2568
|
+
captionsEnabledAttempted: health?.captionsEnabledAttempted,
|
|
2569
|
+
transcriptLines: health?.transcriptLines,
|
|
2570
|
+
lastCaptionAt: health?.lastCaptionAt,
|
|
2571
|
+
lastCaptionSpeaker: health?.lastCaptionSpeaker,
|
|
2572
|
+
lastCaptionText: health?.lastCaptionText,
|
|
2573
|
+
recentTranscript: health?.recentTranscript,
|
|
2574
|
+
session: result.session
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
async #refreshCaptionHealthForSession(session) {
|
|
2578
|
+
if (session.mode !== "transcribe") {
|
|
2579
|
+
this.#refreshSpeechReadiness(session);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
await this.#refreshBrowserHealthForChromeSession(session);
|
|
2583
|
+
}
|
|
2584
|
+
async #refreshBrowserHealthForChromeSession(session) {
|
|
2585
|
+
if (!isManagedChromeBrowserSession(session)) {
|
|
2586
|
+
this.#refreshSpeechReadiness(session);
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
if (session.mode === "realtime" && evaluateSpeechReadiness(session).ready) {
|
|
2590
|
+
this.#refreshSpeechReadiness(session);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
try {
|
|
2594
|
+
const result = session.transport === "chrome-node" ? await recoverCurrentMeetTabOnNode({
|
|
2595
|
+
runtime: this.params.runtime,
|
|
2596
|
+
config: this.params.config,
|
|
2597
|
+
mode: session.mode,
|
|
2598
|
+
url: session.url
|
|
2599
|
+
}) : await recoverCurrentMeetTab({
|
|
2600
|
+
config: this.params.config,
|
|
2601
|
+
mode: session.mode,
|
|
2602
|
+
url: session.url
|
|
2603
|
+
});
|
|
2604
|
+
if (result.found && result.browser && session.chrome) {
|
|
2605
|
+
session.chrome.health = {
|
|
2606
|
+
...session.chrome.health,
|
|
2607
|
+
...result.browser
|
|
2608
|
+
};
|
|
2609
|
+
session.updatedAt = nowIso();
|
|
2610
|
+
}
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
this.params.logger.debug?.(`[google-meet] browser readiness refresh ignored: ${formatErrorMessage(error)}`);
|
|
2613
|
+
}
|
|
2614
|
+
this.#refreshSpeechReadiness(session);
|
|
2615
|
+
}
|
|
2616
|
+
#refreshSpeechReadiness(session) {
|
|
2617
|
+
const readiness = evaluateSpeechReadiness(session);
|
|
2618
|
+
if (session.chrome) session.chrome.health = {
|
|
2619
|
+
...session.chrome.health,
|
|
2620
|
+
speechReady: readiness.ready,
|
|
2621
|
+
speechBlockedReason: readiness.reason,
|
|
2622
|
+
speechBlockedMessage: readiness.message
|
|
2623
|
+
};
|
|
2624
|
+
return readiness;
|
|
2625
|
+
}
|
|
2626
|
+
#refreshHealth(sessionId) {
|
|
2627
|
+
const ids = sessionId ? [sessionId] : [...this.#sessionHealth.keys()];
|
|
2628
|
+
for (const id of ids) {
|
|
2629
|
+
const session = this.#sessions.get(id);
|
|
2630
|
+
const getHealth = this.#sessionHealth.get(id);
|
|
2631
|
+
if (!session?.chrome || !getHealth) continue;
|
|
2632
|
+
session.chrome.health = {
|
|
2633
|
+
...session.chrome.health,
|
|
2634
|
+
...getHealth()
|
|
2635
|
+
};
|
|
2636
|
+
this.#refreshSpeechReadiness(session);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
};
|
|
2640
|
+
//#endregion
|
|
2641
|
+
//#region extensions/google-meet/index.ts
|
|
2642
|
+
const googleMeetConfigSchema = {
|
|
2643
|
+
parse(value) {
|
|
2644
|
+
return resolveGoogleMeetConfig(value);
|
|
2645
|
+
},
|
|
2646
|
+
uiHints: {
|
|
2647
|
+
"defaults.meeting": {
|
|
2648
|
+
label: "Default Meeting",
|
|
2649
|
+
help: "Meet URL, meeting code, or spaces/{id} used when CLI commands omit a meeting."
|
|
2650
|
+
},
|
|
2651
|
+
"preview.enrollmentAcknowledged": {
|
|
2652
|
+
label: "Preview Acknowledged",
|
|
2653
|
+
help: "Confirms you understand the Google Meet Media API is still Developer Preview.",
|
|
2654
|
+
advanced: true
|
|
2655
|
+
},
|
|
2656
|
+
defaultTransport: {
|
|
2657
|
+
label: "Default Transport",
|
|
2658
|
+
help: "Chrome uses a signed-in browser profile. Chrome-node runs Chrome on a paired node. Twilio uses Meet dial-in numbers."
|
|
2659
|
+
},
|
|
2660
|
+
defaultMode: {
|
|
2661
|
+
label: "Default Mode",
|
|
2662
|
+
help: "Realtime starts the duplex voice model loop. Transcribe joins/observes without the realtime talk-back bridge."
|
|
2663
|
+
},
|
|
2664
|
+
"chrome.audioBackend": {
|
|
2665
|
+
label: "Chrome Audio Backend",
|
|
2666
|
+
help: "BlackHole 2ch is required for local duplex audio routing."
|
|
2667
|
+
},
|
|
2668
|
+
"chrome.launch": { label: "Launch Chrome" },
|
|
2669
|
+
"chrome.browserProfile": {
|
|
2670
|
+
label: "Chrome Profile",
|
|
2671
|
+
advanced: true
|
|
2672
|
+
},
|
|
2673
|
+
"chrome.guestName": {
|
|
2674
|
+
label: "Guest Name",
|
|
2675
|
+
help: "Used when Chrome lands on the signed-out Meet guest-name screen."
|
|
2676
|
+
},
|
|
2677
|
+
"chrome.reuseExistingTab": {
|
|
2678
|
+
label: "Reuse Existing Meet Tab",
|
|
2679
|
+
help: "Avoids opening duplicate tabs for the same Meet URL."
|
|
2680
|
+
},
|
|
2681
|
+
"chrome.autoJoin": {
|
|
2682
|
+
label: "Auto Join Guest Screen",
|
|
2683
|
+
help: "Best-effort guest-name fill and Join Now click through OpenClaw browser automation."
|
|
2684
|
+
},
|
|
2685
|
+
"chrome.waitForInCallMs": {
|
|
2686
|
+
label: "Wait For In-Call (ms)",
|
|
2687
|
+
help: "Waits for Chrome to report that the Meet tab is in-call before the realtime intro speaks.",
|
|
2688
|
+
advanced: true
|
|
2689
|
+
},
|
|
2690
|
+
"chrome.audioFormat": {
|
|
2691
|
+
label: "Audio Format",
|
|
2692
|
+
help: "Command-pair audio format. PCM16 24 kHz is the default Chrome/Meet path; G.711 mu-law 8 kHz remains available for legacy command pairs.",
|
|
2693
|
+
advanced: true
|
|
2694
|
+
},
|
|
2695
|
+
"chrome.audioInputCommand": {
|
|
2696
|
+
label: "Audio Input Command",
|
|
2697
|
+
help: "Command that writes meeting audio to stdout in chrome.audioFormat.",
|
|
2698
|
+
advanced: true
|
|
2699
|
+
},
|
|
2700
|
+
"chrome.audioOutputCommand": {
|
|
2701
|
+
label: "Audio Output Command",
|
|
2702
|
+
help: "Command that reads assistant audio from stdin in chrome.audioFormat.",
|
|
2703
|
+
advanced: true
|
|
2704
|
+
},
|
|
2705
|
+
"chrome.bargeInInputCommand": {
|
|
2706
|
+
label: "Barge-In Input Command",
|
|
2707
|
+
help: "Optional Gateway-hosted microphone command that writes signed 16-bit little-endian mono PCM for human interruption detection while assistant playback is active.",
|
|
2708
|
+
advanced: true
|
|
2709
|
+
},
|
|
2710
|
+
"chrome.bargeInRmsThreshold": {
|
|
2711
|
+
label: "Barge-In RMS Threshold",
|
|
2712
|
+
help: "RMS level on chrome.bargeInInputCommand that counts as a human interruption.",
|
|
2713
|
+
advanced: true
|
|
2714
|
+
},
|
|
2715
|
+
"chrome.bargeInPeakThreshold": {
|
|
2716
|
+
label: "Barge-In Peak Threshold",
|
|
2717
|
+
help: "Peak level on chrome.bargeInInputCommand that counts as a human interruption.",
|
|
2718
|
+
advanced: true
|
|
2719
|
+
},
|
|
2720
|
+
"chrome.bargeInCooldownMs": {
|
|
2721
|
+
label: "Barge-In Cooldown (ms)",
|
|
2722
|
+
help: "Minimum delay between repeated barge-in clears.",
|
|
2723
|
+
advanced: true
|
|
2724
|
+
},
|
|
2725
|
+
"chrome.audioBridgeCommand": {
|
|
2726
|
+
label: "Audio Bridge Command",
|
|
2727
|
+
advanced: true
|
|
2728
|
+
},
|
|
2729
|
+
"chrome.audioBridgeHealthCommand": {
|
|
2730
|
+
label: "Audio Bridge Health Command",
|
|
2731
|
+
advanced: true
|
|
2732
|
+
},
|
|
2733
|
+
"chromeNode.node": {
|
|
2734
|
+
label: "Chrome Node",
|
|
2735
|
+
help: "Node id/name/IP that owns Chrome, BlackHole, and SoX for chrome-node transport.",
|
|
2736
|
+
advanced: true
|
|
2737
|
+
},
|
|
2738
|
+
"twilio.defaultDialInNumber": {
|
|
2739
|
+
label: "Default Dial-In Number",
|
|
2740
|
+
placeholder: "+15551234567"
|
|
2741
|
+
},
|
|
2742
|
+
"twilio.defaultPin": {
|
|
2743
|
+
label: "Default PIN",
|
|
2744
|
+
advanced: true
|
|
2745
|
+
},
|
|
2746
|
+
"twilio.defaultDtmfSequence": {
|
|
2747
|
+
label: "Default DTMF Sequence",
|
|
2748
|
+
advanced: true
|
|
2749
|
+
},
|
|
2750
|
+
"voiceCall.enabled": { label: "Delegate To Voice Call" },
|
|
2751
|
+
"voiceCall.gatewayUrl": {
|
|
2752
|
+
label: "Voice Call Gateway URL",
|
|
2753
|
+
advanced: true
|
|
2754
|
+
},
|
|
2755
|
+
"voiceCall.token": {
|
|
2756
|
+
label: "Voice Call Gateway Token",
|
|
2757
|
+
sensitive: true,
|
|
2758
|
+
advanced: true
|
|
2759
|
+
},
|
|
2760
|
+
"voiceCall.requestTimeoutMs": {
|
|
2761
|
+
label: "Voice Call Request Timeout (ms)",
|
|
2762
|
+
advanced: true
|
|
2763
|
+
},
|
|
2764
|
+
"voiceCall.dtmfDelayMs": {
|
|
2765
|
+
label: "Legacy DTMF Delay (ms)",
|
|
2766
|
+
help: "Compatibility setting from the old post-connect DTMF flow. Twilio Meet joins now play DTMF before realtime connect.",
|
|
2767
|
+
advanced: true
|
|
2768
|
+
},
|
|
2769
|
+
"voiceCall.postDtmfSpeechDelayMs": {
|
|
2770
|
+
label: "Legacy Post-DTMF Speech Delay (ms)",
|
|
2771
|
+
help: "Compatibility setting from the old delayed-speech flow. Twilio Meet joins now carry the intro as the initial Voice Call message.",
|
|
2772
|
+
advanced: true
|
|
2773
|
+
},
|
|
2774
|
+
"voiceCall.introMessage": {
|
|
2775
|
+
label: "Voice Call Intro Message",
|
|
2776
|
+
advanced: true
|
|
2777
|
+
},
|
|
2778
|
+
"realtime.provider": {
|
|
2779
|
+
label: "Realtime Provider",
|
|
2780
|
+
help: "Defaults to OpenAI; uses OPENAI_API_KEY when no provider config is set."
|
|
2781
|
+
},
|
|
2782
|
+
"realtime.model": {
|
|
2783
|
+
label: "Realtime Model",
|
|
2784
|
+
advanced: true
|
|
2785
|
+
},
|
|
2786
|
+
"realtime.instructions": {
|
|
2787
|
+
label: "Realtime Instructions",
|
|
2788
|
+
advanced: true
|
|
2789
|
+
},
|
|
2790
|
+
"realtime.introMessage": {
|
|
2791
|
+
label: "Realtime Intro Message",
|
|
2792
|
+
help: "Spoken once when the realtime bridge is ready. Set to an empty string to join silently."
|
|
2793
|
+
},
|
|
2794
|
+
"realtime.agentId": {
|
|
2795
|
+
label: "Realtime Consult Agent",
|
|
2796
|
+
help: "OpenClaw agent id used by openclaw_agent_consult. Defaults to \"main\".",
|
|
2797
|
+
advanced: true
|
|
2798
|
+
},
|
|
2799
|
+
"realtime.toolPolicy": {
|
|
2800
|
+
label: "Realtime Tool Policy",
|
|
2801
|
+
help: "Safe read-only tools are available by default; owner requests can unlock broader tools.",
|
|
2802
|
+
advanced: true
|
|
2803
|
+
},
|
|
2804
|
+
"oauth.clientId": { label: "OAuth Client ID" },
|
|
2805
|
+
"oauth.clientSecret": {
|
|
2806
|
+
label: "OAuth Client Secret",
|
|
2807
|
+
sensitive: true
|
|
2808
|
+
},
|
|
2809
|
+
"oauth.refreshToken": {
|
|
2810
|
+
label: "OAuth Refresh Token",
|
|
2811
|
+
sensitive: true
|
|
2812
|
+
},
|
|
2813
|
+
"oauth.accessToken": {
|
|
2814
|
+
label: "Cached Access Token",
|
|
2815
|
+
sensitive: true,
|
|
2816
|
+
advanced: true
|
|
2817
|
+
},
|
|
2818
|
+
"oauth.expiresAt": {
|
|
2819
|
+
label: "Cached Access Token Expiry",
|
|
2820
|
+
help: "Unix epoch milliseconds used only for the cached access-token fast path.",
|
|
2821
|
+
advanced: true
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
const GoogleMeetToolSchema = Type.Object({
|
|
2826
|
+
action: Type.String({
|
|
2827
|
+
enum: [
|
|
2828
|
+
"join",
|
|
2829
|
+
"create",
|
|
2830
|
+
"status",
|
|
2831
|
+
"setup_status",
|
|
2832
|
+
"resolve_space",
|
|
2833
|
+
"preflight",
|
|
2834
|
+
"latest",
|
|
2835
|
+
"calendar_events",
|
|
2836
|
+
"artifacts",
|
|
2837
|
+
"attendance",
|
|
2838
|
+
"export",
|
|
2839
|
+
"recover_current_tab",
|
|
2840
|
+
"leave",
|
|
2841
|
+
"end_active_conference",
|
|
2842
|
+
"speak",
|
|
2843
|
+
"test_speech",
|
|
2844
|
+
"test_listen"
|
|
2845
|
+
],
|
|
2846
|
+
description: "Google Meet action to run. create creates and joins by default; pass join=false to only mint a URL. After a timeout or unclear browser state, call recover_current_tab before retrying join."
|
|
2847
|
+
}),
|
|
2848
|
+
join: Type.Optional(Type.Boolean({ description: "For action=create, set false to create the URL without joining." })),
|
|
2849
|
+
accessType: Type.Optional(Type.String({
|
|
2850
|
+
enum: [
|
|
2851
|
+
"OPEN",
|
|
2852
|
+
"TRUSTED",
|
|
2853
|
+
"RESTRICTED"
|
|
2854
|
+
],
|
|
2855
|
+
description: "For action=create with Google Meet OAuth, configure who can join without knocking."
|
|
2856
|
+
})),
|
|
2857
|
+
entryPointAccess: Type.Optional(Type.String({
|
|
2858
|
+
enum: ["ALL", "CREATOR_APP_ONLY"],
|
|
2859
|
+
description: "For action=create with Google Meet OAuth, configure allowed join entry points."
|
|
2860
|
+
})),
|
|
2861
|
+
url: Type.Optional(Type.String({ description: "Explicit https://meet.google.com/... URL" })),
|
|
2862
|
+
transport: Type.Optional(Type.String({
|
|
2863
|
+
enum: [
|
|
2864
|
+
"chrome",
|
|
2865
|
+
"chrome-node",
|
|
2866
|
+
"twilio"
|
|
2867
|
+
],
|
|
2868
|
+
description: "Join transport"
|
|
2869
|
+
})),
|
|
2870
|
+
mode: Type.Optional(Type.String({
|
|
2871
|
+
enum: ["realtime", "transcribe"],
|
|
2872
|
+
description: "Join mode. realtime starts live listen/talk-back through the realtime voice model; transcribe joins without the realtime talk-back bridge."
|
|
2873
|
+
})),
|
|
2874
|
+
dialInNumber: Type.Optional(Type.String({ description: "Meet dial-in phone number for Twilio. Required for Twilio unless twilio.defaultDialInNumber is configured; Meet URLs cannot be dialed directly." })),
|
|
2875
|
+
pin: Type.Optional(Type.String({ description: "Meet phone PIN for Twilio; # is appended if omitted" })),
|
|
2876
|
+
dtmfSequence: Type.Optional(Type.String({ description: "Explicit DTMF sequence for Twilio" })),
|
|
2877
|
+
sessionId: Type.Optional(Type.String({ description: "Meet session ID" })),
|
|
2878
|
+
message: Type.Optional(Type.String({ description: "Realtime instructions to speak now" })),
|
|
2879
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Probe timeout in milliseconds" })),
|
|
2880
|
+
meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })),
|
|
2881
|
+
today: Type.Optional(Type.Boolean({ description: "For latest, artifacts, or attendance, find a Meet link on today's calendar." })),
|
|
2882
|
+
event: Type.Optional(Type.String({ description: "For latest, artifacts, or attendance, find a matching Calendar event." })),
|
|
2883
|
+
calendarId: Type.Optional(Type.String({ description: "Calendar id for today/event lookup" })),
|
|
2884
|
+
conferenceRecord: Type.Optional(Type.String({ description: "Meet conferenceRecords/{id} resource name or id" })),
|
|
2885
|
+
pageSize: Type.Optional(Type.Number({ description: "Meet API page size for list actions" })),
|
|
2886
|
+
includeTranscriptEntries: Type.Optional(Type.Boolean({ description: "For artifacts, include structured transcript entries" })),
|
|
2887
|
+
includeDocumentBodies: Type.Optional(Type.Boolean({ description: "For artifacts/export, export linked transcript and smart-note Google Docs text through Drive." })),
|
|
2888
|
+
outputDir: Type.Optional(Type.String({ description: "For export, output directory" })),
|
|
2889
|
+
zip: Type.Optional(Type.Boolean({ description: "For export, also write a .zip archive" })),
|
|
2890
|
+
dryRun: Type.Optional(Type.Boolean({ description: "For export, return the manifest without writing files." })),
|
|
2891
|
+
includeAllConferenceRecords: Type.Optional(Type.Boolean({ description: "For artifacts, attendance, or export with meeting input, fetch all conference records instead of only the latest." })),
|
|
2892
|
+
mergeDuplicateParticipants: Type.Optional(Type.Boolean({ description: "For attendance, merge duplicate participant resources." })),
|
|
2893
|
+
lateAfterMinutes: Type.Optional(Type.Number({ description: "For attendance, mark participants late after this many minutes." })),
|
|
2894
|
+
earlyBeforeMinutes: Type.Optional(Type.Number({ description: "For attendance, mark early leavers before this many minutes." })),
|
|
2895
|
+
accessToken: Type.Optional(Type.String({ description: "Access token override" })),
|
|
2896
|
+
refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })),
|
|
2897
|
+
clientId: Type.Optional(Type.String({ description: "OAuth client id override" })),
|
|
2898
|
+
clientSecret: Type.Optional(Type.String({ description: "OAuth client secret override" })),
|
|
2899
|
+
expiresAt: Type.Optional(Type.Number({ description: "Cached access token expiry ms" }))
|
|
2900
|
+
});
|
|
2901
|
+
function asParamRecord(params) {
|
|
2902
|
+
return params && typeof params === "object" && !Array.isArray(params) ? params : {};
|
|
2903
|
+
}
|
|
2904
|
+
function json(payload) {
|
|
2905
|
+
return {
|
|
2906
|
+
content: [{
|
|
2907
|
+
type: "text",
|
|
2908
|
+
text: JSON.stringify(payload, null, 2)
|
|
2909
|
+
}],
|
|
2910
|
+
details: payload
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
function normalizeTransport(value) {
|
|
2914
|
+
return value === "chrome" || value === "chrome-node" || value === "twilio" ? value : void 0;
|
|
2915
|
+
}
|
|
2916
|
+
function normalizeMode(value) {
|
|
2917
|
+
return value === "realtime" || value === "transcribe" ? value : void 0;
|
|
2918
|
+
}
|
|
2919
|
+
function resolveMeetingInput(config, value) {
|
|
2920
|
+
const meeting = normalizeOptionalString(value) ?? config.defaults.meeting;
|
|
2921
|
+
if (!meeting) throw new Error("Meeting input is required");
|
|
2922
|
+
return meeting;
|
|
2923
|
+
}
|
|
2924
|
+
function resolveOptionalPositiveInteger(value) {
|
|
2925
|
+
if (value === void 0) return;
|
|
2926
|
+
const parsed = typeof value === "number" ? value : Number(normalizeOptionalString(value));
|
|
2927
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Expected pageSize to be a positive integer");
|
|
2928
|
+
return parsed;
|
|
2929
|
+
}
|
|
2930
|
+
function shouldJoinCreatedMeet(raw) {
|
|
2931
|
+
return raw.join !== false && raw.join !== "false";
|
|
2932
|
+
}
|
|
2933
|
+
const googleMeetToolDeps = {
|
|
2934
|
+
callGatewayFromCli,
|
|
2935
|
+
platform: () => process.platform
|
|
2936
|
+
};
|
|
2937
|
+
const __testing = {
|
|
2938
|
+
setCallGatewayFromCliForTests(next) {
|
|
2939
|
+
googleMeetToolDeps.callGatewayFromCli = next ?? callGatewayFromCli;
|
|
2940
|
+
},
|
|
2941
|
+
setPlatformForTests(next) {
|
|
2942
|
+
googleMeetToolDeps.platform = next ?? (() => process.platform);
|
|
2943
|
+
},
|
|
2944
|
+
isGoogleMeetAgentToolActionUnsupportedOnHost
|
|
2945
|
+
};
|
|
2946
|
+
function googleMeetGatewayMethodForToolAction(action) {
|
|
2947
|
+
switch (action) {
|
|
2948
|
+
case "recover_current_tab": return "googlemeet.recoverCurrentTab";
|
|
2949
|
+
case "setup_status": return "googlemeet.setup";
|
|
2950
|
+
case "test_speech": return "googlemeet.testSpeech";
|
|
2951
|
+
case "test_listen": return "googlemeet.testListen";
|
|
2952
|
+
case "end_active_conference": return "googlemeet.endActiveConference";
|
|
2953
|
+
default: return `googlemeet.${action}`;
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
function isGoogleMeetAgentToolActionUnsupportedOnHost(params) {
|
|
2957
|
+
if ((params.platform ?? googleMeetToolDeps.platform()) === "darwin") return false;
|
|
2958
|
+
const action = params.raw.action;
|
|
2959
|
+
if (action !== "join" && action !== "test_speech" && !(action === "create" && shouldJoinCreatedMeet(params.raw))) return false;
|
|
2960
|
+
const transport = normalizeTransport(params.raw.transport) ?? params.config.defaultTransport;
|
|
2961
|
+
const mode = action === "test_speech" ? "realtime" : normalizeMode(params.raw.mode) ?? params.config.defaultMode;
|
|
2962
|
+
return transport === "chrome" && mode === "realtime";
|
|
2963
|
+
}
|
|
2964
|
+
function assertGoogleMeetAgentToolActionSupported(params) {
|
|
2965
|
+
if (!isGoogleMeetAgentToolActionUnsupportedOnHost(params)) return;
|
|
2966
|
+
throw new Error("Google Meet local Chrome realtime audio is macOS-only. On this host, use mode: transcribe, transport: twilio, or transport: chrome-node backed by a macOS node.");
|
|
2967
|
+
}
|
|
2968
|
+
function resolveGoogleMeetToolGatewayTimeoutMs(config) {
|
|
2969
|
+
return Math.max(6e4, config.chrome.joinTimeoutMs + 3e4, config.voiceCall.requestTimeoutMs + 1e4);
|
|
2970
|
+
}
|
|
2971
|
+
function readGatewayErrorDetails(err) {
|
|
2972
|
+
if (!err || typeof err !== "object" || !("details" in err)) return;
|
|
2973
|
+
return err.details;
|
|
2974
|
+
}
|
|
2975
|
+
async function callGoogleMeetGatewayFromTool(params) {
|
|
2976
|
+
try {
|
|
2977
|
+
return await googleMeetToolDeps.callGatewayFromCli(googleMeetGatewayMethodForToolAction(params.action), {
|
|
2978
|
+
json: true,
|
|
2979
|
+
timeout: String(resolveGoogleMeetToolGatewayTimeoutMs(params.config))
|
|
2980
|
+
}, params.raw, { progress: false });
|
|
2981
|
+
} catch (err) {
|
|
2982
|
+
const details = readGatewayErrorDetails(err);
|
|
2983
|
+
if (details && typeof details === "object") return details;
|
|
2984
|
+
throw err;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
async function createMeetFromParams(params) {
|
|
2988
|
+
return (await import("./create-0ye_2zVk.js")).createMeetFromParams(params);
|
|
2989
|
+
}
|
|
2990
|
+
async function createAndJoinMeetFromParams(params) {
|
|
2991
|
+
return (await import("./create-0ye_2zVk.js")).createAndJoinMeetFromParams(params);
|
|
2992
|
+
}
|
|
2993
|
+
async function resolveGoogleMeetTokenFromParams(config, raw) {
|
|
2994
|
+
const { resolveGoogleMeetAccessToken } = await import("./oauth-BJwzuzT-.js");
|
|
2995
|
+
return resolveGoogleMeetAccessToken({
|
|
2996
|
+
clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId,
|
|
2997
|
+
clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret,
|
|
2998
|
+
refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken,
|
|
2999
|
+
accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken,
|
|
3000
|
+
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
function wantsCalendarLookup(raw) {
|
|
3004
|
+
return raw.today === true || Boolean(normalizeOptionalString(raw.event));
|
|
3005
|
+
}
|
|
3006
|
+
async function resolveMeetingFromParams(params) {
|
|
3007
|
+
if (wantsCalendarLookup(params.raw)) {
|
|
3008
|
+
const window = params.raw.today === true ? buildGoogleMeetCalendarDayWindow() : {};
|
|
3009
|
+
const calendarEvent = await findGoogleMeetCalendarEvent({
|
|
3010
|
+
accessToken: params.accessToken,
|
|
3011
|
+
calendarId: normalizeOptionalString(params.raw.calendarId),
|
|
3012
|
+
eventQuery: normalizeOptionalString(params.raw.event),
|
|
3013
|
+
...window
|
|
3014
|
+
});
|
|
3015
|
+
return {
|
|
3016
|
+
meeting: calendarEvent.meetingUri,
|
|
3017
|
+
calendarEvent
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
return { meeting: resolveMeetingInput(params.config, params.raw.meeting) };
|
|
3021
|
+
}
|
|
3022
|
+
async function resolveSpaceFromParams(config, raw) {
|
|
3023
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3024
|
+
const { meeting, calendarEvent } = await resolveMeetingFromParams({
|
|
3025
|
+
config,
|
|
3026
|
+
raw,
|
|
3027
|
+
accessToken: token.accessToken
|
|
3028
|
+
});
|
|
3029
|
+
return {
|
|
3030
|
+
meeting,
|
|
3031
|
+
token,
|
|
3032
|
+
space: await fetchGoogleMeetSpace({
|
|
3033
|
+
accessToken: token.accessToken,
|
|
3034
|
+
meeting
|
|
3035
|
+
}),
|
|
3036
|
+
calendarEvent
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
async function resolveArtifactQueryFromParams(config, raw) {
|
|
3040
|
+
const meeting = normalizeOptionalString(raw.meeting) ?? config.defaults.meeting;
|
|
3041
|
+
const conferenceRecord = normalizeOptionalString(raw.conferenceRecord);
|
|
3042
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3043
|
+
const resolvedMeeting = conferenceRecord ? { meeting } : wantsCalendarLookup(raw) ? await resolveMeetingFromParams({
|
|
3044
|
+
config,
|
|
3045
|
+
raw,
|
|
3046
|
+
accessToken: token.accessToken
|
|
3047
|
+
}) : { meeting };
|
|
3048
|
+
if (!resolvedMeeting.meeting && !conferenceRecord) throw new Error("Meeting input, calendar lookup, or conferenceRecord required");
|
|
3049
|
+
return {
|
|
3050
|
+
token,
|
|
3051
|
+
meeting: resolvedMeeting.meeting,
|
|
3052
|
+
calendarEvent: resolvedMeeting.calendarEvent,
|
|
3053
|
+
conferenceRecord,
|
|
3054
|
+
pageSize: resolveOptionalPositiveInteger(raw.pageSize),
|
|
3055
|
+
includeTranscriptEntries: raw.includeTranscriptEntries !== false,
|
|
3056
|
+
includeDocumentBodies: raw.includeDocumentBodies === true,
|
|
3057
|
+
allConferenceRecords: raw.includeAllConferenceRecords === true,
|
|
3058
|
+
mergeDuplicateParticipants: raw.mergeDuplicateParticipants !== false,
|
|
3059
|
+
lateAfterMinutes: resolveOptionalPositiveInteger(raw.lateAfterMinutes),
|
|
3060
|
+
earlyBeforeMinutes: resolveOptionalPositiveInteger(raw.earlyBeforeMinutes)
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
3063
|
+
async function exportGoogleMeetBundleFromParams(config, raw) {
|
|
3064
|
+
const resolved = await resolveArtifactQueryFromParams(config, raw);
|
|
3065
|
+
const [artifacts, attendance] = await Promise.all([fetchGoogleMeetArtifacts({
|
|
3066
|
+
accessToken: resolved.token.accessToken,
|
|
3067
|
+
meeting: resolved.meeting,
|
|
3068
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3069
|
+
pageSize: resolved.pageSize,
|
|
3070
|
+
includeTranscriptEntries: resolved.includeTranscriptEntries,
|
|
3071
|
+
includeDocumentBodies: resolved.includeDocumentBodies,
|
|
3072
|
+
allConferenceRecords: resolved.allConferenceRecords
|
|
3073
|
+
}), fetchGoogleMeetAttendance({
|
|
3074
|
+
accessToken: resolved.token.accessToken,
|
|
3075
|
+
meeting: resolved.meeting,
|
|
3076
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3077
|
+
pageSize: resolved.pageSize,
|
|
3078
|
+
allConferenceRecords: resolved.allConferenceRecords,
|
|
3079
|
+
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
|
|
3080
|
+
lateAfterMinutes: resolved.lateAfterMinutes,
|
|
3081
|
+
earlyBeforeMinutes: resolved.earlyBeforeMinutes
|
|
3082
|
+
})]);
|
|
3083
|
+
const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-CSyT9_N6.js");
|
|
3084
|
+
const calendarId = normalizeOptionalString(raw.calendarId);
|
|
3085
|
+
const request = {
|
|
3086
|
+
...resolved.meeting ? { meeting: resolved.meeting } : {},
|
|
3087
|
+
...resolved.conferenceRecord ? { conferenceRecord: resolved.conferenceRecord } : {},
|
|
3088
|
+
...resolved.calendarEvent?.event.id ? { calendarEventId: resolved.calendarEvent.event.id } : {},
|
|
3089
|
+
...resolved.calendarEvent?.event.summary ? { calendarEventSummary: resolved.calendarEvent.event.summary } : {},
|
|
3090
|
+
...calendarId ? { calendarId } : {},
|
|
3091
|
+
...resolved.pageSize !== void 0 ? { pageSize: resolved.pageSize } : {},
|
|
3092
|
+
includeTranscriptEntries: resolved.includeTranscriptEntries,
|
|
3093
|
+
includeDocumentBodies: resolved.includeDocumentBodies,
|
|
3094
|
+
allConferenceRecords: resolved.allConferenceRecords,
|
|
3095
|
+
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
|
|
3096
|
+
...resolved.lateAfterMinutes !== void 0 ? { lateAfterMinutes: resolved.lateAfterMinutes } : {},
|
|
3097
|
+
...resolved.earlyBeforeMinutes !== void 0 ? { earlyBeforeMinutes: resolved.earlyBeforeMinutes } : {}
|
|
3098
|
+
};
|
|
3099
|
+
const tokenSource = resolved.token.refreshed ? "refresh-token" : "cached-access-token";
|
|
3100
|
+
if (raw.dryRun === true) return {
|
|
3101
|
+
dryRun: true,
|
|
3102
|
+
manifest: buildGoogleMeetExportManifest({
|
|
3103
|
+
artifacts,
|
|
3104
|
+
attendance,
|
|
3105
|
+
files: googleMeetExportFileNames(),
|
|
3106
|
+
request,
|
|
3107
|
+
tokenSource,
|
|
3108
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}
|
|
3109
|
+
}),
|
|
3110
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {},
|
|
3111
|
+
tokenSource
|
|
3112
|
+
};
|
|
3113
|
+
const outputDir = normalizeOptionalString(raw.outputDir) ?? normalizeOptionalString(raw.output);
|
|
3114
|
+
return {
|
|
3115
|
+
...await writeMeetExportBundle({
|
|
3116
|
+
...outputDir ? { outputDir } : {},
|
|
3117
|
+
artifacts,
|
|
3118
|
+
attendance,
|
|
3119
|
+
zip: raw.zip === true,
|
|
3120
|
+
request,
|
|
3121
|
+
tokenSource,
|
|
3122
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}
|
|
3123
|
+
}),
|
|
3124
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {},
|
|
3125
|
+
tokenSource
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
var google_meet_default = definePluginEntry({
|
|
3129
|
+
id: "google-meet",
|
|
3130
|
+
name: "Google Meet",
|
|
3131
|
+
description: "Join Google Meet calls through Chrome or Twilio transports",
|
|
3132
|
+
configSchema: googleMeetConfigSchema,
|
|
3133
|
+
register(api) {
|
|
3134
|
+
const config = googleMeetConfigSchema.parse(api.pluginConfig);
|
|
3135
|
+
let runtime = null;
|
|
3136
|
+
const ensureRuntime = async () => {
|
|
3137
|
+
if (!config.enabled) throw new Error("Google Meet plugin disabled in plugin config");
|
|
3138
|
+
if (!runtime) runtime = new GoogleMeetRuntime({
|
|
3139
|
+
config,
|
|
3140
|
+
fullConfig: api.config,
|
|
3141
|
+
runtime: api.runtime,
|
|
3142
|
+
logger: api.logger
|
|
3143
|
+
});
|
|
3144
|
+
return runtime;
|
|
3145
|
+
};
|
|
3146
|
+
const formatGatewayError = (err) => isGoogleMeetBrowserManualActionError(err) ? err.payload : { error: formatErrorMessage(err) };
|
|
3147
|
+
const sendError = (respond, err, code = ErrorCodes.UNAVAILABLE) => {
|
|
3148
|
+
const payload = formatGatewayError(err);
|
|
3149
|
+
respond(false, payload, errorShape(code, typeof payload.error === "string" ? payload.error : "Google Meet request failed", { details: payload }));
|
|
3150
|
+
};
|
|
3151
|
+
api.registerGatewayMethod("googlemeet.join", async ({ params, respond }) => {
|
|
3152
|
+
try {
|
|
3153
|
+
respond(true, await (await ensureRuntime()).join({
|
|
3154
|
+
url: resolveMeetingInput(config, params?.url),
|
|
3155
|
+
transport: normalizeTransport(params?.transport),
|
|
3156
|
+
mode: normalizeMode(params?.mode),
|
|
3157
|
+
dialInNumber: normalizeOptionalString(params?.dialInNumber),
|
|
3158
|
+
pin: normalizeOptionalString(params?.pin),
|
|
3159
|
+
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
|
3160
|
+
message: normalizeOptionalString(params?.message)
|
|
3161
|
+
}));
|
|
3162
|
+
} catch (err) {
|
|
3163
|
+
sendError(respond, err);
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
api.registerGatewayMethod("googlemeet.create", async ({ params, respond }) => {
|
|
3167
|
+
try {
|
|
3168
|
+
const raw = asParamRecord(params);
|
|
3169
|
+
respond(true, shouldJoinCreatedMeet(raw) ? await createAndJoinMeetFromParams({
|
|
3170
|
+
config,
|
|
3171
|
+
runtime: api.runtime,
|
|
3172
|
+
raw,
|
|
3173
|
+
ensureRuntime
|
|
3174
|
+
}) : await createMeetFromParams({
|
|
3175
|
+
config,
|
|
3176
|
+
runtime: api.runtime,
|
|
3177
|
+
raw
|
|
3178
|
+
}));
|
|
3179
|
+
} catch (err) {
|
|
3180
|
+
sendError(respond, err);
|
|
3181
|
+
}
|
|
3182
|
+
});
|
|
3183
|
+
api.registerGatewayMethod("googlemeet.status", async ({ params, respond }) => {
|
|
3184
|
+
try {
|
|
3185
|
+
respond(true, await (await ensureRuntime()).status(normalizeOptionalString(params?.sessionId)));
|
|
3186
|
+
} catch (err) {
|
|
3187
|
+
sendError(respond, err);
|
|
3188
|
+
}
|
|
3189
|
+
});
|
|
3190
|
+
api.registerGatewayMethod("googlemeet.recoverCurrentTab", async ({ params, respond }) => {
|
|
3191
|
+
try {
|
|
3192
|
+
respond(true, await (await ensureRuntime()).recoverCurrentTab({
|
|
3193
|
+
url: normalizeOptionalString(params?.url),
|
|
3194
|
+
transport: normalizeTransport(params?.transport)
|
|
3195
|
+
}));
|
|
3196
|
+
} catch (err) {
|
|
3197
|
+
sendError(respond, err);
|
|
3198
|
+
}
|
|
3199
|
+
});
|
|
3200
|
+
api.registerGatewayMethod("googlemeet.setup", async ({ params, respond }) => {
|
|
3201
|
+
try {
|
|
3202
|
+
respond(true, await (await ensureRuntime()).setupStatus({
|
|
3203
|
+
transport: normalizeTransport(params?.transport),
|
|
3204
|
+
mode: normalizeMode(params?.mode),
|
|
3205
|
+
dialInNumber: normalizeOptionalString(params?.dialInNumber)
|
|
3206
|
+
}));
|
|
3207
|
+
} catch (err) {
|
|
3208
|
+
sendError(respond, err);
|
|
3209
|
+
}
|
|
3210
|
+
});
|
|
3211
|
+
api.registerGatewayMethod("googlemeet.latest", async ({ params, respond }) => {
|
|
3212
|
+
try {
|
|
3213
|
+
const raw = asParamRecord(params);
|
|
3214
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3215
|
+
const resolved = await resolveMeetingFromParams({
|
|
3216
|
+
config,
|
|
3217
|
+
raw,
|
|
3218
|
+
accessToken: token.accessToken
|
|
3219
|
+
});
|
|
3220
|
+
respond(true, {
|
|
3221
|
+
...await fetchLatestGoogleMeetConferenceRecord({
|
|
3222
|
+
accessToken: token.accessToken,
|
|
3223
|
+
meeting: resolved.meeting
|
|
3224
|
+
}),
|
|
3225
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}
|
|
3226
|
+
});
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
sendError(respond, err);
|
|
3229
|
+
}
|
|
3230
|
+
});
|
|
3231
|
+
api.registerGatewayMethod("googlemeet.calendarEvents", async ({ params, respond }) => {
|
|
3232
|
+
try {
|
|
3233
|
+
const raw = asParamRecord(params);
|
|
3234
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3235
|
+
const window = raw.today === true ? buildGoogleMeetCalendarDayWindow() : {};
|
|
3236
|
+
respond(true, await listGoogleMeetCalendarEvents({
|
|
3237
|
+
accessToken: token.accessToken,
|
|
3238
|
+
calendarId: normalizeOptionalString(raw.calendarId),
|
|
3239
|
+
eventQuery: normalizeOptionalString(raw.event),
|
|
3240
|
+
...window
|
|
3241
|
+
}));
|
|
3242
|
+
} catch (err) {
|
|
3243
|
+
sendError(respond, err);
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
api.registerGatewayMethod("googlemeet.artifacts", async ({ params, respond }) => {
|
|
3247
|
+
try {
|
|
3248
|
+
const resolved = await resolveArtifactQueryFromParams(config, asParamRecord(params));
|
|
3249
|
+
respond(true, await fetchGoogleMeetArtifacts({
|
|
3250
|
+
accessToken: resolved.token.accessToken,
|
|
3251
|
+
meeting: resolved.meeting,
|
|
3252
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3253
|
+
pageSize: resolved.pageSize,
|
|
3254
|
+
includeTranscriptEntries: resolved.includeTranscriptEntries,
|
|
3255
|
+
includeDocumentBodies: resolved.includeDocumentBodies,
|
|
3256
|
+
allConferenceRecords: resolved.allConferenceRecords
|
|
3257
|
+
}));
|
|
3258
|
+
} catch (err) {
|
|
3259
|
+
sendError(respond, err);
|
|
3260
|
+
}
|
|
3261
|
+
});
|
|
3262
|
+
api.registerGatewayMethod("googlemeet.attendance", async ({ params, respond }) => {
|
|
3263
|
+
try {
|
|
3264
|
+
const resolved = await resolveArtifactQueryFromParams(config, asParamRecord(params));
|
|
3265
|
+
respond(true, await fetchGoogleMeetAttendance({
|
|
3266
|
+
accessToken: resolved.token.accessToken,
|
|
3267
|
+
meeting: resolved.meeting,
|
|
3268
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3269
|
+
pageSize: resolved.pageSize,
|
|
3270
|
+
allConferenceRecords: resolved.allConferenceRecords,
|
|
3271
|
+
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
|
|
3272
|
+
lateAfterMinutes: resolved.lateAfterMinutes,
|
|
3273
|
+
earlyBeforeMinutes: resolved.earlyBeforeMinutes
|
|
3274
|
+
}));
|
|
3275
|
+
} catch (err) {
|
|
3276
|
+
sendError(respond, err);
|
|
3277
|
+
}
|
|
3278
|
+
});
|
|
3279
|
+
api.registerGatewayMethod("googlemeet.export", async ({ params, respond }) => {
|
|
3280
|
+
try {
|
|
3281
|
+
respond(true, await exportGoogleMeetBundleFromParams(config, asParamRecord(params)));
|
|
3282
|
+
} catch (err) {
|
|
3283
|
+
sendError(respond, err);
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
api.registerGatewayMethod("googlemeet.leave", async ({ params, respond }) => {
|
|
3287
|
+
try {
|
|
3288
|
+
const sessionId = normalizeOptionalString(params?.sessionId);
|
|
3289
|
+
if (!sessionId) {
|
|
3290
|
+
sendError(respond, /* @__PURE__ */ new Error("sessionId required"), ErrorCodes.INVALID_REQUEST);
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
respond(true, await (await ensureRuntime()).leave(sessionId));
|
|
3294
|
+
} catch (err) {
|
|
3295
|
+
sendError(respond, err);
|
|
3296
|
+
}
|
|
3297
|
+
});
|
|
3298
|
+
api.registerGatewayMethod("googlemeet.endActiveConference", async ({ params, respond }) => {
|
|
3299
|
+
try {
|
|
3300
|
+
const raw = asParamRecord(params);
|
|
3301
|
+
respond(true, await endGoogleMeetActiveConference({
|
|
3302
|
+
accessToken: (await resolveGoogleMeetTokenFromParams(config, raw)).accessToken,
|
|
3303
|
+
meeting: resolveMeetingInput(config, raw.meeting)
|
|
3304
|
+
}));
|
|
3305
|
+
} catch (err) {
|
|
3306
|
+
sendError(respond, err);
|
|
3307
|
+
}
|
|
3308
|
+
});
|
|
3309
|
+
api.registerGatewayMethod("googlemeet.speak", async ({ params, respond }) => {
|
|
3310
|
+
try {
|
|
3311
|
+
const sessionId = normalizeOptionalString(params?.sessionId);
|
|
3312
|
+
if (!sessionId) {
|
|
3313
|
+
sendError(respond, /* @__PURE__ */ new Error("sessionId required"), ErrorCodes.INVALID_REQUEST);
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
respond(true, await (await ensureRuntime()).speak(sessionId, normalizeOptionalString(params?.message)));
|
|
3317
|
+
} catch (err) {
|
|
3318
|
+
sendError(respond, err);
|
|
3319
|
+
}
|
|
3320
|
+
});
|
|
3321
|
+
api.registerGatewayMethod("googlemeet.testSpeech", async ({ params, respond }) => {
|
|
3322
|
+
try {
|
|
3323
|
+
respond(true, await (await ensureRuntime()).testSpeech({
|
|
3324
|
+
url: resolveMeetingInput(config, params?.url),
|
|
3325
|
+
transport: normalizeTransport(params?.transport),
|
|
3326
|
+
mode: normalizeMode(params?.mode),
|
|
3327
|
+
dialInNumber: normalizeOptionalString(params?.dialInNumber),
|
|
3328
|
+
pin: normalizeOptionalString(params?.pin),
|
|
3329
|
+
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
|
3330
|
+
message: normalizeOptionalString(params?.message)
|
|
3331
|
+
}));
|
|
3332
|
+
} catch (err) {
|
|
3333
|
+
sendError(respond, err);
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3336
|
+
api.registerGatewayMethod("googlemeet.testListen", async ({ params, respond }) => {
|
|
3337
|
+
try {
|
|
3338
|
+
respond(true, await (await ensureRuntime()).testListen({
|
|
3339
|
+
url: resolveMeetingInput(config, params?.url),
|
|
3340
|
+
transport: normalizeTransport(params?.transport),
|
|
3341
|
+
mode: normalizeMode(params?.mode),
|
|
3342
|
+
timeoutMs: typeof params?.timeoutMs === "number" ? params.timeoutMs : void 0
|
|
3343
|
+
}));
|
|
3344
|
+
} catch (err) {
|
|
3345
|
+
sendError(respond, err);
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
api.registerTool({
|
|
3349
|
+
name: "google_meet",
|
|
3350
|
+
label: "Google Meet",
|
|
3351
|
+
description: "Join and track Google Meet sessions through Chrome or Twilio. Call setup_status before join/create/test_listen/test_speech; if it reports a Chrome node offline, local audio missing, or missing Twilio dial plan, surface that blocker instead of retrying or switching transports. Twilio cannot dial a Meet URL directly: provide dialInNumber plus optional pin/dtmfSequence, or configure twilio.defaultDialInNumber. Offline nodes are diagnostics only, not usable candidates. If local Chrome realtime audio is unsupported on this OS, use mode=transcribe, transport=twilio, or a macOS chrome-node for realtime Chrome. If a Meet tab is already open after a timeout, call recover_current_tab before retrying join to report login, permission, or admission blockers without opening another tab.",
|
|
3352
|
+
parameters: GoogleMeetToolSchema,
|
|
3353
|
+
async execute(_toolCallId, params) {
|
|
3354
|
+
const raw = asParamRecord(params);
|
|
3355
|
+
try {
|
|
3356
|
+
assertGoogleMeetAgentToolActionSupported({
|
|
3357
|
+
config,
|
|
3358
|
+
raw
|
|
3359
|
+
});
|
|
3360
|
+
switch (raw.action) {
|
|
3361
|
+
case "join": return json(await callGoogleMeetGatewayFromTool({
|
|
3362
|
+
config,
|
|
3363
|
+
action: "join",
|
|
3364
|
+
raw
|
|
3365
|
+
}));
|
|
3366
|
+
case "create": return json(await callGoogleMeetGatewayFromTool({
|
|
3367
|
+
config,
|
|
3368
|
+
action: "create",
|
|
3369
|
+
raw
|
|
3370
|
+
}));
|
|
3371
|
+
case "test_speech": return json(await callGoogleMeetGatewayFromTool({
|
|
3372
|
+
config,
|
|
3373
|
+
action: "test_speech",
|
|
3374
|
+
raw
|
|
3375
|
+
}));
|
|
3376
|
+
case "test_listen": return json(await callGoogleMeetGatewayFromTool({
|
|
3377
|
+
config,
|
|
3378
|
+
action: "test_listen",
|
|
3379
|
+
raw
|
|
3380
|
+
}));
|
|
3381
|
+
case "status": return json(await callGoogleMeetGatewayFromTool({
|
|
3382
|
+
config,
|
|
3383
|
+
action: "status",
|
|
3384
|
+
raw
|
|
3385
|
+
}));
|
|
3386
|
+
case "recover_current_tab": return json(await callGoogleMeetGatewayFromTool({
|
|
3387
|
+
config,
|
|
3388
|
+
action: "recover_current_tab",
|
|
3389
|
+
raw
|
|
3390
|
+
}));
|
|
3391
|
+
case "setup_status": return json(await callGoogleMeetGatewayFromTool({
|
|
3392
|
+
config,
|
|
3393
|
+
action: "setup_status",
|
|
3394
|
+
raw
|
|
3395
|
+
}));
|
|
3396
|
+
case "resolve_space": {
|
|
3397
|
+
const { token: _token, ...result } = await resolveSpaceFromParams(config, raw);
|
|
3398
|
+
return json(result);
|
|
3399
|
+
}
|
|
3400
|
+
case "preflight": {
|
|
3401
|
+
const { meeting, token, space } = await resolveSpaceFromParams(config, raw);
|
|
3402
|
+
return json(buildGoogleMeetPreflightReport({
|
|
3403
|
+
input: meeting,
|
|
3404
|
+
space,
|
|
3405
|
+
previewAcknowledged: config.preview.enrollmentAcknowledged,
|
|
3406
|
+
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token"
|
|
3407
|
+
}));
|
|
3408
|
+
}
|
|
3409
|
+
case "latest": {
|
|
3410
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3411
|
+
const resolved = await resolveMeetingFromParams({
|
|
3412
|
+
config,
|
|
3413
|
+
raw,
|
|
3414
|
+
accessToken: token.accessToken
|
|
3415
|
+
});
|
|
3416
|
+
return json({
|
|
3417
|
+
...await fetchLatestGoogleMeetConferenceRecord({
|
|
3418
|
+
accessToken: token.accessToken,
|
|
3419
|
+
meeting: resolved.meeting
|
|
3420
|
+
}),
|
|
3421
|
+
...resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
case "calendar_events": {
|
|
3425
|
+
const token = await resolveGoogleMeetTokenFromParams(config, raw);
|
|
3426
|
+
const window = raw.today === true ? buildGoogleMeetCalendarDayWindow() : {};
|
|
3427
|
+
return json(await listGoogleMeetCalendarEvents({
|
|
3428
|
+
accessToken: token.accessToken,
|
|
3429
|
+
calendarId: normalizeOptionalString(raw.calendarId),
|
|
3430
|
+
eventQuery: normalizeOptionalString(raw.event),
|
|
3431
|
+
...window
|
|
3432
|
+
}));
|
|
3433
|
+
}
|
|
3434
|
+
case "artifacts": {
|
|
3435
|
+
const resolved = await resolveArtifactQueryFromParams(config, raw);
|
|
3436
|
+
return json(await fetchGoogleMeetArtifacts({
|
|
3437
|
+
accessToken: resolved.token.accessToken,
|
|
3438
|
+
meeting: resolved.meeting,
|
|
3439
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3440
|
+
pageSize: resolved.pageSize,
|
|
3441
|
+
includeTranscriptEntries: resolved.includeTranscriptEntries,
|
|
3442
|
+
includeDocumentBodies: resolved.includeDocumentBodies,
|
|
3443
|
+
allConferenceRecords: resolved.allConferenceRecords
|
|
3444
|
+
}));
|
|
3445
|
+
}
|
|
3446
|
+
case "attendance": {
|
|
3447
|
+
const resolved = await resolveArtifactQueryFromParams(config, raw);
|
|
3448
|
+
return json(await fetchGoogleMeetAttendance({
|
|
3449
|
+
accessToken: resolved.token.accessToken,
|
|
3450
|
+
meeting: resolved.meeting,
|
|
3451
|
+
conferenceRecord: resolved.conferenceRecord,
|
|
3452
|
+
pageSize: resolved.pageSize,
|
|
3453
|
+
allConferenceRecords: resolved.allConferenceRecords,
|
|
3454
|
+
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
|
|
3455
|
+
lateAfterMinutes: resolved.lateAfterMinutes,
|
|
3456
|
+
earlyBeforeMinutes: resolved.earlyBeforeMinutes
|
|
3457
|
+
}));
|
|
3458
|
+
}
|
|
3459
|
+
case "export": return json(await exportGoogleMeetBundleFromParams(config, raw));
|
|
3460
|
+
case "leave":
|
|
3461
|
+
if (!normalizeOptionalString(raw.sessionId)) throw new Error("sessionId required");
|
|
3462
|
+
return json(await callGoogleMeetGatewayFromTool({
|
|
3463
|
+
config,
|
|
3464
|
+
action: "leave",
|
|
3465
|
+
raw
|
|
3466
|
+
}));
|
|
3467
|
+
case "end_active_conference": return json(await callGoogleMeetGatewayFromTool({
|
|
3468
|
+
config,
|
|
3469
|
+
action: "end_active_conference",
|
|
3470
|
+
raw
|
|
3471
|
+
}));
|
|
3472
|
+
case "speak":
|
|
3473
|
+
if (!normalizeOptionalString(raw.sessionId)) throw new Error("sessionId required");
|
|
3474
|
+
return json(await callGoogleMeetGatewayFromTool({
|
|
3475
|
+
config,
|
|
3476
|
+
action: "speak",
|
|
3477
|
+
raw
|
|
3478
|
+
}));
|
|
3479
|
+
default: throw new Error("unknown google_meet action");
|
|
3480
|
+
}
|
|
3481
|
+
} catch (err) {
|
|
3482
|
+
return json(formatGatewayError(err));
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
});
|
|
3486
|
+
api.registerNodeHostCommand({
|
|
3487
|
+
command: "googlemeet.chrome",
|
|
3488
|
+
cap: "google-meet",
|
|
3489
|
+
handle: handleGoogleMeetNodeHostCommand
|
|
3490
|
+
});
|
|
3491
|
+
api.registerCli(async ({ program }) => {
|
|
3492
|
+
const { registerGoogleMeetCli } = await import("./cli-CSyT9_N6.js");
|
|
3493
|
+
registerGoogleMeetCli({
|
|
3494
|
+
program,
|
|
3495
|
+
config,
|
|
3496
|
+
ensureRuntime
|
|
3497
|
+
});
|
|
3498
|
+
}, {
|
|
3499
|
+
commands: ["googlemeet"],
|
|
3500
|
+
descriptors: [{
|
|
3501
|
+
name: "googlemeet",
|
|
3502
|
+
description: "Join and manage Google Meet calls",
|
|
3503
|
+
hasSubcommands: true
|
|
3504
|
+
}]
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
});
|
|
3508
|
+
//#endregion
|
|
3509
|
+
export { __testing, google_meet_default as default };
|