UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

491 lines (490 loc) • 22.4 kB
//#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/page-script-source.ts 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 = () => { 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; } const mediaSourceUrl = (element) => String(element?.currentSrc || element?.src || ""); const sources = Array.isArray(entry?.sources) ? entry.sources : entry?.source ? [{ element: entry.source, muted: Boolean(entry.sourceMuted), stream: entry.stream, url: entry.sourceUrl }] : []; for (const source of sources) { const element = source?.element; const sourceMatches = source?.stream || element?.srcObject ? element?.srcObject === source?.stream : Boolean(source?.url && mediaSourceUrl(element) === source.url); const sourceIsEmpty = Boolean(element && !element.srcObject && !mediaSourceUrl(element)); if (!element) continue; if (sourceIsEmpty) { element.muted = true; continue; } if (!sourceMatches) continue; const detachedLiveSource = Boolean( element.isConnected === false && element.srcObject?.getAudioTracks?.().some((track) => track.readyState === "live") ); if (detachedLiveSource) { element.muted = true; element.pause?.(); element.srcObject = null; } else { element.muted = Boolean(source.muted); } } entry?.bridge?.pause?.(); if (entry?.bridge) entry.bridge.srcObject = null; entry?.bridge?.remove?.(); } 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 { createMeetingBrowserAudioCaptureSource, createMeetingLeaveSource, createMeetingTranscriptSource };