@openclaw/google-meet 2026.5.1-beta.2 → 2026.5.2-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/src/runtime.ts CHANGED
@@ -66,6 +66,43 @@ function hasRealtimeAudioOutputAdvanced(
66
66
  return (health?.lastOutputBytes ?? 0) > startOutputBytes;
67
67
  }
68
68
 
69
+ type TranscriptCheckpoint = {
70
+ lines: number;
71
+ lastCaptionAt?: string;
72
+ lastCaptionText?: string;
73
+ };
74
+
75
+ function transcriptCheckpoint(health: GoogleMeetChromeHealth | undefined): TranscriptCheckpoint {
76
+ return {
77
+ lines: health?.transcriptLines ?? 0,
78
+ lastCaptionAt: health?.lastCaptionAt,
79
+ lastCaptionText: health?.lastCaptionText,
80
+ };
81
+ }
82
+
83
+ function hasTranscriptAdvanced(
84
+ health: GoogleMeetChromeHealth | undefined,
85
+ start: TranscriptCheckpoint,
86
+ ): boolean {
87
+ if ((health?.transcriptLines ?? 0) > start.lines) {
88
+ return true;
89
+ }
90
+ if (health?.lastCaptionAt && health.lastCaptionAt !== start.lastCaptionAt) {
91
+ return true;
92
+ }
93
+ return Boolean(health?.lastCaptionText && health.lastCaptionText !== start.lastCaptionText);
94
+ }
95
+
96
+ function resolveProbeTimeoutMs(input: number | undefined, fallback: number): number {
97
+ if (input === undefined) {
98
+ return Math.min(Math.max(fallback, 1), 120_000);
99
+ }
100
+ if (!Number.isFinite(input) || input <= 0) {
101
+ throw new Error("timeoutMs must be a positive number");
102
+ }
103
+ return Math.min(Math.trunc(input), 120_000);
104
+ }
105
+
69
106
  function sleep(ms: number): Promise<void> {
70
107
  return new Promise((resolve) => setTimeout(resolve, ms));
71
108
  }
@@ -189,9 +226,17 @@ export class GoogleMeetRuntime {
189
226
  return session ? { found: true, session } : { found: false };
190
227
  }
191
228
 
