@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,46 @@
1
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
2
+
3
+ const DTMF_PATTERN = /^[0-9*#wWpP,]+$/;
4
+
5
+ export function normalizeDialInNumber(value: unknown): string | undefined {
6
+ const normalized = normalizeOptionalString(value);
7
+ if (!normalized) {
8
+ return undefined;
9
+ }
10
+ const compact = normalized.replace(/[()\s.-]/g, "");
11
+ if (!/^\+?[0-9]{5,20}$/.test(compact)) {
12
+ throw new Error("dialInNumber must be a phone number");
13
+ }
14
+ return compact;
15
+ }
16
+
17
+ function normalizeDtmfSequence(value: unknown): string | undefined {
18
+ const normalized = normalizeOptionalString(value);
19
+ if (!normalized) {
20
+ return undefined;
21
+ }
22
+ const compact = normalized.replace(/\s+/g, "");
23
+ if (!DTMF_PATTERN.test(compact)) {
24
+ throw new Error("dtmfSequence may only contain digits, *, #, comma, w, p");
25
+ }
26
+ return compact;
27
+ }
28
+
29
+ export function buildMeetDtmfSequence(params: {
30
+ pin?: string;
31
+ dtmfSequence?: string;
32
+ }): string | undefined {
33
+ const explicit = normalizeDtmfSequence(params.dtmfSequence);
34
+ if (explicit) {
35
+ return explicit;
36
+ }
37
+ const pin = normalizeOptionalString(params.pin);
38
+ if (!pin) {
39
+ return undefined;
40
+ }
41
+ const compactPin = pin.replace(/\s+/g, "");
42
+ if (!/^[0-9]+#?$/.test(compactPin)) {
43
+ throw new Error("pin may only contain digits and an optional trailing #");
44
+ }
45
+ return compactPin.endsWith("#") ? compactPin : `${compactPin}#`;
46
+ }
@@ -0,0 +1,112 @@
1
+ import type { GoogleMeetMode, GoogleMeetTransport } from "../config.js";
2
+
3
+ type GoogleMeetSessionState = "active" | "ended";
4
+
5
+ export type GoogleMeetJoinRequest = {
6
+ url: string;
7
+ transport?: GoogleMeetTransport;
8
+ mode?: GoogleMeetMode;
9
+ message?: string;
10
+ dialInNumber?: string;
11
+ pin?: string;
12
+ dtmfSequence?: string;
13
+ };
14
+
15
+ type GoogleMeetManualActionReason =
16
+ | "google-login-required"
17
+ | "meet-admission-required"
18
+ | "meet-permission-required"
19
+ | "meet-audio-choice-required"
20
+ | "browser-control-unavailable";
21
+
22
+ type GoogleMeetSpeechBlockedReason =
23
+ | GoogleMeetManualActionReason
24
+ | "not-in-call"
25
+ | "browser-unverified"
26
+ | "audio-bridge-unavailable";
27
+
28
+ export type GoogleMeetChromeHealth = {
29
+ inCall?: boolean;
30
+ micMuted?: boolean;
31
+ lobbyWaiting?: boolean;
32
+ leaveReason?: string;
33
+ captioning?: boolean;
34
+ captionsEnabledAttempted?: boolean;
35
+ transcriptLines?: number;
36
+ lastCaptionAt?: string;
37
+ lastCaptionSpeaker?: string;
38
+ lastCaptionText?: string;
39
+ recentTranscript?: Array<{
40
+ at?: string;
41
+ speaker?: string;
42
+ text: string;
43
+ }>;
44
+ manualActionRequired?: boolean;
45
+ manualActionReason?: GoogleMeetManualActionReason;
46
+ manualActionMessage?: string;
47
+ speechReady?: boolean;
48
+ speechBlockedReason?: GoogleMeetSpeechBlockedReason;
49
+ speechBlockedMessage?: string;
50
+ providerConnected?: boolean;
51
+ realtimeReady?: boolean;
52
+ audioInputActive?: boolean;
53
+ audioOutputActive?: boolean;
54
+ lastInputAt?: string;
55
+ lastOutputAt?: string;
56
+ lastSuppressedInputAt?: string;
57
+ lastClearAt?: string;
58
+ lastInputBytes?: number;
59
+ lastOutputBytes?: number;
60
+ suppressedInputBytes?: number;
61
+ consecutiveInputErrors?: number;
62
+ lastInputError?: string;
63
+ clearCount?: number;
64
+ queuedInputChunks?: number;
65
+ browserUrl?: string;
66
+ browserTitle?: string;
67
+ bridgeClosed?: boolean;
68
+ status?: string;
69
+ notes?: string[];
70
+ };
71
+
72
+ export type GoogleMeetSession = {
73
+ id: string;
74
+ url: string;
75
+ transport: GoogleMeetTransport;
76
+ mode: GoogleMeetMode;
77
+ state: GoogleMeetSessionState;
78
+ createdAt: string;
79
+ updatedAt: string;
80
+ participantIdentity: string;
81
+ realtime: {
82
+ enabled: boolean;
83
+ provider?: string;
84
+ model?: string;
85
+ toolPolicy: string;
86
+ };
87
+ chrome?: {
88
+ audioBackend: "blackhole-2ch";
89
+ launched: boolean;
90
+ nodeId?: string;
91
+ browserProfile?: string;
92
+ audioBridge?: {
93
+ type: "command-pair" | "node-command-pair" | "external-command";
94
+ provider?: string;
95
+ };
96
+ health?: GoogleMeetChromeHealth;
97
+ };
98
+ twilio?: {
99
+ dialInNumber: string;
100
+ pinProvided: boolean;
101
+ dtmfSequence?: string;
102
+ voiceCallId?: string;
103
+ dtmfSent?: boolean;
104
+ introSent?: boolean;
105
+ };
106
+ notes: string[];
107
+ };
108
+
109
+ export type GoogleMeetJoinResult = {
110
+ session: GoogleMeetSession;
111
+ spoken?: boolean;
112
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { resolveGoogleMeetConfig } from "./config.js";
3
+ import { joinMeetViaVoiceCallGateway } from "./voice-call-gateway.js";
4
+
5
+ const gatewayMocks = vi.hoisted(() => ({
6
+ request: vi.fn(),
7
+ stopAndWait: vi.fn(async () => {}),
8
+ startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })),
9
+ }));
10
+
11
+ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
12
+ GatewayClient: vi.fn(function MockGatewayClient(params: { onHelloOk?: () => void }) {
13
+ queueMicrotask(() => params.onHelloOk?.());
14
+ return {
15
+ request: gatewayMocks.request,
16
+ stopAndWait: gatewayMocks.stopAndWait,
17
+ };
18
+ }),
19
+ startGatewayClientWhenEventLoopReady: gatewayMocks.startGatewayClientWhenEventLoopReady,
20
+ }));
21
+
22
+ describe("Google Meet voice-call gateway", () => {
23
+ beforeEach(() => {
24
+ gatewayMocks.request.mockReset();
25
+ gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
26
+ gatewayMocks.stopAndWait.mockClear();
27
+ gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
28
+ });
29
+
30
+ it("starts Twilio Meet calls with pre-connect DTMF and intro metadata", async () => {
31
+ const config = resolveGoogleMeetConfig({
32
+ voiceCall: {
33
+ gatewayUrl: "ws://127.0.0.1:18789",
34
+ dtmfDelayMs: 1,
35
+ },
36
+ realtime: { introMessage: "Say exactly: I'm here and listening." },
37
+ });
38
+
39
+ await joinMeetViaVoiceCallGateway({
40
+ config,
41
+ dialInNumber: "+15551234567",
42
+ dtmfSequence: "123456#",
43
+ message: "Say exactly: I'm here and listening.",
44
+ });
45
+
46
+ expect(gatewayMocks.request).toHaveBeenNthCalledWith(
47
+ 1,
48
+ "voicecall.start",
49
+ {
50
+ to: "+15551234567",
51
+ mode: "conversation",
52
+ message: "Say exactly: I'm here and listening.",
53
+ dtmfSequence: "123456#",
54
+ },
55
+ { timeoutMs: 30_000 },
56
+ );
57
+ expect(gatewayMocks.request).toHaveBeenCalledTimes(1);
58
+ });
59
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ GatewayClient,
3
+ startGatewayClientWhenEventLoopReady,
4
+ } from "openclaw/plugin-sdk/gateway-runtime";
5
+ import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
6
+ import type { GoogleMeetConfig } from "./config.js";
7
+
8
+ type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
9
+
10
+ type VoiceCallStartResult = {
11
+ callId?: string;
12
+ initiated?: boolean;
13
+ error?: string;
14
+ };
15
+
16
+ type VoiceCallSpeakResult = {
17
+ success?: boolean;
18
+ error?: string;
19
+ };
20
+
21
+ type VoiceCallMeetJoinResult = {
22
+ callId: string;
23
+ dtmfSent: boolean;
24
+ introSent: boolean;
25
+ };
26
+
27
+ async function createConnectedGatewayClient(
28
+ config: GoogleMeetConfig,
29
+ ): Promise<VoiceCallGatewayClient> {
30
+ let client: VoiceCallGatewayClient;
31
+ await new Promise<void>((resolve, reject) => {
32
+ const abortStart = new AbortController();
33
+ const timer = setTimeout(() => {
34
+ abortStart.abort();
35
+ reject(new Error("gateway connect timeout"));
36
+ }, config.voiceCall.requestTimeoutMs);
37
+ client = new GatewayClient({
38
+ url: config.voiceCall.gatewayUrl,
39
+ token: config.voiceCall.token,
40
+ requestTimeoutMs: config.voiceCall.requestTimeoutMs,
41
+ clientName: "cli",
42
+ clientDisplayName: "Google Meet plugin",
43
+ scopes: ["operator.write"],
44
+ onHelloOk: () => {
45
+ clearTimeout(timer);
46
+ resolve();
47
+ },
48
+ onConnectError: (err) => {
49
+ clearTimeout(timer);
50
+ abortStart.abort();
51
+ reject(err);
52
+ },
53
+ });
54
+ void startGatewayClientWhenEventLoopReady(client, {
55
+ timeoutMs: config.voiceCall.requestTimeoutMs,
56
+ signal: abortStart.signal,
57
+ })
58
+ .then((readiness) => {
59
+ if (!readiness.ready && !readiness.aborted) {
60
+ clearTimeout(timer);
61
+ reject(new Error("gateway event loop readiness timeout"));
62
+ }
63
+ })
64
+ .catch((err) => {
65
+ clearTimeout(timer);
66
+ reject(err instanceof Error ? err : new Error(String(err)));
67
+ });
68
+ });
69
+ return client!;
70
+ }
71
+
72
+ export async function joinMeetViaVoiceCallGateway(params: {
73
+ config: GoogleMeetConfig;
74
+ dialInNumber: string;
75
+ dtmfSequence?: string;
76
+ logger?: RuntimeLogger;
77
+ message?: string;
78
+ }): Promise<VoiceCallMeetJoinResult> {
79
+ let client: VoiceCallGatewayClient | undefined;
80
+
81
+ try {
82
+ client = await createConnectedGatewayClient(params.config);
83
+ params.logger?.info(
84
+ `[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "yes" : "no"}, intro=${params.message ? "yes" : "no"})`,
85
+ );
86
+ const start = (await client.request(
87
+ "voicecall.start",
88
+ {
89
+ to: params.dialInNumber,
90
+ mode: "conversation",
91
+ ...(params.message ? { message: params.message } : {}),
92
+ ...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
93
+ },
94
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
95
+ )) as VoiceCallStartResult;
96
+ if (!start.callId) {
97
+ throw new Error(start.error || "voicecall.start did not return callId");
98
+ }
99
+ params.logger?.info(
100
+ `[google-meet] Voice Call Twilio join started: callId=${start.callId} dtmf=${params.dtmfSequence ? "yes" : "no"} intro=${params.message ? "yes" : "no"}`,
101
+ );
102
+ return {
103
+ callId: start.callId,
104
+ dtmfSent: Boolean(params.dtmfSequence),
105
+ introSent: Boolean(params.message),
106
+ };
107
+ } finally {
108
+ await client?.stopAndWait({ timeoutMs: 1_000 });
109
+ }
110
+ }
111
+
112
+ export async function endMeetVoiceCallGatewayCall(params: {
113
+ config: GoogleMeetConfig;
114
+ callId: string;
115
+ }): Promise<void> {
116
+ let client: VoiceCallGatewayClient | undefined;
117
+
118
+ try {
119
+ client = await createConnectedGatewayClient(params.config);
120
+ await client.request(
121
+ "voicecall.end",
122
+ {
123
+ callId: params.callId,
124
+ },
125
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
126
+ );
127
+ } finally {
128
+ await client?.stopAndWait({ timeoutMs: 1_000 });
129
+ }
130
+ }
131
+
132
+ export async function speakMeetViaVoiceCallGateway(params: {
133
+ config: GoogleMeetConfig;
134
+ callId: string;
135
+ message: string;
136
+ }): Promise<void> {
137
+ let client: VoiceCallGatewayClient | undefined;
138
+
139
+ try {
140
+ client = await createConnectedGatewayClient(params.config);
141
+ const spoken = (await client.request(
142
+ "voicecall.speak",
143
+ {
144
+ callId: params.callId,
145
+ message: params.message,
146
+ },
147
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
148
+ )) as VoiceCallSpeakResult;
149
+ if (spoken.success === false) {
150
+ throw new Error(spoken.error || "voicecall.speak failed");
151
+ }
152
+ } finally {
153
+ await client?.stopAndWait({ timeoutMs: 1_000 });
154
+ }
155
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../tsconfig.package-boundary.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["./*.ts", "./src/**/*.ts"],
7
+ "exclude": [
8
+ "./**/*.test.ts",
9
+ "./dist/**",
10
+ "./node_modules/**",
11
+ "./src/test-support/**",
12
+ "./src/**/*test-helpers.ts",
13
+ "./src/**/*test-harness.ts",
14
+ "./src/**/*test-support.ts"
15
+ ]
16
+ }