openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,647 lines • 74.1 kB
JavaScript
//#region src/meeting-bot/browser-audio-capture-source.ts
/** Captures browser playback before the native virtual microphone bus. */
function createMeetingBrowserAudioCaptureSource(params) {
return `async () => {
const action = ${JSON.stringify(params.action)};
const captureId = ${JSON.stringify(params.captureId)};
const sessionId = ${JSON.stringify(params.meetingSessionId)};
const ownsSession = () => { ${params.ownershipSource} };
const current = window.__openclawMeetingRemoteAudio;
if (action === "stop") {
if (current?.captureId === captureId) await current.stop();
return JSON.stringify({ closed: true, captureId });
}
if (action === "pull") {
if (!current || current.captureId !== captureId || !current.isCurrent()) {
if (current?.captureId === captureId) await current.stop();
return JSON.stringify({ closed: true, captureId });
}
current.lastPull = Date.now();
current.scan();
if (current.error) throw new Error(current.error);
const chunks = current.chunks.splice(0);
let binary = "";
for (const chunk of chunks) for (const byte of chunk) binary += String.fromCharCode(byte);
return JSON.stringify({ captureId, isolated: true, base64: btoa(binary) });
}
if (!ownsSession()) throw new Error("Meeting audio capture no longer owns this browser session.");
if (current) throw new Error("Meeting browser audio capture is already active.");
const context = new AudioContext({ sampleRate: 24000 });
const entries = new Map();
const pendingMute = new Map();
const sourceNodes = new Map();
const mutedBridges = new Map();
const chunks = [];
let observer;
let timer;
let processor;
let stopped = false;
let deviceRefresh;
let inputDevices = new Set(["default", "communications"]);
let inputGroups = new Set();
const sourceUrl = (element) => String(element.currentSrc || element.src || "");
const ownsMutedSource = (entry, liveTracks) => entry.stream
? entry.element.srcObject === entry.stream &&
(liveTracks ?? entry.stream.getAudioTracks().filter((track) => track.readyState === "live"))
.every((track) => entry.tracks.includes(track))
: !entry.element.srcObject && sourceUrl(entry.element) === entry.url;
const sameSource = (entry) => {
const liveTracks = entry.stream && entry.element.srcObject === entry.stream
? entry.stream.getAudioTracks().filter((track) => track.readyState === "live")
: undefined;
return ownsMutedSource(entry, liveTracks) && (!entry.stream || (
liveTracks.length === entry.tracks.length &&
entry.tracks.every((track) => track.readyState === "live")
));
};
const restoreOwnedMute = (entry, restore = capture.isCurrent()) => {
if (restore && ownsMutedSource(entry)) entry.element.muted = entry.muted;
};
const capture = {
captureId, sessionId, chunks, lastPull: Date.now(), error: undefined,
isCurrent: () => !stopped && window.__openclawMeetingRemoteAudio === capture && ownsSession(),
stop: async () => {
if (stopped) return;
const restore = capture.isCurrent();
stopped = true;
clearInterval(timer);
observer?.disconnect();
document.removeEventListener("play", scan, true);
document.removeEventListener("loadedmetadata", scan, true);
window.removeEventListener("pagehide", onPageHide);
navigator.mediaDevices.removeEventListener("devicechange", onDeviceChange);
for (const entry of entries.values()) {
if (entry.captured) entry.captured.getTracks().forEach((track) => track.stop());
restoreOwnedMute(entry, restore);
}
for (const entry of pendingMute.values()) {
restoreOwnedMute(entry, restore);
}
for (const entry of mutedBridges.values()) {
restoreOwnedMute(entry, restore);
}
entries.clear();
pendingMute.clear();
for (const node of sourceNodes.values()) node.disconnect();
sourceNodes.clear();
mutedBridges.clear();
chunks.length = 0;
processor?.disconnect();
if (window.__openclawMeetingRemoteAudio === capture) delete window.__openclawMeetingRemoteAudio;
await context.close();
},
scan: () => scan(),
};
const onPageHide = () => { void capture.stop(); };
const ownedOutputEntries = () => {
const all = ${params.audioOutputsGlobal ? `window[${JSON.stringify(params.audioOutputsGlobal)}]` : "undefined"};
return Array.isArray(all) ? all.filter((entry) => entry?.sessionId === sessionId) : [];
};
const outputSources = (entry) => Array.isArray(entry.sources) ? entry.sources : entry.source
? [{ element: entry.source, muted: Boolean(entry.sourceMuted), stream: entry.stream }]
: [];
const hasDeviceTrack = (tracks) => tracks.some((track) => {
const settings = track.getSettings();
// Chromium receiver tracks also have deviceId (equal to the track id).
// Only device identities actually enumerated as inputs identify microphone audio.
return Boolean(inputDevices.has(settings.deviceId) || inputGroups.has(settings.groupId));
});
const refreshDevices = () => {
deviceRefresh ??= (async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
if (!capture.isCurrent()) throw new Error("Meeting changed while audio devices were being inspected.");
const inputs = devices.filter((device) => device.kind === "audioinput");
inputDevices = new Set(["default", "communications", ...inputs.map((device) => device.deviceId).filter(Boolean)]);
inputGroups = new Set(inputs.map((device) => device.groupId).filter(Boolean));
})().finally(() => { deviceRefresh = undefined; });
return deviceRefresh;
};
const onDeviceChange = () => {
chunks.length = 0;
void refreshDevices().then(scan).catch((error) => {
capture.error = error?.message || String(error);
void capture.stop();
});
};
function scan() {
if (!capture.isCurrent() || Date.now() - capture.lastPull > 10000) {
void capture.stop();
return;
}
if (deviceRefresh) return;
try {
const bridges = ownedOutputEntries();
const bridgeElements = new Set(bridges.map((entry) => entry.bridge).filter(Boolean));
for (const [bridge, entry] of mutedBridges) {
if (!bridgeElements.has(bridge) || !ownsMutedSource(entry)) {
restoreOwnedMute(entry);
mutedBridges.delete(bridge);
}
}
const ownedSources = new Map(bridges.flatMap((entry) => outputSources(entry).map((source) => [source.element, source])));
const elements = new Set(document.querySelectorAll("audio, video"));
for (const [element, entry] of pendingMute) {
if (!elements.has(element)) {
restoreOwnedMute(entry);
pendingMute.delete(element);
}
}
for (const [element, entry] of entries) {
if (!elements.has(element) || !sameSource(entry) || hasDeviceTrack(entry.tracks)) {
chunks.length = 0;
if (entry.captured) entry.captured.getTracks().forEach((track) => track.stop());
// Retain the source and track identities when input goes idle so stop
// can undo our mute without touching replacement media on this element.
if (elements.has(element)) pendingMute.set(element, entry);
else restoreOwnedMute(entry);
entries.delete(element);
}
}
for (const entry of entries.values()) {
entry.element.muted = true;
}
for (const element of elements) {
if (bridgeElements.has(element) || entries.has(element)) continue;
const owned = ownedSources.get(element);
const pending = pendingMute.get(element);
const originalMute = pending ? pending.muted
: owned && owned.stream === element.srcObject ? owned.muted : element.muted;
if (originalMute) continue;
const stream = element.srcObject;
const tracks = stream?.getAudioTracks?.().filter((track) => track.readyState === "live") || [];
// Device-backed tracks include the virtual microphone and must never enter provider input.
if (hasDeviceTrack(tracks)) {
if (pending) restoreOwnedMute(pending);
pendingMute.delete(element);
continue;
}
if (stream && !tracks.length) continue;
if (!stream && (!sourceUrl(element) || element.readyState < 2)) continue;
if (entries.size + pendingMute.size >= 64 && !pending) throw new Error("Meeting remote audio source limit exceeded.");
const captured = stream ? undefined : element.captureStream();
const audioTracks = stream ? tracks : captured.getAudioTracks();
if (!audioTracks.length || hasDeviceTrack(audioTracks)) {
captured?.getTracks().forEach((track) => track.stop());
continue;
}
entries.set(element, { element, stream, url: sourceUrl(element), muted: Boolean(originalMute), captured, tracks: audioTracks });
pendingMute.delete(element);
element.muted = true;
}
const tracks = new Set([...entries.values()].flatMap((entry) => entry.tracks));
if (tracks.size > 64) throw new Error("Meeting remote audio track limit exceeded.");
for (const [track, node] of sourceNodes) {
if (!tracks.has(track)) { node.disconnect(); sourceNodes.delete(track); }
}
for (const track of tracks) {
if (sourceNodes.has(track)) continue;
const node = context.createMediaStreamSource(new MediaStream([track]));
node.connect(processor);
sourceNodes.set(track, node);
}
for (const entry of bridges) {
if (!entry.bridge) continue;
if (!mutedBridges.has(entry.bridge)) mutedBridges.set(entry.bridge, {
element: entry.bridge,
muted: entry.bridge.muted,
stream: entry.bridge.srcObject,
tracks: entry.bridge.srcObject?.getAudioTracks?.() || [],
url: sourceUrl(entry.bridge),
});
entry.bridge.muted = true;
}
} catch (error) {
capture.error = error?.message || String(error);
void capture.stop();
}
}
window.__openclawMeetingRemoteAudio = capture;
try {
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
await refreshDevices();
// Page CSP can prohibit blob AudioWorklet modules. This native WebAudio node
// needs neither an injected module nor a CSP bypass and emits ~21 ms frames.
processor = context.createScriptProcessor(512, 1, 1);
processor.connect(context.destination);
processor.onaudioprocess = ({ inputBuffer }) => {
if (!capture.isCurrent()) { void capture.stop(); return; }
if (deviceRefresh) return;
// Recheck source identity before admitting an asynchronously delivered render quantum.
if ([...entries.values()].some((entry) => !sameSource(entry))) { chunks.length = 0; scan(); return; }
const data = inputBuffer.getChannelData(0);
const pcm = new Uint8Array(data.length * 2);
const view = new DataView(pcm.buffer);
for (let index = 0; index < data.length; index++) view.setInt16(index * 2, Math.round(Math.max(-1, Math.min(1, data[index])) * 32767), true);
chunks.push(pcm);
// One second of 512-sample frames; stale backlog never grows without bound.
if (chunks.length > 47) chunks.splice(0, chunks.length - 47);
};
await context.resume();
if (!capture.isCurrent() || context.state !== "running") throw new Error("Meeting browser audio capture could not start. Enable audio playback in the meeting tab and retry.");
scan();
if (capture.error) throw new Error(capture.error);
observer = new MutationObserver(scan);
observer.observe(document.documentElement, { childList: true, subtree: true });
document.addEventListener("play", scan, true);
document.addEventListener("loadedmetadata", scan, true);
window.addEventListener("pagehide", onPageHide);
timer = setInterval(scan, 100);
return JSON.stringify({ captureId, isolated: true });
} catch (error) {
await capture.stop();
throw error;
}
}`;
}
//#endregion
//#region src/meeting-bot/audio-bridge-source.ts
const MEETING_AUDIO_BRIDGE_SOURCE = ` const mediaSourceUrl = (element) => String(element?.currentSrc || element?.src || "");
const bridgeSources = (entry) => Array.isArray(entry?.sources)
? entry.sources
: entry?.source
? [{ element: entry.source, muted: Boolean(entry.sourceMuted), pending: Boolean(entry.pending), stream: entry.stream, url: entry.sourceUrl }]
: [];
const bridgeSourceMatches = (element, source) => {
if (!element) return false;
if (source?.pending && mediaSourceIsEmpty(element) && !source.stream && !source.url) return true;
if (source?.stream || element.srcObject) return element.srcObject === source?.stream;
const currentUrl = mediaSourceUrl(element);
return Boolean(source?.url && currentUrl && source.url === currentUrl);
};
const mediaSourceIsEmpty = (element) => Boolean(
element && !element.srcObject && !mediaSourceUrl(element)
);
const restoreAudioBridgeSource = (source) => {
const element = source?.element;
// An empty element may receive a replacement source after cleanup. Keep it
// silent because there is no source identity that is safe to restore.
if (mediaSourceIsEmpty(element)) {
element.muted = true;
return;
}
// Teams reuses media elements across source changes. Restore only the exact
// source this bridge muted.
if (!bridgeSourceMatches(element, source)) return;
const detachedLiveSource = Boolean(
element.isConnected === false &&
element.srcObject?.getAudioTracks?.().some((track) => track.readyState === "live")
);
if (detachedLiveSource) {
element.muted = true;
element.pause?.();
element.srcObject = null;
return;
}
element.muted = Boolean(source.muted);
};
const retireAudioBridge = (entry, restoreSources = true) => {
if (restoreSources) bridgeSources(entry).forEach(restoreAudioBridgeSource);
entry?.bridge?.pause?.();
if (entry?.bridge) entry.bridge.srcObject = null;
entry?.bridge?.remove?.();
};`;
//#endregion
//#region src/meeting-bot/status-call-ownership-source.ts
/**
* In-page prelude for platforms that opt into live ownership checks during the shared status tail's
* media routing. It opens the `audioOutputRouting` block that the tail closes after routing.
*/
function createMeetingRoutingOwnershipSource(params) {
return `
const routingSources = [];
const routingBridges = [];
const recheckAudioOwnership = () => {
if (${params.liveOwnershipSource}) return true;
// A session that took over the page now owns this media; undoing our pass would clobber its routing.
const replacedBy = window[${params.meetingGlobal}]?.sessionId;
if (canMutateSession && !(replacedBy && replacedBy !== sessionId)) {
routingBridges.forEach((entry) => retireAudioBridge(entry, false));
retireOwnedAudioBridges();
routingSources.forEach((source) => {
restoreAudioBridgeSource(source);
// A direct sink change can finish after ownership moved; return that exact source's playback.
if (bridgeSourceMatches(source.element, source) && source.element.sinkId !== source.sinkId) {
const pending = (window.__openclawMeetingSinkRestores ||= new Set());
const restore = source.element
.setSinkId(source.sinkId)
.catch(() => {})
.finally(() => pending.delete(restore));
pending.add(restore);
}
});
}
audioOutputRouted = false;
audioOutputRouteRetryable = true;
notes.push("Call ownership changed during audio routing; stopped this pass.");
return false;
};
// Restores from an earlier ownership loss stay in flight until they settle, so every overlapping
// pass waits for them before routing again.
if (window.__openclawMeetingSinkRestores?.size) {
await Promise.allSettled([...window.__openclawMeetingSinkRestores]);
}
audioOutputRouting: {`;
}
//#endregion
//#region src/meeting-bot/status-call-source.ts
function createMeetingStatusCallSource(options) {
const captionSettleMs = options.captionSettleMs ?? 1e3;
const audioOutputsGlobal = JSON.stringify(options.platform.globals.audioOutputs);
const captionsGlobal = JSON.stringify(options.platform.globals.captions);
const meetingGlobal = JSON.stringify(options.platform.globals.meeting);
const transcriptMaxLines = options.transcriptMaxLines ?? 500;
const withLiveOwnership = (source) => options.liveOwnershipSource === void 0 ? "" : source;
const ownershipCheck = (indent) => withLiveOwnership(`\n${" ".repeat(indent)}if (!recheckAudioOwnership()) break audioOutputRouting;`);
return ` let audioOutputRouted;
let audioOutputDeviceLabel;
let audioOutputRouteError;
let audioOutputRouteRetryable = false;${options.liveOwnershipSource === void 0 ? "" : createMeetingRoutingOwnershipSource({
liveOwnershipSource: options.liveOwnershipSource,
meetingGlobal
})}
const remoteCapture = window.__openclawMeetingRemoteAudio;
if (remoteCapture && remoteCapture.sessionId === sessionId && remoteCapture.isCurrent()) {
if (canMutateSession) remoteCapture.scan();
audioOutputRouted = remoteCapture.isCurrent();
audioOutputDeviceLabel = "Isolated browser playback";
} else if (inCall && allowMicrophone && navigator.mediaDevices?.enumerateDevices) {
const media = [...document.querySelectorAll("audio, video")].filter(
(element) =>
typeof element.setSinkId === "function" &&
!String(element.id || "").startsWith(${JSON.stringify(options.platform.audioOutputElementIdPrefix)}),
);
if (media.length === 0) audioOutputRouted = false;
try {
const devices = await navigator.mediaDevices.enumerateDevices();${ownershipCheck(6)}
const output = devices.find(
(device) => device.kind === "audiooutput" && isVirtualAudioDevice(device.label)
);
if (!output?.deviceId) {
audioOutputRouted = false;
if (media.length > 0 && canMutateSession) suspendOwnedAudioBridges();
notes.push("The OpenClaw virtual audio speaker output was not visible to ${options.platform.displayName}.");
} else if (media.length === 0) {
// Retry an in-call rerender only after proving the required output still exists.
audioOutputRouteRetryable = true;
audioOutputDeviceLabel = output.label || "Virtual audio device";
} else {
const routeErrors = [];
const liveStream = (element) =>
element.srcObject?.getAudioTracks?.().some((track) => track.readyState === "live")
? element.srcObject
: undefined;
const allBridgeEntries = Array.isArray(window[${audioOutputsGlobal}])
? window[${audioOutputsGlobal}]
: [];
const retainedBridgeEntries = allBridgeEntries.filter((entry) => !bridgeOwnedBySession(entry));
const previousBridgeEntries = allBridgeEntries.filter(bridgeOwnedBySession);
const originalMuteBySource = new Map(previousBridgeEntries.flatMap((entry) =>
bridgeSources(entry).flatMap((source) =>
source?.element ? [[source.element, Boolean(source.muted)]] : []
)
));
const routeCandidates = media
.map((element) => ({ element, stream: liveStream(element) }))
// Teams mutes local/self-view and intentionally suppressed playback. Preserve
// that product decision; only our own already-bridged source stays eligible.
.filter((entry) => !entry.element.muted || originalMuteBySource.has(entry.element));
// The self-view often exists before Teams attaches remote playback. With the
// required output present, an all-filtered list is still a transient DOM state.
if (routeCandidates.length === 0) audioOutputRouteRetryable = true;
if (canMutateSession) {
for (const { element } of routeCandidates) {
if (!originalMuteBySource.has(element)) {
originalMuteBySource.set(element, Boolean(element.muted));
}
// Sink changes are asynchronous. Silence the physical output until either
// the source or its fallback bridge is confirmed on the virtual device.
${withLiveOwnership(`routingSources.push({
element,
muted: originalMuteBySource.get(element),
sinkId: element.sinkId,
stream: element.srcObject,
url: mediaSourceUrl(element),
});
`)}element.muted = true;
}
}
const currentSources = new Set(routeCandidates.map((entry) => entry.element));
const bridgeEntries = previousBridgeEntries.filter((entry) =>
entry?.source &&
entry?.stream === liveStream(entry.source) &&
entry?.bridge?.isConnected &&
currentSources.has(entry.source)
);
const suspendedBySource = new Map();
for (const entry of previousBridgeEntries) {
if (bridgeEntries.includes(entry)) continue;
for (const source of bridgeSources(entry)) {
if (
!source?.element ||
source.muted ||
!bridgeSourceMatches(source.element, source)
) continue;
const sourceStillPresent = currentSources.has(source.element);
const detachedLiveSource = !sourceStillPresent && Boolean(liveStream(source.element));
if (!sourceStillPresent && !detachedLiveSource) continue;
suspendedBySource.set(source.element, {
detached: detachedLiveSource,
sessionId: entry.sessionId || sessionId,
source: source.element,
sourceMuted: false,
sourceUrl: mediaSourceUrl(source.element) || source.url,
stream: source.element.srcObject,
suspended: true,
});
}
}
if (canMutateSession) {
// One bridge owns one Teams playback element. Stream or element replacement
// retires that bridge so it cannot keep playing or satisfy route verification.
previousBridgeEntries.filter((entry) => !bridgeEntries.includes(entry)).forEach((entry) => {
for (const source of bridgeSources(entry)) {
if (
!source?.element ||
suspendedBySource.has(source.element) ||
currentSources.has(source.element)
) continue;
restoreAudioBridgeSource(source);
}
// Reused current elements stay silent until this pass confirms their
// replacement source; unrelated exact sources were restored above.
retireAudioBridge(entry, false);
});
}
const routed = [];
for (const { element, stream } of routeCandidates) {
let entry = bridgeEntries.find((candidate) => candidate.source === element);
let elementRouted = element.sinkId === output.deviceId;
let directRouteError;
if (canMutateSession && !elementRouted) {
try {
await element.setSinkId(output.deviceId);${ownershipCheck(16)}
elementRouted = element.sinkId === output.deviceId;
} catch (error) {${ownershipCheck(16)}
directRouteError = {
message: error?.message || String(error),
retryable: error?.name === "AbortError",
};
}
}
if (elementRouted && entry && canMutateSession) {
bridgeEntries.splice(bridgeEntries.indexOf(entry), 1);
retireAudioBridge(entry);
entry = undefined;
}
// Direct sink routing is valid for src/MediaSource and pre-attachment elements.
// A live MediaStream is required only when the hidden bridge fallback is needed.
if (elementRouted) {
if (canMutateSession) {
element.muted = originalMuteBySource.get(element);
}
suspendedBySource.delete(element);
routed.push(true);
continue;
}
if (!stream) {
const hasLoadedPlaybackSource = Number(element.readyState) > 0;
routed.push(false);
if (hasLoadedPlaybackSource && directRouteError) routeErrors.push(directRouteError);
if (!hasLoadedPlaybackSource) audioOutputRouteRetryable = true;
if (canMutateSession && originalMuteBySource.get(element) === false) {
// Teams may attach the remote MediaStream after creating its media element.
// Keep it silent until a later serialized status poll routes that source.
suspendedBySource.set(element, {
sessionId,
pending: true,
source: element,
sourceMuted: false,
sourceUrl: mediaSourceUrl(element),
stream: element.srcObject,
suspended: true,
});
}
continue;
}
if (!entry && canMutateSession) {
const bridge = document.createElement("audio");
bridge.id = ${JSON.stringify(options.platform.audioOutputElementIdPrefix)} + bridgeEntries.length;
bridge.autoplay = false;
bridge.hidden = true;
bridge.srcObject = stream;
document.body.appendChild(bridge);
entry = {
bridge,
playing: false,
sessionId,
source: element,
sourceMuted: originalMuteBySource.get(element),
sourceUrl: mediaSourceUrl(element),
stream,
};
bridgeEntries.push(entry);${withLiveOwnership("\n routingBridges.push(entry);")}
suspendedBySource.delete(element);
}
if (entry?.bridge) {
try {
if (canMutateSession) {
if (entry.bridge.sinkId !== output.deviceId) {
await entry.bridge.setSinkId(output.deviceId);${ownershipCheck(22)}
}
await entry.bridge.play();${ownershipCheck(20)}
entry.playing = true;
}
elementRouted =
entry.bridge.sinkId === output.deviceId && entry.playing === true;
if (elementRouted) {
suspendedBySource.delete(element);
if (canMutateSession && !entry.sourceMuted) element.muted = true;
}
} catch (error) {${ownershipCheck(18)}
entry.playing = false;
if (canMutateSession) retireAudioBridge(entry, false);
routeErrors.push({
message: error?.message || String(error),
retryable: error?.name === "AbortError",
});
}
}
routed.push(elementRouted);
}
if (canMutateSession) {
const nextBridgeEntries = [
...retainedBridgeEntries,
...bridgeEntries,
...suspendedBySource.values(),
];
if (nextBridgeEntries.length > 0) {
window[${audioOutputsGlobal}] = nextBridgeEntries;
} else {
delete window[${audioOutputsGlobal}];
}
}
audioOutputRouted = routed.length > 0 && routed.every(Boolean);
if (canMutateSession && !audioOutputRouted) suspendOwnedAudioBridges();
if (audioOutputRouted && bridgeEntries.length > 0) {
notes.push(
"Routed ${options.platform.displayName} remote audio to " +
(output.label || "the virtual audio device") +
" through MediaStream bridges."
);
}
audioOutputDeviceLabel = output.label || "Virtual audio device";
// An unloaded Teams media element can reject setSinkId before its stream
// arrives. Keep that state retryable; loaded-source failures are terminal.
if (!audioOutputRouted && routed.length > 0 && routeErrors.length > 0) {
audioOutputRouteError = routeErrors[routeErrors.length - 1]?.message;
audioOutputRouteRetryable = routeErrors.every((error) => error.retryable === true);
}
}
} catch (error) {${ownershipCheck(6)}
if (media.length > 0) audioOutputRouted = false;
audioOutputRouteError = error?.message || String(error);
if (media.length > 0) {
if (canMutateSession) suspendOwnedAudioBridges();
} else {
notes.push("Could not inspect ${options.platform.displayName} speaker outputs: " + audioOutputRouteError);
}
}
if (media.length === 0) {
// Suspend ownership until the source returns; call teardown retires it.
if (canMutateSession) suspendOwnedAudioBridges();
} else if (!audioOutputRouted && audioOutputRouteError) {
notes.push("Could not route ${options.platform.displayName} speaker output to the OpenClaw virtual audio device: " + audioOutputRouteError);
}
} else if (inCall && allowMicrophone) {
audioOutputRouted = false;
if (canMutateSession) retireOwnedAudioBridges();
}
${withLiveOwnership(" }\n")}${options.afterAudioRoutingSource ? ` ${options.afterAudioRoutingSource}\n${withLiveOwnership(" recheckAudioOwnership();\n")}` : ""} let captioning = false;
let captionsEnabledAttempted = false;
let transcriptLines = 0;
let lastCaptionAt;
let lastCaptionSpeaker;
let lastCaptionText;
let recentTranscript = [];
const captionState = (() => {
let active = window[${captionsGlobal}];
const activeOwnedByRequest = Boolean(
!active || (sessionId && (!active.sessionId || active.sessionId === sessionId))
);
if (!identityVerified) {
if (identityAwaitingRerender && activeOwnedByRequest) return active;
if (canMutateSession && activeOwnedByRequest) finalizeOwnedCaptions();
return undefined;
}
if (!activeOwnedByRequest) {
const replacedPriorOwner = Boolean(
canMutateSession &&
active?.sessionId &&
active.sessionId !== sessionId
);
if (replacedPriorOwner) {
if (priorMeeting?.sessionId === active.sessionId) {
active.identity ||= priorMeeting.identity;
}
finalizeCaptionState(active);
}
else if (!canMutateSession || !captureCaptions || active?.finalized !== true) return undefined;
archiveFinalizedCaptions(active);
if (active.settleTimer !== undefined) clearTimeout(active.settleTimer);
active.observer?.disconnect?.();
delete window[${captionsGlobal}];
active = undefined;
}
if (!captureCaptions) {
if (!canMutateSession) return undefined;
if (active?.settleTimer !== undefined) clearTimeout(active.settleTimer);
active?.observer?.disconnect?.();
if (active) delete window[${captionsGlobal}];
return undefined;
}
if (!inCall && !active) return undefined;
if (!active && !canMutateSession) return undefined;
if (!active) {
window[${captionsGlobal}] = {
sessionId,
identity: expectedIdentity,
epoch: crypto.randomUUID(),
enabledAttempted: false,
observerInstalled: false,
observer: undefined,
droppedLines: 0,
lines: [],
settled: [],
settleTimer: undefined,
visible: [],
};
}
return window[${captionsGlobal}];
})();
const normalizeCaption = (speaker, captionText) => {
if (!captionState) return undefined;
const clean = String(captionText || "").replace(/\\s+/g, " ").trim();
const cleanSpeaker = String(speaker || "").replace(/\\s+/g, " ").trim();
if (!clean) return undefined;
return { speaker: cleanSpeaker || undefined, text: clean };
};
const captionRowIdentity = (row) => {
// aria-posinset identifies the logical caption item across virtual-list
// rerenders. DOM ids and data indexes can belong to the recycled element.
const value = row?.getAttribute?.("aria-posinset");
return typeof value === "string" && value.trim()
? "aria-posinset:" + value.trim()
: undefined;
};
const sameCaptionUtterance = (prior, current) => {
if (prior.rowIdentity || current.rowIdentity) {
return Boolean(
prior.rowIdentity &&
current.rowIdentity &&
prior.rowIdentity === current.rowIdentity
);
}
if (prior.speaker && current.speaker && prior.speaker !== current.speaker) return false;
return prior.node === current.node;
};
const sameCaptionRow = (left, right) =>
right.rowIdentity
? left.rowIdentity === right.rowIdentity
: left.node === right.node;
const commitCaptionLines = (state, entries) => {
state.lines.push(...entries.map((entry) => {
entry.utteranceId ||= crypto.randomUUID();
return {
at: entry.at,
speaker: entry.speaker,
text: entry.text,
utteranceId: entry.utteranceId,
};
}));
const excess = state.lines.length - ${transcriptMaxLines};
if (excess > 0) {
state.lines.splice(0, excess);
state.droppedLines = (state.droppedLines || 0) + excess;
}
const settled = [...state.settled];
for (const entry of entries) {
const priorIndex = settled.findIndex((candidate) => sameCaptionRow(candidate, entry));
if (priorIndex >= 0) settled.splice(priorIndex, 1, { ...entry });
else settled.push({ ...entry });
}
const retainedLineIds = new Set(state.lines.map((entry) => entry.utteranceId));
state.settled = settled.filter((entry) => retainedLineIds.has(entry.utteranceId));
};
const scheduleCaptionSettle = () => {
if (!captionState || captionState.visible.length === 0) return;
if (captionState.settleTimer !== undefined) clearTimeout(captionState.settleTimer);
const pendingState = captionState;
pendingState.settleTimer = setTimeout(() => {
if (window[${captionsGlobal}] !== pendingState) return;
commitCaptionLines(pendingState, pendingState.visible);
pendingState.visible = [];
pendingState.settleTimer = undefined;
}, ${captionSettleMs});
};
const captionCaptureMatchesCurrentMeeting = () => {
if (
!captionState ||
captionState.finalized === true ||
window[${captionsGlobal}] !== captionState
) return false;
const observedIdentity = meetingIdentity(location.href);
const observedMeeting = window[${meetingGlobal}];
const identityConflicts = Boolean(
observedIdentity && expectedIdentity && observedIdentity !== expectedIdentity
);
const sessionConflicts = Boolean(
observedMeeting?.sessionId && sessionId && observedMeeting.sessionId !== sessionId
);
if (identityConflicts || sessionConflicts) {
// The observer outlives Teams SPA navigation. Freeze the old buffer before
// any caption nodes from the replacement meeting can be attributed to it.
finalizeOwnedCaptions();
return false;
}
if (observedIdentity === expectedIdentity) return true;
const observedMarkerAgeMs = Date.now() - (observedMeeting?.verifiedAt || 0);
const observedAwaitingRerender = Boolean(
!observedIdentity &&
observedMeeting?.identity === expectedIdentity &&
(!observedMeeting.sessionId || !sessionId || observedMeeting.sessionId === sessionId) &&
observedMeeting.inCallControl?.isConnected === false &&
observedMeeting.inCallUrl === location.href &&
observedMarkerAgeMs >= 0 &&
observedMarkerAgeMs < 5_000
);
if (observedAwaitingRerender) return true;
return Boolean(
observedMeeting?.identity === expectedIdentity &&
(!observedMeeting.sessionId || !sessionId || observedMeeting.sessionId === sessionId) &&
observedMeeting.inCallControl?.isConnected !== false &&
observedMeeting.inCallUrl === location.href
);
};
const scrapeCaptions = (mutations = []) => {
if (!captionCaptureMatchesCurrentMeeting()) return;
const content = firstRaw(selectors.captionContent);
const rows = content
? selectors.captionRows.flatMap((selector) => [...content.querySelectorAll(selector)])
: [];
captionState.settled = Array.isArray(captionState.settled) ? captionState.settled : [];
const removedNodes = mutations.flatMap((mutation) => [...(mutation.removedNodes || [])]);
const rowWasRemoved = (entry) => removedNodes.some((node) =>
node === entry.node || node?.contains?.(entry.node)
);
const removedVisible = captionState.visible.filter(rowWasRemoved);
if (removedVisible.length > 0) {
if (captionState.settleTimer !== undefined) clearTimeout(captionState.settleTimer);
captionState.settleTimer = undefined;
captionState.visible = captionState.visible.filter((entry) => !rowWasRemoved(entry));
commitCaptionLines(captionState, removedVisible);
}
const retainedLineIds = new Set(captionState.lines.map((entry) => entry.utteranceId));
captionState.settled = captionState.settled.filter((entry) =>
entry.rowIdentity
? retainedLineIds.has(entry.utteranceId)
: !rowWasRemoved(entry) && rows.some((row) => sameCaptionRow(entry, {
node: row,
rowIdentity: captionRowIdentity(row),
}))
);
const parsedRows = rows.flatMap((row) => {
const speaker = text(firstWithin(row, selectors.captionAuthor));
const captionText = text(firstWithin(row, selectors.captionText));
const parsed = normalizeCaption(speaker, captionText);
if (!parsed) return [];
const current = { ...parsed, node: row, rowIdentity: captionRowIdentity(row) };
const settledIndex = captionState.settled.findIndex((entry) =>
sameCaptionRow(entry, current)
);
const settled = settledIndex >= 0 ? captionState.settled[settledIndex] : undefined;
if (
settled &&
settled.text === current.text &&
(settled.speaker || "") === (current.speaker || "")
) return [];
if (settled?.rowIdentity && settled.rowIdentity === current.rowIdentity) {
const committed = captionState.lines.find((entry) =>
entry.utteranceId === settled.utteranceId
);
if (committed) {
committed.speaker = current.speaker || committed.speaker;
committed.text = current.text;
}
captionState.settled.splice(settledIndex, 1, {
...settled,
...current,
speaker: current.speaker || settled.speaker,
});
return [];
}
if (settledIndex >= 0) captionState.settled.splice(settledIndex, 1);
return [current];
});
if (parsedRows.length === 0) {
if (captionState.visible.length > 0 && captionState.settleTimer === undefined) {
scheduleCaptionSettle();
}
return;
}
const unmatchedPrevious = [...captionState.visible];
const nextVisible = [];
const now = Date.now();
let captionChanged = false;
for (const row of parsedRows) {
const priorIndex = unmatchedPrevious.findIndex((candidate) => sameCaptionRow(candidate, row));
const candidate = priorIndex >= 0 ? unmatchedPrevious[priorIndex] : undefined;
const prior = candidate && sameCaptionUtterance(candidate, row)
? unmatchedPrevious.splice(priorIndex, 1)[0]
: undefined;
if (prior) {
captionChanged ||=
prior.text !== row.text ||
prior.speaker !== row.speaker ||
prior.node !== row.node;
prior.speaker = row.speaker || prior.speaker;
prior.text = row.text;
prior.node = row.node;
prior.rowIdentity = row.rowIdentity || prior.rowIdentity;
prior.seenAt = now;
nextVisible.push(prior);
} else {
captionChanged = true;
nextVisible.push({
at: new Date().toISOString(),
node: row.node,
rowIdentity: row.rowIdentity,
seenAt: now,
speaker: row.speaker,
text: row.text,
});
}
}
captionChanged ||= unmatchedPrevious.length > 0;
commitCaptionLines(captionState, unmatchedPrevious);
captionState.visible = nextVisible;
// Identity-less rows stay mutable while rendered; removal is their only
// reliable utterance boundary. Stable logical rows may settle on quiet.
if (
(captionChanged || captionState.settleTimer === undefined) &&
captionState.visible.every((entry) => entry.rowIdentity)
) {
scheduleCaptionSettle();
}
};
if (captionState) {
const captionsFinalized = captionState.finalized === true;
let captionsEnabledNow = captionsFinalized
? Boolean(captionState.enabledAttempted)
: Boolean(firstRaw(selectors.captionRenderer) || firstRaw(selectors.captionsOff));
${options.captionEnableSource}
if (!captionsFinalized && canMutateSession) captionState.enabledAttempted = captionsEnabledNow;
captionsEnabledAttempted = Boolean(captionState.enabledAttempted);
if (!captionsFinalized && canMutateSession && inCall && !captionState.observerInstalled) {
captionState.observerInstalled = true;
captionState.observer = new MutationObserver(scrapeCaptions);
captionState.observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
notes.push("Installed ${options.platform.displayName} live-caption observer.");
}
if (!captionsFinalized && canMutateSession && inCall) scrapeCaptions();
const allLines = [...captionState.lines, ...captionState.visible];
const lines = allLines.slice(-${transcriptMaxLines});
const last = lines[lines.length - 1];
captioning = captionsEnabledNow;
transcriptLines = (captionState.droppedLines || 0) + allLines.length;
lastCaptionAt = last?.at;
lastCaptionSpeaker = last?.speaker;
lastCaptionText = last?.text;
recentTranscript = lines.slice(-5).map((entry) => ({
at: entry.at,
speaker: entry.speaker,
text: entry.text,
}));
}
if (inCall && allowMicrophone && !manualAction) {
if (audioInputRouted !== true || audioOutputRouted !== true) {
manualAction = manualActionFor("${options.platform.manualActionReasonPrefix}-audio-choice-required", "Verify the OpenClaw virtual audio device is selected as both the ${options.platform.displayName} microphone and speaker before starting talk-back.");
} else if (micMuted !== false) {
manualAction = manualActionFor("${options.platform.manualActionReasonPrefix}-microphone-required", "Unmute the ${options.platform.displayName} microphone and verify the microphone control shows it is on before starting talk-back.");
}
}
return JSON.stringify({
clickedContinueInBrowser: Boolean(continueInBrowser),
clickedJoin,
inCall,
${options.extraResultSource ?? ""}
micMuted,
cameraOff,
lobbyWaiting,
captionCaptureRequested: captureCaptions,
captioning,
captionsEnabledAttempted,
transcriptLines,
lastCaptionAt,
lastCaptionSpeaker,
lastCaptionText,
recentTranscript,
audioInputRouted,
audioInputDeviceLabel,
audioInputRouteError,
audioOutputRouted,
audioOutputDeviceLabel,
audioOutputRouteError,
audioOutputRouteRetryable,
manualAction,
title: document.title,
url: location.href,
notes,
});
}`;
}
//#endregion
//#region src/meeting-bot/status-prejoin-fragments.ts
function createMeetingStatusPreludeFragments({ guestName, displayName, manualActionReasonPrefix }) {
return {
guestName: (replaceExisting) => ` const guestInput = first(selectors.guestName) || [...document.querySelectorAll("input")].find((input) =>
/enter your name|type your name|your name|display name/i.test(label(input) + " " + (input.placeholder || ""))
);
if (canMutateSession && identityVerifiedBeforeCall && autoJoin && guestInput && ${replaceExisting ? `guestInput.value !== ${JSON.stringify(guestName)}` : "!guestInput.value"}) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
guestInput.focus();
if (setter) setter.call(guestInput, ${JSON.stringify(guestName)});
else guestInput.value = ${JSON.stringify(guestName)};
guestInput.dispatchEvent(new Event("input", { bubbles: true }));
guestInput.dispatchEvent(new Event("change", { bubbles: true }));
}`,
preservedIdentity: ` const identityPreservedInCall = Boolean(
!currentIdentity &&
priorMeeting?.identity === expectedIdentity &&
leave &&
leave.isConnected !== false &&
(
identityAdoptedInCall ||
identityRerenderedInCall ||
(
priorMeeting?.inCallControl === leave &&
priorMeeting?.inCallUrl === location.href
)
)
);
const identityVerified = identityVerifiedBeforeCall || identityPreservedInCall;`,
virtualAudioInput: ({ beforeEnumerationSource = "", selectionSource, afterSelectionSource = "" }) => ` const { isVirtualAudioDevice, isVirtualAudioDeviceNode, microphoneDeviceRoots, selectedMicrophoneLabel } = meetingAudioInput;
let audioInputRouted;
let audioInputDeviceLabel;
let audioInputRouteError;
const ensureVirtualAudioInput = async () => {
${beforeEnumerationSource} if (!navigator.mediaDevices?.enumerateDevices) return false;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const input = devices.find(
(device) => device.kind === "audioinput" && isVirtualAudioDevice(device.label)
);
if (!input?.deviceId) return false;
audioInputDeviceLabel = input.label || "Virtual audio device";
// ${displayName} hides the selected-device control after admission. Reopen the in-call audio
// options and verify the current selection before unmuting; installed devices alone
// do not prove which microphone ${displayName} is using.
${selectionSource}
if (!selected && canMutateSession) {
const settings = first(selectors.deviceSettings);
if (settings) {
settings.click();
await waitForUi();
}
const { control } = microphoneDeviceRoots();
if (control?.tagName?.toLowerCase() === "select") {
const options = [...control.options];
const option = options.find(isVirtualAudioDeviceNode);
if (option) {
control.value = option.value;
control.dispatchEvent(new Event("change", { bubbles: true }));
await waitForUi();
}
} else if (control) {
clickable(control)?.click?.();
await waitForUi();
}
const choices = microphoneDeviceRoots().roots.flatMap((root) =>
selectors.audioDeviceOptions.flatMap((selector) => [
...(root.querySelectorAll?.(selector) || []),
])
);
const choice = choices.find(isVirtualAudioDeviceNode);
if (choice && choice.getAttribute?.("aria-selected") !== "true") {
clickable(choice)?.click?.();
await waitForUi();
}
selected = Boolean(selectedMicrophoneLabel());
}
${afterSelectionSource} return selected;
} catch (error) {
audioInputRouteError = error?.message || String(error);
return false;
}
};`,
prejoinMicrophone: ({ unroutedSource, afterRoutedSource = "", muteInCall }) => ` if (identityVerified && !inCall && allowMicrophone && microphone) {
audioInputRouted = await ensureVirtualAudioInput();
if (!audioInputRouted) {
if (canMutateSession && microphoneState === "on") {
microphone.click();
await refreshMicrophoneState();
}
${unroutedSource}
} else if (canMutateSession && microphoneState === "off") {
microphone.click();
await refreshMicrophoneState();
if (microphoneState === "on") {
notes.push("Unmuted the ${displayName} microphone after verifying the virtual audio input.");
}
}
${afterRoutedSource} } else if (canMutateSession && identityVerified && ${muteInCall ? "" : "!inCall && "}!allowMicrophone && microphoneState === "on") {
microphone.click();
await refreshMicrophoneState();
if (microphoneState === "off") {
notes.push("Muted the ${displayName} microphone for observe-only mode.");
}
}`,
inCallMicrophone: ` if (identityVerified && inCall && allowMicrophone) {
if (!selectedMicrophoneLabel() && canMutateSession && microphoneState === "on") {
microphone?.click();
await refreshMicrophoneState();
}
audioInputRouted = await ensureVirtualAudioInput();
if (audioInputRouted && canMutateSession && microphoneState === "off") {
microphone?.click();
await refreshMicrophoneState();
} else if (!audioInputRouted && canMutateSession && microphoneState === "on") {
microphone?.click();
await refreshMicrophoneState();
if (microphoneState === "off") {
notes.push("Muted the ${displayName} microphone because the virtual audio input could not be reverified.");
}
}
}`,
manualActions: ({ loginDisplayName = displayName, inCallControls }) => ` let microphonePermissionState;
if (allowMicrophone && navigator.permissions?.query) {
try {
microphonePermissionState = (await navigator.permissions.query({ name: "microphone" })).state;
} catch {}
}
const devicePermissionPrompt = !dismissedDevicePrompt && Boolean(
first(selectors.permissionPrompt) || continueWithoutDevices
);
// ${displayName} shows the same no-audio/video warning when only camera access is denied.
// A granted microphone plus the verified virtual audio input is sufficient for talk-back.
const permissionRequired = devicePermissionPrompt &&
(!allowMicrophone || microphonePermissionState !== "granted");
let manualAction;
if (committedOwnerConflict && !canMutateSession) {
manualAction = manualActionFor("${manualActionReasonPrefix}-session-conflict", "This ${displayName} tab is owned by another active meeting session.");
} else if (!inCall && loginRequired) {
manualAction = manualActionFor("${manualActionReasonPrefix}-login-required", tenantLoginRequired ? "This ${displayName} tenant requires sign-in or email verification. Complete it in the OpenClaw browser profile, then retry." : "Sign in to ${loginDisplayName} in the OpenClaw browser profile, then retry the meeting join.");
} else if (!inCall && lobbyWaiting) {
manualAction = manualActionFor("${manualActionReasonPrefix}-admission-required", "Admit the OpenClaw guest from the ${loginDisplayName} lobby, then retry speech.");
} else if (!inCall && permissionRequired) {
manualAction = manualActionFor("${manualActionReasonPrefix}-permission-required", allowMicrophone ? "Allow microphone permission for ${displayName} in the OpenClaw browser profile, then retry." : "Dismiss the ${displayName} device-permission prompt or continue without devices, then retry.");
} else if (${inCallControls ? "" : "!inCall && "}controlManualAction) {
manualAction = controlManualAction;
}
let clickedJoin = false;
if (canMutateSession && identityVerified && autoJoin && !inCall && join && !join.disabled && !manualAction) {
join.click();
clickedJoin = true;
notes.push("Clicked the ${displayName} guest join button.");
}`
};
}
//#endregion
//#region src/meeting-bot/status-prejoin-source.ts
function createMeetingStatusPreludeSource(params, options) {
const audioOutputsGlobal = JSON.stringify(options.platform.globals.audioOutputs);
const captionArchiveGlobal = JSON.stringify(options.platform.globals.captionArchive);
const captionsGlobal = JSON.stringify(options.platform.globals.captions);
const meetingGlobal = JSON.stringify(options.platform.globals.meeting);
const transcriptMaxLines = options.transcriptMaxLines ?? 500;
const fragments = createMeetingStatusPreludeFragments({
...options.platform,
guestName: params.guestName
});
const resolveSource = (source) => typeof source === "function" ? source(fragments) : source;
return `async () => {
${params.pageIdentitySource}
${options.setupSource ?? ""}
const parseToggleState = ${params.toggleStateFunction};
const selectors = ${params.selectors};
const expectedIdentity = ${JSON.stringify(params.expectedIdentity)};
const allowMicrophone = ${JSON.stringify(params.allowMicrophone)};
const allowSessionAdoption = ${JSON.stringify(params.allowSessionAdoption)};
const autoJoin = ${JSON.stringify(params.autoJoin)};
const captureCaptions = ${JSON.stringify(params.captureCaptions)};
const readOnly = ${JSON.stringify(Boolean(params.readOnly))};
const sessionId = ${JSON.stringify(params.meetingSessionId)};
const identityRetentionMs = ${JSON.stringify(Math.max(3e4, params.waitForInCallMs))};
const text = (node) => (node?.innerText || node?.textContent || "").trim();
const label = (node) => [
node?.getAttribute?.("aria-label"),
node?.getAttribute?.("title"),
node?.getAttribute?.("data-tid"),
text(node),
].filter(Boolean).join(" ");
const manualActionFor = (reason, message) => ({ reason, message });
const clickable = (node) => node?.matches?.("button")
? node
: node?.querySelector?.("button") || node?.closest?.("button") || node;
const first = (list) => clickable(firstRaw(list));
const firstRaw = (list) => {
for (const selector of list) {
const node = document.querySelector(selector);
if (node) return node;
}
return undefined;
};
const firstWithin = (root, list) => {
if (!root) return undefined;
for (const selector of list) {
if (root.matches?.(selector)) return root;
const node = root.querySelector?.(selector);
if (node) return node;
}
return undefined;
};
// Keep these names scoped: older plugin lifecycle fragments declare their own helpers.
const meetingAudioInput = (() => {
const isVirtualAudioDevice = (value) =>
/^(?:blackhole 2ch(?: \\(virtual\\))?|openclaw meeting audio)$/i.test(
String(value || "").replace(/\\s+/g, " ").trim()
);
const isVirtualAudioDeviceNode = (node) => [
node?.getAttribute?.("aria-label"),
node?.getAttribute?.("title"),
node?.label,
node?.value,
text(node),
].some(isVirtualAudioDevice);
const microphoneDeviceRoots = () => {
// Consumer in-call controls expose the listbox itself, without the prejoin
// selected-device button/combobox wrapper.
const control = firstRaw(selectors.microphoneDevice) || firstRaw(selectors.microphoneDeviceMenu);
if (!control) return { control, roots: [] };
const roots = [control];
const scope = control.closest?.(selectors.microphoneDeviceScope);
if (scope && !roots.includes(scope)) roots.push(scope);
const listboxId = control.getAttribute?.("aria-controls");
const listbox = listboxId ? document.getElementById?.(listboxId) : undefined;
if (listbox && !roots.includes(listbox)) roots.push(listbox);
const liveMenu = firstRaw(selectors.microphoneDeviceMenu);
if (liveMenu && !roots.includes(liveMenu)) roots.push(liveMenu);
return { control, roots };
};
const selectedMicrophoneLabel = () => {
const { control, roots } = microphoneDeviceRoots();
const selectedOption = control?.selectedOptions?.[0];
if (selectedOption && isVirtualAudioDeviceNode(selectedOption)) {
return label(selectedOption) || selectedOption.value;
}
if (control && isVirtualAudioDeviceNode(control)) return label(control) || control.value;
for (const root of roots) {
const selected = firstWithin(root, selectors.selectedMicrophoneDevice);
if (selected && isVirtualAudioDeviceNode(selected)) {
return label(selected) || selected.value;
}
}
return undefined;
};
return { isVirtualAudioDevice, isVirtualAudioDeviceNode, microphoneDeviceRoots, selectedMicrophoneLabel };
})();
${options.controlLookupSource}
const waitForUi = () => new Promise((resolve) => setTimeout(resolve, 120));
const bridgeOwnedBySession = (entry) => Boolean(
sessionId && (!entry?.sessionId || entry.sessionId === sessionId)
);
${MEETING_AUDIO_BRIDGE_SOURCE}
const retireOwnedAudioBridges = (restoreSources = true) => {
const entries = Array.isArray(window[${audioOutputsGlobal}])
? window[${audioOutputsGlobal}]
: [];
const retained = [];
for (const entry of entries) {
if (!bridgeOwnedBySession(entry)) {
retained.push(entry);
continue;
}
retireAudioBridge(entry, restoreSources);
}
if (retained.length > 0) window[${audioOutputsGlobal}] = retained;
else delete window[${audioOutputsGlobal}];
};
const suspendOwnedAudioBridges = (adopt = false) => {
const entries = Array.isArray(window[${audioOutputsGlobal}])
? window[${audioOutputsGlobal}]
: [];
const retained = [];
const suspendedBySource = new Map();
for (const entry of entries) {
if (!adopt && !bridgeOwnedBySession(entry)) {
retained.push(entry);
continue;
}
// This pending entry owns the muted element until a later serialized
// status poll sees and routes the attached playback source.
if (
!adopt &&
entry?.pending &&
bridgeSources(entry).some((source) => bridgeSourceMatches(source?.element, source))
) {
retained.push(entry);
continue;
}
for (const source of bridgeSources(entry)) {
if (!source?.element || suspendedBySource.has(source.element)) continue;
if (!bridgeSourceMatches(source.element, source)) {
restoreAudioBridgeSource(source);
continue;
}
suspendedBySource.set(source.element, {
sessionId: adopt ? sessionId : entry.sessionId || sessionId,
source: source.element,
sourceMuted: Boolean(source.muted),
sourceUrl: adopt ? mediaSourceUrl(source.element) || source.url : source.url,
stream: source.element.srcObject,
suspended: true,
});
}
retireAudioBridge(entry, false);
}
const next = [...retained, ...suspendedBySource.values()];
if (next.length > 0) window[${audioOutputsGlobal}] = next;
else delete window[${audioOutputsGlobal}];
};
const adoptAudioBridgeSourcesForSession = () => suspendOwnedAudioBridges(true);
const finalizeCaptionState = (active) => {
if (!active) return;
if (active.settleTimer !== undefined) clearTimeout(active.settleTimer);
active.settleTimer = undefined;
active.observer?.disconnect?.();
active.observer = undefined;
active.observerInstalled = false;
active.lines = Array.isArray(active.lines) ? active.lines : [];
if (Array.isArray(active.visible) && active.visible.length > 0) {
active.lines.push(...active.visible.map((entry) => ({
at: entry.at,
speaker: entry.speaker,
text: entry.text,
})));
active.visible = [];
}
const excess = active.lines.length - ${transcriptMaxLines};
if (excess > 0) {
active.lines.splice(0, excess);
active.droppedLines = (active.droppedLines || 0) + excess;
}
active.finalized = true;
active.finalizedAt = Date.now();
};
const archiveFinalizedCaptions = (active) => {
if (active?.finalized !== true || !active.sessionId) return;
const archive = window[${captionArchiveGlobal}] &&
typeof window[${captionArchiveGlobal}] === "object"
? window[${captionArchiveGlobal}]
: {};
archive[active.sessionId] = active;
const retained = Object.entries(archive)
.sort((left, right) => Number(right[1]?.finalizedAt || 0) - Number(left[1]?.finalizedAt || 0))
.slice(0, 4);
window[${captionArchiveGlobal}] = Object.fromEntries(retained);
};
const finalizeOwnedCaptions = () => {
const active = window[${captionsGlobal}];
const owned = Boolean(
active && sessionId && (!active.sessionId || active.sessionId === sessionId)
);
if (owned) {
active.identity ||= priorMeeting?.identity || expectedIdentity;
finalizeCaptionState(active);
}
};
const toggleState = (node, kind) => parseToggleState({
kind,
ariaPressed: node?.getAttribute?.("aria-pressed"),
ariaChecked: node?.getAttribute?.("aria-checked"),
checked: typeof node?.checked === "boolean" ? node.checked : undefined,
iconClass: node?.querySelector?.("svg")?.getAttribute?.("class"),
label: label(node),
});
const notes = [];
const currentIdentity = meetingIdentity(location.href);
const priorMeeting = window[${meetingGlobal}];
if (expectedIdentity && currentIdentity && currentIdentity !== expectedIdentity) {
// A confirmed SPA transition must stop resources still owned by this
// request, while preserving any newer session already committed to the tab.
retireOwnedAudioBridges();
finalizeOwnedCaptions();
const requestOwnsMeeting = Boolean(
priorMeeting &&
sessionId &&
(!priorMeeting.sessionId || priorMeeting.sessionId === sessionId)
);
if (requestOwnsMeeting) delete window[${meetingGlobal}];
return JSON.stringify({
inCall: false,
manualAction: manualActionFor("${options.platform.manualActionReasonPrefix}-session-conflict", "The tracked ${options.platform.displayName} tab now shows a different meeting. Return to the requested meeting link, then retry."),
title: document.title,
url: location.href,
notes,
});
}
const meetingOwnerConflict = Boolean(
priorMeeting?.sessionId && priorMeeting.sessionId !== sessionId
);
const captionOwnerConflict = Boolean(
window[${captionsGlobal}]?.sessionId &&
window[${captionsGlobal}].sessionId !== sessionId
);
const committedOwnerConflict = meetingOwnerConflict || captionOwnerConflict;
const canRepairCaptionOwner = Boolean(
!meetingOwnerConflict && priorMeeting?.sessionId === sessionId
);
const canMutateSession = Boolean(
!readOnly &&
sessionId &&
(!committedOwnerConflict || canRepairCaptionOwner || allowSessionAdoption)
);
const identityMatchedUrl = Boolean(expectedIdentity && currentIdentity === expectedIdentity);
const identityVerifiedBeforeCall = identityMatchedUrl;
const previousRemoteCapture = window.__openclawMeetingRemoteAudio;
if (canMutateSession && allowSessionAdoption && previousRemoteCapture && previousRemoteCapture.sessionId !== sessionId) {
await previousRemoteCapture.stop();
}
${resolveSource(options.lifecycleSource)}
const micMuted = microphoneState === "off" ? true : microphoneState === "on" ? false : undefined;
const cameraOff = cameraState === "off" ? true : cameraState === "on" ? false : undefined;
${resolveSource(options.manualActionSource)}
`;
}
//#endregion
//#region src/meeting-bot/page-script-source.ts
function createMeetingPageScripts(options) {
const identity = (meetingUrl) => {
const expectedIdentity = options.normalizeUrl(meetingUrl);
return {
expectedIdentity,
pageIdentitySource: options.pageIdentitySource(expectedIdentity)
};
};
return {
audioCapture: (params) => {
const page = identity(params.meetingUrl);
return createMeetingBrowserAudioCaptureSource({
...params,
audioOutputsGlobal: options.platform.globals.audioOutputs,
ownershipSource: options.audioOwnershipSource?.(page) ?? `
${page.pageIdentitySource}
const expectedIdentity = ${JSON.stringify(page.expectedIdentity)};
const state = ${pageGlobalSource(options.platform.globals.meeting)};
return Boolean(expectedIdentity && state?.sessionId === sessionId &&
state.identity === expectedIdentity && !state.leavePending &&
(meetingIdentity(location.href) === expectedIdentity ||
(state.inCallUrl === location.href && state.inCallControl?.isConnected)));
`
});
},
status: (params) => createMeetingStatusPreludeSource({
...params,
...identity(params.meetingUrl),
selectors: JSON.stringify(options.selectors),
toggleStateFunction: options.toggleStateFunction()
}, {
...options.statusPrelude,
platform: options.platform
}) + createMeetingStatusCallSource({
...options.statusCall,
platform: options.platform
}),
transcript: (meetingUrl, meetingSessionId, finalize) => createMeetingTranscriptSource({
...identity(meetingUrl),
finalize,
globals: options.platform.globals,
meetingSessionId,
platformDisplayName: options.platform.displayName
}),
leave: (params) => createMeetingLeaveSource({
...options.leave,
...identity(params.meetingUrl),
leaveInitiated: params.leaveInitiated,
meetingSessionId: params.meetingSessionId,
platform: options.platform,
selectors: JSON.stringify(options.selectors)
})
};
}
function pageGlobalSource(name) {
if (!/^[$A-Z_a-z][$\w]*$/u.test(name)) throw new Error(`Invalid meeting page global: ${name}`);
return `window.${name}`;
}
function createMeetingTranscriptSource(params) {
const transcriptMaxLines = params.transcriptMaxLines ?? 500;
const meetingGlobal = pageGlobalSource(params.globals.meeting);
const captionsGlobal = pageGlobalSource(params.globals.captions);
const captionArchiveGlobal = pageGlobalSource(params.globals.captionArchive);
return `() => {
${params.pageIdentitySource}
const expectedIdentity = ${JSON.stringify(params.expectedIdentity)};
const expectedSessionId = ${JSON.stringify(params.meetingSessionId)};
const currentIdentity = meetingIdentity(location.href);
const state = ${meetingGlobal};
const activeCaptions = ${captionsGlobal};
const archivedCaptions = ${captionArchiveGlobal}?.[expectedSessionId];
const captions = activeCaptions &&
(!activeCaptions.sessionId || activeCaptions.sessionId === expectedSessionId)
? activeCaptions
: archivedCaptions;
// A same-session finalized buffer belongs to the departed call even if ${params.platformDisplayName}
// immediately navigated this tab into another meeting before transcript pickup.
const useFinalizedCaptions = Boolean(
captions?.finalized === true &&
captions?.identity === expectedIdentity &&
(!captions?.sessionId || captions.sessionId === expectedSessionId)
);
const effectiveIdentity = useFinalizedCaptions
? captions.identity
: currentIdentity || state?.identity || captions?.identity;
if (!expectedIdentity || effectiveIdentity !== expectedIdentity) {
return JSON.stringify({ urlMatched: false, droppedLines: 0, lines: [] });
}
if (!useFinalizedCaptions && state?.sessionId && state.sessionId !== expectedSessionId) {
return JSON.stringify({ urlMatched: true, sessionMatched: false, droppedLines: 0, lines: [] });
}
if (captions?.sessionId && captions.sessionId !== expectedSessionId) {
return JSON.stringify({ urlMatched: true, sessionMatched: false, droppedLines: 0, lines: [] });
}
if (${JSON.stringify(params.finalize)} && Array.isArray(captions?.visible) && captions.visible.length > 0) {
if (captions.settleTimer !== undefined) clearTimeout(captions.settleTimer);
captions.settleTimer = undefined;
captions.lines = Array.isArray(captions.lines) ? captions.lines : [];
captions.lines.push(...captions.visible.map((entry) => ({
at: entry.at,
speaker: entry.speaker,
text: entry.text,
})));
captions.visible = [];
const excess = captions.lines.length - ${transcriptMaxLines};
if (excess > 0) {
captions.lines.splice(0, excess);
captions.droppedLines = (captions.droppedLines || 0) + excess;
}
}
if (${JSON.stringify(params.finalize)} && captions) {
if (captions.settleTimer !== undefined) clearTimeout(captions.settleTimer);
captions.settleTimer = undefined;
captions.observer?.disconnect?.();
captions.observer = undefined;
captions.observerInstalled = false;
captions.identity = expectedIdentity;
captions.finalized = true;
captions.finalizedAt = Date.now();
}
const allLines = [
...(Array.isArray(captions?.lines) ? captions.lines : []),
...(${JSON.stringify(params.finalize)} || !Array.isArray(captions?.visible) ? [] : captions.visible),
];
const visibleOverflow = Math.max(0, allLines.length - ${transcriptMaxLines});
const lines = allLines.slice(-${transcriptMaxLines});
const result = {
urlMatched: true,
sessionMatched: true,
epoch: typeof captions?.epoch === "string" ? captions.epoch : undefined,
droppedLines: (Number.isFinite(captions?.droppedLines)
? Math.max(0, Math.trunc(captions.droppedLines))
: 0) + visibleOverflow,
lines: lines.map((line) => ({
at: typeof line?.at === "string" ? line.at : undefined,
speaker: typeof line?.speaker === "string" ? line.speaker : undefined,
text: typeof line?.text === "string" ? line.text : "",
})).filter((line) => line.text),
};
return JSON.stringify(result);
}`;
}
function createMeetingOwnedAudioLeaveSource(params) {
const audioOutputsGlobal = pageGlobalSource(params.audioOutputsGlobal);
return `const retireOwnedAudioBridges = () => {
${MEETING_AUDIO_BRIDGE_SOURCE}
const entries = Array.isArray(${audioOutputsGlobal})
? ${audioOutputsGlobal}
: [];
const retained = [];
const activeSessionId = expectedSessionId || state?.sessionId;
for (const entry of entries) {
const ownedByActiveSession = Boolean(
!entry?.sessionId || (activeSessionId && entry.sessionId === activeSessionId)
);
if (!ownedByActiveSession) {
retained.push(entry);
continue;
}
retireAudioBridge(entry);
}
if (retained.length > 0) ${audioOutputsGlobal} = retained;
else delete ${audioOutputsGlobal};
};`;
}
function createMeetingLeaveSource(params) {
const documentSetupSource = params.documentSetupSource ? `${params.documentSetupSource}\n ` : "";
const meetingStateSource = params.meetingStateSource ? ` ${params.meetingStateSource}\n` : "";
const meetingGlobal = pageGlobalSource(params.platform.globals.meeting);
return `() => {
${params.pageIdentitySource}
${documentSetupSource}const selectors = ${params.selectors};
const expectedIdentity = ${JSON.stringify(params.expectedIdentity)};
const expectedSessionId = ${JSON.stringify(params.meetingSessionId)};
const leaveInitiated = ${JSON.stringify(params.leaveInitiated)};
const currentIdentity = meetingIdentity(location.href);
const state = ${meetingGlobal};
const enforceSessionOwnership = Boolean(expectedSessionId);
if (enforceSessionOwnership && state?.sessionId && state.sessionId !== expectedSessionId) {
return JSON.stringify({ departed: false, sessionConflict: true, sessionMatched: false, urlMatched: true });
}
${params.sessionMatchSource}
const retainedLeaveOwnership = Boolean(!sessionMatched && leaveInitiated);
if (!sessionMatched && !retainedLeaveOwnership) {
return JSON.stringify({ departed: false, sessionMatched: false, urlMatched: true });
}
${createMeetingOwnedAudioLeaveSource({ audioOutputsGlobal: params.platform.globals.audioOutputs })}
${params.controlSource}
const preservedCallMatches = Boolean(
expectedIdentity &&
!currentIdentity &&
state?.identity === expectedIdentity &&
state?.inCallControl === leave &&
state?.inCallUrl === location.href &&
leave &&
leave.isConnected !== false
);
const pendingLeaveMatches = Boolean(
expectedIdentity &&
state?.identity === expectedIdentity &&
state?.leavePending === true &&
state?.inCallUrl === location.href &&
Date.now() - state?.leavePendingAt < 10_000
);
const rerenderPendingMatches = Boolean(
expectedIdentity &&
!currentIdentity &&
state?.identity === expectedIdentity &&
state?.inCallControl?.isConnected === false &&
state?.inCallUrl === location.href &&
Date.now() - state?.verifiedAt < 5_000 &&
!leave
);
const meetingIdentityMatches = Boolean(
currentUrlMatches || preservedCallMatches || pendingLeaveMatches || rerenderPendingMatches
);
// ${params.platform.displayName} can replace the document between our Leave click and its post-call marker.
// Retain request ownership only while no identity or live-call control contradicts it.
const initiatedLeaveTransitionMatches = Boolean(
leaveInitiated &&
!currentIdentity &&
!leave &&
(!state?.identity || state.identity === expectedIdentity)
);
if (${params.departedMarkerSource} && (meetingIdentityMatches || initiatedLeaveTransitionMatches)) {
retireOwnedAudioBridges();
if (sessionMatched) delete ${meetingGlobal};
return JSON.stringify({ departed: true, sessionMatched: true, urlMatched: true });
}
if (!meetingIdentityMatches && !initiatedLeaveTransitionMatches) {
return JSON.stringify({ departed: false, urlMatched: false });
}
if (!sessionMatched) {
return JSON.stringify({ departed: false, urlMatched: true });
}
if (confirmation) {
confirmation.click();
return JSON.stringify({ departed: false, leaveAction: "confirm", urlMatched: true });
}
if (leave) {
${meetingGlobal} = {
...state,
identity: expectedIdentity,
${meetingStateSource} inCallControl: leave,
inCallUrl: location.href,
leavePending: true,
leavePendingAt: Date.now(),
};
leave.click();
return JSON.stringify({ departed: false, leaveAction: "leave", urlMatched: true });
}
return JSON.stringify({ departed: false, urlMatched: true });
}`;
}
//#endregion
export { createMeetingStatusCallSource as a, createMeetingStatusPreludeSource as i, createMeetingPageScripts as n, createMeetingBrowserAudioCaptureSource as o, createMeetingTranscriptSource as r, createMeetingLeaveSource as t };