mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 23:30:53 +08:00
Update channel dispatch UX and harden sidecar startup behavior.
This aligns Weixin/WhatsApp with admin-managed model routing by adding channel/account-level specialist controls, improves OSS startup resilience by skipping missing sidecars gracefully, and refreshes runbook/readme guidance for the new default behavior and Weixin OpenAI-key silent handling. Made-with: Cursor
This commit is contained in:
parent
b1a0f94cd6
commit
5ab69e88d3
29 changed files with 1226 additions and 100 deletions
|
|
@ -4,9 +4,11 @@
|
|||
[switch]$SkipInstall = $false,
|
||||
[switch]$Background = $false,
|
||||
[switch]$WithoutWeixin = $false,
|
||||
[switch]$WithoutWhatsApp = $false,
|
||||
[bool]$WithWikiWorker = $true,
|
||||
[string]$WeixinChannelId = "oclaw-weixin",
|
||||
[string]$WeixinGatewayBaseUrl = ""
|
||||
[string]$WeixinGatewayBaseUrl = "",
|
||||
[string]$WhatsAppChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -15,6 +17,10 @@ function Write-Step([string]$msg) {
|
|||
Write-Host "==> $msg" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Warn([string]$msg) {
|
||||
Write-Host "[WARN] $msg" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
|
||||
$repoParent = Split-Path -Parent $repoRoot
|
||||
Set-Location $repoRoot
|
||||
|
|
@ -43,7 +49,20 @@ if (-not $WithoutWeixin) {
|
|||
$gwBase = "http://$BindHost`:$Port"
|
||||
}
|
||||
Write-Step "Starting weixin sidecar"
|
||||
& "$PSScriptRoot/weixin_start.ps1" -ChannelId $WeixinChannelId -GatewayBaseUrl $gwBase
|
||||
try {
|
||||
& "$PSScriptRoot/weixin_start.ps1" -ChannelId $WeixinChannelId -GatewayBaseUrl $gwBase
|
||||
} catch {
|
||||
Warn "weixin sidecar skipped: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if (-not $WithoutWhatsApp) {
|
||||
$waBase = "http://$BindHost`:$Port"
|
||||
Write-Step "Starting whatsapp sidecar"
|
||||
try {
|
||||
& "$PSScriptRoot/whatsapp_start.ps1" -ChannelId $WhatsAppChannelId -GatewayBaseUrl $waBase
|
||||
} catch {
|
||||
Warn "whatsapp sidecar skipped: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "All started." -ForegroundColor Green
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ param(
|
|||
[string]$BindHost = "127.0.0.1",
|
||||
[int]$Port = 8787,
|
||||
[switch]$WithoutWeixin = $false,
|
||||
[switch]$WithoutWhatsApp = $false,
|
||||
[switch]$WithWikiWorker = $false,
|
||||
[string]$WeixinChannelId = "oclaw-weixin"
|
||||
[string]$WeixinChannelId = "oclaw-weixin",
|
||||
[string]$WhatsAppChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -127,6 +129,15 @@ if (-not $WithoutWeixin) {
|
|||
Warn "weixin status failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if (-not $WithoutWhatsApp) {
|
||||
Write-Host ""
|
||||
Write-Step "WhatsApp sidecar"
|
||||
try {
|
||||
& "$PSScriptRoot/whatsapp_status.ps1" -ChannelId $WhatsAppChannelId
|
||||
} catch {
|
||||
Warn "whatsapp status failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($WithWikiWorker) {
|
||||
Write-Host ""
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ param(
|
|||
[int]$Port = 8787,
|
||||
[switch]$Force = $false,
|
||||
[switch]$WithoutWeixin = $false,
|
||||
[switch]$WithoutWhatsApp = $false,
|
||||
[switch]$WithWikiWorker = $false,
|
||||
[string]$WeixinChannelId = "oclaw-weixin"
|
||||
[string]$WeixinChannelId = "oclaw-weixin",
|
||||
[string]$WhatsAppChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -34,6 +36,14 @@ if (-not $WithoutWeixin) {
|
|||
if (-not $Force) { throw }
|
||||
}
|
||||
}
|
||||
if (-not $WithoutWhatsApp) {
|
||||
Write-Step "Stopping whatsapp sidecar"
|
||||
try {
|
||||
& "$PSScriptRoot/whatsapp_stop.ps1" -ChannelId $WhatsAppChannelId -Force:$Force
|
||||
} catch {
|
||||
if (-not $Force) { throw }
|
||||
}
|
||||
}
|
||||
if ($WithWikiWorker) {
|
||||
Write-Step "Stopping wiki worker"
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,15 @@ if ($procId -le 0) {
|
|||
}
|
||||
|
||||
try {
|
||||
# Kill the whole process tree (cmd -> npm -> electron/node)
|
||||
& taskkill /PID $procId /T /F | Out-Null
|
||||
# Kill the whole process tree (cmd -> npm -> electron/node).
|
||||
# Some shells promote native command non-zero exit codes to terminating errors.
|
||||
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||
try {
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
& taskkill /PID $procId /T /F 2>$null | Out-Null
|
||||
} finally {
|
||||
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||
}
|
||||
Write-Host "Stopped PID=$procId (tree)" -ForegroundColor Green
|
||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -37,15 +37,9 @@ function Ensure-OfficialPluginRuntimeDeps {
|
|||
}
|
||||
|
||||
if ($UseOpenclawCli) {
|
||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
||||
if (-not $openclawCmd) {
|
||||
throw "openclaw command not found. Install first: npm install -g openclaw"
|
||||
}
|
||||
npx -y @tencent-weixin/openclaw-weixin-cli@latest install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "openclaw-weixin-cli install failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
Ensure-OfficialPluginRuntimeDeps
|
||||
# Do not require a globally-installed openclaw CLI.
|
||||
# We install openclaw into the sidecar runtime and put node_modules/.bin on PATH
|
||||
# so the official installer can run `openclaw ...` commands.
|
||||
Push-Location $sidecarRoot
|
||||
try {
|
||||
if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) {
|
||||
|
|
@ -54,6 +48,21 @@ if ($UseOpenclawCli) {
|
|||
throw "npm init failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
npm.cmd install openclaw@latest --save
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm install openclaw failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$env:PATH = (Join-Path $sidecarRoot "node_modules\\.bin") + ";" + $env:PATH
|
||||
npx.cmd -y @tencent-weixin/openclaw-weixin-cli@latest install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "openclaw-weixin-cli install failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Ensure-OfficialPluginRuntimeDeps
|
||||
Push-Location $sidecarRoot
|
||||
try {
|
||||
npm.cmd install openclaw@latest --save tsx@4.21.0 typescript@6.0.3
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm install bridge runtime deps failed with exit code $LASTEXITCODE"
|
||||
|
|
|
|||
|
|
@ -27,15 +27,7 @@ try {
|
|||
npm.cmd exec -- tsx login.ts
|
||||
exit 0
|
||||
}
|
||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
||||
if (-not $openclawCmd) {
|
||||
throw "official mode requires openclaw command. Install first: npm install -g openclaw"
|
||||
}
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
openclaw channels login --channel openclaw-weixin
|
||||
throw "login.ts missing. Re-run weixin_install.ps1 -UseOpenclawCli to install sidecar runtime."
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,12 +42,14 @@ function Get-SidecarProcesses {
|
|||
|
||||
function Stop-SidecarProcesses {
|
||||
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
||||
foreach ($proc in $procs) {
|
||||
try {
|
||||
taskkill.exe /PID $proc.ProcessId /T /F | Out-Null
|
||||
} catch {
|
||||
# Best-effort cleanup; keep going if a process already exited.
|
||||
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||
try {
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
foreach ($proc in $procs) {
|
||||
taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null
|
||||
}
|
||||
} finally {
|
||||
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||
}
|
||||
return $procs.Count
|
||||
}
|
||||
|
|
@ -112,17 +114,5 @@ if ((Test-Path (Join-Path $sidecarRoot "runner.ts")) -or (Test-Path (Join-Path $
|
|||
exit 0
|
||||
}
|
||||
|
||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
||||
if (-not $openclawCmd) {
|
||||
throw "official mode requires openclaw command. Install first: npm install -g openclaw"
|
||||
}
|
||||
# Ensure OpenClaw runs on the real Node.js runtime (includes npm layout).
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
$args = @("/c", "openclaw gateway --allow-unconfigured")
|
||||
$p = Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $oclawRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath
|
||||
Set-Content -Path $pidFile -Value $p.Id
|
||||
Write-Host "[ok] started openclaw gateway pid=$($p.Id) cleaned=$cleaned out=$logPath err=$errPath"
|
||||
throw "No runner.ts/official_runner.ts found in sidecar root. Re-run weixin_install.ps1 -UseOpenclawCli."
|
||||
|
||||
|
|
|
|||
|
|
@ -69,16 +69,6 @@ if ($exists) {
|
|||
Write-Host "status=orphaned stale_pid=$procId count=$($sidecarProcs.Count) pids=$pids"
|
||||
exit 0
|
||||
}
|
||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
||||
if ($openclawCmd) {
|
||||
try {
|
||||
$txt = (& openclaw channels status --probe) -join "`n"
|
||||
if ($txt -match "openclaw-weixin .*running") {
|
||||
Write-Host "status=running mode=official"
|
||||
exit 0
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
Write-Host "status=stale_pid pid=$procId"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,16 +42,22 @@ function Stop-SidecarProcesses {
|
|||
[switch]$ForceKill
|
||||
)
|
||||
$procs = @(Get-SidecarProcesses | Sort-Object ProcessId -Descending)
|
||||
foreach ($proc in $procs) {
|
||||
try {
|
||||
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||
try {
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
foreach ($proc in $procs) {
|
||||
if ($ForceKill) {
|
||||
taskkill.exe /PID $proc.ProcessId /T /F | Out-Null
|
||||
} else {
|
||||
taskkill.exe /PID $proc.ProcessId /T | Out-Null
|
||||
taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null
|
||||
continue
|
||||
}
|
||||
taskkill.exe /PID $proc.ProcessId /T 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# Some process trees require force kill on Windows.
|
||||
taskkill.exe /PID $proc.ProcessId /T /F 2>$null | Out-Null
|
||||
}
|
||||
} catch {
|
||||
# Ignore already-exited processes and continue best-effort cleanup.
|
||||
}
|
||||
} finally {
|
||||
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||
}
|
||||
return $procs.Count
|
||||
}
|
||||
|
|
@ -73,21 +79,23 @@ if (-not $procId) {
|
|||
exit 0
|
||||
}
|
||||
|
||||
$oldNativePref = $PSNativeCommandUseErrorActionPreference
|
||||
try {
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
if ($Force) {
|
||||
taskkill.exe /PID $procId /T /F | Out-Null
|
||||
taskkill.exe /PID $procId /T /F 2>$null | Out-Null
|
||||
} else {
|
||||
taskkill.exe /PID $procId /T | Out-Null
|
||||
taskkill.exe /PID $procId /T 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# Retry with /F to avoid noisy parent/child kill failures.
|
||||
taskkill.exe /PID $procId /T /F 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Ignore if already dead.
|
||||
} finally {
|
||||
$PSNativeCommandUseErrorActionPreference = $oldNativePref
|
||||
}
|
||||
|
||||
$killed = Stop-SidecarProcesses -ForceKill:$Force
|
||||
Remove-Item -Force $pidFile -ErrorAction SilentlyContinue
|
||||
$openclawCmd = Get-Command openclaw -ErrorAction SilentlyContinue
|
||||
if ($openclawCmd) {
|
||||
try { openclaw gateway stop | Out-Null } catch {}
|
||||
}
|
||||
Write-Host "[ok] stopped pid=$procId extra_cleaned=$killed"
|
||||
|
||||
|
|
|
|||
52
runtime/operations/scripts/whatsapp_install.ps1
Normal file
52
runtime/operations/scripts/whatsapp_install.ps1
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
param(
|
||||
[string]$ChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$here = Split-Path -Parent $PSCommandPath
|
||||
return (Resolve-Path (Join-Path $here "..\\..\\..")).Path
|
||||
}
|
||||
|
||||
$oclawRoot = Resolve-RepoRoot
|
||||
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||
$stateDir = Join-Path $sidecarRoot "state"
|
||||
$logDir = Join-Path $sidecarRoot "logs"
|
||||
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
|
||||
Push-Location $sidecarRoot
|
||||
try {
|
||||
if (-not (Test-Path (Join-Path $sidecarRoot "package.json"))) {
|
||||
npm.cmd init -y | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm init failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
npm.cmd install --save @whiskeysockets/baileys@latest qrcode-terminal@latest
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install deps failed with exit code $LASTEXITCODE" }
|
||||
npm.cmd install --save https-proxy-agent@latest
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install proxy deps failed with exit code $LASTEXITCODE" }
|
||||
npm.cmd install --save tsx@latest typescript@latest
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install dev deps failed with exit code $LASTEXITCODE" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if (Test-Path $bridgeSrc) {
|
||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) {
|
||||
$srcPath = Join-Path $bridgeSrc $name
|
||||
if (Test-Path $srcPath) {
|
||||
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[ok] installed whatsapp baileys sidecar runtime into $sidecarRoot"
|
||||
32
runtime/operations/scripts/whatsapp_login.ps1
Normal file
32
runtime/operations/scripts/whatsapp_login.ps1
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
param(
|
||||
[string]$ChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$here = Split-Path -Parent $PSCommandPath
|
||||
return (Resolve-Path (Join-Path $here "..\\..\\..")).Path
|
||||
}
|
||||
|
||||
$oclawRoot = Resolve-RepoRoot
|
||||
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||
$stateDir = Join-Path $sidecarRoot "state"
|
||||
|
||||
if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
|
||||
throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first."
|
||||
}
|
||||
|
||||
Push-Location $sidecarRoot
|
||||
try {
|
||||
$env:OCLAW_STATE_DIR = $stateDir
|
||||
$args = @("/c", "set OCLAW_STATE_DIR=$stateDir&& npx.cmd -y tsx baileys_runner.ts --login")
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $sidecarRoot -Wait
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
57
runtime/operations/scripts/whatsapp_start.ps1
Normal file
57
runtime/operations/scripts/whatsapp_start.ps1
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
param(
|
||||
[string]$ChannelId = "whatsapp",
|
||||
[string]$GatewayBaseUrl = "http://127.0.0.1:8787",
|
||||
[int]$GatewayWaitSeconds = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$here = Split-Path -Parent $PSCommandPath
|
||||
return (Resolve-Path (Join-Path $here "..\\..\\..")).Path
|
||||
}
|
||||
|
||||
$oclawRoot = Resolve-RepoRoot
|
||||
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||
$stateDir = Join-Path $sidecarRoot "state"
|
||||
$logDir = Join-Path $sidecarRoot "logs"
|
||||
$sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
|
||||
$logPath = Join-Path $logDir "whatsapp_sidecar.log"
|
||||
$errPath = Join-Path $logDir "whatsapp_sidecar.err.log"
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
|
||||
throw "whatsapp sidecar not installed. Run whatsapp_install.ps1 first."
|
||||
}
|
||||
|
||||
$healthUrl = ($GatewayBaseUrl.TrimEnd("/") + "/health")
|
||||
for ($i = 0; $i -lt $GatewayWaitSeconds; $i++) {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 3
|
||||
if ($resp -and $resp.StatusCode -eq 200) {
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
if ($i -lt ($GatewayWaitSeconds - 1)) {
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
if (-not $resp -or $resp.StatusCode -ne 200) {
|
||||
throw "oclaw gateway is not reachable at $healthUrl. Start it first: powershell -ExecutionPolicy Bypass -File .\\scripts\\start_gateway.ps1 -SkipInstall -Background"
|
||||
}
|
||||
|
||||
$args = @(
|
||||
"/c",
|
||||
"cd /d $sidecarRoot && set OCLAW_STATE_DIR=$stateDir&& set AIA_GATEWAY_BASE_URL=$GatewayBaseUrl&& npx.cmd -y tsx baileys_runner.ts"
|
||||
)
|
||||
$p = Start-Process -FilePath "cmd.exe" -ArgumentList $args -WorkingDirectory $sidecarRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errPath
|
||||
Set-Content -Path $sidecarPidFile -Value $p.Id
|
||||
Write-Host "[ok] started whatsapp sidecar pid=$($p.Id) mode=baileys out=$logPath err=$errPath"
|
||||
18
runtime/operations/scripts/whatsapp_status.ps1
Normal file
18
runtime/operations/scripts/whatsapp_status.ps1
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
param(
|
||||
[string]$ChannelId = "whatsapp"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$oclawRoot = (Resolve-Path (Join-Path (Split-Path -Parent $PSCommandPath) "..\\..\\..")).Path
|
||||
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||
$sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
|
||||
if (Test-Path $sidecarPidFile) {
|
||||
$procId = (Get-Content -Path $sidecarPidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||||
if ($procId -and (Get-Process -Id $procId -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "status=running mode=baileys channel=$ChannelId"
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "status=stopped mode=baileys channel=$ChannelId"
|
||||
37
runtime/operations/scripts/whatsapp_stop.ps1
Normal file
37
runtime/operations/scripts/whatsapp_stop.ps1
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
param(
|
||||
[string]$ChannelId = "whatsapp",
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$here = Split-Path -Parent $PSCommandPath
|
||||
return (Resolve-Path (Join-Path $here "..\\..\\..")).Path
|
||||
}
|
||||
|
||||
$systemNodeDir = "C:\\Program Files\\nodejs"
|
||||
if (Test-Path (Join-Path $systemNodeDir "node.exe")) {
|
||||
$env:PATH = "$systemNodeDir;$env:PATH"
|
||||
}
|
||||
|
||||
$oclawRoot = Resolve-RepoRoot
|
||||
$sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId"
|
||||
$sidecarPidFile = Join-Path $sidecarRoot "pid.txt"
|
||||
|
||||
if (Test-Path $sidecarPidFile) {
|
||||
$procId = (Get-Content -Path $sidecarPidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||||
if ($procId) {
|
||||
if ($Force) {
|
||||
taskkill.exe /PID $procId /T /F 2>$null | Out-Null
|
||||
} else {
|
||||
taskkill.exe /PID $procId /T 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
taskkill.exe /PID $procId /T /F 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
Remove-Item -Force $sidecarPidFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "[ok] stopped whatsapp channel=$ChannelId"
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
function resolveLocalOpenclawBin(): string {
|
||||
// Prefer local openclaw installed in the sidecar runtime (no global CLI required).
|
||||
// Windows: node_modules/.bin/openclaw.cmd
|
||||
return path.join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "openclaw.cmd" : "openclaw");
|
||||
}
|
||||
|
||||
function run(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("openclaw", ["channels", "login", "--channel", "openclaw-weixin"], {
|
||||
const openclawBin = resolveLocalOpenclawBin();
|
||||
const child = spawn(openclawBin, ["channels", "login", "--channel", "openclaw-weixin"], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
|
|
|
|||
13
runtime/operations/whatsapp_bridge/auth.ts
Normal file
13
runtime/operations/whatsapp_bridge/auth.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import path from "node:path";
|
||||
import { useMultiFileAuthState } from "@whiskeysockets/baileys";
|
||||
|
||||
export type AuthState = Awaited<ReturnType<typeof useMultiFileAuthState>>;
|
||||
|
||||
export function resolveAuthDir(stateDir: string): string {
|
||||
return path.join(stateDir, "auth");
|
||||
}
|
||||
|
||||
export async function loadAuthState(stateDir: string): Promise<AuthState> {
|
||||
return await useMultiFileAuthState(resolveAuthDir(stateDir));
|
||||
}
|
||||
|
||||
270
runtime/operations/whatsapp_bridge/baileys_runner.ts
Normal file
270
runtime/operations/whatsapp_bridge/baileys_runner.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import dns from "node:dns/promises";
|
||||
|
||||
import makeWASocket, {
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
jidNormalizedUser,
|
||||
proto,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
import { loadAuthState } from "./auth";
|
||||
import { printQrToTerminal } from "./qr";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
const LOCAL_BASE_URL = (process.env.AIA_GATEWAY_BASE_URL || "http://127.0.0.1:8787").trim();
|
||||
const ACCOUNT_ID = (process.env.AIA_WHATSAPP_ACCOUNT_ID || "wa-default").trim();
|
||||
const STATE_DIR = (process.env.OCLAW_STATE_DIR || path.resolve(process.cwd(), "state")).trim();
|
||||
const LOGIN_ONLY = process.argv.includes("--login") || String(process.env.AIA_WHATSAPP_LOGIN_ONLY || "").trim() === "1";
|
||||
const VERBOSE = process.argv.includes("--verbose") || String(process.env.AIA_WHATSAPP_VERBOSE || "").trim() === "1";
|
||||
const PROXY_URL = (
|
||||
process.env.AIA_WHATSAPP_PROXY_URL ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.http_proxy ||
|
||||
""
|
||||
).trim();
|
||||
|
||||
function log(msg: string): void {
|
||||
process.stdout.write(`${new Date().toISOString()} [baileys-whatsapp] ${msg}\n`);
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function pickText(m: proto.IMessage | null | undefined): string {
|
||||
if (!m) return "";
|
||||
const c = (m.conversation || "").trim();
|
||||
if (c) return c;
|
||||
const ext = (m.extendedTextMessage?.text || "").trim();
|
||||
if (ext) return ext;
|
||||
const imgCap = (m.imageMessage?.caption || "").trim();
|
||||
if (imgCap) return imgCap;
|
||||
const vidCap = (m.videoMessage?.caption || "").trim();
|
||||
if (vidCap) return vidCap;
|
||||
return "";
|
||||
}
|
||||
|
||||
function isStatusOrBroadcastJid(jid: string): boolean {
|
||||
const low = String(jid || "").toLowerCase();
|
||||
return low === "status@broadcast" || low.endsWith("@broadcast");
|
||||
}
|
||||
|
||||
function buildInboundPayload(params: {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
text: string;
|
||||
raw: unknown;
|
||||
}): Json {
|
||||
return {
|
||||
channel: "whatsapp",
|
||||
account_id: ACCOUNT_ID,
|
||||
user_id: params.userId,
|
||||
chat_id: params.chatId,
|
||||
text: params.text,
|
||||
metadata: {
|
||||
source: "whatsapp_baileys",
|
||||
raw: params.raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function postInbound(payload: Json): Promise<Json> {
|
||||
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/inbound/whatsapp`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`inbound ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return text ? (JSON.parse(text) as Json) : {};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function _ipLooksHijackedOrUnroutable(ip: string): boolean {
|
||||
const s = String(ip || "").trim();
|
||||
if (!s) return false;
|
||||
if (s.startsWith("198.18.") || s.startsWith("198.19.")) return true; // reserved benchmark range
|
||||
if (s.startsWith("0.") || s.startsWith("127.") || s.startsWith("10.")) return true;
|
||||
if (s.startsWith("192.168.") || s.startsWith("169.254.")) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function logNetworkHints(): Promise<void> {
|
||||
try {
|
||||
const a = await dns.resolve4("web.whatsapp.com").catch(() => []);
|
||||
const aaaa = await dns.resolve6("web.whatsapp.com").catch(() => []);
|
||||
const sample = [...a, ...aaaa].slice(0, 6);
|
||||
if (sample.length > 0) {
|
||||
log(`dns web.whatsapp.com => ${sample.join(", ")}`);
|
||||
const bad = sample.find(_ipLooksHijackedOrUnroutable);
|
||||
if (bad) {
|
||||
log(
|
||||
`WARNING: DNS looks suspicious (e.g. ${bad}). This often causes WhatsApp Web handshake failures. Try switching system DNS (1.1.1.1/8.8.8.8) or check proxy/hosts rules.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || "";
|
||||
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy || "";
|
||||
if (httpProxy || httpsProxy) {
|
||||
log(`proxy env detected HTTP_PROXY=${httpProxy ? "set" : "unset"} HTTPS_PROXY=${httpsProxy ? "set" : "unset"}`);
|
||||
}
|
||||
if (PROXY_URL) {
|
||||
log(`proxy configured for Baileys via PROXY_URL=${PROXY_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeduper(params: { ttlMs: number; max: number }) {
|
||||
const seen = new Map<string, number>();
|
||||
const prune = (now: number): void => {
|
||||
for (const [k, ts] of seen) {
|
||||
if (now - ts > params.ttlMs) {
|
||||
seen.delete(k);
|
||||
}
|
||||
}
|
||||
if (seen.size <= params.max) return;
|
||||
const ordered = Array.from(seen.entries()).sort((a, b) => a[1] - b[1]);
|
||||
const drop = ordered.slice(0, Math.max(0, ordered.length - params.max));
|
||||
for (const [k] of drop) seen.delete(k);
|
||||
};
|
||||
return {
|
||||
has: (id: string): boolean => {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
const ts = seen.get(id);
|
||||
return typeof ts === "number" && now - ts <= params.ttlMs;
|
||||
},
|
||||
add: (id: string): void => {
|
||||
const now = Date.now();
|
||||
seen.set(id, now);
|
||||
prune(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
ensureDir(STATE_DIR);
|
||||
const { state, saveCreds } = await loadAuthState(STATE_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
const dedupe = makeDeduper({ ttlMs: 10 * 60 * 1000, max: 20_000 });
|
||||
|
||||
let reconnectAttempt = 0;
|
||||
let sock: ReturnType<typeof makeWASocket> | null = null;
|
||||
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
|
||||
|
||||
const connectOnce = async () => {
|
||||
sock = makeWASocket({
|
||||
auth: state,
|
||||
browser: Browsers.windows("oclaw"),
|
||||
version,
|
||||
agent: wsAgent as any,
|
||||
printQRInTerminal: false,
|
||||
generateHighQualityLinkPreview: false,
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", saveCreds);
|
||||
|
||||
sock.ev.on("connection.update", async (update) => {
|
||||
if (update.qr) {
|
||||
log("QR received. Scan it from WhatsApp -> Linked devices.");
|
||||
printQrToTerminal(update.qr);
|
||||
}
|
||||
if (update.connection === "open") {
|
||||
reconnectAttempt = 0;
|
||||
const me = sock?.user?.id ? jidNormalizedUser(sock.user.id) : "";
|
||||
log(`connected. me=${me || "unknown"} loginOnly=${LOGIN_ONLY}`);
|
||||
if (LOGIN_ONLY) {
|
||||
log("login-only mode: exiting after successful link.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (update.connection === "close") {
|
||||
const statusCode = (update.lastDisconnect?.error as any)?.output?.statusCode as number | undefined;
|
||||
const reason = statusCode ? (DisconnectReason as any)[statusCode] || String(statusCode) : "unknown";
|
||||
const errText = String(update.lastDisconnect?.error || "").slice(0, 200);
|
||||
log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`);
|
||||
if (statusCode === DisconnectReason.loggedOut) {
|
||||
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
|
||||
return;
|
||||
}
|
||||
reconnectAttempt += 1;
|
||||
const base = Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt)));
|
||||
const jitter = Math.floor(Math.random() * 500);
|
||||
const delay = base + jitter;
|
||||
log(`reconnecting in ${delay}ms attempt=${reconnectAttempt}`);
|
||||
await sleep(delay);
|
||||
await connectOnce();
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on("messages.upsert", async (upsert) => {
|
||||
const msgs = Array.isArray(upsert.messages) ? upsert.messages : [];
|
||||
for (const msg of msgs) {
|
||||
try {
|
||||
const key = msg.key;
|
||||
const id = String(key.id || "").trim();
|
||||
const remoteJid = String(key.remoteJid || "").trim();
|
||||
if (!id || !remoteJid) continue;
|
||||
if (isStatusOrBroadcastJid(remoteJid)) continue;
|
||||
if (key.fromMe) continue;
|
||||
if (dedupe.has(id)) continue;
|
||||
dedupe.add(id);
|
||||
|
||||
const text = pickText(msg.message);
|
||||
if (!text) continue;
|
||||
|
||||
const isGroup = remoteJid.endsWith("@g.us");
|
||||
const from = isGroup ? String(key.participant || "").trim() : remoteJid;
|
||||
const userId = from ? jidNormalizedUser(from) : jidNormalizedUser(remoteJid);
|
||||
const chatId = jidNormalizedUser(remoteJid);
|
||||
|
||||
const raw = {
|
||||
id,
|
||||
remoteJid,
|
||||
participant: key.participant || null,
|
||||
pushName: (msg as any).pushName || null,
|
||||
messageTimestamp: (msg as any).messageTimestamp || null,
|
||||
};
|
||||
|
||||
const inbound = buildInboundPayload({ chatId, userId, text, raw });
|
||||
if (VERBOSE) log(`inbound posting chat=${chatId} user=${userId} textLen=${text.length}`);
|
||||
const out = await postInbound(inbound);
|
||||
const replies = Array.isArray(out.replies) ? (out.replies as Json[]) : [];
|
||||
if (VERBOSE) log(`inbound ok replies=${replies.length}`);
|
||||
for (const r of replies) {
|
||||
const outText = String((r as any).text || "").trim();
|
||||
if (!outText) continue;
|
||||
const deliverTo = String((r as any).chat_id || chatId).trim() || chatId;
|
||||
await sock?.sendMessage(deliverTo, { text: outText });
|
||||
}
|
||||
} catch (err) {
|
||||
log(`handle message error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
log(`runner started local=${LOCAL_BASE_URL} stateDir=${STATE_DIR} verbose=${VERBOSE} node=${process.version}`);
|
||||
await logNetworkHints();
|
||||
await connectOnce();
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
7
runtime/operations/whatsapp_bridge/qr.ts
Normal file
7
runtime/operations/whatsapp_bridge/qr.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import qrcode from "qrcode-terminal";
|
||||
|
||||
export function printQrToTerminal(qr: string): void {
|
||||
// Keep it simple and dependency-light for Windows terminals.
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue