fix(portable): 微信扫码重定向/Telegram botToken/Windows 启动端口 (v2.1.11)

- 微信扫码卡死: config-server 跟随 IDC scaned_but_redirect 把后续轮询切到
  redirect_host,刷新二维码时重置 pollBaseUrl(扫码后不再卡住等不到 confirmed)。新增测试。
- Telegram 渠道改用 botToken(顶层 telegram 只认 botToken,旧 token 别名被静默忽略 → 连不上)。
- Windows-Start: 先起 config-server 再探测 gateway 端口,从 runtime.json 读实际
  configServerPort 并按该端口开 Config Center / 传给 wait-gateway(18788 被占也能开)。
- wait-gateway.bat: 用 ping 代替 timeout /t(避开 PATH 上 GNU coreutils timeout 冲突),接受 CONFIG_PORT 参数。
- Windows-Menu: IF 块内 echo 的 () 转义为 ^(^),修 v2.1.10 闪退回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-24 00:41:20 +08:00
parent 3439db32ad
commit 6ab87c6b67
8 changed files with 177 additions and 34 deletions

View File

@@ -477,12 +477,12 @@ echo Fetching latest version...
for /f "tokens=*" %%v in ('"%NODE_BIN%" -e "const https=require('https');https.get('https://registry.npmmirror.com/openclaw/latest',r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{try{console.log(JSON.parse(d).version)}catch(e){console.log('error')}})})" 2^>nul') do set LATEST_VER=%%v
if "!LATEST_VER!"=="" (
echo Could not fetch latest version (network issue?)
echo Could not fetch latest version ^(network issue?^)
pause
goto :menu
)
if "!LATEST_VER!"=="error" (
echo Could not fetch latest version (network issue?)
echo Could not fetch latest version ^(network issue?^)
pause
goto :menu
)
@@ -550,7 +550,7 @@ if !BK_COUNT! gtr 3 (
echo Cleaned !DEL_NUM! old backups.
)
) else (
echo Backups: !BK_COUNT! (no cleanup needed)
echo Backups: !BK_COUNT! ^(no cleanup needed^)
)
REM Clean old logs (>7 days)
@@ -598,7 +598,7 @@ if "%plgchoice%"=="b" (
echo.
echo Common plugins:
echo @icesword760/openclaw-wechat - WeChat
echo @nicepkg/openclaw-plugin-qq - QQ (community)
echo @nicepkg/openclaw-plugin-qq - QQ ^(community^)
echo.
set /p plgname=" Plugin name (empty to cancel): "
if not "!plgname!"=="" (

View File

@@ -107,7 +107,8 @@ for /f "usebackq tokens=1,* delims==" %%a in (`""%NODE_BIN%" "%UCLAW_DIR%lib\res
)
if defined NO_PROXY (
set "no_proxy=%NO_PROXY%"
echo Direct-connect (NO_PROXY): %NO_PROXY%
REM Note: no unescaped parens in echo inside this IF block - cmd treats ) as block-end.
echo Direct-connect via NO_PROXY: %NO_PROXY%
)
REM Async update check (non-blocking, 5s timeout, silent failure)
@@ -134,7 +135,30 @@ if exist "%WECHAT_PLUGIN_SRC%\openclaw.plugin.json" (
)
)
REM Find available port
REM Start Config Server in background
echo Starting Config Center on port 18788...
set "CONFIG_SERVER=%UCLAW_DIR%config-server"
set "RUNTIME_JSON=%STATE_DIR%\runtime.json"
del "%RUNTIME_JSON%" >nul 2>&1
start /B "" "%NODE_BIN%" "%CONFIG_SERVER%\server.js" >nul 2>&1
REM Wait for Config Server with polling instead of a fixed delay.
REM It writes runtime.json with the actual fallback port when ready.
set /a CFG_TRIES=0
:wait_config
if exist "%RUNTIME_JSON%" goto :config_ready
set /a CFG_TRIES+=1
if %CFG_TRIES% geq 20 goto :config_ready
ping -n 1 -w 300 127.0.0.1 >nul 2>&1
goto :wait_config
:config_ready
set "CONFIG_PORT=18788"
if exist "%RUNTIME_JSON%" (
for /f "usebackq tokens=*" %%p in (`powershell -NoProfile -Command "try { (Get-Content -Raw '%RUNTIME_JSON%' | ConvertFrom-Json).configServerPort } catch {}" 2^>nul`) do set "CONFIG_PORT=%%p"
)
echo Config Center port: %CONFIG_PORT%
REM Find available gateway port after Config Center has bound its port.
set PORT=18789
:check_port
netstat -an | findstr ":%PORT% " | findstr "LISTENING" >nul 2>&1
@@ -152,24 +176,6 @@ if %errorlevel%==0 (
echo Starting OpenClaw on port %PORT%...
echo.
REM Start Config Server in background
echo Starting Config Center on port 18788...
set "CONFIG_SERVER=%UCLAW_DIR%config-server"
start /B "" "%NODE_BIN%" "%CONFIG_SERVER%\server.js" >nul 2>&1
REM Wait for Config Server with polling instead of a fixed delay.
REM It is usually ready in under one second. Poll about every 0.3 seconds,
REM up to about 6 seconds, then continue anyway.
set /a CFG_TRIES=0
:wait_config
netstat -an | findstr ":18788 " | findstr "LISTENING" >nul 2>&1
if %errorlevel%==0 goto :config_ready
set /a CFG_TRIES+=1
if %CFG_TRIES% geq 20 goto :config_ready
ping -n 1 -w 300 127.0.0.1 >nul 2>&1
goto :wait_config
:config_ready
REM Do not open Dashboard before the gateway is ready.
REM Slow USB drives may need tens of seconds to stage bundled deps.
REM Open the local startup page now, and always open Config Center too.
@@ -182,11 +188,11 @@ set "LOADING_URL=file:///%LOADING_PATH:\=/%?port=%PORT%&token=uclaw"
start "" "%LOADING_URL%"
echo Opening Config Center...
start "" http://127.0.0.1:18788/
start "" http://127.0.0.1:%CONFIG_PORT%/
REM Fallback watcher: if the startup page cannot poll from file URLs,
REM keep polling and reopen Config Center after the gateway is ready.
start /B "" cmd /c ""%UCLAW_DIR%lib\wait-gateway.bat" %PORT%"
start /B "" cmd /c ""%UCLAW_DIR%lib\wait-gateway.bat" %PORT% %CONFIG_PORT%"
REM Prewarm gateway in the background after it becomes ready.
start /B "" "%NODE_BIN%" "%UCLAW_DIR%lib\prewarm.mjs" %PORT% uclaw >nul 2>&1

View File

@@ -678,7 +678,9 @@ async function saveChannelsAndOpen() {
var channels = {};
var tgToken = document.getElementById('ch-telegram-token').value.trim();
if (tgToken) channels.telegram = { enabled: true, token: tgToken, dmPolicy: 'pairing' };
// OpenClaw 顶层 telegram 渠道只认 botToken旧 token 别名仅在 accounts.<id> 里有效)。
// 之前写成 token 会被静默忽略 → Telegram 接不上。
if (tgToken) channels.telegram = { enabled: true, botToken: tgToken, dmPolicy: 'pairing' };
var qqId = document.getElementById('ch-qqbot-appid').value.trim();
var qqSecret = document.getElementById('ch-qqbot-secret').value.trim();

View File

@@ -246,7 +246,13 @@ async function handleWeChatStatus(sessionKey) {
return { status: 'expired', message: 'Session expired' };
}
const result = await pollWeChatQrStatus(login.apiBaseUrl, login.qrcode);
// 状态轮询用 pollBaseUrlIDC 重定向后会指向新主机);二维码获取/刷新始终用原始
// apiBaseUrl与官方插件一致refresh 回到固定主机,只有 status 轮询跟随重定向)。
const result = await pollWeChatQrStatus(login.pollBaseUrl || login.apiBaseUrl, login.qrcode);
// 微信登录状态流转日志(跳过高频的 wait便于排查"扫码卡死"类问题)。
if (result.status && result.status !== 'wait') {
console.log(`[wechat] status=${result.status}` + (result.redirect_host ? ` redirect_host=${result.redirect_host}` : ''));
}
if (result.status === 'expired') {
// Try to refresh QR code
@@ -261,6 +267,8 @@ async function handleWeChatStatus(sessionKey) {
login.qrcode = refreshed.qrcode;
login.qrcodeUrl = newQr;
login.startedAt = Date.now();
// 新二维码来自原始主机,重置轮询主机,避免拿新码去轮询旧的重定向主机。
login.pollBaseUrl = null;
return { status: 'refreshed', qrcodeUrl: newQr };
}
@@ -302,6 +310,18 @@ async function handleWeChatStatus(sessionKey) {
};
}
// IDC 重定向用户扫码后ilink 服务端可能要求把后续轮询切换到另一个数据中心主机
// (status=scaned_but_redirect + redirect_host)。必须跟着切,否则一直轮询旧主机,
// 扫码后永远等不到 confirmed——表现为「扫了码却卡死不前进」。
// 同款逻辑见官方插件 openclaw-weixin/src/auth/login-qr.ts 的 scaned_but_redirect 分支。
if (result.status === 'scaned_but_redirect') {
if (result.redirect_host) {
login.pollBaseUrl = 'https://' + result.redirect_host;
}
// 对前端按「已扫码」处理:显示提示并继续轮询,下一轮已指向新主机。
return { status: 'scaned' };
}
return { status: result.status };
}

View File

@@ -4,11 +4,13 @@ REM Slow USB drives can need tens of seconds before the gateway listens.
REM
REM Windows-Start opens Config Center immediately, so the ready path only
REM exits quietly. On timeout, reopen Config Center as a recovery hint.
REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT
REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT CONFIG_PORT
REM Polls every 2 seconds, up to about 5 minutes.
set "PORT=%~1"
if "%PORT%"=="" set "PORT=18789"
set "CONFIG_PORT=%~2"
if "%CONFIG_PORT%"=="" set "CONFIG_PORT=18788"
set /a TRIES=0
:wait_loop
@@ -16,12 +18,15 @@ netstat -an | findstr ":%PORT% " | findstr "LISTENING" >nul 2>&1
if %errorlevel%==0 goto :ready
set /a TRIES+=1
if %TRIES% geq 150 goto :timeout
timeout /t 2 /nobreak >nul
REM Use ping (not "timeout /t") for the ~2s delay: if a GNU coreutils "timeout"
REM is on PATH (Git/MSYS bin), it rejects "/t" and this poll becomes a busy-loop.
REM ping for the delay is PATH-robust and matches Windows-Start.bat's wait loop.
ping -n 3 127.0.0.1 >nul 2>&1
goto :wait_loop
:ready
exit /b 0
:timeout
start "" http://127.0.0.1:18788/
start "" http://127.0.0.1:%CONFIG_PORT%/
exit /b 1

View File

@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const server = readFileSync(join(repoRoot, 'portable', 'config-server', 'server.js'), 'utf8');
const configUi = readFileSync(join(repoRoot, 'portable', 'config-server', 'public', 'index.html'), 'utf8');
// The ilink QR API (StatusResponse in openclaw-weixin/src/auth/login-qr.ts) can return
// status "scaned_but_redirect" with a redirect_host: after the user scans, polling must
// move to a new IDC host or "confirmed" never arrives and the QR screen hangs forever.
// The config-server must mirror the plugin's redirect handling.
test('config-server follows the WeChat scaned_but_redirect IDC redirect', () => {
// Status polling uses a redirect-aware host, not the fixed apiBaseUrl directly.
assert.match(
server,
/pollWeChatQrStatus\(\s*login\.pollBaseUrl\s*\|\|\s*login\.apiBaseUrl/,
'status polling must use login.pollBaseUrl (falls back to apiBaseUrl)',
);
// On scaned_but_redirect, switch the poll host to redirect_host.
assert.match(
server,
/scaned_but_redirect[\s\S]{0,200}login\.pollBaseUrl\s*=\s*['"]https:\/\/['"]\s*\+\s*result\.redirect_host/,
'must set login.pollBaseUrl to https://<redirect_host> on scaned_but_redirect',
);
// The redirect case is reported to the client as "scaned" so it keeps polling.
assert.match(
server,
/scaned_but_redirect[\s\S]{0,260}return\s*\{\s*status:\s*['"]scaned['"]\s*\}/,
'scaned_but_redirect should surface as status "scaned" to the client',
);
});
test('config-server writes the Telegram bot token under the field OpenClaw reads (botToken)', () => {
// OpenClaw's top-level telegram channel schema only reads `botToken` (the legacy
// `token` alias is honored only inside accounts.<id>). Writing flat `token` silently
// disables Telegram, so the Config Center must write `botToken`.
assert.match(
configUi,
/channels\.telegram\s*=\s*\{[^}]*botToken:\s*tgToken/,
'telegram channel must be saved with botToken',
);
assert.doesNotMatch(
configUi,
/channels\.telegram\s*=\s*\{[^}]*\btoken:\s*tgToken/,
'telegram channel must not use the flat `token` field (ignored by OpenClaw)',
);
});
test('config-server resets the poll host when the QR is refreshed', () => {
// A refreshed QR comes from the original host, so the redirected poll host must reset,
// otherwise the new QR would be polled against a stale redirect host.
assert.match(
server,
/status:\s*'refreshed'[\s\S]{0,400}/,
'refresh branch should exist',
);
assert.match(
server,
/login\.pollBaseUrl\s*=\s*null;[\s\S]{0,200}status:\s*'refreshed'/,
'QR refresh must reset login.pollBaseUrl before returning refreshed',
);
});

View File

@@ -50,7 +50,7 @@ test('Windows startup keeps Config Center available even after model setup', ()
assert.match(
script,
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:18788\//,
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'Windows-Start.bat should always open Config Center for model/channel changes',
);
assert.doesNotMatch(
@@ -65,7 +65,7 @@ test('Windows gateway fallback does not force-open Dashboard', () => {
assert.match(
script,
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:18788\//,
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'wait-gateway.bat should return users to Config Center on timeout',
);
assert.doesNotMatch(
@@ -147,6 +147,48 @@ test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () =
assert.doesNotMatch(bat, /doctor[^\n]*--force/);
});
// cmd.exe treats ')' as the end of an IF/FOR ( ... ) block, so an unescaped paren in
// an `echo` *inside* a block aborts parsing ("was unexpected at this time") and the
// window flash-closes. This shipped once in v2.1.10 (echo Direct-connect (NO_PROXY)).
// Track block depth structurally and flag any unescaped ( or ) in echoes inside a block.
function unescapedParenEchoesInsideBlocks(bat) {
const offenders = [];
let depth = 0;
for (const raw of bat.split(/\r?\n/)) {
const line = raw.trim();
if (depth > 0 && /^echo\b/i.test(line)) {
// cmd eats an unescaped ')' that sits at the END of an echo (cosmetic: the ')'
// just disappears). The flash-exit only happens when a ')' is followed by more
// text on the line (e.g. "(NO_PROXY): value" → ')' closes the block, ": value"
// then errors). So: drop escaped ^), drop trailing ')'/whitespace, and flag any
// ')' that survives (meaning it had text after it).
let s = line.replace(/\^\)/g, '').replace(/[)\s]+$/, '');
if (s.includes(')')) offenders.push(raw.trim());
}
// structural depth: ') else (' keeps depth; leading ')' closes; if/for line ending in '(' opens
if (/^\)\s*else\b.*\($/i.test(line)) { /* same depth */ }
else if (/^\)/.test(line)) depth = Math.max(0, depth - 1);
if (/^\($/.test(line) || /\b(if|for)\b.*[^^]\(\s*$/i.test(line)) depth += 1;
}
return offenders;
}
test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => {
for (const name of [
'Windows-Start.bat',
'Windows-IntranetFix.bat',
'Windows-LocalModel.bat',
'OpenClaw-Doctor.bat',
'Windows-Diagnose.bat',
'Windows-Menu.bat',
'Windows-Install.bat',
]) {
const bat = readRepoFile('portable', name);
const offenders = unescapedParenEchoesInsideBlocks(bat);
assert.deepEqual(offenders, [], `${name} has unescaped parens in block echo(es): ${offenders.join(' | ')}`);
}
});
test('PowerShell installer generated start.bat disables OpenClaw bonjour discovery', () => {
const script = readRepoFile('install', 'install.ps1');

View File

@@ -1,6 +1,6 @@
{
"name": "u-claw",
"version": "2.1.10",
"version": "2.1.11",
"description": "U-Claw - AI 助手桌面版,插上 U 盘就能用",
"main": "src/main.js",
"author": "U-Claw <hello@u-claw.org> (https://u-claw.org)",