@openclaw/google-meet 2026.5.1-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.
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ type MockChild = EventEmitter & {
6
+ exitCode: number | null;
7
+ signalCode: NodeJS.Signals | null;
8
+ kill: ReturnType<typeof vi.fn>;
9
+ stdout?: EventEmitter;
10
+ stderr?: EventEmitter;
11
+ stdin?: { write: ReturnType<typeof vi.fn> };
12
+ };
13
+
14
+ const children: MockChild[] = [];
15
+
16
+ vi.mock("node:child_process", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("node:child_process")>();
18
+ return {
19
+ ...actual,
20
+ spawnSync: vi.fn(() => ({
21
+ status: 0,
22
+ stdout: "BlackHole 2ch",
23
+ stderr: "",
24
+ })),
25
+ spawn: vi.fn(() => {
26
+ const child = Object.assign(new EventEmitter(), {
27
+ exitCode: null,
28
+ signalCode: null,
29
+ kill: vi.fn((signal?: NodeJS.Signals) => {
30
+ child.signalCode = signal ?? "SIGTERM";
31
+ return true;
32
+ }),
33
+ stdout: new EventEmitter(),
34
+ stderr: new EventEmitter(),
35
+ stdin: { write: vi.fn() },
36
+ }) as MockChild;
37
+ children.push(child);
38
+ return child;
39
+ }),
40
+ };
41
+ });
42
+
43
+ describe("google-meet node host bridge sessions", () => {
44
+ it("starts observe-only Chrome without BlackHole or bridge processes", async () => {
45
+ const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
46
+ const originalPlatform = process.platform;
47
+ children.length = 0;
48
+ vi.mocked(spawnSync).mockClear();
49
+
50
+ Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
51
+ try {
52
+ const start = JSON.parse(
53
+ await handleGoogleMeetNodeHostCommand(
54
+ JSON.stringify({
55
+ action: "start",
56
+ url: "https://meet.google.com/xyz-abcd-uvw",
57
+ mode: "transcribe",
58
+ launch: false,
59
+ audioInputCommand: ["mock-rec"],
60
+ audioOutputCommand: ["mock-play"],
61
+ }),
62
+ ),
63
+ );
64
+
65
+ expect(start).toEqual({ launched: false });
66
+ expect(spawnSync).not.toHaveBeenCalled();
67
+ expect(children).toHaveLength(0);
68
+ } finally {
69
+ Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
70
+ }
71
+ });
72
+
73
+ it("clears output playback without closing the active bridge when the old output exits", async () => {
74
+ const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
75
+ const originalPlatform = process.platform;
76
+ children.length = 0;
77
+
78
+ Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
79
+ try {
80
+ const start = JSON.parse(
81
+ await handleGoogleMeetNodeHostCommand(
82
+ JSON.stringify({
83
+ action: "start",
84
+ url: "https://meet.google.com/xyz-abcd-uvw",
85
+ mode: "realtime",
86
+ launch: false,
87
+ audioInputCommand: ["mock-rec"],
88
+ audioOutputCommand: ["mock-play"],
89
+ }),
90
+ ),
91
+ );
92
+
93
+ expect(children).toHaveLength(2);
94
+ const firstOutput = children[0];
95
+
96
+ const cleared = JSON.parse(
97
+ await handleGoogleMeetNodeHostCommand(
98
+ JSON.stringify({
99
+ action: "clearAudio",
100
+ bridgeId: start.bridgeId,
101
+ }),
102
+ ),
103
+ );
104
+
105
+ expect(cleared).toEqual({ bridgeId: start.bridgeId, ok: true, clearCount: 1 });
106
+ expect(children).toHaveLength(3);
107
+ expect(firstOutput?.kill).toHaveBeenCalledWith("SIGTERM");
108
+
109
+ firstOutput?.emit("error", new Error("stale output failed after clear"));
110
+ firstOutput?.emit("exit", 0, "SIGTERM");
111
+
112
+ const status = JSON.parse(
113
+ await handleGoogleMeetNodeHostCommand(
114
+ JSON.stringify({
115
+ action: "status",
116
+ bridgeId: start.bridgeId,
117
+ }),
118
+ ),
119
+ );
120
+
121
+ expect(status.bridge).toMatchObject({
122
+ bridgeId: start.bridgeId,
123
+ closed: false,
124
+ clearCount: 1,
125
+ });
126
+
127
+ const audio = Buffer.from([1, 2, 3]);
128
+ await handleGoogleMeetNodeHostCommand(
129
+ JSON.stringify({
130
+ action: "pushAudio",
131
+ bridgeId: start.bridgeId,
132
+ base64: audio.toString("base64"),
133
+ }),
134
+ );
135
+
136
+ expect(children[2]?.stdin?.write).toHaveBeenCalledWith(audio);
137
+ expect(firstOutput?.stdin?.write).not.toHaveBeenCalled();
138
+
139
+ await handleGoogleMeetNodeHostCommand(
140
+ JSON.stringify({
141
+ action: "stop",
142
+ bridgeId: start.bridgeId,
143
+ }),
144
+ );
145
+ } finally {
146
+ Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
147
+ }
148
+ });
149
+
150
+ it("lists active bridge sessions and hides closed sessions", async () => {
151
+ const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
152
+ const originalPlatform = process.platform;
153
+ children.length = 0;
154
+
155
+ Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
156
+ try {
157
+ const start = JSON.parse(
158
+ await handleGoogleMeetNodeHostCommand(
159
+ JSON.stringify({
160
+ action: "start",
161
+ url: "https://meet.google.com/abc-defg-hij?authuser=1",
162
+ mode: "realtime",
163
+ launch: false,
164
+ audioInputCommand: ["mock-rec"],
165
+ audioOutputCommand: ["mock-play"],
166
+ }),
167
+ ),
168
+ );
169
+
170
+ expect(start).toMatchObject({
171
+ audioBridge: { type: "node-command-pair" },
172
+ bridgeId: expect.any(String),
173
+ });
174
+
175
+ const activeList = JSON.parse(
176
+ await handleGoogleMeetNodeHostCommand(
177
+ JSON.stringify({
178
+ action: "list",
179
+ url: "https://meet.google.com/abc-defg-hij",
180
+ mode: "realtime",
181
+ }),
182
+ ),
183
+ );
184
+
185
+ expect(activeList.bridges).toHaveLength(1);
186
+ expect(activeList.bridges[0]).toMatchObject({
187
+ bridgeId: start.bridgeId,
188
+ closed: false,
189
+ mode: "realtime",
190
+ url: "https://meet.google.com/abc-defg-hij?authuser=1",
191
+ });
192
+
193
+ children[1]?.emit("exit", 0, null);
194
+
195
+ const afterExitList = JSON.parse(
196
+ await handleGoogleMeetNodeHostCommand(
197
+ JSON.stringify({
198
+ action: "list",
199
+ url: "https://meet.google.com/abc-defg-hij",
200
+ mode: "realtime",
201
+ }),
202
+ ),
203
+ );
204
+
205
+ expect(afterExitList).toEqual({ bridges: [] });
206
+
207
+ const stopped = JSON.parse(
208
+ await handleGoogleMeetNodeHostCommand(
209
+ JSON.stringify({
210
+ action: "stopByUrl",
211
+ url: "https://meet.google.com/abc-defg-hij",
212
+ mode: "realtime",
213
+ }),
214
+ ),
215
+ );
216
+
217
+ expect(stopped).toEqual({ ok: true, stopped: 0 });
218
+ } finally {
219
+ Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
220
+ }
221
+ });
222
+ });
@@ -0,0 +1,479 @@
1
+ {
2
+ "id": "google-meet",
3
+ "name": "Google Meet",
4
+ "description": "Join Google Meet calls through Chrome or Twilio transports.",
5
+ "enabledByDefault": false,
6
+ "commandAliases": [{ "name": "googlemeet" }],
7
+ "activation": {
8
+ "onStartup": true,
9
+ "onCommands": ["googlemeet"],
10
+ "onCapabilities": ["tool"]
11
+ },
12
+ "uiHints": {
13
+ "defaults.meeting": {
14
+ "label": "Default Meeting",
15
+ "help": "Meet URL, meeting code, or spaces/{id} used when commands omit a meeting."
16
+ },
17
+ "preview.enrollmentAcknowledged": {
18
+ "label": "Preview Acknowledged",
19
+ "help": "Confirms you understand the Google Meet Media API is still Developer Preview.",
20
+ "advanced": true
21
+ },
22
+ "defaultTransport": {
23
+ "label": "Default Transport",
24
+ "help": "Chrome uses a signed-in browser profile. Chrome-node runs Chrome on a paired node. Twilio uses Meet dial-in numbers."
25
+ },
26
+ "defaultMode": {
27
+ "label": "Default Mode",
28
+ "help": "Realtime voice is the default."
29
+ },
30
+ "chrome.audioBackend": {
31
+ "label": "Chrome Audio Backend",
32
+ "help": "BlackHole 2ch is required for local duplex audio routing."
33
+ },
34
+ "chrome.launch": {
35
+ "label": "Launch Chrome"
36
+ },
37
+ "chrome.browserProfile": {
38
+ "label": "Chrome Profile",
39
+ "advanced": true
40
+ },
41
+ "chrome.guestName": {
42
+ "label": "Guest Name",
43
+ "help": "Used when Chrome lands on the signed-out Meet guest-name screen."
44
+ },
45
+ "chrome.reuseExistingTab": {
46
+ "label": "Reuse Existing Meet Tab",
47
+ "help": "Avoids opening duplicate tabs for the same Meet URL."
48
+ },
49
+ "chrome.autoJoin": {
50
+ "label": "Auto Join Guest Screen",
51
+ "help": "Best-effort guest-name fill and Join Now click through OpenClaw browser automation."
52
+ },
53
+ "chrome.waitForInCallMs": {
54
+ "label": "Wait For In-Call (ms)",
55
+ "help": "Waits for Chrome to report that the Meet tab is in-call before the realtime intro speaks.",
56
+ "advanced": true
57
+ },
58
+ "chrome.audioInputCommand": {
59
+ "label": "Audio Input Command",
60
+ "help": "Command that writes meeting audio to stdout in chrome.audioFormat.",
61
+ "advanced": true
62
+ },
63
+ "chrome.audioOutputCommand": {
64
+ "label": "Audio Output Command",
65
+ "help": "Command that reads assistant audio from stdin in chrome.audioFormat.",
66
+ "advanced": true
67
+ },
68
+ "chrome.bargeInInputCommand": {
69
+ "label": "Barge-In Input Command",
70
+ "help": "Optional Gateway-hosted microphone command that writes signed 16-bit little-endian mono PCM for human interruption detection while assistant playback is active.",
71
+ "advanced": true
72
+ },
73
+ "chrome.bargeInRmsThreshold": {
74
+ "label": "Barge-In RMS Threshold",
75
+ "help": "RMS level on chrome.bargeInInputCommand that counts as a human interruption.",
76
+ "advanced": true
77
+ },
78
+ "chrome.bargeInPeakThreshold": {
79
+ "label": "Barge-In Peak Threshold",
80
+ "help": "Peak level on chrome.bargeInInputCommand that counts as a human interruption.",
81
+ "advanced": true
82
+ },
83
+ "chrome.bargeInCooldownMs": {
84
+ "label": "Barge-In Cooldown (ms)",
85
+ "help": "Minimum delay between repeated barge-in clears.",
86
+ "advanced": true
87
+ },
88
+ "chrome.audioFormat": {
89
+ "label": "Audio Format",
90
+ "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.",
91
+ "advanced": true
92
+ },
93
+ "chrome.audioBridgeCommand": {
94
+ "label": "Audio Bridge Command",
95
+ "advanced": true
96
+ },
97
+ "chrome.audioBridgeHealthCommand": {
98
+ "label": "Audio Bridge Health Command",
99
+ "advanced": true
100
+ },
101
+ "chromeNode.node": {
102
+ "label": "Chrome Node",
103
+ "help": "Node id/name/IP that owns Chrome, BlackHole, and SoX for chrome-node transport.",
104
+ "advanced": true
105
+ },
106
+ "twilio.defaultDialInNumber": {
107
+ "label": "Default Dial-In Number",
108
+ "placeholder": "+15551234567"
109
+ },
110
+ "twilio.defaultPin": {
111
+ "label": "Default PIN",
112
+ "advanced": true
113
+ },
114
+ "twilio.defaultDtmfSequence": {
115
+ "label": "Default DTMF Sequence",
116
+ "advanced": true
117
+ },
118
+ "voiceCall.enabled": {
119
+ "label": "Delegate To Voice Call"
120
+ },
121
+ "voiceCall.gatewayUrl": {
122
+ "label": "Voice Call Gateway URL",
123
+ "advanced": true
124
+ },
125
+ "voiceCall.token": {
126
+ "label": "Voice Call Gateway Token",
127
+ "sensitive": true,
128
+ "advanced": true
129
+ },
130
+ "voiceCall.requestTimeoutMs": {
131
+ "label": "Voice Call Request Timeout (ms)",
132
+ "advanced": true
133
+ },
134
+ "voiceCall.dtmfDelayMs": {
135
+ "label": "Legacy DTMF Delay (ms)",
136
+ "help": "Compatibility setting from the old post-connect DTMF flow. Twilio Meet joins now play DTMF before realtime connect.",
137
+ "advanced": true
138
+ },
139
+ "voiceCall.introMessage": {
140
+ "label": "Voice Call Intro Message",
141
+ "advanced": true
142
+ },
143
+ "realtime.provider": {
144
+ "label": "Realtime Provider",
145
+ "help": "Defaults to OpenAI; uses OPENAI_API_KEY when no provider config is set."
146
+ },
147
+ "realtime.model": {
148
+ "label": "Realtime Model",
149
+ "advanced": true
150
+ },
151
+ "realtime.instructions": {
152
+ "label": "Realtime Instructions",
153
+ "advanced": true
154
+ },
155
+ "realtime.introMessage": {
156
+ "label": "Realtime Intro Message",
157
+ "help": "Spoken once when the realtime bridge is ready. Set to an empty string to join silently."
158
+ },
159
+ "realtime.agentId": {
160
+ "label": "Realtime Consult Agent",
161
+ "help": "OpenClaw agent id used by openclaw_agent_consult. Defaults to \"main\".",
162
+ "advanced": true
163
+ },
164
+ "realtime.toolPolicy": {
165
+ "label": "Realtime Tool Policy",
166
+ "help": "Safe read-only tools are available by default; owner requests can unlock broader tools.",
167
+ "advanced": true
168
+ },
169
+ "oauth.clientId": {
170
+ "label": "OAuth Client ID"
171
+ },
172
+ "oauth.clientSecret": {
173
+ "label": "OAuth Client Secret",
174
+ "sensitive": true
175
+ },
176
+ "oauth.refreshToken": {
177
+ "label": "OAuth Refresh Token",
178
+ "sensitive": true
179
+ },
180
+ "oauth.accessToken": {
181
+ "label": "Cached Access Token",
182
+ "sensitive": true,
183
+ "advanced": true
184
+ },
185
+ "oauth.expiresAt": {
186
+ "label": "Cached Access Token Expiry",
187
+ "help": "Unix epoch milliseconds used only for the cached access-token fast path.",
188
+ "advanced": true
189
+ }
190
+ },
191
+ "configSchema": {
192
+ "type": "object",
193
+ "additionalProperties": false,
194
+ "properties": {
195
+ "enabled": {
196
+ "type": "boolean"
197
+ },
198
+ "defaults": {
199
+ "type": "object",
200
+ "additionalProperties": false,
201
+ "properties": {
202
+ "meeting": {
203
+ "type": "string"
204
+ }
205
+ }
206
+ },
207
+ "preview": {
208
+ "type": "object",
209
+ "additionalProperties": false,
210
+ "properties": {
211
+ "enrollmentAcknowledged": {
212
+ "type": "boolean"
213
+ }
214
+ }
215
+ },
216
+ "defaultTransport": {
217
+ "type": "string",
218
+ "enum": ["chrome", "chrome-node", "twilio"],
219
+ "default": "chrome"
220
+ },
221
+ "defaultMode": {
222
+ "type": "string",
223
+ "enum": ["realtime", "transcribe"],
224
+ "default": "realtime"
225
+ },
226
+ "chrome": {
227
+ "type": "object",
228
+ "additionalProperties": false,
229
+ "properties": {
230
+ "audioBackend": {
231
+ "type": "string",
232
+ "enum": ["blackhole-2ch"],
233
+ "default": "blackhole-2ch"
234
+ },
235
+ "launch": {
236
+ "type": "boolean",
237
+ "default": true
238
+ },
239
+ "browserProfile": {
240
+ "type": "string"
241
+ },
242
+ "guestName": {
243
+ "type": "string",
244
+ "default": "OpenClaw Agent"
245
+ },
246
+ "reuseExistingTab": {
247
+ "type": "boolean",
248
+ "default": true
249
+ },
250
+ "autoJoin": {
251
+ "type": "boolean",
252
+ "default": true
253
+ },
254
+ "joinTimeoutMs": {
255
+ "type": "number",
256
+ "default": 30000
257
+ },
258
+ "waitForInCallMs": {
259
+ "type": "number",
260
+ "default": 20000
261
+ },
262
+ "audioFormat": {
263
+ "type": "string",
264
+ "enum": ["pcm16-24khz", "g711-ulaw-8khz"],
265
+ "default": "pcm16-24khz"
266
+ },
267
+ "audioInputCommand": {
268
+ "type": "array",
269
+ "default": [
270
+ "sox",
271
+ "-q",
272
+ "-t",
273
+ "coreaudio",
274
+ "BlackHole 2ch",
275
+ "-t",
276
+ "raw",
277
+ "-r",
278
+ "24000",
279
+ "-c",
280
+ "1",
281
+ "-e",
282
+ "signed-integer",
283
+ "-b",
284
+ "16",
285
+ "-L",
286
+ "-"
287
+ ],
288
+ "items": {
289
+ "type": "string"
290
+ }
291
+ },
292
+ "audioOutputCommand": {
293
+ "type": "array",
294
+ "default": [
295
+ "sox",
296
+ "-q",
297
+ "-t",
298
+ "raw",
299
+ "-r",
300
+ "24000",
301
+ "-c",
302
+ "1",
303
+ "-e",
304
+ "signed-integer",
305
+ "-b",
306
+ "16",
307
+ "-L",
308
+ "-",
309
+ "-t",
310
+ "coreaudio",
311
+ "BlackHole 2ch"
312
+ ],
313
+ "items": {
314
+ "type": "string"
315
+ }
316
+ },
317
+ "bargeInInputCommand": {
318
+ "type": "array",
319
+ "items": {
320
+ "type": "string"
321
+ }
322
+ },
323
+ "bargeInRmsThreshold": {
324
+ "type": "number",
325
+ "default": 650
326
+ },
327
+ "bargeInPeakThreshold": {
328
+ "type": "number",
329
+ "default": 2500
330
+ },
331
+ "bargeInCooldownMs": {
332
+ "type": "number",
333
+ "default": 900
334
+ },
335
+ "audioBridgeCommand": {
336
+ "type": "array",
337
+ "items": {
338
+ "type": "string"
339
+ }
340
+ },
341
+ "audioBridgeHealthCommand": {
342
+ "type": "array",
343
+ "items": {
344
+ "type": "string"
345
+ }
346
+ }
347
+ }
348
+ },
349
+ "chromeNode": {
350
+ "type": "object",
351
+ "additionalProperties": false,
352
+ "properties": {
353
+ "node": {
354
+ "type": "string"
355
+ }
356
+ }
357
+ },
358
+ "twilio": {
359
+ "type": "object",
360
+ "additionalProperties": false,
361
+ "properties": {
362
+ "defaultDialInNumber": {
363
+ "type": "string"
364
+ },
365
+ "defaultPin": {
366
+ "type": "string"
367
+ },
368
+ "defaultDtmfSequence": {
369
+ "type": "string"
370
+ }
371
+ }
372
+ },
373
+ "voiceCall": {
374
+ "type": "object",
375
+ "additionalProperties": false,
376
+ "properties": {
377
+ "enabled": {
378
+ "type": "boolean",
379
+ "default": true
380
+ },
381
+ "gatewayUrl": {
382
+ "type": "string"
383
+ },
384
+ "token": {
385
+ "type": "string"
386
+ },
387
+ "requestTimeoutMs": {
388
+ "type": "number",
389
+ "default": 30000
390
+ },
391
+ "dtmfDelayMs": {
392
+ "type": "number",
393
+ "default": 2500
394
+ },
395
+ "introMessage": {
396
+ "type": "string"
397
+ }
398
+ }
399
+ },
400
+ "realtime": {
401
+ "type": "object",
402
+ "additionalProperties": false,
403
+ "properties": {
404
+ "provider": {
405
+ "type": "string",
406
+ "default": "openai"
407
+ },
408
+ "model": {
409
+ "type": "string"
410
+ },
411
+ "instructions": {
412
+ "type": "string",
413
+ "default": "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 openclaw_agent_consult before answering."
414
+ },
415
+ "introMessage": {
416
+ "type": "string",
417
+ "default": "Say exactly: I'm here and listening."
418
+ },
419
+ "agentId": {
420
+ "type": "string",
421
+ "description": "OpenClaw agent id used by openclaw_agent_consult. Defaults to \"main\"."
422
+ },
423
+ "toolPolicy": {
424
+ "type": "string",
425
+ "enum": ["safe-read-only", "owner", "none"],
426
+ "default": "safe-read-only"
427
+ },
428
+ "providers": {
429
+ "type": "object",
430
+ "additionalProperties": {
431
+ "type": "object",
432
+ "additionalProperties": true
433
+ }
434
+ }
435
+ }
436
+ },
437
+ "oauth": {
438
+ "type": "object",
439
+ "additionalProperties": false,
440
+ "properties": {
441
+ "clientId": {
442
+ "type": "string"
443
+ },
444
+ "clientSecret": {
445
+ "type": "string"
446
+ },
447
+ "refreshToken": {
448
+ "type": "string"
449
+ },
450
+ "accessToken": {
451
+ "type": "string"
452
+ },
453
+ "expiresAt": {
454
+ "type": "number"
455
+ }
456
+ }
457
+ },
458
+ "auth": {
459
+ "type": "object",
460
+ "additionalProperties": false,
461
+ "properties": {
462
+ "provider": {
463
+ "type": "string",
464
+ "enum": ["google-oauth"]
465
+ },
466
+ "clientId": {
467
+ "type": "string"
468
+ },
469
+ "clientSecret": {
470
+ "type": "string"
471
+ },
472
+ "tokenPath": {
473
+ "type": "string"
474
+ }
475
+ }
476
+ }
477
+ }
478
+ }
479
+ }