@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.
- package/autobot.plugin.json +532 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +1224 -0
- package/package.json +47 -0
- package/src/agent-consult.ts +158 -0
- package/src/calendar.ts +252 -0
- package/src/cli.ts +2350 -0
- package/src/config-compat.ts +84 -0
- package/src/config.ts +589 -0
- package/src/create.ts +157 -0
- package/src/drive.ts +72 -0
- package/src/google-api-errors.ts +20 -0
- package/src/meet.ts +1024 -0
- package/src/node-host.ts +520 -0
- package/src/oauth.ts +229 -0
- package/src/realtime-node.ts +780 -0
- package/src/realtime.ts +1334 -0
- package/src/runtime.ts +1008 -0
- package/src/setup.ts +285 -0
- package/src/transports/chrome-browser-proxy.ts +204 -0
- package/src/transports/chrome-create.ts +364 -0
- package/src/transports/chrome.ts +1065 -0
- package/src/transports/twilio.ts +57 -0
- package/src/transports/types.ts +147 -0
- package/src/voice-call-gateway.ts +241 -0
- package/tsconfig.json +16 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isBlockedHostnameOrIp } from "autobot/plugin-sdk/ssrf-runtime";
|
|
5
|
+
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
|
|
6
|
+
|
|
7
|
+
type SetupCheck = {
|
|
8
|
+
id: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type GoogleMeetSetupStatus = {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
checks: SetupCheck[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function resolveUserPath(input: string): string {
|
|
19
|
+
if (input === "~") {
|
|
20
|
+
return os.homedir();
|
|
21
|
+
}
|
|
22
|
+
if (input.startsWith("~/")) {
|
|
23
|
+
return path.join(os.homedir(), input.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return input;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(webhookUrl);
|
|
31
|
+
return isBlockedHostnameOrIp(parsed.hostname);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
|
|
38
|
+
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
|
|
39
|
+
const tunnel = asRecord(voiceCallConfig.tunnel);
|
|
40
|
+
const tailscale = asRecord(voiceCallConfig.tailscale);
|
|
41
|
+
const tunnelProvider = normalizeOptionalString(tunnel.provider);
|
|
42
|
+
const tailscaleMode = normalizeOptionalString(tailscale.mode);
|
|
43
|
+
|
|
44
|
+
if (publicUrl) {
|
|
45
|
+
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
|
|
46
|
+
return {
|
|
47
|
+
id: "twilio-voice-call-webhook",
|
|
48
|
+
ok,
|
|
49
|
+
message: ok
|
|
50
|
+
? `Voice-call public webhook URL configured: ${publicUrl}`
|
|
51
|
+
: `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tunnelProvider && tunnelProvider !== "none") {
|
|
56
|
+
return {
|
|
57
|
+
id: "twilio-voice-call-webhook",
|
|
58
|
+
ok: true,
|
|
59
|
+
message: "Voice-call webhook exposure configured through tunnel",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (tailscaleMode && tailscaleMode !== "off") {
|
|
64
|
+
return {
|
|
65
|
+
id: "twilio-voice-call-webhook",
|
|
66
|
+
ok: true,
|
|
67
|
+
message: "Voice-call webhook exposure configured through Tailscale",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: "twilio-voice-call-webhook",
|
|
73
|
+
ok: false,
|
|
74
|
+
message:
|
|
75
|
+
"Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
checks: SetupCheck[];
|
|
82
|
+
};
|
|
83
|
+
export function getGoogleMeetSetupStatus(
|
|
84
|
+
config: GoogleMeetConfig,
|
|
85
|
+
options?: {
|
|
86
|
+
env?: NodeJS.ProcessEnv;
|
|
87
|
+
fullConfig?: unknown;
|
|
88
|
+
mode?: GoogleMeetMode;
|
|
89
|
+
transport?: GoogleMeetTransport;
|
|
90
|
+
twilioDialInNumber?: string;
|
|
91
|
+
},
|
|
92
|
+
): {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
checks: SetupCheck[];
|
|
95
|
+
};
|
|
96
|
+
export function getGoogleMeetSetupStatus(
|
|
97
|
+
config: GoogleMeetConfig,
|
|
98
|
+
options?: {
|
|
99
|
+
env?: NodeJS.ProcessEnv;
|
|
100
|
+
fullConfig?: unknown;
|
|
101
|
+
mode?: GoogleMeetMode;
|
|
102
|
+
transport?: GoogleMeetTransport;
|
|
103
|
+
twilioDialInNumber?: string;
|
|
104
|
+
},
|
|
105
|
+
) {
|
|
106
|
+
const checks: SetupCheck[] = [];
|
|
107
|
+
const env = options?.env ?? process.env;
|
|
108
|
+
const fullConfig = asRecord(options?.fullConfig);
|
|
109
|
+
const mode = options?.mode ?? config.defaultMode;
|
|
110
|
+
const transport = options?.transport ?? config.defaultTransport;
|
|
111
|
+
const needsChromeRealtimeAudio =
|
|
112
|
+
(mode === "agent" || mode === "bidi") &&
|
|
113
|
+
(transport === "chrome" || transport === "chrome-node");
|
|
114
|
+
const pluginEntries = asRecord(asRecord(fullConfig.plugins).entries);
|
|
115
|
+
const pluginAllow = asRecord(fullConfig.plugins).allow;
|
|
116
|
+
const voiceCallEntry = asRecord(pluginEntries["voice-call"]);
|
|
117
|
+
const voiceCallConfig = asRecord(voiceCallEntry.config);
|
|
118
|
+
const voiceCallTwilioConfig = asRecord(voiceCallConfig.twilio);
|
|
119
|
+
|
|
120
|
+
if (config.auth.tokenPath) {
|
|
121
|
+
const tokenPath = resolveUserPath(config.auth.tokenPath);
|
|
122
|
+
checks.push({
|
|
123
|
+
id: "google-oauth-token",
|
|
124
|
+
ok: fs.existsSync(tokenPath),
|
|
125
|
+
message: fs.existsSync(tokenPath)
|
|
126
|
+
? "Google OAuth token file found"
|
|
127
|
+
: `Google OAuth token file missing at ${config.auth.tokenPath}`,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
checks.push({
|
|
131
|
+
id: "google-oauth-token",
|
|
132
|
+
ok: true,
|
|
133
|
+
message: "Google OAuth token path not configured; Chrome profile auth will be used",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "chrome-profile",
|
|
139
|
+
ok: true,
|
|
140
|
+
message: config.chrome.browserProfile
|
|
141
|
+
? "Local Chrome uses the AutoBot browser profile; chrome.browserProfile is passed to chrome-node hosts"
|
|
142
|
+
: "Local Chrome uses the AutoBot browser profile; configure browser.defaultProfile to choose another profile",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (needsChromeRealtimeAudio) {
|
|
146
|
+
const hasCommandPair = Boolean(
|
|
147
|
+
config.chrome.audioInputCommand && config.chrome.audioOutputCommand,
|
|
148
|
+
);
|
|
149
|
+
const hasExternalBridge = Boolean(config.chrome.audioBridgeCommand);
|
|
150
|
+
const agentModeExternalBridgeInvalid = mode === "agent" && hasExternalBridge;
|
|
151
|
+
checks.push({
|
|
152
|
+
id: "audio-bridge",
|
|
153
|
+
ok:
|
|
154
|
+
mode === "agent"
|
|
155
|
+
? hasCommandPair && !agentModeExternalBridgeInvalid
|
|
156
|
+
: hasExternalBridge || hasCommandPair,
|
|
157
|
+
message: agentModeExternalBridgeInvalid
|
|
158
|
+
? "Chrome agent mode requires chrome.audioInputCommand and chrome.audioOutputCommand; chrome.audioBridgeCommand is bidi-only"
|
|
159
|
+
: hasExternalBridge
|
|
160
|
+
? "Chrome audio bridge command configured"
|
|
161
|
+
: hasCommandPair
|
|
162
|
+
? `Chrome command-pair talk-back audio bridge configured (${config.chrome.audioFormat})`
|
|
163
|
+
: "Chrome talk-back audio bridge not configured",
|
|
164
|
+
});
|
|
165
|
+
} else if (transport === "chrome" || transport === "chrome-node") {
|
|
166
|
+
checks.push({
|
|
167
|
+
id: "audio-bridge",
|
|
168
|
+
ok: true,
|
|
169
|
+
message: "Chrome observe-only mode does not require a realtime audio bridge",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
checks.push({
|
|
174
|
+
id: "guest-join-defaults",
|
|
175
|
+
ok: Boolean(
|
|
176
|
+
config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab,
|
|
177
|
+
),
|
|
178
|
+
message:
|
|
179
|
+
config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab
|
|
180
|
+
? "Guest auto-join and tab reuse defaults are enabled"
|
|
181
|
+
: "Set chrome.guestName, chrome.autoJoin, and chrome.reuseExistingTab for unattended guest joins",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
checks.push({
|
|
185
|
+
id: "chrome-node-target",
|
|
186
|
+
ok: config.defaultTransport !== "chrome-node" || Boolean(config.chromeNode.node),
|
|
187
|
+
message:
|
|
188
|
+
config.defaultTransport === "chrome-node" && !config.chromeNode.node
|
|
189
|
+
? "chrome-node default should pin chromeNode.node when multiple nodes may be connected"
|
|
190
|
+
: config.chromeNode.node
|
|
191
|
+
? `Chrome node pinned to ${config.chromeNode.node}`
|
|
192
|
+
: "Chrome node not pinned; automatic selection works when exactly one capable node is connected",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (needsChromeRealtimeAudio) {
|
|
196
|
+
checks.push({
|
|
197
|
+
id: "intro-after-in-call",
|
|
198
|
+
ok: config.chrome.waitForInCallMs > 0,
|
|
199
|
+
message:
|
|
200
|
+
config.chrome.waitForInCallMs > 0
|
|
201
|
+
? `Realtime intro waits up to ${config.chrome.waitForInCallMs}ms for the Meet tab to be in-call`
|
|
202
|
+
: "Set chrome.waitForInCallMs to delay realtime intro until the Meet tab is in-call",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (transport === "twilio") {
|
|
207
|
+
const hasRequestDialPlan = Boolean(options?.twilioDialInNumber);
|
|
208
|
+
const hasDefaultDialPlan = Boolean(config.twilio.defaultDialInNumber);
|
|
209
|
+
const hasDialPlan = hasRequestDialPlan || hasDefaultDialPlan;
|
|
210
|
+
checks.push({
|
|
211
|
+
id: "twilio-dial-plan",
|
|
212
|
+
ok: hasDialPlan,
|
|
213
|
+
message: hasRequestDialPlan
|
|
214
|
+
? "Twilio request includes a Meet dial-in number"
|
|
215
|
+
: hasDefaultDialPlan
|
|
216
|
+
? "Twilio default Meet dial-in number is configured"
|
|
217
|
+
: "Twilio joins require a Meet dial-in phone number; pass dialInNumber with optional pin/dtmfSequence or configure twilio.defaultDialInNumber",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const shouldCheckTwilioDelegation =
|
|
222
|
+
config.voiceCall.enabled &&
|
|
223
|
+
(transport === "twilio" ||
|
|
224
|
+
Boolean(config.twilio.defaultDialInNumber) ||
|
|
225
|
+
Object.hasOwn(pluginEntries, "voice-call"));
|
|
226
|
+
if (shouldCheckTwilioDelegation) {
|
|
227
|
+
const voiceCallAllowed = !Array.isArray(pluginAllow) || pluginAllow.includes("voice-call");
|
|
228
|
+
const hasVoiceCallEntry = Object.hasOwn(pluginEntries, "voice-call");
|
|
229
|
+
const voiceCallEnabled = hasVoiceCallEntry && voiceCallEntry.enabled !== false;
|
|
230
|
+
checks.push({
|
|
231
|
+
id: "twilio-voice-call-plugin",
|
|
232
|
+
ok: voiceCallAllowed && voiceCallEnabled,
|
|
233
|
+
message:
|
|
234
|
+
voiceCallAllowed && voiceCallEnabled
|
|
235
|
+
? "Twilio transport can delegate dialing to the voice-call plugin"
|
|
236
|
+
: "Enable plugins.entries.voice-call and include voice-call in plugins.allow for Twilio dialing",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const provider = normalizeOptionalString(voiceCallConfig.provider) ?? "twilio";
|
|
240
|
+
if (provider === "twilio") {
|
|
241
|
+
const accountSid = normalizeOptionalString(voiceCallTwilioConfig.accountSid);
|
|
242
|
+
const authToken = normalizeOptionalString(voiceCallTwilioConfig.authToken);
|
|
243
|
+
const fromNumber = normalizeOptionalString(voiceCallConfig.fromNumber);
|
|
244
|
+
const twilioReady = Boolean(
|
|
245
|
+
(accountSid || env.TWILIO_ACCOUNT_SID) &&
|
|
246
|
+
(authToken || env.TWILIO_AUTH_TOKEN) &&
|
|
247
|
+
(fromNumber || env.TWILIO_FROM_NUMBER),
|
|
248
|
+
);
|
|
249
|
+
checks.push({
|
|
250
|
+
id: "twilio-voice-call-credentials",
|
|
251
|
+
ok: twilioReady,
|
|
252
|
+
message: twilioReady
|
|
253
|
+
? "Twilio voice-call credentials are configured"
|
|
254
|
+
: "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials",
|
|
255
|
+
});
|
|
256
|
+
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
ok: checks.every((check) => check.ok),
|
|
262
|
+
checks,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function addGoogleMeetSetupCheck(
|
|
267
|
+
status: GoogleMeetSetupStatus,
|
|
268
|
+
check: SetupCheck,
|
|
269
|
+
): GoogleMeetSetupStatus {
|
|
270
|
+
const checks = [...status.checks, check];
|
|
271
|
+
return {
|
|
272
|
+
ok: checks.every((item) => item.ok),
|
|
273
|
+
checks,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
278
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
279
|
+
? (value as Record<string, unknown>)
|
|
280
|
+
: {};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
284
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
285
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { PluginRuntime } from "autobot/plugin-sdk/plugin-runtime";
|
|
2
|
+
|
|
3
|
+
type BrowserProxyResult = {
|
|
4
|
+
result?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type BrowserTab = {
|
|
8
|
+
targetId?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function normalizeMeetUrlForReuse(url: string | undefined): string | undefined {
|
|
14
|
+
if (!url) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "meet.google.com") {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const match = parsed.pathname.match(/^\/(new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:\/)?$/i);
|
|
23
|
+
if (!match?.[1]) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return `https://meet.google.com/${match[1].toLowerCase()}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefined): boolean {
|
|
33
|
+
const normalizedA = normalizeMeetUrlForReuse(a);
|
|
34
|
+
const normalizedB = normalizeMeetUrlForReuse(b);
|
|
35
|
+
return Boolean(normalizedA && normalizedB && normalizedA === normalizedB);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type GoogleMeetNodeInfo = {
|
|
39
|
+
caps?: string[];
|
|
40
|
+
commands?: string[];
|
|
41
|
+
connected?: boolean;
|
|
42
|
+
nodeId?: string;
|
|
43
|
+
displayName?: string;
|
|
44
|
+
remoteIp?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function isGoogleMeetNode(node: GoogleMeetNodeInfo) {
|
|
48
|
+
const commands = Array.isArray(node.commands) ? node.commands : [];
|
|
49
|
+
const caps = Array.isArray(node.caps) ? node.caps : [];
|
|
50
|
+
return (
|
|
51
|
+
node.connected === true &&
|
|
52
|
+
commands.includes("googlemeet.chrome") &&
|
|
53
|
+
(commands.includes("browser.proxy") || caps.includes("browser"))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchesRequestedNode(node: GoogleMeetNodeInfo, requested: string): boolean {
|
|
58
|
+
return [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatNodeLabel(node: GoogleMeetNodeInfo): string {
|
|
62
|
+
const parts = [node.displayName, node.nodeId, node.remoteIp].filter(Boolean);
|
|
63
|
+
return parts.length > 0 ? parts.join(" / ") : "unknown node";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function describeNodeUsabilityIssues(node: GoogleMeetNodeInfo): string[] {
|
|
67
|
+
const commands = Array.isArray(node.commands) ? node.commands : [];
|
|
68
|
+
const caps = Array.isArray(node.caps) ? node.caps : [];
|
|
69
|
+
const issues: string[] = [];
|
|
70
|
+
if (node.connected !== true) {
|
|
71
|
+
issues.push("offline");
|
|
72
|
+
}
|
|
73
|
+
if (!commands.includes("googlemeet.chrome")) {
|
|
74
|
+
issues.push("missing googlemeet.chrome");
|
|
75
|
+
}
|
|
76
|
+
if (!commands.includes("browser.proxy") && !caps.includes("browser")) {
|
|
77
|
+
issues.push("missing browser.proxy/browser capability");
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listGoogleMeetNodes(
|
|
83
|
+
runtime: PluginRuntime,
|
|
84
|
+
params?: { connected?: boolean },
|
|
85
|
+
): Promise<{ nodes: GoogleMeetNodeInfo[] }> {
|
|
86
|
+
try {
|
|
87
|
+
return params ? await runtime.nodes.list(params) : await runtime.nodes.list();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error("Google Meet node inventory unavailable", {
|
|
90
|
+
cause: error,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolveChromeNodeInfo(params: {
|
|
96
|
+
runtime: PluginRuntime;
|
|
97
|
+
requestedNode?: string;
|
|
98
|
+
}): Promise<GoogleMeetNodeInfo> {
|
|
99
|
+
const requested = params.requestedNode?.trim();
|
|
100
|
+
if (requested) {
|
|
101
|
+
const list = await listGoogleMeetNodes(params.runtime);
|
|
102
|
+
const matches = list.nodes.filter((node) => matchesRequestedNode(node, requested));
|
|
103
|
+
if (matches.length === 1) {
|
|
104
|
+
const [node] = matches;
|
|
105
|
+
if (isGoogleMeetNode(node)) {
|
|
106
|
+
return node;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Configured Google Meet node ${requested} is not usable (${formatNodeLabel(node)}): ${describeNodeUsabilityIssues(node).join("; ")}. Start or reinstall \`autobot node run\` on that Chrome host, approve pairing, and allow googlemeet.chrome plus browser.proxy.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (matches.length > 1) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Configured Google Meet node ${requested} is ambiguous (${matches.length} matches). Pin chromeNode.node to a unique node id, display name, or remote IP.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Configured Google Meet node ${requested} was not found. Run \`autobot nodes status\` and start or approve the Chrome node.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const list = await listGoogleMeetNodes(params.runtime, { connected: true });
|
|
123
|
+
const nodes = list.nodes.filter(isGoogleMeetNode);
|
|
124
|
+
if (nodes.length === 0) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"No connected Google Meet-capable node with browser proxy. Run `autobot node run` on the Chrome host with browser proxy enabled, approve pairing, and allow googlemeet.chrome plus browser.proxy.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (nodes.length === 1) {
|
|
130
|
+
return nodes[0];
|
|
131
|
+
}
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Multiple Google Meet-capable nodes connected. Set plugins.entries.google-meet.config.chromeNode.node.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function resolveChromeNode(params: {
|
|
138
|
+
runtime: PluginRuntime;
|
|
139
|
+
requestedNode?: string;
|
|
140
|
+
}): Promise<string> {
|
|
141
|
+
const node = await resolveChromeNodeInfo(params);
|
|
142
|
+
if (!node.nodeId) {
|
|
143
|
+
throw new Error("Google Meet node did not include a node id.");
|
|
144
|
+
}
|
|
145
|
+
return node.nodeId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function unwrapNodeInvokePayload(raw: unknown): unknown {
|
|
149
|
+
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
150
|
+
if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(record.payloadJSON);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error("Google Meet browser proxy returned malformed payloadJSON.", {
|
|
155
|
+
cause: error,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if ("payload" in record) {
|
|
160
|
+
return record.payload;
|
|
161
|
+
}
|
|
162
|
+
return raw;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseBrowserProxyResult(raw: unknown): unknown {
|
|
166
|
+
const payload = unwrapNodeInvokePayload(raw);
|
|
167
|
+
const proxy =
|
|
168
|
+
payload && typeof payload === "object" ? (payload as BrowserProxyResult) : undefined;
|
|
169
|
+
if (!proxy || !("result" in proxy)) {
|
|
170
|
+
throw new Error("Google Meet browser proxy returned an invalid result.");
|
|
171
|
+
}
|
|
172
|
+
return proxy.result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function callBrowserProxyOnNode(params: {
|
|
176
|
+
runtime: PluginRuntime;
|
|
177
|
+
nodeId: string;
|
|
178
|
+
method: "GET" | "POST" | "DELETE";
|
|
179
|
+
path: string;
|
|
180
|
+
body?: unknown;
|
|
181
|
+
timeoutMs: number;
|
|
182
|
+
}) {
|
|
183
|
+
const raw = await params.runtime.nodes.invoke({
|
|
184
|
+
nodeId: params.nodeId,
|
|
185
|
+
command: "browser.proxy",
|
|
186
|
+
params: {
|
|
187
|
+
method: params.method,
|
|
188
|
+
path: params.path,
|
|
189
|
+
body: params.body,
|
|
190
|
+
timeoutMs: params.timeoutMs,
|
|
191
|
+
},
|
|
192
|
+
timeoutMs: params.timeoutMs + 5_000,
|
|
193
|
+
});
|
|
194
|
+
return parseBrowserProxyResult(raw);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function asBrowserTabs(result: unknown): BrowserTab[] {
|
|
198
|
+
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
|
199
|
+
return Array.isArray(record.tabs) ? (record.tabs as BrowserTab[]) : [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function readBrowserTab(result: unknown): BrowserTab | undefined {
|
|
203
|
+
return result && typeof result === "object" ? (result as BrowserTab) : undefined;
|
|
204
|
+
}
|