Files
openclaw/extensions/google-meet/src/runtime.ts
Peter Steinberger d7b0bacf10 fix(teams-meetings): join and transcribe live meetings reliably (#110615)
* fix(teams-meetings): validate live web flows

* chore(plugin-sdk): update meeting runtime baseline

* test(teams-meetings): align live validation fixtures

* fix(teams-meetings): harden live browser validation

* fix(teams-meetings): preserve live transition lifecycles

* fix(meeting-bot): pass wait budget during recovery

* test(teams-meetings): satisfy extension lint

* fix(teams-meetings): preserve browser session ownership

* fix(teams-meetings): retire audio bridges after leave

* fix(teams-meetings): harden leave and audio ownership

* fix(teams-meetings): preserve leave transition ownership

* fix(teams-meetings): serialize leave cleanup safely

* fix(teams-meetings): guard live transition cleanup

* fix(teams-meetings): preserve adapter boundaries

* fix(teams-meetings): await caption readiness

* fix(teams-meetings): surface caption blockers

* fix(teams-meetings): retry transient media playback

* chore(teams-meetings): defer release notes

* refactor(meeting-bot): isolate navigation error helper

* test(teams-meetings): keep page control helper private

* fix(meeting-bot): bound caption lifecycle cleanup

* chore(plugin-sdk): refresh API baseline

* test(teams-meetings): split caption ownership coverage

* fix(teams-meetings): retry pending audio routing

* fix(teams-meetings): retain leave transition ownership

* fix(teams-meetings): refresh transcript target

* fix(teams-meetings): bound audio routing retries

* fix(teams-meetings): retry pending remote audio

* fix(teams-meetings): harden media source ownership
2026-07-18 15:57:22 +01:00

734 lines
27 KiB
TypeScript

// Google Meet composes platform strategies with the shared meeting session runtime.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
MeetingSessionRuntime,
type MeetingSessionLeaveResult,
type MeetingSessionRuntimeHandles,
type MeetingSessionRuntimeJoinContext,
} from "openclaw/plugin-sdk/meeting-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
GoogleMeetConfig,
GoogleMeetMode,
GoogleMeetModeInput,
GoogleMeetTransport,
} from "./config.js";
import {
testGoogleMeetListening,
testGoogleMeetSpeech,
type GoogleMeetRuntimeProbeContext,
} from "./runtime-probes.js";
import { createGoogleMeetSession } from "./runtime-session.js";
import { getGoogleMeetRuntimeSetupStatus } from "./runtime-setup.js";
import {
launchChromeMeet,
launchChromeMeetOnNode,
leaveChromeMeet,
leaveChromeMeetOnNode,
readChromeMeetTranscript,
readChromeMeetTranscriptOnNode,
recoverCurrentMeetTab,
recoverCurrentMeetTabOnNode,
} from "./transports/chrome.js";
import { GOOGLE_MEET_PLATFORM_ADAPTER } from "./transports/google-meet-platform-adapter.js";
import type {
GoogleMeetBrowserTab,
GoogleMeetChromeHealth,
GoogleMeetJoinRequest,
GoogleMeetJoinResult,
GoogleMeetSession,
} from "./transports/types.js";
import {
createVoiceCallGateway,
endMeetVoiceCallGatewayCall,
getMeetVoiceCallGatewayCall,
isVoiceCallMissingError,
joinMeetViaVoiceCallGateway,
speakMeetViaVoiceCallGateway,
type VoiceCallGateway,
} from "./voice-call-gateway.js";
type ChromeAudioBridgeResult = NonNullable<
| Awaited<ReturnType<typeof launchChromeMeet>>["audioBridge"]
| Awaited<ReturnType<typeof launchChromeMeetOnNode>>["audioBridge"]
>;
type ChromeLaunchResult =
| Awaited<ReturnType<typeof launchChromeMeet>>
| Awaited<ReturnType<typeof launchChromeMeetOnNode>>;
type GoogleMeetManualActionReason = NonNullable<GoogleMeetChromeHealth["manualActionReason"]>;
type GoogleMeetSpeechBlockedReason = NonNullable<GoogleMeetChromeHealth["speechBlockedReason"]>;
type GoogleMeetSessionRuntime = MeetingSessionRuntime<
GoogleMeetSession,
GoogleMeetJoinRequest,
GoogleMeetTransport,
GoogleMeetMode,
GoogleMeetChromeHealth,
GoogleMeetBrowserTab,
GoogleMeetManualActionReason,
GoogleMeetSpeechBlockedReason
>;
type GoogleMeetJoinContext = MeetingSessionRuntimeJoinContext<
GoogleMeetSession,
GoogleMeetTransport,
GoogleMeetMode,
GoogleMeetChromeHealth,
GoogleMeetBrowserTab
>;
function nowIso(): string {
return new Date().toISOString();
}
function buildTwilioVoiceCallSessionKey(meetingSessionId: string): string {
return `voice:google-meet:${meetingSessionId}`;
}
function resolveTransport(input: GoogleMeetTransport | undefined, config: GoogleMeetConfig) {
return input ?? config.defaultTransport;
}
function resolveMode(input: GoogleMeetModeInput | undefined, config: GoogleMeetConfig) {
return input === "realtime" ? "agent" : (input ?? config.defaultMode);
}
function resolveSessionAgentId(request: GoogleMeetJoinRequest, config: GoogleMeetConfig): string {
return normalizeAgentId(request.agentId ?? config.realtime.agentId);
}
function withSessionAgentConfig(config: GoogleMeetConfig, agentId: string): GoogleMeetConfig {
return config.realtime.agentId === agentId
? config
: { ...config, realtime: { ...config.realtime, agentId } };
}
function isGoogleMeetTalkBackMode(mode: GoogleMeetMode): boolean {
return mode === "agent" || mode === "bidi";
}
function isBrowserTransport(transport: GoogleMeetTransport): boolean {
return transport === "chrome" || transport === "chrome-node";
}
function noteSession(session: GoogleMeetSession, note: string): void {
session.notes = [...session.notes.filter((item) => item !== note), note];
}
export class GoogleMeetRuntime {
readonly #createdBrowserTabs = new Map<string, string>();
readonly #voiceCallGateway: VoiceCallGateway;
readonly #sessions: GoogleMeetSessionRuntime;
constructor(
private readonly params: {
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
runtime: PluginRuntime;
logger: RuntimeLogger;
},
) {
this.#voiceCallGateway = createVoiceCallGateway(params);
this.#sessions = new MeetingSessionRuntime({
logger: params.logger,
logScope: "[google-meet]",
formatError: formatErrorMessage,
reuseExistingBrowserTab: params.config.chrome.reuseExistingTab,
waitForInCallMs: params.config.chrome.waitForInCallMs,
joinTimeoutMs: params.config.chrome.joinTimeoutMs,
defaultSpeechInstructions: params.config.realtime.introMessage,
transientSpeechBlockedReasons: new Set<GoogleMeetSpeechBlockedReason>([
"not-in-call",
"browser-unverified",
"meet-microphone-muted",
]),
messages: {
previousBrowserLeaveFailed:
"Could not leave the previous Meet browser tab before reassignment.",
reassignedSessionNote: "Ended before the same Meet tab was reassigned to another agent.",
reusedSessionNote: "Reused existing active Meet session.",
replacementBrowserLeaveFailed:
"Could not leave the previous Meet browser tab before reassignment.",
speechBlockedFallback: "Realtime speech blocked until Google Meet is ready.",
speech: {
audioBridgeUnavailable: "Realtime speech requires an active Chrome audio bridge.",
browserUnverified: "Google Meet browser state has not been verified yet.",
manualActionFallback:
"Resolve the Google Meet browser prompt before asking OpenClaw to speak.",
microphoneMuted:
"Turn on the OpenClaw Google Meet microphone before asking OpenClaw to speak.",
microphoneMutedReason: "meet-microphone-muted",
notInCall: "Google Meet has not reported that the browser participant is in the call.",
notInCallReason: "not-in-call",
browserUnverifiedReason: "browser-unverified",
audioBridgeUnavailableReason: "audio-bridge-unavailable",
},
},
resolveJoin: (request) => ({
url: GOOGLE_MEET_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url),
transport: resolveTransport(request.transport, params.config),
mode: resolveMode(request.mode, params.config),
agentId: resolveSessionAgentId(request, params.config),
}),
createSession: ({ request: _request, resolved, createdAt }) =>
createGoogleMeetSession({ config: params.config, resolved, createdAt }),
resolveSpeechInstructions: (request) =>
request.message ?? params.config.realtime.introMessage,
isBrowserTransport,
isTalkBackMode: isGoogleMeetTalkBackMode,
isTranscribeMode: (mode) => mode === "transcribe",
sameMeetingUrl: (left, right) => GOOGLE_MEET_PLATFORM_ADAPTER.urls.isSameMeeting(left, right),
normalizeMeetingUrlForReuse: (url) =>
GOOGLE_MEET_PLATFORM_ADAPTER.urls.normalizeForReuse(url),
getBrowser: (session) =>
session.chrome
? {
launched: session.chrome.launched,
nodeId: session.chrome.nodeId,
tab: session.chrome.browserTab,
health: session.chrome.health,
hasAudioBridge: Boolean(session.chrome.audioBridge),
}
: undefined,
setBrowserTab: (session, tab) => {
if (session.chrome) {
session.chrome.browserTab = tab;
}
},
setBrowserHealth: (session, health) => {
if (session.chrome) {
session.chrome.health = health;
}
},
joinTransport: async ({ request, session, context }) =>
await this.#joinTransport(request, session, context),
releaseBrowserTab: async (session) => await this.#releaseBrowserTab(session),
refreshBrowserHealth: async (session, options) =>
await this.#refreshBrowserHealth(session, options),
refreshStatus: async (session) => await this.#refreshStatus(session),
refreshReusableSession: async (session) => {
if (session.transport === "twilio") {
await this.#refreshTwilioVoiceCallStatus(session);
}
},
ensureRealtimeBridge: async (session) => await this.#ensureChromeRealtimeBridge(session),
captureTranscript: async (session, options) =>
await this.#captureTranscript(session, options),
speakViaTransport: async (session, instructions) =>
await this.#speakViaTransport(session, instructions),
});
}
list(): GoogleMeetSession[] {
return this.#sessions.list();
}
async status(sessionId?: string) {
return await this.#sessions.status(sessionId);
}
async transcript(sessionId: string, options: { sinceIndex?: number } = {}) {
return await this.#sessions.transcript(sessionId, options);
}
async setupStatus(
options: {
transport?: GoogleMeetTransport;
mode?: GoogleMeetModeInput;
dialInNumber?: string;
} = {},
) {
return await getGoogleMeetRuntimeSetupStatus({
config: this.params.config,
fullConfig: this.params.fullConfig,
runtime: this.params.runtime,
options,
});
}
async createViaBrowser() {
const result = await GOOGLE_MEET_PLATFORM_ADAPTER.create!.browser({
runtime: this.params.runtime,
config: this.params.config,
});
if (result.openedByPlugin && result.targetId) {
this.#createdBrowserTabs.set(`${result.nodeId}:${result.targetId}`, result.meetingUri);
}
return result;
}
async recoverCurrentTab(request: { url?: string; transport?: GoogleMeetTransport } = {}) {
const transport = resolveTransport(request.transport, this.params.config);
if (transport === "twilio") {
throw new Error("recover_current_tab only supports chrome or chrome-node transports");
}
const url = request.url
? GOOGLE_MEET_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url)
: undefined;
return transport === "chrome-node"
? await recoverCurrentMeetTabOnNode({
runtime: this.params.runtime,
config: this.params.config,
url,
})
: await recoverCurrentMeetTab({
runtime: this.params.runtime,
config: this.params.config,
url,
});
}
async join(request: GoogleMeetJoinRequest): Promise<GoogleMeetJoinResult> {
return await this.#sessions.join(request);
}
async leave(
sessionId: string,
options?: { keepBrowserTab?: boolean },
): Promise<MeetingSessionLeaveResult<GoogleMeetSession>> {
return await this.#sessions.leave(sessionId, options);
}
async speak(sessionId: string, instructions?: string) {
return await this.#sessions.speak(sessionId, instructions);
}
async testSpeech(request: GoogleMeetJoinRequest) {
return await testGoogleMeetSpeech(this.#probeContext(), request);
}
async testListen(request: GoogleMeetJoinRequest) {
return await testGoogleMeetListening(this.#probeContext(), request);
}
#probeContext(): GoogleMeetRuntimeProbeContext {
return {
config: this.params.config,
resolveAgentId: (request) => resolveSessionAgentId(request, this.params.config),
list: () => this.list(),
join: async (request) => await this.join(request),
isReusable: (session, resolved) => this.#sessions.isReusableSession(session, resolved),
hasHealthHandle: (sessionId) => this.#sessions.hasHealthHandle(sessionId),
refreshHealth: (sessionId) => this.#sessions.refreshHealth(sessionId),
refreshCaptionHealth: async (session) => await this.#sessions.refreshCaptionHealth(session),
};
}
async #joinTransport(
request: GoogleMeetJoinRequest,
session: GoogleMeetSession,
context: GoogleMeetJoinContext,
): Promise<{ delegatedSpoken?: boolean }> {
if (isBrowserTransport(session.transport)) {
const chromeConfig = withSessionAgentConfig(this.params.config, session.agentId);
const result: ChromeLaunchResult =
session.transport === "chrome-node"
? await launchChromeMeetOnNode({
runtime: this.params.runtime,
config: chromeConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: request.requesterSessionKey,
mode: session.mode,
url: session.url,
logger: this.params.logger,
})
: await launchChromeMeet({
runtime: this.params.runtime,
config: chromeConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
requesterSessionKey: request.requesterSessionKey,
mode: session.mode,
url: session.url,
logger: this.params.logger,
});
const nodeId = "nodeId" in result ? result.nodeId : undefined;
let tab = result.tab;
const createdKey =
session.transport === "chrome-node" && nodeId && tab
? `${nodeId}:${tab.targetId}`
: undefined;
const createdUrl = createdKey ? this.#createdBrowserTabs.get(createdKey) : undefined;
if (createdKey) {
this.#createdBrowserTabs.delete(createdKey);
}
if (tab && GOOGLE_MEET_PLATFORM_ADAPTER.urls.isSameMeeting(createdUrl, session.url)) {
tab = { ...tab, openedByPlugin: true };
}
tab = context.inheritedBrowserTab({
session,
transport: session.transport,
nodeId,
meetingUrl: session.url,
tab,
});
session.chrome = {
audioBackend: this.params.config.chrome.audioBackend,
launched: result.launched,
nodeId,
browserProfile: this.params.config.chrome.browserProfile,
browserTab: tab,
health: result.browser,
};
const handles = this.#attachChromeAudioBridge(session, result.audioBridge);
if (handles) {
context.attachRuntimeHandles(session, handles);
}
session.notes.push(
result.audioBridge
? session.transport === "chrome-node"
? "Chrome node transport joins as the signed-in Google profile on the selected node and routes realtime audio through the node bridge."
: "Chrome transport joins as the signed-in Google profile and routes realtime audio through the configured bridge."
: isGoogleMeetTalkBackMode(session.mode)
? "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing."
: "Chrome transport joins as the signed-in Google profile without starting the realtime audio bridge.",
);
this.#sessions.refreshSpeechReadiness(session);
return {};
}
const dialPlan = GOOGLE_MEET_PLATFORM_ADAPTER.dialIn!.buildPlan({
dialInNumber: request.dialInNumber,
defaultDialInNumber: this.params.config.twilio.defaultDialInNumber,
pin: request.pin,
defaultPin: this.params.config.twilio.defaultPin,
dtmfSequence: request.dtmfSequence,
defaultDtmfSequence: this.params.config.twilio.defaultDtmfSequence,
dtmfDelayMs: this.params.config.voiceCall.dtmfDelayMs,
});
const dialInNumber = dialPlan.number;
if (!dialInNumber) {
throw new Error(
"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.",
);
}
const dtmfSequence = dialPlan.dtmfSequence;
const hasExplicitAgent = Boolean(
normalizeOptionalString(request.agentId) ||
normalizeOptionalString(this.params.config.realtime.agentId),
);
const delegatedAgentId = hasExplicitAgent ? session.agentId : undefined;
const voiceCallResult = this.params.config.voiceCall.enabled
? await joinMeetViaVoiceCallGateway({
config: this.params.config,
gateway: this.#voiceCallGateway,
dialInNumber,
dtmfSequence,
logger: this.params.logger,
...(request.requesterSessionKey
? { requesterSessionKey: request.requesterSessionKey }
: {}),
agentId: delegatedAgentId,
sessionKey: delegatedAgentId
? `agent:${delegatedAgentId}:google-meet:${session.id}`
: buildTwilioVoiceCallSessionKey(session.id),
message: isGoogleMeetTalkBackMode(session.mode)
? (request.message ??
this.params.config.voiceCall.introMessage ??
this.params.config.realtime.introMessage)
: undefined,
})
: undefined;
session.twilio = {
dialInNumber,
pinProvided: Boolean(dialPlan.pin),
dtmfSequence,
voiceCallId: voiceCallResult?.callId,
dtmfSent: voiceCallResult?.dtmfSent,
introSent: voiceCallResult?.introSent,
};
if (voiceCallResult?.callId) {
context.attachRuntimeHandles(session, {
stop: async () => {
await endMeetVoiceCallGatewayCall({
gateway: this.#voiceCallGateway,
callId: voiceCallResult.callId,
});
},
});
}
session.notes.push(
this.params.config.voiceCall.enabled
? dtmfSequence
? "Twilio transport delegated the phone leg to the voice-call plugin, then queued configured DTMF before realtime connect."
: "Twilio transport delegated the call to the voice-call plugin without configured DTMF."
: "Twilio transport is an explicit dial plan; voice-call delegation is disabled.",
);
return { delegatedSpoken: Boolean(voiceCallResult?.introSent) };
}
#attachChromeAudioBridge(
session: GoogleMeetSession,
audioBridge: ChromeAudioBridgeResult | undefined,
): MeetingSessionRuntimeHandles<GoogleMeetChromeHealth> | undefined {
if (!session.chrome || !audioBridge) {
return undefined;
}
session.chrome.audioBridge = {
type: audioBridge.type,
provider:
audioBridge.type === "command-pair" || audioBridge.type === "node-command-pair"
? audioBridge.providerId
: undefined,
};
return audioBridge.type === "command-pair" || audioBridge.type === "node-command-pair"
? { stop: audioBridge.stop, speak: audioBridge.speak, getHealth: audioBridge.getHealth }
: undefined;
}
async #ensureChromeRealtimeBridge(
session: GoogleMeetSession,
): Promise<MeetingSessionRuntimeHandles<GoogleMeetChromeHealth> | undefined> {
if (
!isGoogleMeetTalkBackMode(session.mode) ||
!isBrowserTransport(session.transport) ||
session.state !== "active" ||
!session.chrome ||
session.chrome.audioBridge ||
session.chrome.health?.inCall !== true ||
session.chrome.health.micMuted !== false ||
session.chrome.health.manualActionRequired === true
) {
return undefined;
}
const config = withSessionAgentConfig(this.params.config, session.agentId);
// This session already owns its browser tab. Bridge recovery must not
// launch or navigate another tab, even when tab reuse is disabled.
const recoveryConfig = {
...config,
chrome: { ...config.chrome, launch: false },
...(session.chrome.nodeId
? { chromeNode: { ...config.chromeNode, node: session.chrome.nodeId } }
: {}),
};
const result: ChromeLaunchResult =
session.transport === "chrome-node"
? await launchChromeMeetOnNode({
runtime: this.params.runtime,
config: recoveryConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
mode: session.mode,
url: session.url,
logger: this.params.logger,
})
: await launchChromeMeet({
runtime: this.params.runtime,
config: recoveryConfig,
fullConfig: this.params.fullConfig,
meetingSessionId: session.id,
mode: session.mode,
url: session.url,
logger: this.params.logger,
});
session.updatedAt = nowIso();
return this.#attachChromeAudioBridge(session, result.audioBridge);
}
async #refreshBrowserHealth(
session: GoogleMeetSession,
options: { force?: boolean; readOnly?: boolean } = {},
): Promise<void> {
try {
const result =
session.transport === "chrome-node"
? await recoverCurrentMeetTabOnNode({
runtime: this.params.runtime,
config: this.params.config,
mode: session.mode,
readOnly: options.readOnly,
trackedMeetingUrl: session.url,
trackedTargetId: session.chrome?.browserTab?.targetId,
url: session.url,
})
: await recoverCurrentMeetTab({
runtime: this.params.runtime,
config: this.params.config,
mode: session.mode,
readOnly: options.readOnly,
trackedMeetingUrl: session.url,
trackedTargetId: session.chrome?.browserTab?.targetId,
url: session.url,
});
if (result.found && session.chrome) {
if (result.targetId) {
const currentTab = session.chrome.browserTab;
session.chrome.browserTab = {
targetId: result.targetId,
openedByPlugin:
result.targetId === currentTab?.targetId ? currentTab.openedByPlugin : false,
};
}
if (result.browser) {
session.chrome.health = { ...session.chrome.health, ...result.browser };
}
session.updatedAt = nowIso();
}
} catch (error) {
this.params.logger.debug?.(
`[google-meet] browser readiness refresh ignored: ${formatErrorMessage(error)}`,
);
}
}
async #refreshStatus(session: GoogleMeetSession): Promise<void> {
if (isBrowserTransport(session.transport)) {
await this.#sessions.refreshBrowserHealth(session, { force: true, readOnly: true });
} else if (session.transport === "twilio") {
await this.#refreshTwilioVoiceCallStatus(session);
} else {
this.#sessions.refreshSpeechReadiness(session);
}
}
async #refreshTwilioVoiceCallStatus(session: GoogleMeetSession): Promise<void> {
const callId = session.twilio?.voiceCallId;
if (!callId || session.state !== "active") {
this.#sessions.refreshSpeechReadiness(session);
return;
}
try {
const status = await getMeetVoiceCallGatewayCall({
gateway: this.#voiceCallGateway,
callId,
});
if (status.found === false) {
this.#sessions.markSessionEnded(session, "Voice Call is no longer active.");
}
} catch (error) {
this.params.logger.debug?.(
`[google-meet] voice-call status refresh ignored: ${formatErrorMessage(error)}`,
);
}
this.#sessions.refreshSpeechReadiness(session);
}
async #speakViaTransport(
session: GoogleMeetSession,
instructions?: string,
): Promise<{ handled: boolean; spoken: boolean } | undefined> {
if (session.transport !== "twilio" || !session.twilio?.voiceCallId) {
return undefined;
}
try {
await speakMeetViaVoiceCallGateway({
gateway: this.#voiceCallGateway,
callId: session.twilio.voiceCallId,
message:
instructions ||
this.params.config.voiceCall.introMessage ||
this.params.config.realtime.introMessage ||
"",
});
} catch (error) {
if (!isVoiceCallMissingError(error)) {
throw error;
}
this.#sessions.markSessionEnded(session, "Voice Call is no longer active.");
return { handled: true, spoken: false };
}
session.twilio.introSent = true;
session.updatedAt = nowIso();
return { handled: true, spoken: true };
}
async #captureTranscript(session: GoogleMeetSession, options: { finalize?: boolean } = {}) {
const tab = session.chrome?.browserTab;
if (!tab) {
return undefined;
}
return session.transport === "chrome-node"
? await readChromeMeetTranscriptOnNode({
runtime: this.params.runtime,
nodeId: session.chrome?.nodeId,
config: this.params.config,
...(options.finalize === undefined ? {} : { finalize: options.finalize }),
meetingUrl: session.url,
meetingSessionId: session.id,
tab,
})
: await readChromeMeetTranscript({
runtime: this.params.runtime,
config: this.params.config,
...(options.finalize === undefined ? {} : { finalize: options.finalize }),
meetingUrl: session.url,
meetingSessionId: session.id,
tab,
});
}
async #releaseBrowserTab(session: GoogleMeetSession): Promise<boolean | undefined> {
if (!isBrowserTransport(session.transport)) {
return undefined;
}
const tab = session.chrome?.browserTab;
if (!tab) {
noteSession(
session,
"No tracked Meet browser tab for this session; close the Meet tab manually if it is still in the call.",
);
session.browserLeft = false;
return false;
}
const shared = this.list().some(
(other) =>
other.id !== session.id &&
other.state === "active" &&
isBrowserTransport(other.transport) &&
other.chrome?.browserTab?.targetId === tab.targetId &&
other.chrome?.nodeId === session.chrome?.nodeId,
);
if (shared) {
noteSession(session, "Kept the shared Meet tab open because another active session uses it.");
session.browserLeft = undefined;
return undefined;
}
let left: boolean;
try {
const result =
session.transport === "chrome-node"
? await leaveChromeMeetOnNode({
runtime: this.params.runtime,
nodeId: session.chrome?.nodeId,
config: this.params.config,
meetingSessionId: session.id,
meetingUrl: session.url,
tab,
})
: await leaveChromeMeet({
runtime: this.params.runtime,
config: this.params.config,
meetingSessionId: session.id,
meetingUrl: session.url,
tab,
});
noteSession(session, result.note);
left = result.left;
} catch (error) {
noteSession(
session,
`Browser control could not leave the Meet tab: ${formatErrorMessage(error)}`,
);
left = false;
}
if (session.chrome && left) {
session.chrome.browserTab = undefined;
if (session.chrome.health) {
session.chrome.health = {
...session.chrome.health,
captioning: false,
audioOutputRouted: false,
providerConnected: false,
realtimeReady: false,
audioInputActive: false,
audioOutputActive: false,
};
}
}
session.browserLeft = left;
return left;
}
}