Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import type { GoogleMeetConfig } from "../config.js";
import {
startNodeRealtimeAudioBridge,
type ChromeNodeRealtimeAudioBridgeHandle,
} from "../realtime-node.js";
import {
startCommandRealtimeAudioBridge,
type ChromeRealtimeAudioBridgeHandle,
} from "../realtime.js";
import {
asBrowserTabs,
callBrowserProxyOnNode,
isSameMeetUrlForReuse,
normalizeMeetUrlForReuse,
readBrowserTab,
resolveChromeNode,
type BrowserTab,
} from "./chrome-browser-proxy.js";
import type { GoogleMeetChromeHealth } from "./types.js";
export const GOOGLE_MEET_SYSTEM_PROFILER_COMMAND = "/usr/sbin/system_profiler";
export function outputMentionsBlackHole2ch(output: string): boolean {
return /\bBlackHole\s+2ch\b/i.test(output);
}
export async function assertBlackHole2chAvailable(params: {
runtime: PluginRuntime;
timeoutMs: number;
}): Promise<void> {
if (process.platform !== "darwin") {
throw new Error("Chrome Meet transport with blackhole-2ch audio is currently macOS-only");
}
const result = await params.runtime.system.runCommandWithTimeout(
[GOOGLE_MEET_SYSTEM_PROFILER_COMMAND, "SPAudioDataType"],
{ timeoutMs: params.timeoutMs },
);
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
if (result.code !== 0 || !outputMentionsBlackHole2ch(output)) {
const hint =
params.runtime.system.formatNativeDependencyHint?.({
packageName: "BlackHole 2ch",
downloadCommand: "brew install blackhole-2ch",
}) ?? "";
throw new Error(
[
"BlackHole 2ch audio device not found.",
"Install BlackHole 2ch and route Chrome input/output through the OpenClaw audio bridge.",
hint,
]
.filter(Boolean)
.join(" "),
);
}
}
export async function launchChromeMeet(params: {
runtime: PluginRuntime;
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
meetingSessionId: string;
mode: "realtime" | "transcribe";
url: string;
logger: RuntimeLogger;
}): Promise<{
launched: boolean;
audioBridge?:
| { type: "external-command" }
| ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle);
}> {
await assertBlackHole2chAvailable({
runtime: params.runtime,
timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 10_000),
});
if (params.config.chrome.audioBridgeHealthCommand) {
const health = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeHealthCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code} `,
);
}
}
let audioBridge:
| { type: "external-command" }
| ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle)
| undefined;
if (params.config.chrome.audioBridgeCommand) {
const bridge = await params.runtime.system.runCommandWithTimeout(
params.config.chrome.audioBridgeCommand,
{ timeoutMs: params.config.chrome.joinTimeoutMs },
);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
);
}
audioBridge = { type: "external-command" };
} else if (params.mode === "realtime") {
if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) {
throw new Error(
"Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.",
);
}
audioBridge = {
type: "command-pair",
...(await startCommandRealtimeAudioBridge({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
meetingSessionId: params.meetingSessionId,
inputCommand: params.config.chrome.audioInputCommand,
outputCommand: params.config.chrome.audioOutputCommand,
logger: params.logger,
})),
};
}
if (!params.config.chrome.launch) {
return { launched: false, audioBridge };
}
const argv = ["open", "-a", "Google Chrome"];
if (params.config.chrome.browserProfile) {
argv.push("--args", `--profile-directory=${params.config.chrome.browserProfile}`);
}
argv.push(params.url);
let commandPairBridgeStopped = false;
const stopCommandPairBridge = async () => {
if (commandPairBridgeStopped) {
return;
}
commandPairBridgeStopped = true;
if (audioBridge?.type === "command-pair") {
await audioBridge.stop();
}
};
try {
const result = await params.runtime.system.runCommandWithTimeout(argv, {
timeoutMs: params.config.chrome.joinTimeoutMs,
});
if (result.code === 0) {
return { launched: true, audioBridge };
}
await stopCommandPairBridge();
throw new Error(
`failed to launch Chrome for Meet: ${result.stderr || result.stdout || result.code}`,
);
} catch (error) {
await stopCommandPairBridge();
throw error;
}
}
function parseNodeStartResult(raw: unknown): {
launched?: boolean;
bridgeId?: string;
audioBridge?: { type?: string };
browser?: GoogleMeetChromeHealth;
} {
const value =
raw && typeof raw === "object" && "payload" in raw
? (raw as { payload?: unknown }).payload
: raw;
if (!value || typeof value !== "object") {
throw new Error("Google Meet node returned an invalid start result.");
}
return value as {
launched?: boolean;
bridgeId?: string;
audioBridge?: { type?: string };
browser?: GoogleMeetChromeHealth;
};
}
function parseMeetBrowserStatus(result: unknown): GoogleMeetChromeHealth | undefined {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
const raw = record.result;
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const parsed = JSON.parse(raw) as {
inCall?: boolean;
micMuted?: boolean;
manualActionRequired?: boolean;
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
manualActionMessage?: string;
url?: string;
title?: string;
notes?: string[];
};
return {
inCall: parsed.inCall,
micMuted: parsed.micMuted,
manualActionRequired: parsed.manualActionRequired,
manualActionReason: parsed.manualActionReason,
manualActionMessage: parsed.manualActionMessage,
browserUrl: parsed.url,
browserTitle: parsed.title,
status: "browser-control",
notes: Array.isArray(parsed.notes)
? parsed.notes.filter((note): note is string => typeof note === "string")
: undefined,
};
}
function meetStatusScript(params: { guestName: string; autoJoin: boolean }) {
return `() => {
const text = (node) => (node?.innerText || node?.textContent || "").trim();
const buttons = [...document.querySelectorAll('button')];
const notes = [];
const findButton = (pattern) =>
buttons.find((button) => {
const label = [
button.getAttribute("aria-label"),
button.getAttribute("data-tooltip"),
text(button),
]
.filter(Boolean)
.join(" ");
return pattern.test(label) && !button.disabled;
});
const input = [...document.querySelectorAll('input')].find((el) =>
/your name/i.test(el.getAttribute('aria-label') || el.placeholder || '')
);
if (${JSON.stringify(params.autoJoin)} && input && !input.value) {
input.focus();
input.value = ${JSON.stringify(params.guestName)};
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
const pageText = text(document.body).toLowerCase();
const host = location.hostname.toLowerCase();
const pageUrl = location.href;
const join = ${JSON.stringify(params.autoJoin)}
? findButton(/join now|ask to join/i)
: null;
if (join) join.click();
const microphoneChoice = findButton(/\\buse microphone\\b/i);
if (microphoneChoice) {
microphoneChoice.click();
notes.push("Accepted Meet microphone prompt with browser automation.");
}
const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
const inCall = buttons.some((button) => /leave call/i.test(button.getAttribute('aria-label') || text(button)));
let manualActionReason;
let manualActionMessage;
if (!inCall && (host === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)/i.test(pageText))) {
manualActionReason = "google-login-required";
manualActionMessage = "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.";
} else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText)) {
manualActionReason = "meet-admission-required";
manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech.";
} else if (!inCall && /allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera)/i.test(pageText)) {
manualActionReason = "meet-permission-required";
manualActionMessage = "Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry.";
} else if (!inCall && !microphoneChoice && /do you want people to hear you in the meeting/i.test(pageText)) {
manualActionReason = "meet-audio-choice-required";
manualActionMessage = "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry.";
}
return JSON.stringify({
clickedJoin: Boolean(join),
clickedMicrophoneChoice: Boolean(microphoneChoice),
inCall,
micMuted: mic ? /turn on microphone/i.test(mic.getAttribute('aria-label') || text(mic)) : undefined,
manualActionRequired: Boolean(manualActionReason),
manualActionReason,
manualActionMessage,
title: document.title,
url: pageUrl,
notes
});
}`;
}
async function openMeetWithBrowserProxy(params: {
runtime: PluginRuntime;
nodeId: string;
config: GoogleMeetConfig;
url: string;
}): Promise<{ launched: boolean; browser?: GoogleMeetChromeHealth }> {
if (!params.config.chrome.launch) {
return { launched: false };
}
const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs);
let targetId: string | undefined;
let tab: BrowserTab | undefined;
if (params.config.chrome.reuseExistingTab) {
const tabs = asBrowserTabs(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "GET",
path: "/tabs",
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url));
targetId = tab?.targetId;
if (targetId) {
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "POST",
path: "/tabs/focus",
body: { targetId },
timeoutMs: Math.min(timeoutMs, 5_000),
});
}
}
if (!targetId) {
tab = readBrowserTab(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "POST",
path: "/tabs/open",
body: { url: params.url },
timeoutMs,
}),
);
targetId = tab?.targetId;
}
if (!targetId) {
return {
launched: true,
browser: {
status: "browser-control",
notes: ["Browser proxy opened Meet but did not return a targetId."],
browserUrl: tab?.url,
browserTitle: tab?.title,
},
};
}
const deadline = Date.now() + Math.max(0, params.config.chrome.waitForInCallMs);
let browser: GoogleMeetChromeHealth | undefined = {
status: "browser-control",
browserUrl: tab?.url,
browserTitle: tab?.title,
};
do {
try {
const evaluated = await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId,
fn: meetStatusScript({
guestName: params.config.chrome.guestName,
autoJoin: params.config.chrome.autoJoin,
}),
},
timeoutMs: Math.min(timeoutMs, 10_000),
});
browser = parseMeetBrowserStatus(evaluated) ?? browser;
if (browser?.inCall === true) {
return { launched: true, browser };
}
if (browser?.manualActionRequired === true) {
return { launched: true, browser };
}
} catch (error) {
browser = {
...browser,
inCall: false,
manualActionRequired: true,
manualActionReason: "browser-control-unavailable",
manualActionMessage:
"Open the OpenClaw browser profile, finish Google Meet login, admission, or permission prompts, then retry.",
notes: [
`Browser control could not inspect or auto-join Meet: ${
error instanceof Error ? error.message : String(error)
}`,
],
};
break;
}
if (Date.now() <= deadline) {
await new Promise((resolve) => setTimeout(resolve, 750));
}
} while (Date.now() <= deadline);
return { launched: true, browser };
}
function isRecoverableMeetTab(tab: BrowserTab, url?: string): boolean {
if (url) {
return isSameMeetUrlForReuse(tab.url, url);
}
if (normalizeMeetUrlForReuse(tab.url)) {
return true;
}
const tabUrl = tab.url ?? "";
return (
tabUrl.startsWith("https://accounts.google.com/") &&
/sign in|google accounts|meet/i.test(tab.title ?? "")
);
}
export async function recoverCurrentMeetTabOnNode(params: {
runtime: PluginRuntime;
config: GoogleMeetConfig;
url?: string;
}): Promise<{
nodeId: string;
found: boolean;
targetId?: string;
tab?: BrowserTab;
browser?: GoogleMeetChromeHealth;
message: string;
}> {
const nodeId = await resolveChromeNode({
runtime: params.runtime,
requestedNode: params.config.chromeNode.node,
});
const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs);
const tabs = asBrowserTabs(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "GET",
path: "/tabs",
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url));
const targetId = tab?.targetId;
if (!tab || !targetId) {
return {
nodeId,
found: false,
tab,
message: params.url
? `No existing Meet tab matched ${params.url}.`
: "No existing Meet tab found on the selected Chrome node.",
};
}
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/tabs/focus",
body: { targetId },
timeoutMs: Math.min(timeoutMs, 5_000),
});
const evaluated = await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId,
fn: meetStatusScript({
guestName: params.config.chrome.guestName,
autoJoin: false,
}),
},
timeoutMs: Math.min(timeoutMs, 10_000),
});
const browser = parseMeetBrowserStatus(evaluated);
const manual = browser?.manualActionRequired
? browser.manualActionMessage || browser.manualActionReason
: undefined;
return {
nodeId,
found: true,
targetId,
tab,
browser,
message:
manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused."),
};
}
export async function launchChromeMeetOnNode(params: {
runtime: PluginRuntime;
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
meetingSessionId: string;
mode: "realtime" | "transcribe";
url: string;
logger: RuntimeLogger;
}): Promise<{
nodeId: string;
launched: boolean;
audioBridge?:
| { type: "external-command" }
| ({ type: "node-command-pair" } & ChromeNodeRealtimeAudioBridgeHandle);
browser?: GoogleMeetChromeHealth;
}> {
const nodeId = await resolveChromeNode({
runtime: params.runtime,
requestedNode: params.config.chromeNode.node,
});
const browserControl = await openMeetWithBrowserProxy({
runtime: params.runtime,
nodeId,
config: params.config,
url: params.url,
});
const raw = await params.runtime.nodes.invoke({
nodeId,
command: "googlemeet.chrome",
params: {
action: "start",
url: params.url,
mode: params.mode,
launch: false,
browserProfile: params.config.chrome.browserProfile,
joinTimeoutMs: params.config.chrome.joinTimeoutMs,
audioInputCommand: params.config.chrome.audioInputCommand,
audioOutputCommand: params.config.chrome.audioOutputCommand,
audioBridgeCommand: params.config.chrome.audioBridgeCommand,
audioBridgeHealthCommand: params.config.chrome.audioBridgeHealthCommand,
},
timeoutMs: params.config.chrome.joinTimeoutMs + 5_000,
});
const result = parseNodeStartResult(raw);
if (result.audioBridge?.type === "node-command-pair") {
if (!result.bridgeId) {
throw new Error("Google Meet node did not return an audio bridge id.");
}
const bridge = await startNodeRealtimeAudioBridge({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
meetingSessionId: params.meetingSessionId,
nodeId,
bridgeId: result.bridgeId,
logger: params.logger,
});
return {
nodeId,
launched: browserControl.launched || result.launched === true,
audioBridge: bridge,
browser: browserControl.browser ?? result.browser,
};
}
if (result.audioBridge?.type === "external-command") {
return {
nodeId,
launched: browserControl.launched || result.launched === true,
audioBridge: { type: "external-command" },
browser: browserControl.browser ?? result.browser,
};
}
return {
nodeId,
launched: browserControl.launched || result.launched === true,
browser: browserControl.browser ?? result.browser,
};
}
¤ Dauer der Verarbeitung: 0.19 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|