@gakr-gakr/google-meet 0.1.0

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,57 @@
1
+ import { normalizeOptionalString } from "autobot/plugin-sdk/string-coerce-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
+ }
47
+
48
+ export function prefixDtmfWait(sequence: string | undefined, delayMs: number): string | undefined {
49
+ if (!sequence || delayMs <= 0) {
50
+ return sequence;
51
+ }
52
+ const waitCount = Math.ceil(delayMs / 500);
53
+ if (waitCount <= 0) {
54
+ return sequence;
55
+ }
56
+ return `${"w".repeat(waitCount)}${sequence}`;
57
+ }
@@ -0,0 +1,147 @@
1
+ import type { GoogleMeetMode, GoogleMeetModeInput, GoogleMeetTransport } from "../config.js";
2
+
3
+ type GoogleMeetSessionState = "active" | "ended";
4
+
5
+ export type GoogleMeetJoinRequest = {
6
+ url: string;
7
+ transport?: GoogleMeetTransport;
8
+ mode?: GoogleMeetModeInput;
9
+ message?: string;
10
+ requesterSessionKey?: string;
11
+ timeoutMs?: number;
12
+ dialInNumber?: string;
13
+ pin?: string;
14
+ dtmfSequence?: string;
15
+ };
16
+
17
+ type GoogleMeetManualActionReason =
18
+ | "google-login-required"
19
+ | "meet-admission-required"
20
+ | "meet-permission-required"
21
+ | "meet-audio-choice-required"
22
+ | "browser-control-unavailable";
23
+
24
+ type GoogleMeetSpeechBlockedReason =
25
+ | GoogleMeetManualActionReason
26
+ | "not-in-call"
27
+ | "browser-unverified"
28
+ | "audio-bridge-unavailable"
29
+ | "meet-microphone-muted";
30
+
31
+ export type GoogleMeetChromeHealth = {
32
+ inCall?: boolean;
33
+ micMuted?: boolean;
34
+ lobbyWaiting?: boolean;
35
+ leaveReason?: string;
36
+ captioning?: boolean;
37
+ captionsEnabledAttempted?: boolean;
38
+ transcriptLines?: number;
39
+ lastCaptionAt?: string;
40
+ lastCaptionSpeaker?: string;
41
+ lastCaptionText?: string;
42
+ recentTranscript?: Array<{
43
+ at?: string;
44
+ speaker?: string;
45
+ text: string;
46
+ }>;
47
+ realtimeTranscriptLines?: number;
48
+ lastRealtimeTranscriptAt?: string;
49
+ lastRealtimeTranscriptRole?: "user" | "assistant";
50
+ lastRealtimeTranscriptText?: string;
51
+ recentRealtimeTranscript?: Array<{
52
+ at: string;
53
+ role: "user" | "assistant";
54
+ text: string;
55
+ }>;
56
+ lastRealtimeEventAt?: string;
57
+ lastRealtimeEventType?: string;
58
+ lastRealtimeEventDetail?: string;
59
+ recentRealtimeEvents?: Array<{
60
+ at: string;
61
+ direction: "client" | "server";
62
+ type: string;
63
+ detail?: string;
64
+ }>;
65
+ recentTalkEvents?: Array<{
66
+ id: string;
67
+ type: string;
68
+ sessionId: string;
69
+ turnId?: string;
70
+ seq: number;
71
+ timestamp: string;
72
+ final?: boolean;
73
+ }>;
74
+ manualActionRequired?: boolean;
75
+ manualActionReason?: GoogleMeetManualActionReason;
76
+ manualActionMessage?: string;
77
+ speechReady?: boolean;
78
+ speechBlockedReason?: GoogleMeetSpeechBlockedReason;
79
+ speechBlockedMessage?: string;
80
+ providerConnected?: boolean;
81
+ realtimeReady?: boolean;
82
+ audioInputActive?: boolean;
83
+ audioOutputActive?: boolean;
84
+ audioOutputRouted?: boolean;
85
+ audioOutputDeviceLabel?: string;
86
+ audioOutputRouteError?: string;
87
+ lastInputAt?: string;
88
+ lastOutputAt?: string;
89
+ lastSuppressedInputAt?: string;
90
+ lastClearAt?: string;
91
+ lastInputBytes?: number;
92
+ lastOutputBytes?: number;
93
+ suppressedInputBytes?: number;
94
+ consecutiveInputErrors?: number;
95
+ lastInputError?: string;
96
+ clearCount?: number;
97
+ queuedInputChunks?: number;
98
+ browserUrl?: string;
99
+ browserTitle?: string;
100
+ bridgeClosed?: boolean;
101
+ status?: string;
102
+ notes?: string[];
103
+ };
104
+
105
+ export type GoogleMeetSession = {
106
+ id: string;
107
+ url: string;
108
+ transport: GoogleMeetTransport;
109
+ mode: GoogleMeetMode;
110
+ state: GoogleMeetSessionState;
111
+ createdAt: string;
112
+ updatedAt: string;
113
+ participantIdentity: string;
114
+ realtime: {
115
+ enabled: boolean;
116
+ strategy?: string;
117
+ provider?: string;
118
+ model?: string;
119
+ transcriptionProvider?: string;
120
+ toolPolicy: string;
121
+ };
122
+ chrome?: {
123
+ audioBackend: "blackhole-2ch";
124
+ launched: boolean;
125
+ nodeId?: string;
126
+ browserProfile?: string;
127
+ audioBridge?: {
128
+ type: "command-pair" | "node-command-pair" | "external-command";
129
+ provider?: string;
130
+ };
131
+ health?: GoogleMeetChromeHealth;
132
+ };
133
+ twilio?: {
134
+ dialInNumber: string;
135
+ pinProvided: boolean;
136
+ dtmfSequence?: string;
137
+ voiceCallId?: string;
138
+ dtmfSent?: boolean;
139
+ introSent?: boolean;
140
+ };
141
+ notes: string[];
142
+ };
143
+
144
+ export type GoogleMeetJoinResult = {
145
+ session: GoogleMeetSession;
146
+ spoken?: boolean;
147
+ };
@@ -0,0 +1,241 @@
1
+ import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
2
+ import {
3
+ GatewayClient,
4
+ startGatewayClientWhenEventLoopReady,
5
+ } from "autobot/plugin-sdk/gateway-runtime";
6
+ import type { RuntimeLogger } from "autobot/plugin-sdk/plugin-runtime";
7
+ import { sleep } from "autobot/plugin-sdk/runtime-env";
8
+ import type { GoogleMeetConfig } from "./config.js";
9
+
10
+ type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
11
+
12
+ type VoiceCallStartResult = {
13
+ callId?: string;
14
+ initiated?: boolean;
15
+ error?: string;
16
+ };
17
+
18
+ type VoiceCallSpeakResult = {
19
+ success?: boolean;
20
+ error?: string;
21
+ };
22
+
23
+ type VoiceCallStatusResult = {
24
+ found?: boolean;
25
+ call?: unknown;
26
+ };
27
+
28
+ type VoiceCallMeetJoinResult = {
29
+ callId: string;
30
+ dtmfSent: boolean;
31
+ introSent: boolean;
32
+ };
33
+
34
+ async function createConnectedGatewayClient(
35
+ config: GoogleMeetConfig,
36
+ ): Promise<VoiceCallGatewayClient> {
37
+ let client: VoiceCallGatewayClient;
38
+ await new Promise<void>((resolve, reject) => {
39
+ const abortStart = new AbortController();
40
+ const timer = setTimeout(() => {
41
+ abortStart.abort();
42
+ reject(new Error("gateway connect timeout"));
43
+ }, config.voiceCall.requestTimeoutMs);
44
+ client = new GatewayClient({
45
+ url: config.voiceCall.gatewayUrl,
46
+ token: config.voiceCall.token,
47
+ requestTimeoutMs: config.voiceCall.requestTimeoutMs,
48
+ clientName: "cli",
49
+ clientDisplayName: "Google Meet plugin",
50
+ scopes: ["operator.write"],
51
+ onHelloOk: () => {
52
+ clearTimeout(timer);
53
+ resolve();
54
+ },
55
+ onConnectError: (err) => {
56
+ clearTimeout(timer);
57
+ abortStart.abort();
58
+ reject(err);
59
+ },
60
+ });
61
+ void startGatewayClientWhenEventLoopReady(client, {
62
+ timeoutMs: config.voiceCall.requestTimeoutMs,
63
+ signal: abortStart.signal,
64
+ })
65
+ .then((readiness) => {
66
+ if (!readiness.ready && !readiness.aborted) {
67
+ clearTimeout(timer);
68
+ reject(new Error("gateway event loop readiness timeout"));
69
+ }
70
+ })
71
+ .catch((err) => {
72
+ clearTimeout(timer);
73
+ reject(err instanceof Error ? err : new Error(String(err)));
74
+ });
75
+ });
76
+ return client!;
77
+ }
78
+
79
+ export function isVoiceCallMissingError(error: unknown): boolean {
80
+ const message = formatErrorMessage(error).toLowerCase();
81
+ return message.includes("call not found") || message.includes("call is not active");
82
+ }
83
+
84
+ export async function joinMeetViaVoiceCallGateway(params: {
85
+ config: GoogleMeetConfig;
86
+ dialInNumber: string;
87
+ dtmfSequence?: string;
88
+ logger?: RuntimeLogger;
89
+ message?: string;
90
+ requesterSessionKey?: string;
91
+ sessionKey?: string;
92
+ }): Promise<VoiceCallMeetJoinResult> {
93
+ let client: VoiceCallGatewayClient | undefined;
94
+
95
+ try {
96
+ client = await createConnectedGatewayClient(params.config);
97
+ params.logger?.info(
98
+ `[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
99
+ );
100
+ const start = (await client.request(
101
+ "voicecall.start",
102
+ {
103
+ to: params.dialInNumber,
104
+ mode: "conversation",
105
+ ...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
106
+ ...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
107
+ ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
108
+ },
109
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
110
+ )) as VoiceCallStartResult;
111
+ if (!start.callId) {
112
+ throw new Error(start.error || "voicecall.start did not return callId");
113
+ }
114
+ params.logger?.info(
115
+ `[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`,
116
+ );
117
+ const dtmfSent = Boolean(params.dtmfSequence);
118
+ if (dtmfSent) {
119
+ params.logger?.info(
120
+ `[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
121
+ );
122
+ }
123
+ let introSent = false;
124
+ if (params.message) {
125
+ const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
126
+ if (delayMs > 0) {
127
+ params.logger?.info(
128
+ `[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
129
+ );
130
+ await sleep(delayMs);
131
+ }
132
+ let spoken: VoiceCallSpeakResult;
133
+ try {
134
+ spoken = (await client.request(
135
+ "voicecall.speak",
136
+ {
137
+ callId: start.callId,
138
+ allowTwimlFallback: false,
139
+ message: params.message,
140
+ },
141
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
142
+ )) as VoiceCallSpeakResult;
143
+ } catch (err) {
144
+ params.logger?.warn?.(
145
+ `[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
146
+ );
147
+ spoken = { success: false };
148
+ }
149
+ if (spoken.success === false) {
150
+ params.logger?.warn?.(
151
+ `[google-meet] Skipped intro speech because realtime bridge was not ready: ${
152
+ spoken.error || "voicecall.speak failed"
153
+ }`,
154
+ );
155
+ } else {
156
+ introSent = true;
157
+ params.logger?.info(
158
+ `[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
159
+ );
160
+ }
161
+ }
162
+ return {
163
+ callId: start.callId,
164
+ dtmfSent,
165
+ introSent,
166
+ };
167
+ } finally {
168
+ await client?.stopAndWait({ timeoutMs: 1_000 });
169
+ }
170
+ }
171
+
172
+ export async function endMeetVoiceCallGatewayCall(params: {
173
+ config: GoogleMeetConfig;
174
+ callId: string;
175
+ }): Promise<void> {
176
+ let client: VoiceCallGatewayClient | undefined;
177
+
178
+ try {
179
+ client = await createConnectedGatewayClient(params.config);
180
+ try {
181
+ await client.request(
182
+ "voicecall.end",
183
+ {
184
+ callId: params.callId,
185
+ },
186
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
187
+ );
188
+ } catch (err) {
189
+ if (!isVoiceCallMissingError(err)) {
190
+ throw err;
191
+ }
192
+ }
193
+ } finally {
194
+ await client?.stopAndWait({ timeoutMs: 1_000 });
195
+ }
196
+ }
197
+
198
+ export async function getMeetVoiceCallGatewayCall(params: {
199
+ config: GoogleMeetConfig;
200
+ callId: string;
201
+ }): Promise<VoiceCallStatusResult> {
202
+ let client: VoiceCallGatewayClient | undefined;
203
+
204
+ try {
205
+ client = await createConnectedGatewayClient(params.config);
206
+ return (await client.request(
207
+ "voicecall.status",
208
+ {
209
+ callId: params.callId,
210
+ },
211
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
212
+ )) as VoiceCallStatusResult;
213
+ } finally {
214
+ await client?.stopAndWait({ timeoutMs: 1_000 });
215
+ }
216
+ }
217
+
218
+ export async function speakMeetViaVoiceCallGateway(params: {
219
+ config: GoogleMeetConfig;
220
+ callId: string;
221
+ message: string;
222
+ }): Promise<void> {
223
+ let client: VoiceCallGatewayClient | undefined;
224
+
225
+ try {
226
+ client = await createConnectedGatewayClient(params.config);
227
+ const spoken = (await client.request(
228
+ "voicecall.speak",
229
+ {
230
+ callId: params.callId,
231
+ message: params.message,
232
+ },
233
+ { timeoutMs: params.config.voiceCall.requestTimeoutMs },
234
+ )) as VoiceCallSpeakResult;
235
+ if (spoken.success === false) {
236
+ throw new Error(spoken.error || "voicecall.speak failed");
237
+ }
238
+ } finally {
239
+ await client?.stopAndWait({ timeoutMs: 1_000 });
240
+ }
241
+ }
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
+ }