Skip to content

Commit 880a41b

Browse files
committed
fix(google-meet): force English Meet UI via hl=en so browser automation works on any locale
Replace the per-locale label regex approach with one seam: append hl=en to every Meet URL the plugin opens (join + create), so the existing English DOM matchers work regardless of the account/browser language. Live-verified on a signed-in zh-TW profile: meeting pre-join and the /new redirect both honor hl=en and keep it through navigation. Also match the 'Join here too' switch-here dialog button and keep the created meeting link canonical by stripping query/hash from meetingUri.
1 parent 9c0dcf8 commit 880a41b

6 files changed

Lines changed: 66 additions & 29 deletions

File tree

extensions/google-meet/index.create.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ describe("google-meet create flow", () => {
303303
return false;
304304
}
305305
const body = proxy.body as Record<string, unknown>;
306-
return proxy.path === "/tabs/open" && body.url === "https://meet.google.com/new";
306+
return proxy.path === "/tabs/open" && body.url === "https://meet.google.com/new?hl=en";
307307
});
308308
});
309309

@@ -425,7 +425,9 @@ describe("google-meet create flow", () => {
425425
payload: {
426426
result: {
427427
targetId:
428-
proxy.body?.url === "https://meet.google.com/new" ? "create-tab" : "join-tab",
428+
proxy.body?.url === "https://meet.google.com/new?hl=en"
429+
? "create-tab"
430+
: "join-tab",
429431
title: "Meet",
430432
url: proxy.body?.url,
431433
},

extensions/google-meet/index.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,7 +2284,7 @@ describe("google-meet plugin", () => {
22842284
method: "POST",
22852285
path: "/tabs/open",
22862286
timeoutMs: 30000,
2287-
body: { url: "https://meet.google.com/abc-defg-hij" },
2287+
body: { url: "https://meet.google.com/abc-defg-hij?hl=en" },
22882288
});
22892289
expect(openCall[3]).toEqual({ progress: false });
22902290
expect(
@@ -3095,7 +3095,7 @@ describe("google-meet plugin", () => {
30953095
method: "POST",
30963096
path: "/tabs/open",
30973097
timeoutMs: 30000,
3098-
body: { url: "https://meet.google.com/abc-defg-hij" },
3098+
body: { url: "https://meet.google.com/abc-defg-hij?hl=en" },
30993099
});
31003100
const startCall = nodesInvoke.mock.calls.find(([rawCall]) => {
31013101
const call = requireRecord(rawCall, "node invoke");
@@ -4089,7 +4089,7 @@ describe("google-meet plugin", () => {
40894089
const request = requireRecord(params, "local browser open request");
40904090
expect(request.method).toBe("POST");
40914091
expect(request.path).toBe("/tabs/open");
4092-
expect(request.body).toStrictEqual({ url: "https://meet.google.com/abc-defg-hij" });
4092+
expect(request.body).toStrictEqual({ url: "https://meet.google.com/abc-defg-hij?hl=en" });
40934093
expect(extra).toStrictEqual({ progress: false });
40944094
} finally {
40954095
Object.defineProperty(process, "platform", { value: originalPlatform });

extensions/google-meet/src/transports/chrome-browser-proxy.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
11
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
22
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
33
import { describe, expect, it, vi } from "vitest";
4-
import { callBrowserProxyOnNode } from "./chrome-browser-proxy.js";
4+
import { callBrowserProxyOnNode, forceMeetEnglishUi } from "./chrome-browser-proxy.js";
5+
6+
describe("forceMeetEnglishUi", () => {
7+
it("pins hl=en on Meet URLs", () => {
8+
expect(forceMeetEnglishUi("https://meet.google.com/abc-defg-hij")).toBe(
9+
"https://meet.google.com/abc-defg-hij?hl=en",
10+
);
11+
expect(forceMeetEnglishUi("https://meet.google.com/new")).toBe(
12+
"https://meet.google.com/new?hl=en",
13+
);
14+
});
15+
16+
it("overrides an existing hl and keeps other params", () => {
17+
expect(forceMeetEnglishUi("https://meet.google.com/abc-defg-hij?hl=zh-TW&authuser=1")).toBe(
18+
"https://meet.google.com/abc-defg-hij?hl=en&authuser=1",
19+
);
20+
});
21+
});
522

623
describe("Google Meet Chrome browser proxy", () => {
724
it("reports malformed node proxy payloadJSON with an owned error", async () => {

extensions/google-meet/src/transports/chrome-browser-proxy.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ export type BrowserTab = {
1111
url?: string;
1212
};
1313

14+
// Meet automation scripts match English UI labels ("Join now", "Turn off microphone").
15+
// hl=en pins the Meet page language regardless of account/browser locale; without it,
16+
// non-English profiles render localized labels and every DOM matcher goes blind.
17+
export function forceMeetEnglishUi(url: string): string {
18+
try {
19+
const parsed = new URL(url);
20+
parsed.searchParams.set("hl", "en");
21+
return parsed.toString();
22+
} catch {
23+
return url;
24+
}
25+
}
26+
1427
export function normalizeMeetUrlForReuse(url: string | undefined): string | undefined {
1528
if (!url) {
1629
return undefined;

extensions/google-meet/src/transports/chrome-create.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { GoogleMeetConfig } from "../config.js";
44
import {
55
asBrowserTabs,
66
callBrowserProxyOnNode,
7+
forceMeetEnglishUi,
78
readBrowserTab,
89
resolveChromeNode,
910
type BrowserTab,
@@ -198,7 +199,9 @@ export const CREATE_MEET_FROM_BROWSER_SCRIPT = `async () => {
198199
}
199200
const href = current();
200201
if (meetUrlPattern.test(href)) {
201-
return { meetingUri: href, browserUrl: href, browserTitle: document.title, notes };
202+
// The /new redirect keeps the hl=en param we open with; strip query/hash so the
203+
// meeting link handed to users stays canonical instead of forcing English on them.
204+
return { meetingUri: href.split(/[?#]/)[0], browserUrl: href, browserTitle: document.title, notes };
202205
}
203206
const pageText = text(document.body);
204207
if (clickButton(/\\buse microphone\\b/i, "Accepted Meet microphone prompt with browser automation.")) {
@@ -287,7 +290,7 @@ export async function createMeetWithBrowserProxyOnNode(params: {
287290
nodeId,
288291
method: "POST",
289292
path: "/tabs/open",
290-
body: { url: GOOGLE_MEET_NEW_URL },
293+
body: { url: forceMeetEnglishUi(GOOGLE_MEET_NEW_URL) },
291294
timeoutMs: stepTimeoutMs,
292295
}),
293296
);

extensions/google-meet/src/transports/chrome.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Google Meet plugin module implements chrome behavior.
12
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
23
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
34
import { addTimerTimeoutGraceMs } from "openclaw/plugin-sdk/number-runtime";
@@ -22,6 +23,7 @@ import {
2223
import {
2324
asBrowserTabs,
2425
callBrowserProxyOnNode,
26+
forceMeetEnglishUi,
2527
isSameMeetUrlForReuse,
2628
normalizeMeetUrlForReuse,
2729
readBrowserTab,
@@ -403,7 +405,7 @@ function meetStatusScript(params: {
403405
return pattern.test(label) && !/remotely mute|someone else/i.test(label) && !button.disabled;
404406
});
405407
const input = [...document.querySelectorAll('input')].find((el) =>
406-
/your name|您的姓名|姓名|nombre|nom|name/i.test(el.getAttribute('aria-label') || el.placeholder || '')
408+
/your name/i.test(el.getAttribute('aria-label') || el.placeholder || '')
407409
);
408410
if (!readOnly && ${JSON.stringify(params.autoJoin)} && input && !input.value) {
409411
input.focus();
@@ -416,35 +418,35 @@ function meetStatusScript(params: {
416418
const host = location.hostname.toLowerCase();
417419
const pageUrl = location.href;
418420
const permissionNeeded = /permission needed|microphone problem|speaker problem|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(permissionText);
419-
let mic = findCallControlButton(/^\\s*(?:(?:turn (?:off|on) microphone|(?:activar|desactivar) micrófono|(?:activer|désactiver) le micro|mikrofon (?:einschalten|ausschalten))\\b|關閉麥克風|開啟麥克風|关闭麦克风|开启麦克风|マイクを(?:オン|オフ))/i);
421+
let mic = findCallControlButton(/^\\s*turn (?:off|on) microphone\\b/i);
420422
if (!mic) {
421423
const callControls = document.querySelector('[role="region"][aria-label="Call controls"]');
422424
mic = [...(callControls?.querySelectorAll('button') || [])].find((button) =>
423-
/^\\s*(?:(?:turn (?:off|on) microphone|(?:activar|desactivar) micrófono|(?:activer|désactiver) le micro|mikrofon (?:einschalten|ausschalten))\\b|關閉麥克風|開啟麥克風|关闭麦克风|开启麦克风|マイクを(?:オン|オフ))/i.test(buttonLabel(button))
425+
/^\\s*turn (?:off|on) microphone\\b/i.test(buttonLabel(button))
424426
);
425427
}
426-
if (!readOnly && allowMicrophone && mic && /(?:turn on microphone|開啟麥克風|开启麦克风|マイクをオン|activar micrófono|activer le micro|mikrofon einschalten)/i.test(buttonLabel(mic))) {
428+
if (!readOnly && allowMicrophone && mic && /turn on microphone/i.test(buttonLabel(mic))) {
427429
mic.click();
428430
notes.push("Attempted to turn on the Meet microphone for talk-back mode.");
429431
}
430-
if (!readOnly && !allowMicrophone && mic && /(?:turn off microphone|關閉麥克風|关闭麦克风|マイクをオフ|desactivar micrófono|désactiver le micro|mikrofon ausschalten)/i.test(mic.getAttribute('aria-label') || text(mic))) {
432+
if (!readOnly && !allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
431433
mic.click();
432434
notes.push("Muted Meet microphone for observe-only mode.");
433435
}
434436
const join = !readOnly && ${JSON.stringify(params.autoJoin)}
435-
? findButton(/join now|ask to join|立即加入|要求加入|申請加入|申请加入|同时在?此处加入|同時在?這裡加入|Join here too|今すぐ参加|参加をリクエスト|こちらからも参加|unirse|solicitar unirse|rejoindre|participer|demander à participer|jetzt teilnehmen|teilnahme erbitten/i) || buttons.find(b => b.getAttribute('jsname') === 'Qx7uuf')
437+
? findButton(/join now|ask to join|join here too/i)
436438
: null;
437439
if (join) join.click();
438-
const microphoneChoice = findButton(/\\buse microphone\\b|使用麥克風|使用麦克风|マイクを使用|usar micrófono|utiliser le micro|mikrofon verwenden/i);
439-
const noMicrophoneChoice = findButton(/\\b(continue|join|use) without (microphone|mic)\\b|\\bnot now\\b|不使用麥克風直接加入|不開啟麥克風直接加入|不使用麦克风直接加入|不开启麦克风直接加入|マイクなしで参加|unirse sin micrófono|continuer sans micro|ohne mikrofon teilnehmen/i);
440+
const microphoneChoice = findButton(/\\buse microphone\\b/i);
441+
const noMicrophoneChoice = findButton(/\\b(continue|join|use) without (microphone|mic)\\b|\\bnot now\\b/i);
440442
if (!readOnly && allowMicrophone && microphoneChoice) {
441443
microphoneChoice.click();
442444
notes.push("Accepted Meet microphone prompt with browser automation.");
443445
} else if (!readOnly && !allowMicrophone && noMicrophoneChoice) {
444446
noMicrophoneChoice.click();
445447
notes.push("Skipped Meet microphone prompt for observe-only mode.");
446448
}
447-
const inCall = buttons.some((button) => /(?:leave call|離開|結束|结束|退出|hang up|salir|quitter|verlassen)/i.test(button.getAttribute('aria-label') || text(button)) || button.getAttribute('jsname') === 'HeV7id');
449+
const inCall = buttons.some((button) => /leave call/i.test(button.getAttribute('aria-label') || text(button)));
448450
const routeMeetAudioOutput = async () => {
449451
if (
450452
!allowMicrophone ||
@@ -501,7 +503,7 @@ function meetStatusScript(params: {
501503
let lastCaptionSpeaker;
502504
let lastCaptionText;
503505
let recentTranscript = [];
504-
const captionSelector = '[role="region"][aria-label*="aption" i], [role="region"][aria-label*="字幕" i], [role="region"][aria-label*="cc" i], [aria-live="polite"][role="region"], div[aria-live="polite"]';
506+
const captionSelector = '[role="region"][aria-label*="aption" i], [aria-live="polite"][role="region"], div[aria-live="polite"]';
505507
const captionState = (() => {
506508
if (!captureCaptions) return undefined;
507509
const w = window;
@@ -521,7 +523,7 @@ function meetStatusScript(params: {
521523
const clean = String(captionText || "").replace(/\\s+/g, " ").trim();
522524
const cleanSpeaker = String(speaker || "").replace(/\\s+/g, " ").trim();
523525
if (!clean || clean.length < 2) return;
524-
if (/^(turn on captions|turn off captions|captions|開啟字幕|开启字幕|字幕をオン|字幕をオフ|關閉字幕|关闭字幕|activar subtítulos|desactivar subtítulos|activer les sous-titres|désactiver les sous-titres|untertitel einschalten|untertitel ausschalten)$/i.test(clean)) return;
526+
if (/^(turn on captions|turn off captions|captions)$/i.test(clean)) return;
525527
const key = (cleanSpeaker + "\\n" + clean).toLowerCase();
526528
if (captionState.seen[key]) return;
527529
captionState.seen[key] = true;
@@ -545,12 +547,12 @@ function meetStatusScript(params: {
545547
};
546548
if (captionState) {
547549
if (!readOnly && inCall && !captionState.enabledAttempted) {
548-
const captionButton = findButton(/turn on captions|show captions|captions|開啟字幕|开启字幕|字幕をオン|activar subtítulos|activer les sous-titres|untertitel einschalten/i);
550+
const captionButton = findButton(/turn on captions|show captions|captions/i);
549551
const captionLabel = captionButton ? (captionButton.getAttribute("aria-label") || captionButton.getAttribute("data-tooltip") || text(captionButton)) : "";
550552
if (captionButton) {
551553
captionState.enabledAttempted = true;
552554
captionsEnabledAttempted = true;
553-
if (!/turn off captions|hide captions|關閉字幕|关闭字幕|字幕をオフ|desactivar subtítulos|désactiver les sous-titres|untertitel ausschalten/i.test(captionLabel)) {
555+
if (!/turn off captions|hide captions/i.test(captionLabel)) {
554556
captionButton.click();
555557
notes.push("Attempted to enable Meet captions for observe-only transcript health.");
556558
}
@@ -579,24 +581,24 @@ function meetStatusScript(params: {
579581
lastCaptionText = last?.text;
580582
recentTranscript = lines.slice(-5);
581583
}
582-
const lobbyWaiting = !inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join|等待對方同意|等待主辦人同意|要求加入|等待批准|等待接纳|参加のリクエスト中|esperando|en attente|teilnahme angefragt/i.test(pageText);
583-
const leaveReason = /you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended|你已離開這場會議|通話已結束|會議已結束|你已离开这场会议|会議から退出しました|has salido|vous avez quitté|sie haben die besprechung verlassen/i.test(pageText)
584-
? pageText.match(/you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended|你已離開這場會議|通話已結束|會議已結束|你已离开这场会议|会議から退出しました|has salido|vous avez quitté|sie haben die besprechung verlassen/i)?.[0]
584+
const lobbyWaiting = !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);
585+
const leaveReason = /you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended/i.test(pageText)
586+
? pageText.match(/you left the meeting|you.?ve left the meeting|removed from the meeting|you were removed|call ended|meeting ended/i)?.[0]
585587
: undefined;
586588
let manualActionReason;
587589
let manualActionMessage;
588-
if (!inCall && (host === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)|使用您的 Google 帳戶|選擇帳戶|登入|使用您的 Google 帐户|选择帐户|登录|ログイン|iniciar sesión|se connecter|anmelden/i.test(pageText))) {
590+
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))) {
589591
manualActionReason = "google-login-required";
590592
manualActionMessage = "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.";
591-
} else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join|等待對方同意|等待主辦人同意|要求加入|等待批准|等待接纳|参加のリクエスト中|esperando|en attente|teilnahme angefragt/i.test(pageText)) {
593+
} 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)) {
592594
manualActionReason = "meet-admission-required";
593595
manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech.";
594596
} else if (permissionNeeded) {
595597
manualActionReason = "meet-permission-required";
596598
manualActionMessage = allowMicrophone
597599
? "Allow microphone/camera/speaker permissions for Meet in the OpenClaw browser profile, then retry."
598600
: "Join without microphone/camera permissions in the OpenClaw browser profile, then retry.";
599-
} else if (!inCall && (allowMicrophone ? !microphoneChoice : !noMicrophoneChoice) && /do you want people to hear you in the meeting|你希望大家在會議中聽到你的聲音嗎|在会议中听到你的声音吗/i.test(pageText)) {
601+
} else if (!inCall && (allowMicrophone ? !microphoneChoice : !noMicrophoneChoice) && /do you want people to hear you in the meeting/i.test(pageText)) {
600602
manualActionReason = "meet-audio-choice-required";
601603
manualActionMessage = allowMicrophone
602604
? "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry."
@@ -606,7 +608,7 @@ function meetStatusScript(params: {
606608
clickedJoin: Boolean(join),
607609
clickedMicrophoneChoice: Boolean(allowMicrophone && microphoneChoice),
608610
inCall,
609-
micMuted: mic ? /(?:turn on microphone|開啟麥克風|开启麦克风|マイクをオン|activar micrófono|activer le micro|mikrofon einschalten)/i.test(buttonLabel(mic)) : undefined,
611+
micMuted: mic ? /turn on microphone/i.test(buttonLabel(mic)) : undefined,
610612
lobbyWaiting,
611613
leaveReason,
612614
captioning,
@@ -689,7 +691,7 @@ async function openMeetWithBrowserRequest(params: {
689691
await params.callBrowser({
690692
method: "POST",
691693
path: "/tabs/open",
692-
body: { url: params.url },
694+
body: { url: forceMeetEnglishUi(params.url) },
693695
timeoutMs,
694696
}),
695697
);

0 commit comments

Comments
 (0)