192
- async setupStatus(options: { transport?: GoogleMeetTransport; mode?: GoogleMeetMode } = {}) {
229
+ async setupStatus(
230
+ options: {
231
+ transport?: GoogleMeetTransport;
232
+ mode?: GoogleMeetMode;
233
+ dialInNumber?: string;
234
+ } = {},
235
+ ) {
193
236
  const transport = resolveTransport(options.transport, this.params.config);
194
237
  const mode = resolveMode(options.mode, this.params.config);
238
+ const twilioDialInNumber =
239
+ transport === "twilio" ? normalizeDialInNumber(options.dialInNumber) : undefined;
195
240
  const shouldCheckChromeNode =
196
241
  transport === "chrome-node" ||
197
242
  (!options.transport && Boolean(this.params.config.chromeNode.node));
@@ -199,6 +244,7 @@ export class GoogleMeetRuntime {
199
244
  fullConfig: this.params.fullConfig,
200
245
  mode,
201
246
  transport,
247
+ twilioDialInNumber,
202
248
  });
203
249
  if (shouldCheckChromeNode) {
204
250
  try {
@@ -403,7 +449,9 @@ export class GoogleMeetRuntime {
403
449
  request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber,
404
450
  );
405
451
  if (!dialInNumber) {
406
- throw new Error("dialInNumber required for twilio transport");
452
+ throw new Error(
453
+ "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.",
454
+ );
407
455
  }
408
456
  const dtmfSequence = buildMeetDtmfSequence({
409
457
  pin: request.pin ?? this.params.config.twilio.defaultPin,
@@ -443,7 +491,7 @@ export class GoogleMeetRuntime {
443
491
  session.notes.push(
444
492
  this.params.config.voiceCall.enabled
445
493
  ? dtmfSequence
446
- ? "Twilio transport delegated the call to the voice-call plugin and queued configured DTMF."
494
+ ? "Twilio transport delegated the phone leg to the voice-call plugin, then sent configured DTMF after connect before speaking."
447
495
  : "Twilio transport delegated the call to the voice-call plugin without configured DTMF."
448
496
  : "Twilio transport is an explicit dial plan; voice-call delegation is disabled.",
449
497
  );
@@ -597,6 +645,88 @@ export class GoogleMeetRuntime {
597
645
  };
598
646
  }
599
647
 
648
+ async testListen(request: GoogleMeetJoinRequest): Promise<{
649
+ createdSession: boolean;
650
+ inCall?: boolean;
651
+ manualActionRequired?: boolean;
652
+ manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
653
+ manualActionMessage?: string;
654
+ listenVerified: boolean;
655
+ listenTimedOut: boolean;
656
+ captioning?: boolean;
657
+ captionsEnabledAttempted?: boolean;
658
+ transcriptLines?: number;
659
+ lastCaptionAt?: string;
660
+ lastCaptionSpeaker?: string;
661
+ lastCaptionText?: string;
662
+ recentTranscript?: GoogleMeetChromeHealth["recentTranscript"];
663
+ session: GoogleMeetSession;
664
+ }> {
665
+ if (request.mode === "realtime") {
666
+ throw new Error(
667
+ "test_listen requires mode: transcribe; use test_speech for realtime talk-back.",
668
+ );
669
+ }
670
+ const url = normalizeMeetUrl(request.url);
671
+ const transport = resolveTransport(request.transport, this.params.config);
672
+ if (transport === "twilio") {
673
+ throw new Error("test_listen supports chrome or chrome-node transports");
674
+ }
675
+ const beforeSessions = this.list();
676
+ const before = new Set(beforeSessions.map((session) => session.id));
677
+ const existingSession = beforeSessions.find(
678
+ (session) =>
679
+ session.state === "active" &&
680
+ isSameMeetUrlForReuse(session.url, url) &&
681
+ session.transport === transport &&
682
+ session.mode === "transcribe",
683
+ );
684
+ const start = transcriptCheckpoint(existingSession?.chrome?.health);
685
+ const result = await this.join({
686
+ ...request,
687
+ transport,
688
+ url,
689
+ mode: "transcribe",
690
+ message: undefined,
691
+ });
692
+ let health = result.session.chrome?.health;
693
+ const timeoutMs = resolveProbeTimeoutMs(
694
+ request.timeoutMs,
695
+ this.params.config.chrome.joinTimeoutMs,
696
+ );
697
+ const shouldWait =
698
+ health?.manualActionRequired !== true && isManagedChromeBrowserSession(result.session);
699
+ if (shouldWait && !hasTranscriptAdvanced(health, start)) {
700
+ const deadline = Date.now() + timeoutMs;
701
+ while (Date.now() < deadline) {
702
+ await sleep(250);
703
+ await this.#refreshCaptionHealthForSession(result.session);
704
+ health = result.session.chrome?.health;
705
+ if (health?.manualActionRequired || hasTranscriptAdvanced(health, start)) {
706
+ break;
707
+ }
708
+ }
709
+ }
710
+ const listenVerified = hasTranscriptAdvanced(health, start);
711
+ return {
712
+ createdSession: !before.has(result.session.id),
713
+ inCall: health?.inCall,
714
+ manualActionRequired: health?.manualActionRequired,
715
+ manualActionReason: health?.manualActionReason,
716
+ manualActionMessage: health?.manualActionMessage,
717
+ listenVerified,
718
+ listenTimedOut: shouldWait && !listenVerified && health?.manualActionRequired !== true,
719
+ captioning: health?.captioning,
720
+ captionsEnabledAttempted: health?.captionsEnabledAttempted,
721
+ transcriptLines: health?.transcriptLines,
722
+ lastCaptionAt: health?.lastCaptionAt,
723
+ lastCaptionSpeaker: health?.lastCaptionSpeaker,
724
+ lastCaptionText: health?.lastCaptionText,
725
+ recentTranscript: health?.recentTranscript,
726
+ session: result.session,
727
+ };
728
+ }
729
+
600
730
  async #refreshCaptionHealthForSession(session: GoogleMeetSession) {
601
731
  if (session.mode !== "transcribe") {
602
732
  this.#refreshSpeechReadiness(session);
package/src/setup.ts CHANGED
@@ -87,6 +87,7 @@ export function getGoogleMeetSetupStatus(
87
87
  fullConfig?: unknown;
88
88
  mode?: GoogleMeetMode;
89
89
  transport?: GoogleMeetTransport;
90
+ twilioDialInNumber?: string;
90
91
  },
91
92
  ): {
92
93
  ok: boolean;
@@ -99,6 +100,7 @@ export function getGoogleMeetSetupStatus(
99
100
  fullConfig?: unknown;
100
101
  mode?: GoogleMeetMode;
101
102
  transport?: GoogleMeetTransport;
103
+ twilioDialInNumber?: string;
102
104
  },
103
105
  ) {
104
106
  const checks: SetupCheck[] = [];
@@ -193,6 +195,21 @@ export function getGoogleMeetSetupStatus(
193
195
  });
194
196
  }
195
197
 
198
+ if (transport === "twilio") {
199
+ const hasRequestDialPlan = Boolean(options?.twilioDialInNumber);
200
+ const hasDefaultDialPlan = Boolean(config.twilio.defaultDialInNumber);
201
+ const hasDialPlan = hasRequestDialPlan || hasDefaultDialPlan;
202
+ checks.push({
203
+ id: "twilio-dial-plan",
204
+ ok: hasDialPlan,
205
+ message: hasRequestDialPlan
206
+ ? "Twilio request includes a Meet dial-in number"
207
+ : hasDefaultDialPlan
208
+ ? "Twilio default Meet dial-in number is configured"
209
+ : "Twilio joins require a Meet dial-in phone number; pass dialInNumber with optional pin/dtmfSequence or configure twilio.defaultDialInNumber",
210
+ });
211
+ }
212
+
196
213
  const shouldCheckTwilioDelegation =
197
214
  config.voiceCall.enabled &&
198
215
  (transport === "twilio" ||
@@ -60,6 +60,7 @@ export function setupGoogleMeetPlugin(
60
60
  argv: string[],
61
61
  options?: { timeoutMs?: number },
62
62
  ) => Promise<CommandResult>;
63
+ registerPlatform?: NodeJS.Platform;
63
64
  } = {},
64
65
  ) {
65
66
  const methods = new Map<string, unknown>();
@@ -157,7 +158,16 @@ export function setupGoogleMeetPlugin(
157
158
  registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
158
159
  registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
159
160
  });
160
- plugin.register(api);
161
+ const originalPlatform = process.platform;
162
+ Object.defineProperty(process, "platform", {
163
+ configurable: true,
164
+ value: options.registerPlatform ?? "darwin",
165
+ });
166
+ try {
167
+ plugin.register(api);
168
+ } finally {
169
+ Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
170
+ }
161
171
  return {
162
172
  cliRegistrations,
163
173
  methods,
@@ -7,6 +7,7 @@ export type GoogleMeetJoinRequest = {
7
7
  transport?: GoogleMeetTransport;
8
8
  mode?: GoogleMeetMode;
9
9
  message?: string;
10
+ timeoutMs?: number;
10
11
  dialInNumber?: string;
11
12
  pin?: string;
12
13
  dtmfSequence?: string;
@@ -21,39 +21,59 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
21
21
 
22
22
  describe("Google Meet voice-call gateway", () => {
23
23
  beforeEach(() => {
24
+ vi.useRealTimers();
24
25
  gatewayMocks.request.mockReset();
25
26
  gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
26
27
  gatewayMocks.stopAndWait.mockClear();
27
28
  gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
28
29
  });
29
30
 
30
- it("starts Twilio Meet calls with pre-connect DTMF and intro metadata", async () => {
31
+ it("starts Twilio Meet calls, sends delayed DTMF, then speaks the intro", async () => {
31
32
  const config = resolveGoogleMeetConfig({
32
33
  voiceCall: {
33
34
  gatewayUrl: "ws://127.0.0.1:18789",
34
35
  dtmfDelayMs: 1,
36
+ postDtmfSpeechDelayMs: 2,
35
37
  },
36
38
  realtime: { introMessage: "Say exactly: I'm here and listening." },
37
39
  });
38
40
 
39
- await joinMeetViaVoiceCallGateway({
41
+ const join = joinMeetViaVoiceCallGateway({
40
42
  config,
41
43
  dialInNumber: "+15551234567",
42
44
  dtmfSequence: "123456#",
43
45
  message: "Say exactly: I'm here and listening.",
44
46
  });
45
47
 
48
+ await join;
49
+
46
50
  expect(gatewayMocks.request).toHaveBeenNthCalledWith(
47
51
  1,
48
52
  "voicecall.start",
49
53
  {
50
54
  to: "+15551234567",
51
55
  mode: "conversation",
56
+ },
57
+ { timeoutMs: 30_000 },
58
+ );
59
+ expect(gatewayMocks.request).toHaveBeenNthCalledWith(
60
+ 2,
61
+ "voicecall.dtmf",
62
+ {
63
+ callId: "call-1",
64
+ digits: "123456#",
65
+ },
66
+ { timeoutMs: 30_000 },
67
+ );
68
+ expect(gatewayMocks.request).toHaveBeenNthCalledWith(
69
+ 3,
70
+ "voicecall.speak",
71
+ {
72
+ callId: "call-1",
52
73
  message: "Say exactly: I'm here and listening.",
53
- dtmfSequence: "123456#",
54
74
  },
55
75
  { timeoutMs: 30_000 },
56
76
  );
57
- expect(gatewayMocks.request).toHaveBeenCalledTimes(1);
77
+ expect(gatewayMocks.request).toHaveBeenCalledTimes(3);
58
78
  });
59
79
  });
@@ -18,12 +18,24 @@ type VoiceCallSpeakResult = {
18
18
  error?: string;
19
19
  };
20
20
 
21
+ type VoiceCallDtmfResult = {
22
+ success?: boolean;
23
+ error?: string;
24
+ };
25
+
21
26
  type VoiceCallMeetJoinResult = {
22
27
  callId: string;
23
28
  dtmfSent: boolean;
24
29
  introSent: boolean;
25
30
  };
26
31
 
32
+ function sleep(ms: number): Promise<void> {
33
+ if (ms <= 0) {
34
+ return Promise.resolve();
35
+ }
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+
27
39
  async function createConnectedGatewayClient(
28
40
  config: GoogleMeetConfig,
29
41
  ): Promise<VoiceCallGatewayClient> {
@@ -81,15 +93,13 @@ export async function joinMeetViaVoiceCallGateway(params: {
81
93
  try {
82
94
  client = await createConnectedGatewayClient(params.config);
83
95
  params.logger?.info(
84
- `[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "yes" : "no"}, intro=${params.message ? "yes" : "no"})`,
96
+ `[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "post-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
85
97
  );
86
98
  const start = (await client.request(
87
99
  "voicecall.start",
88
100
  {
89
101
  to: params.dialInNumber,
90
102
  mode: "conversation",
91
- ...(params.message ? { message: params.message } : {}),
92
- ...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
93
103
  },
94
104
  { timeoutMs: params.config.voiceCall.requestTimeoutMs },
95
105
  )) as VoiceCallStartResult;
@@ -97,12 +107,60 @@ export async function joinMeetViaVoiceCallGateway(params: {
97
107
  throw new Error(start.error || "voicecall.start did not return callId");
98
108
  }
99
109
  params.logger?.info(
100
- `[google-meet] Voice Call Twilio join started: callId=${start.callId} dtmf=${params.dtmfSequence ? "yes" : "no"} intro=${params.message ? "yes" : "no"}`,
110
+ `[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`,
101
111
  );
112
+ let dtmfSent = false;
113
+ if (params.dtmfSequence) {
114
+ const delayMs = params.config.voiceCall.dtmfDelayMs;
115
+ params.logger?.info(
116
+ `[google-meet] Waiting ${delayMs}ms before sending Meet DTMF for callId=${start.callId}`,
117
+ );
118
+ await sleep(delayMs);
119
+ const dtmf = (await client.request(
120
+ "voicecall.dtmf",
121
+ {
122
+ callId: start.callId,
123
+ digits: params.dtmfSequence,
124
+ },
125
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
126
+ )) as VoiceCallDtmfResult;
127
+ if (dtmf.success === false) {
128
+ throw new Error(dtmf.error || "voicecall.dtmf failed");
129
+ }
130
+ dtmfSent = true;
131
+ params.logger?.info(
132
+ `[google-meet] Meet DTMF sent after phone leg connected: callId=${start.callId} digits=${params.dtmfSequence.length}`,
133
+ );
134
+ }
135
+ let introSent = false;
136
+ if (params.message) {
137
+ const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
138
+ if (delayMs > 0) {
139
+ params.logger?.info(
140
+ `[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
141
+ );
142
+ await sleep(delayMs);
143
+ }
144
+ const spoken = (await client.request(
145
+ "voicecall.speak",
146
+ {
147
+ callId: start.callId,
148
+ message: params.message,
149
+ },
150
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
151
+ )) as VoiceCallSpeakResult;
152
+ if (spoken.success === false) {
153
+ throw new Error(spoken.error || "voicecall.speak failed");
154
+ }
155
+ introSent = true;
156
+ params.logger?.info(
157
+ `[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
158
+ );
159
+ }
102
160
  return {
103
161
  callId: start.callId,
104
- dtmfSent: Boolean(params.dtmfSequence),
105
- introSent: Boolean(params.message),
162
+ dtmfSent,
163
+ introSent,
106
164
  };
107
165
  } finally {
108
166
  await client?.stopAndWait({ timeoutMs: 1_000 });
@@ -1 +0,0 @@
1
- 2026-04-29T11:28:56.702Z