Files
op-packages/luci-app-openclaw/luasrc/controller/openclaw.lua
T
github-actions[bot] 410d762592
Merge-upstream / merge (push) Canceled after 0s
🎄 Sync 2026-08-27 02:13:09
2026-08-27 02:13:09 +08:00

2208 lines
96 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- luci-app-openclaw — LuCI Controller
module("luci.controller.openclaw", package.seeall)
local paths_ok, oc_paths = pcall(require, "openclaw.paths")
local function shellquote(value)
if paths_ok and oc_paths.shellquote then
return oc_paths.shellquote(value)
end
return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'"
end
local function normalize_install_base(value)
if paths_ok and oc_paths.normalize_install_path then
return oc_paths.normalize_install_path(value)
end
local raw = tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""):gsub("/+$", "")
if raw == "" then raw = "/opt" end
if raw:sub(1, 1) ~= "/" then return nil end
if raw:match("[%s'\"`$;&|<>()]") then return nil end
local unsafe = { "proc", "sys", "dev", "tmp", "var", "etc", "usr", "bin", "sbin", "lib", "rom", "overlay" }
if raw == "/" then return nil end
for _, name in ipairs(unsafe) do
if raw == "/" .. name or raw:match("^/" .. name .. "/") then return nil end
end
if raw:match("/openclaw$") then raw = raw:gsub("/openclaw$", "") end
return raw ~= "" and raw or nil
end
local function get_path_info(input)
local uci = require "luci.model.uci".cursor()
local base = input or uci:get("openclaw", "main", "install_path") or "/opt"
if paths_ok and oc_paths.derive_paths then
return oc_paths.derive_paths(base)
end
local normalized = normalize_install_base(base) or "/opt"
local root = normalized .. "/openclaw"
return {
install_path = normalized,
oc_root = root,
node_base = root .. "/node",
oc_global = root .. "/global",
oc_data = root .. "/data",
config_file = root .. "/data/.openclaw/openclaw.json"
}
end
local function is_safe_openclaw_root(value)
if paths_ok and oc_paths.is_safe_openclaw_root then
return oc_paths.is_safe_openclaw_root(value)
end
-- 与 luasrc/openclaw/paths.lua 的白名单保持一致(此处仅作模块加载失败时的兜底)
if value == "/openclaw" or value == "/opt/openclaw" or value == "/overlay/upper/opt/openclaw" then return true end
return value:match("^/mnt/[^/]+/openclaw$") ~= nil or value:match("^/media/[^/]+/openclaw$") ~= nil or value:match("^/srv/[^/]+/openclaw$") ~= nil
end
local function compare_versions(a, b)
local function parts(v)
local out = {}
for n in tostring(v or ""):gsub("^v", ""):gmatch("(%d+)") do
out[#out + 1] = tonumber(n) or 0
end
return out
end
local aa, bb = parts(a), parts(b)
local len = math.max(#aa, #bb)
for i = 1, len do
local av, bv = aa[i] or 0, bb[i] or 0
if av > bv then return 1 end
if av < bv then return -1 end
end
return 0
end
local function is_newer_version(latest, current)
return latest ~= nil and current ~= nil and latest ~= "" and current ~= "" and compare_versions(latest, current) > 0
end
local function fix_openclaw_state_permissions(oc_data)
local sys = require "luci.sys"
local state_dir = tostring(oc_data or "") .. "/.openclaw"
sys.exec("if [ -x /usr/libexec/openclaw-permissions.sh ]; then /usr/libexec/openclaw-permissions.sh fix-state " .. shellquote(state_dir) .. " >/dev/null 2>&1; fi")
end
local function ensure_openclaw_user(oc_data)
local sys = require "luci.sys"
local uid = sys.exec("id -u openclaw 2>/dev/null"):gsub("%s+", "")
if uid ~= "" then return true end
local script = [[
OC_UID=1000
while grep -q "^[^:]*:x:${OC_UID}:" /etc/passwd 2>/dev/null; do OC_UID=$((OC_UID + 1)); done
OC_GID=$OC_UID
while grep -q "^[^:]*:x:${OC_GID}:" /etc/group 2>/dev/null; do OC_GID=$((OC_GID + 1)); done
grep -q '^openclaw:' /etc/passwd 2>/dev/null || echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:${OC_DATA}:/bin/false" >> /etc/passwd
grep -q '^openclaw:' /etc/shadow 2>/dev/null || echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow
grep -q '^openclaw:' /etc/group 2>/dev/null || echo "openclaw:x:${OC_GID}:" >> /etc/group
]]
sys.exec("OC_DATA=" .. shellquote(oc_data) .. " sh -c " .. shellquote(script) .. " >/dev/null 2>&1")
uid = sys.exec("id -u openclaw 2>/dev/null"):gsub("%s+", "")
return uid ~= ""
end
local function find_wechat_plugin_dir(install_path)
local sys = require "luci.sys"
local ext_dir = install_path .. "/data/.openclaw/extensions/openclaw-weixin"
if nixio.fs.stat(ext_dir .. "/openclaw.plugin.json", "type") then
return ext_dir
end
local npm_projects = install_path .. "/data/.openclaw/npm/projects"
local cmd = "find " .. shellquote(npm_projects) .. " -path '*/node_modules/@tencent-weixin/openclaw-weixin/openclaw.plugin.json' -type f 2>/dev/null | head -n 1"
local plugin_json = sys.exec(cmd):match("[^\r\n]+")
if plugin_json and plugin_json ~= "" then
return plugin_json:gsub("/openclaw%.plugin%.json$", "")
end
return nil
end
local function wechat_enable_plugin_config_cmd(install_path, node_bin, log_file, exit_file)
exit_file = exit_file or "/tmp/openclaw-wechat-install.exit"
local oc_data = install_path .. "/data"
local config_file = oc_data .. "/.openclaw/openclaw.json"
local register_js = [[
const fs = require('fs');
const path = require('path');
const configPath = process.env.OC_CONFIG;
let d = {};
try {
d = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
d = {};
}
if (!d.plugins || typeof d.plugins !== 'object') d.plugins = {};
if (!Array.isArray(d.plugins.allow)) d.plugins.allow = [];
// OpenClaw 2026.6.11 persists install records in its SQLite plugin index.
// Remove the deprecated authored config ledger so later config writes do not
// silently discard the only registration record again.
if (d.plugins.installs) delete d.plugins.installs['openclaw-weixin'];
if (!d.plugins.allow.includes('openclaw-weixin')) d.plugins.allow.push('openclaw-weixin');
if (!d.channels || typeof d.channels !== 'object') d.channels = {};
if (!d.channels['openclaw-weixin'] || typeof d.channels['openclaw-weixin'] !== 'object') {
d.channels['openclaw-weixin'] = {};
}
d.channels['openclaw-weixin'].enabled = true;
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(d, null, 2) + '\n');
]]
return "if [ $RC -eq 0 ]; then " ..
"if [ -x " .. shellquote(node_bin) .. " ]; then " ..
"OC_CONFIG=" .. shellquote(config_file) .. " " ..
shellquote(node_bin) .. " -e " .. shellquote(register_js) .. " >> " .. shellquote(log_file) .. " 2>&1; " ..
"REG_RC=$?; " ..
"if [ $REG_RC -eq 0 ]; then " ..
"chown openclaw:openclaw " .. shellquote(config_file) .. " 2>/dev/null; " ..
"[ -x /usr/libexec/openclaw-permissions.sh ] && /usr/libexec/openclaw-permissions.sh fix-state " .. shellquote(oc_data .. "/.openclaw") .. " >/dev/null 2>&1; " ..
"echo 'Enabled openclaw-weixin channel in OpenClaw config.' >> " .. shellquote(log_file) .. "; " ..
"else RC=$REG_RC; echo $RC > " .. shellquote(exit_file) .. "; echo 'Failed to enable openclaw-weixin channel in OpenClaw config.' >> " .. shellquote(log_file) .. "; fi; " ..
"else RC=127; echo $RC > " .. shellquote(exit_file) .. "; echo 'Node.js not found, cannot enable openclaw-weixin channel.' >> " .. shellquote(log_file) .. "; fi; " ..
"fi; "
end
local function wechat_network_probe_cmd(node_bin, log_file)
local target = "https://ilinkai.weixin.qq.com/ilink/bot/getupdates"
local probe_js = [[
const target = 'https://ilinkai.weixin.qq.com/ilink/bot/getupdates';
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('timeout')), 10000);
(async () => {
try {
const res = await fetch(target, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
signal: controller.signal
});
const text = await res.text();
const sample = text.replace(/\s+/g, ' ').slice(0, 180);
console.log(`微信接口连通性检查: HTTP ${res.status} ${res.statusText || ''} ${Date.now() - started}ms ${sample}`);
} catch (e) {
const code = e && (e.code || (e.cause && e.cause.code) || e.name) || 'ERR';
const msg = e && e.message ? e.message : String(e);
console.log(`微信接口连通性检查失败: ${code} ${msg}`);
} finally {
clearTimeout(timer);
}
})();
]]
return "echo '微信接口连通性检查: https://ilinkai.weixin.qq.com' >> " .. shellquote(log_file) .. "; " ..
"if command -v curl >/dev/null 2>&1; then " ..
"_oc_probe_body=/tmp/openclaw-wechat-probe-$$.txt; " ..
"_oc_probe_code=$(curl -sS -o \"$_oc_probe_body\" -w '%%{http_code}' --connect-timeout 8 --max-time 15 -X POST -H 'content-type: application/json' --data '{}' " .. shellquote(target) .. " 2>> " .. shellquote(log_file) .. "); " ..
"_oc_probe_rc=$?; " ..
"if [ $_oc_probe_rc -eq 0 ]; then _oc_probe_sample=$(tr '\\n\\r\\t' ' ' < \"$_oc_probe_body\" 2>/dev/null | cut -c1-180); echo \"微信接口连通性检查: HTTP $_oc_probe_code $_oc_probe_sample\" >> " .. shellquote(log_file) .. "; else echo \"微信接口连通性检查失败: curl exit $_oc_probe_rc\" >> " .. shellquote(log_file) .. "; fi; " ..
"rm -f \"$_oc_probe_body\"; " ..
"elif [ -x " .. shellquote(node_bin) .. " ]; then " ..
"NODE_ICU_DATA=\"${NODE_ICU_DATA:-/opt/openclaw/node/share/icu}\" " .. shellquote(node_bin) .. " -e " .. shellquote(probe_js) .. " >> " .. shellquote(log_file) .. " 2>&1 || true; " ..
"else echo '⚠️ curl/Node.js 不存在,跳过微信接口连通性检查' >> " .. shellquote(log_file) .. "; fi; "
end
local function openclaw_user_runner_cmd()
return "_oc_raise_openclaw_limits() { " ..
"ulimit -v unlimited 2>/dev/null || true; " ..
"ulimit -m unlimited 2>/dev/null || true; " ..
"ulimit -d unlimited 2>/dev/null || true; " ..
"}; " ..
"_oc_as_openclaw() { " ..
"_oc_raise_openclaw_limits; " ..
"if command -v su >/dev/null 2>&1; then su -s /bin/sh openclaw -c \"$1\"; " ..
"elif command -v runuser >/dev/null 2>&1; then runuser -u openclaw -- sh -c \"$1\"; " ..
"elif command -v start-stop-daemon >/dev/null 2>&1; then _oc_pid=/tmp/openclaw-user-$$.pid; _oc_cwd=$(pwd); rm -f \"$_oc_pid\"; start-stop-daemon -S -m -p \"$_oc_pid\" -c openclaw:openclaw -d \"$_oc_cwd\" -x /bin/sh -- -c \"$1\"; _oc_rc=$?; rm -f \"$_oc_pid\"; return $_oc_rc; " ..
"else echo '❌ 缺少 su/runuser/start-stop-daemon,无法以 openclaw 用户运行命令' >&2; return 127; fi; " ..
"}; "
end
local function wechat_openclaw_plugin_install_cmd(install_path, oc_entry, log_file, exit_file)
return "OC_WECHAT_NODE=" .. shellquote(install_path .. "/node/bin/node") .. "; " ..
"OC_WECHAT_ENTRY=" .. shellquote(oc_entry) .. "; export OC_WECHAT_NODE OC_WECHAT_ENTRY; " ..
"echo '使用 OpenClaw 官方插件安装器写入 SQLite 插件索引...' >> " .. shellquote(log_file) .. "; " ..
"_oc_as_openclaw 'HOME=$OC_WECHAT_DATA OPENCLAW_HOME=$OC_WECHAT_DATA OPENCLAW_STATE_DIR=$OC_WECHAT_DATA/.openclaw OPENCLAW_CONFIG_PATH=$OC_WECHAT_DATA/.openclaw/openclaw.json " ..
"NODE_ICU_DATA=" .. install_path .. "/node/share/icu NPM_CONFIG_CACHE=$OC_WECHAT_DATA/.npm npm_config_cache=$OC_WECHAT_DATA/.npm TMPDIR=$OC_WECHAT_DATA/.tmp " ..
"PATH=" .. install_path .. "/node/bin:" .. install_path .. "/global/bin:$PATH " ..
"\"$OC_WECHAT_NODE\" \"$OC_WECHAT_ENTRY\" plugins install --force --pin @tencent-weixin/openclaw-weixin@2.4.6' >> " .. shellquote(log_file) .. " 2>&1; " ..
"RC=$?; echo $RC > " .. shellquote(exit_file) .. "; " ..
"if [ $RC -eq 0 ]; then echo '✅ OpenClaw 插件索引注册完成' >> " .. shellquote(log_file) .. "; else echo '❌ OpenClaw 插件安装失败 (exit: '$RC')' >> " .. shellquote(log_file) .. "; fi; "
end
local function wechat_finalize_plugin_registry_cmd(install_path, oc_entry, log_file, exit_file)
exit_file = exit_file or "/tmp/openclaw-wechat-install.exit"
local oc_data = install_path .. "/data"
local node_bin = install_path .. "/node/bin/node"
local cli_env = "HOME=" .. oc_data .. " OPENCLAW_HOME=" .. oc_data ..
" OPENCLAW_STATE_DIR=" .. oc_data .. "/.openclaw" ..
" OPENCLAW_CONFIG_PATH=" .. oc_data .. "/.openclaw/openclaw.json" ..
" NODE_ICU_DATA=" .. install_path .. "/node/share/icu" ..
" NPM_CONFIG_CACHE=" .. oc_data .. "/.npm TMPDIR=" .. oc_data .. "/.tmp" ..
" PATH=" .. install_path .. "/node/bin:" .. install_path .. "/global/bin:$PATH "
local cli = cli_env .. node_bin .. " " .. oc_entry
return "if [ $RC -eq 0 ]; then " ..
"echo '正在写入微信插件启用状态...' >> " .. shellquote(log_file) .. "; " ..
"_oc_as_openclaw '" .. cli .. " plugins enable openclaw-weixin' >> " .. shellquote(log_file) .. " 2>&1; " ..
"FINAL_RC=$?; " ..
"if [ $FINAL_RC -eq 0 ]; then " ..
"echo '正在刷新 SQLite 插件注册表...' >> " .. shellquote(log_file) .. "; " ..
"_oc_as_openclaw '" .. cli .. " plugins registry --refresh' >> " .. shellquote(log_file) .. " 2>&1; " ..
"FINAL_RC=$?; fi; " ..
"if [ $FINAL_RC -eq 0 ]; then " ..
"_OC_WECHAT_INSPECT=/tmp/openclaw-wechat-inspect-$$.log; " ..
"_oc_as_openclaw '" .. cli .. " plugins inspect openclaw-weixin' > \"$_OC_WECHAT_INSPECT\" 2>&1; " ..
"VERIFY_RC=$?; cat \"$_OC_WECHAT_INSPECT\" >> " .. shellquote(log_file) .. "; " ..
"if [ $VERIFY_RC -ne 0 ] || ! grep -q '^Status: loaded$' \"$_OC_WECHAT_INSPECT\" || ! grep -q '^channel: openclaw-weixin$' \"$_OC_WECHAT_INSPECT\"; then FINAL_RC=1; fi; " ..
"rm -f \"$_OC_WECHAT_INSPECT\"; fi; " ..
"if [ $FINAL_RC -eq 0 ]; then " ..
"echo '✅ 微信插件已启用,SQLite 注册表已刷新并通过加载验证' >> " .. shellquote(log_file) .. "; " ..
"else RC=$FINAL_RC; echo $RC > " .. shellquote(exit_file) .. "; " ..
"echo '❌ 微信插件启用或注册表验证失败' >> " .. shellquote(log_file) .. "; fi; " ..
"fi; "
end
local function wechat_tail_detail(text, max_lines)
if not text or text == "" then
return ""
end
local lines = {}
for line in (text .. "\n"):gmatch("([^\r\n]*)\r?\n") do
if line and line ~= "" then
line = line:gsub("\27%[[0-9;]*m", "")
if not line:match("^%s*$") then
table.insert(lines, line)
end
end
end
local start = math.max(1, #lines - (max_lines or 30) + 1)
local out = {}
for i = start, #lines do
table.insert(out, lines[i])
end
return table.concat(out, "\n")
end
local function write_wechat_log_and_exit(log_file, exit_file, content, exit_code)
local f = io.open(log_file, "w")
if f then
f:write(content)
f:close()
end
local ef = io.open(exit_file, "w")
if ef then
ef:write(tostring(exit_code or 1))
ef:close()
end
end
local function wechat_python3_bootstrap_cmd(log_file)
return "if ! command -v python3 >/dev/null 2>&1; then " ..
"echo '未检测到 python3,正在尝试安装 python3-light...' >> " .. shellquote(log_file) .. "; " ..
"(opkg update && opkg install python3-light) >> " .. shellquote(log_file) .. " 2>&1 || true; " ..
"fi; " ..
"if ! command -v python3 >/dev/null 2>&1; then " ..
"echo '❌ python3-light 自动安装失败,请手动执行: opkg update && opkg install python3-light' >> " .. shellquote(log_file) .. "; " ..
"echo 127 > /tmp/openclaw-wechat-install.exit; exit 0; " ..
"fi; "
end
function index()
-- 主入口: 服务 → OpenClaw (🧠 作为菜单图标)
local page = entry({"admin", "services", "openclaw"}, alias("admin", "services", "openclaw", "basic"), _("OpenClaw"), 90)
page.dependent = false
-- 基本设置 (CBI)
entry({"admin", "services", "openclaw", "basic"}, cbi("openclaw/basic"), _("基本设置"), 10).leaf = true
-- 配置管理 (View — 嵌入 oc-config Web 终端)
entry({"admin", "services", "openclaw", "advanced"}, template("openclaw/advanced"), _("配置管理"), 20).leaf = true
-- 微信配置 (View — 微信渠道配置向导)
entry({"admin", "services", "openclaw", "wechat"}, template("openclaw/wechat"), _("微信配置"), 25).leaf = true
-- Web 控制台 (View — 嵌入 OpenClaw Web UI)
entry({"admin", "services", "openclaw", "console"}, template("openclaw/console"), _("Web 控制台"), 30).leaf = true
-- 状态 API (AJAX 接口, 供前端 XHR 调用)
entry({"admin", "services", "openclaw", "status_api"}, call("action_status"), nil).leaf = true
-- 服务控制 API
-- 会改状态的端点必须用 post(): LuCI 的 test_post_security() 同时要求
-- POST 方法与匹配的 CSRF tokencall() 允许 GET 触发,
-- 诱导已登录管理员访问一个链接即可启停服务。
entry({"admin", "services", "openclaw", "service_ctl"}, post("action_service_ctl"), nil).leaf = true
-- 安装/升级日志 API (轮询)
entry({"admin", "services", "openclaw", "setup_log"}, call("action_setup_log"), nil).leaf = true
-- 版本检查 API (仅检查插件版本)
entry({"admin", "services", "openclaw", "check_update"}, call("action_check_update"), nil).leaf = true
-- 卸载运行环境 API (破坏性操作,必须 POST + CSRF)
entry({"admin", "services", "openclaw", "uninstall"}, post("action_uninstall"), nil).leaf = true
-- 获取网关 Token API (返回凭据,必须 POST + CSRF 防止被第三方页面读取)
entry({"admin", "services", "openclaw", "get_token"}, post("action_get_token"), nil).leaf = true
-- 插件升级 API (会下载并执行 .run,必须 POST + CSRF)
entry({"admin", "services", "openclaw", "plugin_upgrade"}, post("action_plugin_upgrade"), nil).leaf = true
-- 插件升级日志 API (轮询)
entry({"admin", "services", "openclaw", "plugin_upgrade_log"}, call("action_plugin_upgrade_log"), nil).leaf = true
-- 配置备份 API (v2026.3.8+: openclaw backup create/verify)
-- 含 create/restore/delete 等破坏性动作,必须 POST + CSRF。
-- 只读的 list 也走同一入口,一并要求 POST 以保持调用方式统一。
entry({"admin", "services", "openclaw", "backup"}, post("action_backup"), nil).leaf = true
-- 系统配置检测 API (安装前检测)
entry({"admin", "services", "openclaw", "check_system"}, call("action_check_system"), nil).leaf = true
-- 微信状态 API (检测插件安装和登录状态)
entry({"admin", "services", "openclaw", "wechat_status"}, call("action_wechat_status"), nil).leaf = true
-- 微信插件安装 API (后台安装)
entry({"admin", "services", "openclaw", "wechat_install"}, post("action_wechat_install"), nil).leaf = true
-- 微信安装日志轮询 API
entry({"admin", "services", "openclaw", "wechat_install_log"}, call("action_wechat_install_log"), nil).leaf = true
-- 微信登录 API (启动登录流程)
entry({"admin", "services", "openclaw", "wechat_login"}, post("action_wechat_login"), nil).leaf = true
-- 微信登录状态/二维码 API
entry({"admin", "services", "openclaw", "wechat_login_status"}, call("action_wechat_login_status"), nil).leaf = true
-- 微信插件卸载 API
entry({"admin", "services", "openclaw", "wechat_uninstall"}, post("action_wechat_uninstall"), nil).leaf = true
-- 微信插件检测升级 API
entry({"admin", "services", "openclaw", "wechat_check_upgrade"}, call("action_wechat_check_upgrade"), nil).leaf = true
-- 微信插件升级 API
entry({"admin", "services", "openclaw", "wechat_upgrade_plugin"}, post("action_wechat_upgrade_plugin"), nil).leaf = true
-- 微信退出/删除账号 API
entry({"admin", "services", "openclaw", "wechat_logout"}, post("action_wechat_logout"), nil).leaf = true
end-- ═══════════════════════════════════════════
-- 获取安装路径 (唯一权威来源: UCI 配置)
-- ═══════════════════════════════════════════
-- 核心原则: UCI install_path 继续存储公开兼容字段,语义是基础目录。
-- 如果用户误填 /mnt/data/openclaw,这里会规范化为 /mnt/data,再返回真实根目录。
-- ═══════════════════════════════════════════
local function get_install_path()
return get_path_info().oc_root
end
-- 确保网关端口可用:检测占用并尝试优雅停止或强制杀死占用进程
-- 优雅停止必须走安装目录里的 CLI wrapper 全路径:uhttpd 进程不加载
-- /etc/profile.d,裸 `openclaw` 在 LuCI 环境下必然 PATH 不可达。
local function gateway_graceful_stop_cmd()
local oc_cli = get_install_path() .. "/global/bin/openclaw"
return "if [ -x " .. shellquote(oc_cli) .. " ]; then " .. shellquote(oc_cli) .. " gateway stop >/dev/null 2>&1 || true; fi"
end
local function ensure_port_free(port)
local sys = require "luci.sys"
if not port or port == "" then return end
if not tostring(port):match("^%d+$") then return end
-- 优先尝试使用 openclaw 自身的 stop 命令(如果已安装)
sys.exec(gateway_graceful_stop_cmd())
-- 查询占用端口的行
local check_cmd = ""
if os.execute("command -v ss >/dev/null 2>&1") == 0 then
check_cmd = "ss -tulnp 2>/dev/null | grep -E " .. shellquote(":" .. port .. " ") .. " || true"
else
check_cmd = "netstat -tulnp 2>/dev/null | grep -E " .. shellquote(":" .. port .. " ") .. " || true"
end
local out = sys.exec(check_cmd)
out = out or ""
if out:match("%S") then
-- 尝试解析 pid
local pid = out:match("pid=(%d+)") or out:match(" (%d+)/") or out:match("/(%d+)")
pid = pid and pid:gsub("%s+", "") or nil
if pid and pid ~= "" then
-- 再次尝试优雅停止
sys.exec(gateway_graceful_stop_cmd())
-- 发送 SIGTERM
sys.exec("kill -TERM " .. pid .. " >/dev/null 2>&1 || true")
-- 等待释放,最多等待 5 次(每次 1s)
for i = 1,5 do
local still = sys.exec(check_cmd) or ""
if not still:match("%S") then break end
os.execute("sleep 1")
end
-- 如果仍然存在则强杀
local still2 = sys.exec(check_cmd) or ""
if still2:match("%S") then
sys.exec("kill -9 " .. pid .. " >/dev/null 2>&1 || true")
end
else
-- 未能解析 PID,则尝试批量杀死关键进程名
sys.exec("pgrep -f openclaw-gateway 2>/dev/null | xargs -r kill -TERM 2>/dev/null || true")
os.execute("sleep 1")
local still3 = sys.exec(check_cmd) or ""
if still3:match("%S") then
sys.exec("pgrep -f openclaw-gateway 2>/dev/null | xargs -r kill -9 2>/dev/null || true")
end
end
end
end
-- ═══════════════════════════════════════════
-- 状态查询 API: 返回 JSON
-- ═══════════════════════════════════════════
function action_status()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = uci:get("openclaw", "main", "port") or "18789"
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
local enabled = uci:get("openclaw", "main", "enabled") or "0"
-- 使用 get_install_path 获取安装路径 (唯一来源: UCI 配置)
local install_path = get_install_path()
-- 验证端口值为纯数字,防止命令注入
if not port:match("^%d+$") then port = "18789" end
if not pty_port:match("^%d+$") then pty_port = "18793" end
local result = {
enabled = enabled,
port = port,
pty_port = pty_port,
install_path = install_path,
gateway_running = false,
gateway_starting = false,
gateway_failed = false,
gateway_exit_code = "",
pty_running = false,
pid = "",
memory_kb = 0,
uptime = "",
node_version = "",
oc_version = "",
plugin_version = "",
disk_free = "",
}
-- 插件版本
local pvf = io.open("/usr/share/openclaw/VERSION", "r")
if pvf then
result.plugin_version = pvf:read("*a"):gsub("%s+", "")
pvf:close()
end
-- 安装方式检测 (离线 / 在线)
-- 检查 Node.js (使用自定义安装路径)
local node_bin = install_path .. "/node/bin/node"
local f = io.open(node_bin, "r")
if f then
f:close()
local node_ver = sys.exec(node_bin .. " --version 2>/dev/null"):gsub("%s+", "")
result.node_version = node_ver
end
-- OpenClaw 版本 (从 package.json 读取,使用自定义安装路径)
local oc_dirs = {
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}
for _, d in ipairs(oc_dirs) do
local pf = io.open(d .. "/package.json", "r")
if pf then
local pj = pf:read("*a")
pf:close()
local ver = pj:match('"version"%s*:%s*"([^"]+)"')
if ver and ver ~= "" then
result.oc_version = ver
break
end
end
end
-- 网关端口检查
local gw_check_cmd = "if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; else netstat -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; fi"
local gw_check = sys.exec(gw_check_cmd):gsub("%s+", "")
result.gateway_running = (tonumber(gw_check) or 0) > 0
-- 如果端口未监听,结合 procd 与真实进程判断状态。
-- 不能只看 pid 字段或 pidfileprocd crash-loop / stale pidfile 会让 LuCI 误显示“正在启动”。
if not result.gateway_running and enabled == "1" then
local procd_pid = sys.exec("ubus call service list '{\"name\":\"openclaw\"}' 2>/dev/null | jsonfilter -e '$.openclaw.instances.gateway.pid' 2>/dev/null"):gsub("%s+", "")
local procd_running = sys.exec("ubus call service list '{\"name\":\"openclaw\"}' 2>/dev/null | jsonfilter -e '$.openclaw.instances.gateway.running' 2>/dev/null"):gsub("%s+", "")
local procd_exit = sys.exec("ubus call service list '{\"name\":\"openclaw\"}' 2>/dev/null | jsonfilter -e '$.openclaw.instances.gateway.exit_code' 2>/dev/null"):gsub("%s+", "")
if procd_pid == "null" or not procd_pid:match("^%d+$") then procd_pid = "" end
if procd_exit == "null" then procd_exit = "" end
result.gateway_exit_code = procd_exit
local procd_pid_alive = false
if procd_pid ~= "" then
procd_pid_alive = (sys.exec("[ -d /proc/" .. procd_pid .. " ] && echo 1 || echo 0"):gsub("%s+", "") == "1")
end
local pidfile_pid = sys.exec("cat /var/run/openclaw.pid 2>/dev/null || true"):gsub("%s+", "")
local pidfile_stale = false
if pidfile_pid ~= "" and pidfile_pid:match("^%d+$") then
pidfile_stale = (sys.exec("[ -d /proc/" .. pidfile_pid .. " ] && echo 0 || echo 1"):gsub("%s+", "") == "1")
end
local crash_loop = sys.exec("logread 2>/dev/null | grep -E 'Instance openclaw::gateway.*crash loop' | tail -1"):gsub("^%s+", ""):gsub("%s+$", "")
if procd_exit ~= "" and tonumber(procd_exit) and tonumber(procd_exit) ~= 0 and procd_running ~= "true" then
result.gateway_failed = true
elseif crash_loop ~= "" and pidfile_stale and procd_running ~= "true" and not procd_pid_alive then
result.gateway_failed = true
result.gateway_crash_loop = true
if result.gateway_exit_code == "" then result.gateway_exit_code = "crash-loop" end
elseif procd_running == "true" or procd_pid_alive then
result.gateway_starting = true
result.pid = procd_pid
end
end
-- PTY 端口检查
local pty_check = sys.exec("netstat -tulnp 2>/dev/null | grep -c ':" .. pty_port .. " ' || echo 0"):gsub("%s+", "")
result.pty_running = (tonumber(pty_check) or 0) > 0
-- 读取当前活跃模型 (使用自定义安装路径)
local config_file = install_path .. "/data/.openclaw/openclaw.json"
local cf = io.open(config_file, "r")
if cf then
local content = cf:read("*a")
cf:close()
-- 简单正则提取 "primary": "xxx"
local model = content:match('"primary"%s*:%s*"([^"]+)"')
if model and model ~= "" then
result.active_model = model
end
-- 读取已配置的渠道列表
local channels = {}
if content:match('"openclaw%-weixin"%s*:%s*{') then
channels[#channels+1] = "微信"
end
if content:match('"qqbot"%s*:%s*{') and content:match('"appId"%s*:%s*"[^"]+"') then
channels[#channels+1] = "QQ"
end
if content:match('"telegram"%s*:%s*{') and content:match('"botToken"%s*:%s*"[^"]+"') then
channels[#channels+1] = "Telegram"
end
if content:match('"discord"%s*:%s*{') then
channels[#channels+1] = "Discord"
end
if content:match('"feishu"%s*:%s*{') then
channels[#channels+1] = "飞书"
end
if content:match('"slack"%s*:%s*{') then
channels[#channels+1] = "Slack"
end
if #channels > 0 then
result.channels = table.concat(channels, ", ")
end
end
-- PID 和内存
if result.gateway_running then
local pid = sys.exec("netstat -tulnp 2>/dev/null | awk '/:" .. port .. " /{split($NF,a,\"/\");print a[1];exit}'"):gsub("%s+", "")
if pid and pid ~= "" then
result.pid = pid
-- 内存 (VmRSS from /proc)
local rss = sys.exec("awk '/VmRSS/{print $2}' /proc/" .. pid .. "/status 2>/dev/null"):gsub("%s+", "")
result.memory_kb = tonumber(rss) or 0
-- 运行时间
local stat_time = sys.exec("stat -c %Y /proc/" .. pid .. " 2>/dev/null"):gsub("%s+", "")
local start_ts = tonumber(stat_time) or 0
if start_ts > 0 then
local uptime_s = os.time() - start_ts
local hours = math.floor(uptime_s / 3600)
local mins = math.floor((uptime_s % 3600) / 60)
local secs = uptime_s % 60
if hours > 0 then
result.uptime = string.format("%dh %dm %ds", hours, mins, secs)
elseif mins > 0 then
result.uptime = string.format("%dm %ds", mins, secs)
else
result.uptime = string.format("%ds", secs)
end
end
end
end
-- 磁盘剩余空间 (检测安装路径所在分区)
local install_parent = install_path:match("^(.*)/[^/]*$") or "/"
local df_output = sys.exec("df -h " .. shellquote(install_parent) .. " 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
if df_output and df_output ~= "" then
result.disk_free = df_output
end
http.prepare_content("application/json")
http.write_json(result)
end
-- ═══════════════════════════════════════════
-- 服务控制 API: start/stop/restart/setup
-- ═══════════════════════════════════════════
function action_service_ctl()
local http = require "luci.http"
local sys = require "luci.sys"
local action = http.formvalue("action") or ""
if action == "start" then
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
elseif action == "stop" then
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- stop 后额外等待确保端口释放
sys.exec("sleep 2")
elseif action == "restart" then
-- 常规“重启”只重启 Gateway,不重启 Web PTY,避免 stop+start 带来的长时间等待。
-- 如果 procd 没有 gateway 实例,再回退到 start。
local procd_running = sys.exec("ubus call service list '{\"name\":\"openclaw\"}' 2>/dev/null | jsonfilter -e '$.openclaw.instances.gateway.running' 2>/dev/null"):gsub("%s+", "")
if procd_running == "true" then
sys.exec("/etc/init.d/openclaw restart_gateway >/dev/null 2>&1 &")
else
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
end
elseif action == "enable" then
sys.exec("/etc/init.d/openclaw enable 2>/dev/null")
elseif action == "disable" then
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
elseif action == "setup" then
-- 先清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-setup.log /tmp/openclaw-setup.pid /tmp/openclaw-setup.exit")
-- 获取用户选择的版本 (stable=指定版本, latest=最新版)
local version = http.formvalue("version") or ""
-- 获取自定义安装路径 (用户输入的是基础路径,如 /opt 或 /mnt/data)
local install_path = http.formvalue("install_path") or ""
local env_prefix = ""
if version == "stable" then
-- 稳定版: 读取 openclaw-env 中定义的 OC_TESTED_VERSION
local tested_ver = sys.exec("grep '^OC_TESTED_VERSION=' /usr/bin/openclaw-env 2>/dev/null | cut -d'\"' -f2"):gsub("%s+", "")
if tested_ver ~= "" then
env_prefix = "OC_VERSION=" .. shellquote(tested_ver) .. " "
end
elseif version == "latest" then
env_prefix = "OC_VERSION='latest' "
elseif version ~= "" and version ~= "latest" then
-- 校验版本号格式 (仅允许数字、点、横线、字母)
if version:match("^[%d%.%-a-zA-Z]+$") then
env_prefix = "OC_VERSION=" .. shellquote(version) .. " "
end
end
-- 处理自定义安装路径
if install_path ~= "" then
local normalized = normalize_install_base(install_path)
if not normalized then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "安装路径无效:必须是绝对路径,且不能包含空格、引号或 shell 特殊字符。" })
return
end
-- 保存规范化后的基础路径,公开字段仍为 install_path,避免破坏兼容。
sys.exec("uci set openclaw.main.install_path=" .. shellquote(normalized) .. "; uci commit openclaw 2>/dev/null")
env_prefix = env_prefix .. "OC_INSTALL_PATH=" .. shellquote(normalized) .. " "
end
-- 后台安装,成功后自动启用并启动服务
-- 注: openclaw-env 脚本有 set -einit_openclaw 中的非关键失败不应阻止启动
sys.exec("( " .. env_prefix .. "/usr/bin/openclaw-env setup > /tmp/openclaw-setup.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-setup.exit; if [ $RC -eq 0 ]; then uci set openclaw.main.enabled=1; uci commit openclaw; /etc/init.d/openclaw enable 2>/dev/null; sleep 1; /etc/init.d/openclaw start >> /tmp/openclaw-setup.log 2>&1; fi ) & echo $! > /tmp/openclaw-setup.pid")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "安装已启动,请查看安装日志..." })
return
else
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未知操作: " .. action })
return
end
http.prepare_content("application/json")
http.write_json({ status = "ok", action = action })
end
-- ═══════════════════════════════════════════
-- 安装日志轮询 API
-- ═══════════════════════════════════════════
function action_setup_log()
local http = require "luci.http"
local sys = require "luci.sys"
-- 读取日志内容
local log = ""
local f = io.open("/tmp/openclaw-setup.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
-- 检查进程是否还在运行
local running = false
local pid_file = io.open("/tmp/openclaw-setup.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
-- 读取退出码
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-setup.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
-- 判断状态
local state = "idle"
if running then
state = "running"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
http.prepare_content("application/json")
http.write_json({
state = state,
exit_code = exit_code,
log = log
})
end
-- ═══════════════════════════════════════════
-- 版本检查 API
-- ═══════════════════════════════════════════
function action_check_update()
local http = require "luci.http"
local sys = require "luci.sys"
-- 插件版本检查 (从 GitHub API 获取最新 release tag + release notes)
local plugin_current = ""
local pf = io.open("/usr/share/openclaw/VERSION", "r")
or io.open("/root/luci-app-openclaw/VERSION", "r")
if pf then
plugin_current = pf:read("*a"):gsub("%s+", "")
pf:close()
end
local plugin_latest = ""
local release_notes = ""
local plugin_has_update = false
-- 使用 GitHub API 获取最新 release (tag + body)
local gh_json = sys.exec("curl -sf --connect-timeout 5 --max-time 10 'https://api.github.com/repos/10000ge10000/luci-app-openclaw/releases/latest' 2>/dev/null")
if gh_json and gh_json ~= "" then
-- 提取 tag_name
local tag = gh_json:match('"tag_name"%s*:%s*"([^"]+)"')
if tag and tag ~= "" then
plugin_latest = tag:gsub("^v", ""):gsub("%s+", "")
end
-- 提取 body (release notes), 处理 JSON 转义
-- 结束引号后可能紧跟 \n、空格、, 或 },用宽松匹配
local body = gh_json:match('"body"%s*:%s*"(.-)"[,}%]\n ]')
if body and body ~= "" then
-- 还原 JSON 转义: \n \r \" \\
body = body:gsub("\\n", "\n"):gsub("\\r", ""):gsub('\\"', '"'):gsub("\\\\", "\\")
release_notes = body
end
end
if is_newer_version(plugin_latest, plugin_current) then
plugin_has_update = true
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
plugin_current = plugin_current,
plugin_latest = plugin_latest,
plugin_has_update = plugin_has_update,
release_notes = release_notes
})
end
-- ═══════════════════════════════════════════
-- 卸载运行环境 API
-- ═══════════════════════════════════════════
function action_uninstall()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
-- 获取并校验安装路径。卸载只能作用于规范化后的 <base>/openclaw。
local configured_base = uci:get("openclaw", "main", "install_path") or "/opt"
local normalized_base = normalize_install_base(configured_base)
if not normalized_base then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "UCI 安装路径无效,已取消卸载: " .. tostring(configured_base) })
return
end
local path_info = get_path_info(normalized_base)
local install_path = path_info.oc_root
if not is_safe_openclaw_root(install_path) then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "安装路径未通过安全校验,已取消卸载: " .. install_path })
return
end
local q_install_path = shellquote(install_path)
-- 1. 停止服务 (通过 init.d 正常流程)
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- 2. 获取配置端口,确保在清理前端口对应的进程都被杀掉
local port = uci:get("openclaw", "main", "port") or "18789"
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
-- 3. 终极无感化清理僵尸进程
sys.exec("for p in " .. port .. " " .. pty_port .. "; do pid=$(netstat -tulnp 2>/dev/null | grep \":$p \" | awk '{print $7}' | cut -d'/' -f1); [ -n \"$pid\" ] && kill -9 \"$pid\" 2>/dev/null; done")
sys.exec("ss -tulnp 2>/dev/null | awk '/:" .. port .. " |:" .. pty_port .. " /{print $NF}' | awk -F',' '{print $2}' | awk -F'=' '{print $2}' | xargs -r kill -9 2>/dev/null")
sys.exec("pgrep -f 'openclaw-gateway|web-pty.js' 2>/dev/null | xargs -r kill -9 2>/dev/null")
sys.exec("pgrep -u openclaw 2>/dev/null | xargs -r kill -9 2>/dev/null")
-- 禁用开机启动
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
-- 设置 UCI enabled=0
sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null")
-- 删除 Node.js + OpenClaw 运行环境 (包含所有插件: qqbot, 飞书等)
-- 尝试先解绑可能挂载的目录;失败不阻断后续删除。
sys.exec("umount " .. q_install_path .. " 2>/dev/null || true")
-- 删除 Node.js + OpenClaw 运行环境。不要通过全局提权绕过权限问题。
sys.exec("rm -rf " .. q_install_path)
-- OverlayFS 兼容: 只清理同一规范化路径在 upper 层的残留。
local overlay_install_path = "/overlay/upper" .. install_path
sys.exec("[ -d " .. shellquote(overlay_install_path) .. " ] && rm -rf " .. shellquote(overlay_install_path) .. " 2>/dev/null || true")
-- 清理临时文件
sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /tmp/openclaw-plugin-upgrade.* /var/run/openclaw*.pid")
-- 清理 LuCI 缓存
sys.exec("rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null")
-- 删除 openclaw 系统用户
sys.exec("sed -i '/^openclaw:/d' /etc/passwd /etc/shadow /etc/group 2>/dev/null")
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "运行环境已卸载。已清理: Node.js 运行环境 (" .. install_path .. ")、所有插件、临时文件、LuCI 缓存。"
})
end
-- ═══════════════════════════════════════════
-- 获取 Token API
-- 仅通过 LuCI 认证后可调用,避免 Token 嵌入 HTML 源码
-- 返回网关 Token 和 PTY Token
-- ═══════════════════════════════════════════
function action_get_token()
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local token = uci:get("openclaw", "main", "token") or ""
local pty_token = uci:get("openclaw", "main", "pty_token") or ""
http.prepare_content("application/json")
http.write_json({ token = token, pty_token = pty_token })
end
-- ═══════════════════════════════════════════
-- 插件升级 API (后台下载 .run 并执行)
-- 参数: version — 目标版本号 (如 1.0.8)
-- ═══════════════════════════════════════════
function action_plugin_upgrade()
local http = require "luci.http"
local sys = require "luci.sys"
local version = http.formvalue("version") or ""
if version == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "缺少版本号参数" })
return
end
-- 安全检查: version 只允许数字和点
if not version:match("^[%d%.]+$") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "版本号格式无效" })
return
end
-- 清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-plugin-upgrade.log /tmp/openclaw-plugin-upgrade.pid /tmp/openclaw-plugin-upgrade.exit")
-- 后台执行: 下载 .run 并执行安装
local run_url = "https://github.com/10000ge10000/luci-app-openclaw/releases/download/v" .. version .. "/luci-app-openclaw_" .. version .. ".run"
-- 使用 curl 下载 (-L 跟随重定向), 然后 sh 执行
sys.exec(string.format(
"( echo '正在下载插件 v%s ...' > /tmp/openclaw-plugin-upgrade.log; " ..
"curl -sL --connect-timeout 15 --max-time 120 -o /tmp/luci-app-openclaw-update.run '%s' >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
"RC=$?; " ..
"if [ $RC -ne 0 ]; then " ..
" echo '下载失败 (curl exit: '$RC')' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '如果无法访问 GitHub,请手动下载: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo $RC > /tmp/openclaw-plugin-upgrade.exit; " ..
"else " ..
" FSIZE=$(wc -c < /tmp/luci-app-openclaw-update.run 2>/dev/null | tr -d ' '); " ..
" echo \"下载完成 (${FSIZE} bytes)\" >> /tmp/openclaw-plugin-upgrade.log; " ..
" FHEAD=$(head -c 9 /tmp/luci-app-openclaw-update.run 2>/dev/null); " ..
" if [ \"$FSIZE\" -lt 10000 ] 2>/dev/null; then " ..
" if [ \"$FHEAD\" = 'Not Found' ]; then " ..
" echo '❌ GitHub 返回 \"Not Found\",可能是网络被拦截(GFW)或 Release 资产不存在' >> /tmp/openclaw-plugin-upgrade.log; " ..
" else " ..
" echo '❌ 文件过小,可能 GitHub 访问受限或网络异常' >> /tmp/openclaw-plugin-upgrade.log; " ..
" fi; " ..
" echo '请检查路由器是否能访问 github.com,或手动下载后安装: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo 1 > /tmp/openclaw-plugin-upgrade.exit; " ..
" else " ..
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '正在安装...' >> /tmp/openclaw-plugin-upgrade.log; " ..
" sh /tmp/luci-app-openclaw-update.run >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
" RC2=$?; echo $RC2 > /tmp/openclaw-plugin-upgrade.exit; " ..
" if [ $RC2 -eq 0 ]; then " ..
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
" echo '✅ 插件升级完成!请刷新浏览器页面。' >> /tmp/openclaw-plugin-upgrade.log; " ..
" else " ..
" echo '安装执行失败 (exit: '$RC2')' >> /tmp/openclaw-plugin-upgrade.log; " ..
" fi; " ..
" fi; " ..
" rm -f /tmp/luci-app-openclaw-update.run; " ..
"fi " ..
") & echo $! > /tmp/openclaw-plugin-upgrade.pid",
version, run_url, run_url, run_url
))
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "插件升级已在后台启动..."
})
end
-- ═══════════════════════════════════════════
-- 插件升级日志轮询 API
-- ═══════════════════════════════════════════
function action_plugin_upgrade_log()
local http = require "luci.http"
local sys = require "luci.sys"
local log = ""
local f = io.open("/tmp/openclaw-plugin-upgrade.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
local running = false
local pid_file = io.open("/tmp/openclaw-plugin-upgrade.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-plugin-upgrade.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
local state = "idle"
if running then
state = "running"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
log = log,
state = state,
running = running,
exit_code = exit_code
})
end
-- ═══════════════════════════════════════════
-- 配置备份 API (v2026.3.8+)
-- action=create: 创建配置备份
-- action=verify: 验证最新备份
-- action=list: 列出现有备份(含类型/大小)
-- action=delete: 删除指定备份文件
-- ═══════════════════════════════════════════
function action_backup()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local action = http.formvalue("action") or "create"
-- 使用统一路径 helper,兼容用户误填 /mnt/data/openclaw 的场景。
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local oc_entry = ""
-- 查找 openclaw 入口 (使用自定义安装路径)
local search_dirs = {
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}
for _, d in ipairs(search_dirs) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
if oc_entry == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "OpenClaw 未安装,无法执行备份操作" })
return
end
local env_prefix = string.format(
"HOME=%s/data OPENCLAW_HOME=%s/data " ..
"OPENCLAW_STATE_DIR=%s/data/.openclaw " ..
"OPENCLAW_CONFIG_PATH=%s/data/.openclaw/openclaw.json " ..
"PATH=%s/node/bin:%s/global/bin:$PATH ",
install_path, install_path, install_path, install_path, install_path, install_path
)
-- 备份目录 (openclaw backup create 输出到 CWD,需要 cd)
local backup_dir = install_path .. "/data/.openclaw/backups"
local cd_prefix = "mkdir -p " .. backup_dir .. " && cd " .. backup_dir .. " && "
-- ── 辅助: 解析单个备份文件的 manifest 信息 ──
local function parse_backup_info(filepath)
local filename = filepath:match("([^/]+)$") or filepath
-- 文件大小
local st = nixio.fs.stat(filepath)
local size = st and st.size or 0
-- 从文件名提取时间戳: 2026-03-11T18-28-43.149Z-openclaw-backup.tar.gz
local ts = filename:match("^(%d%d%d%d%-%d%d%-%d%dT%d%d%-%d%d%-%d%d%.%d+Z)")
local display_time = ""
if ts then
-- 2026-03-11T18-28-43.149Z -> 2026-03-11 18:28:43
display_time = ts:gsub("T", " "):gsub("(%d%d)%-(%d%d)%-(%d%d)%.%d+Z", "%1:%2:%3")
end
-- 读取 manifest.json 判断备份类型
local backup_type = "unknown"
local manifest_json = sys.exec(
"tar --wildcards -xzf " .. filepath .. " '*/manifest.json' -O 2>/dev/null"
)
if manifest_json and manifest_json ~= "" then
-- 简单字符串匹配,避免依赖 JSON 库
if manifest_json:match('"onlyConfig"%s*:%s*true') then
backup_type = "config"
elseif manifest_json:match('"onlyConfig"%s*:%s*false') then
backup_type = "full"
end
else
-- 无法读取 manifest,通过文件大小推断
if size < 50000 then
backup_type = "config"
else
backup_type = "full"
end
end
-- 格式化大小
local size_str
if size >= 1073741824 then
size_str = string.format("%.1f GB", size / 1073741824)
elseif size >= 1048576 then
size_str = string.format("%.1f MB", size / 1048576)
elseif size >= 1024 then
size_str = string.format("%.1f KB", size / 1024)
else
size_str = tostring(size) .. " B"
end
return {
filename = filename,
filepath = filepath,
size = size,
size_str = size_str,
time = display_time,
backup_type = backup_type
}
end
if action == "create" then
local only_config = http.formvalue("only_config") or "1"
local backup_cmd
if only_config == "1" then
backup_cmd = cd_prefix .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --only-config --no-include-workspace 2>&1"
else
backup_cmd = cd_prefix .. "HOME=" .. backup_dir .. " " .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --no-include-workspace 2>&1"
end
local output = sys.exec(backup_cmd)
-- 完整备份可能输出到 HOME,移动到 backup_dir
sys.exec("mv " .. install_path .. "/data/*-openclaw-backup.tar.gz " .. backup_dir .. "/ 2>/dev/null")
-- 提取备份文件路径
local backup_path = output:match("([%S]+%.tar%.gz)")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "create",
output = output,
backup_path = backup_path or ""
})
elseif action == "verify" then
-- 找到最新的备份文件
local latest = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
if latest == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local output = sys.exec(env_prefix .. node_bin .. " " .. oc_entry .. " backup verify " .. latest .. " 2>&1")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "verify",
output = output,
backup_path = latest
})
elseif action == "restore" then
-- 支持指定文件名,不指定则用最新
local target_file = http.formvalue("file") or ""
local restore_path = ""
if target_file ~= "" then
-- 安全: 只允许文件名,不允许路径穿越
target_file = target_file:match("([^/]+)$") or ""
if target_file:match("%-openclaw%-backup%.tar%.gz$") then
restore_path = backup_dir .. "/" .. target_file
end
end
if restore_path == "" or not nixio.fs.stat(restore_path, "type") then
-- fallback 到最新
restore_path = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
end
if restore_path == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local oc_data_dir = install_path .. "/data/.openclaw"
local config_path = oc_data_dir .. "/openclaw.json"
-- 1) 先验证备份中的 openclaw.json 是否有效
local check_cmd = "tar -xzf " .. restore_path .. " --wildcards '*/openclaw.json' -O 2>/dev/null"
local json_content = sys.exec(check_cmd)
if not json_content or json_content == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件中未找到 openclaw.json" })
return
end
-- 写入临时文件并用 node 验证
local tmpfile = "/tmp/oc-restore-check.json"
local f = io.open(tmpfile, "w")
if f then f:write(json_content); f:close() end
local check = sys.exec(node_bin .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. tmpfile .. "','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
os.remove(tmpfile)
if check ~= "OK" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件中的配置无效,恢复已取消" })
return
end
-- 2) 备份当前配置
sys.exec("cp -f " .. config_path .. " " .. config_path .. ".pre-restore 2>/dev/null")
-- 3) 获取备份名前缀 (如: 2026-03-11T18-21-17.209Z-openclaw-backup)
-- 备份结构: <backup_name>/payload/posix/<绝对路径>
local first_entry = sys.exec("tar -tzf " .. restore_path .. " 2>/dev/null | head -1"):gsub("%s+", "")
local backup_name = first_entry:match("^([^/]+)/") or ""
if backup_name == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件格式无法识别" })
return
end
local payload_prefix = backup_name .. "/payload/posix/"
-- strip 3 层: <backup_name> / payload / posix
local strip_count = 3
-- 4) 停止服务
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
-- 等待端口释放
sys.exec("sleep 2")
-- 5) 提取 payload 文件到根目录 (还原到原始绝对路径)
-- 注: --wildcards 与 --strip-components 组合在某些 tar 版本不兼容
-- 使用精确路径前缀代替 wildcards
local extract_cmd = string.format(
"tar -xzf %s --strip-components=%d -C / '%s' 2>&1",
restore_path, strip_count, payload_prefix
)
local extract_out = sys.exec(extract_cmd)
-- 6) 修复权限
fix_openclaw_state_permissions(oc_data_dir)
-- 7) 重启服务
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "restore",
message = "已从备份完整恢复所有配置和数据,服务正在重启。原配置已保存为 openclaw.json.pre-restore",
backup_path = restore_path,
extract_output = extract_out or ""
})
elseif action == "list" then
-- 返回结构化的备份文件列表(含类型/大小/时间)
local files_raw = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null"):gsub("%s+$", "")
local backups = {}
if files_raw ~= "" then
for fpath in files_raw:gmatch("[^\n]+") do
fpath = fpath:gsub("%s+", "")
if fpath ~= "" then
backups[#backups + 1] = parse_backup_info(fpath)
end
-- 最多返回 20 条
if #backups >= 20 then break end
end
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "list",
backups = backups
})
elseif action == "delete" then
local target_file = http.formvalue("file") or ""
-- 安全: 只允许文件名,不允许路径穿越
target_file = target_file:match("([^/]+)$") or ""
if target_file == "" or not target_file:match("%-openclaw%-backup%.tar%.gz$") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "无效的备份文件名" })
return
end
local del_path = backup_dir .. "/" .. target_file
if not nixio.fs.stat(del_path, "type") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件不存在" })
return
end
os.remove(del_path)
http.prepare_content("application/json")
http.write_json({
status = "ok",
action = "delete",
message = "已删除备份: " .. target_file
})
else
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未知备份操作: " .. action })
end
end
-- ═══════════════════════════════════════════
-- 系统配置检测 API (安装前检测)
-- 检测内存和磁盘空间是否满足最低要求
-- 要求: 内存 > 1GB, 磁盘可用空间 > 2GB (OpenClaw v2026.3.28+ 包体积约 200MB)
-- 支持自定义安装路径,检测对应路径的磁盘空间
-- ═══════════════════════════════════════════
function action_check_system()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
-- 获取自定义安装路径 (用户输入的是基础路径,如 /opt 或 /mnt/data)
local raw_install_path = http.formvalue("install_path") or uci:get("openclaw", "main", "install_path") or "/opt"
local install_path = normalize_install_base(raw_install_path)
-- 最低要求配置 (v2026.3.28: 包体积 ~200MB, 建议 2GB 可用空间)
local MIN_MEMORY_MB = 1024 -- 1GB
local MIN_DISK_MB = 2048 -- 2GB
local result = {
memory_mb = 0,
memory_ok = false,
disk_mb = 0,
disk_ok = false,
disk_path = "",
install_path = install_path or tostring(raw_install_path or ""),
path_valid = install_path ~= nil,
writable_ok = false,
disk_free_str = "",
pass = false,
message = ""
}
if not install_path then
result.message = "安装路径无效:必须是绝对路径,且不能包含空格、引号或 shell 特殊字符。"
http.prepare_content("application/json")
http.write_json(result)
return
end
-- 检测总内存 (从 /proc/meminfo 读取 MemTotal)
local meminfo = io.open("/proc/meminfo", "r")
if meminfo then
for line in meminfo:lines() do
local mem_total = line:match("MemTotal:%s+(%d+)%s+kB")
if mem_total then
result.memory_mb = math.floor(tonumber(mem_total) / 1024)
break
end
end
meminfo:close()
end
result.memory_ok = result.memory_mb >= MIN_MEMORY_MB
-- 检测磁盘可用空间
-- 策略: 从目标路径开始,逐级向上找到存在的挂载点进行检测
-- 例如: /mnt/data -> /mnt/data (若存在) -> /mnt (若存在) -> /
local function find_mount_point(path)
-- 如果路径存在,直接返回
if nixio.fs.stat(path, "type") then
return path
end
-- 逐级向上查找
while path ~= "/" and path ~= "" do
path = path:match("^(.*)/[^/]*$") or "/"
if path == "" then path = "/" end
if nixio.fs.stat(path, "type") then
return path
end
end
return "/"
end
local disk_check_path = find_mount_point(install_path)
-- 使用 df 检测磁盘空间
local df_output = sys.exec("df -m " .. shellquote(disk_check_path) .. " 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
if df_output and df_output ~= "" and tonumber(df_output) then
result.disk_mb = tonumber(df_output)
result.disk_path = disk_check_path
-- 获取可读的磁盘空间格式
result.disk_free_str = sys.exec("df -h " .. shellquote(disk_check_path) .. " 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
else
-- 如果检测失败,尝试检测根分区
df_output = sys.exec("df -m / 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
if df_output and df_output ~= "" and tonumber(df_output) then
result.disk_mb = tonumber(df_output)
result.disk_path = "/"
result.disk_free_str = sys.exec("df -h / 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "")
end
end
result.disk_ok = result.disk_mb >= MIN_DISK_MB
-- 安装前写入探针:先在实际存在的父目录创建临时目录。
-- 这能明确识别 overlay 满、只读挂载、路径挂载点不可写等问题,避免下载完才失败。
local probe_dir = disk_check_path .. "/.openclaw-write-test-" .. tostring(os.time()) .. "-" .. tostring(math.random(1000, 9999))
local probe_rc = os.execute("mkdir " .. shellquote(probe_dir) .. " >/dev/null 2>&1")
if probe_rc == 0 or probe_rc == true then
result.writable_ok = true
os.execute("rmdir " .. shellquote(probe_dir) .. " >/dev/null 2>&1")
else
result.writable_ok = false
end
-- 综合判断
result.pass = result.memory_ok and result.disk_ok and result.writable_ok
-- 生成提示信息
if result.pass then
result.message = "系统配置检测通过"
else
local issues = {}
if not result.memory_ok then
table.insert(issues, string.format("内存不足: 当前 %d MB,需要至少 %d MB", result.memory_mb, MIN_MEMORY_MB))
end
if not result.disk_ok then
table.insert(issues, string.format("磁盘空间不足: 当前 %d MB 可用,需要至少 %d MB", result.disk_mb, MIN_DISK_MB))
end
if not result.writable_ok then
table.insert(issues, "安装路径所在挂载点不可写,可能是 overlay 已满、只读或外置盘未正确挂载")
end
result.message = table.concat(issues, "")
end
http.prepare_content("application/json")
http.write_json(result)
end
-- ═══════════════════════════════════════════
-- 微信状态 API: 检测插件安装和登录状态
-- ═══════════════════════════════════════════
function action_wechat_status()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
-- 使用统一的路径获取函数 (唯一来源: UCI 配置)
local install_path = get_install_path()
local result = {
plugin_installed = false,
logged_in = false,
accounts = {},
install_path = install_path
}
-- 检测微信插件是否已安装
local wechat_ext_dir = find_wechat_plugin_dir(install_path)
local wechat_plugin_json = wechat_ext_dir and (wechat_ext_dir .. "/openclaw.plugin.json") or ""
local wechat_package_json = wechat_ext_dir and (wechat_ext_dir .. "/package.json") or ""
if nixio.fs.stat(wechat_plugin_json, "type") then
result.plugin_installed = true
-- 尝试读取版本号
if nixio.fs.stat(wechat_package_json, "type") then
local pf = io.open(wechat_package_json, "r")
if pf then
local p_content = pf:read("*a")
pf:close()
local ver = p_content:match('"version"%s*:%s*"([^"]+)"')
if ver then
result.plugin_version = ver
end
end
end
end -- 从 openclaw.json 和 accounts.json 读取微信账号配置
local config_file = install_path .. "/data/.openclaw/openclaw.json"
local accounts_file = install_path .. "/data/.openclaw/openclaw-weixin/accounts.json"
-- 新版 OpenClaw WeChat 插件状态读取
if nixio.fs.stat(accounts_file, "type") then
local af = io.open(accounts_file, "r")
if af then
local content = af:read("*a")
af:close()
local count = 0
for acc in content:gmatch('"([^"]+)"') do
count = count + 1
table.insert(result.accounts, {name = acc})
end
if count > 0 then
result.logged_in = true
end
end
end
local cf = io.open(config_file, "r")
if cf then
local content = cf:read("*a")
cf:close()
if content:match('"openclaw%-weixin"%s*:%s*{') then
-- 兼容旧版
local accounts_str = content:match('"accounts"%s*:%s*%[([^%]]*)%]')
if accounts_str and accounts_str ~= "" then
local count = 0
for _ in accounts_str:gmatch('"wxid') do count = count + 1 end
for _ in accounts_str:gmatch('"nickName"') do count = count + 1 end
if count > 0 and not result.logged_in then
result.logged_in = true
result.accounts = {{name = "微信账号"}}
end
end
if content:match('"credential"%s*:%s*{') and not result.logged_in then
result.logged_in = true
result.accounts = {{name = "微信账号"}}
end
end
end http.prepare_content("application/json")
http.write_json(result)
end
-- ═══════════════════════════════════════════
-- 微信插件安装 API (后台安装)
-- ═══════════════════════════════════════════
function action_wechat_install()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
-- 使用统一的路径获取函数 (唯一来源: UCI 配置)
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local npx_bin = install_path .. "/node/bin/npx"
local oc_data = install_path .. "/data"
local oc_entry = ""
for _, d in ipairs({
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
-- 清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-wechat-install.log /tmp/openclaw-wechat-install.pid /tmp/openclaw-wechat-install.exit")
-- 官方插件安装器必须通过当前 OpenClaw CLI 写入 SQLite 安装索引。
if not nixio.fs.stat(node_bin, "type") or oc_entry == "" then
local log_content = "开始安装微信插件...\n" ..
"安装路径: " .. install_path .. "\n" ..
"❌ 错误: Node.js 或 OpenClaw CLI 不存在。\n" ..
"请先在「基本设置」页面安装运行环境。\n" ..
"UCI install_path=" .. (uci:get("openclaw", "main", "install_path") or "未设置")
local f = io.open("/tmp/openclaw-wechat-install.log", "w")
if f then
f:write(log_content)
f:close()
end
local ef = io.open("/tmp/openclaw-wechat-install.exit", "w")
if ef then
ef:write("127")
ef:close()
end
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件安装已在后台启动..." })
return
end
if not ensure_openclaw_user(oc_data) then
write_wechat_log_and_exit(
"/tmp/openclaw-wechat-install.log",
"/tmp/openclaw-wechat-install.exit",
"开始安装微信插件...\n安装路径: " .. install_path .. "\n❌ 错误: 无法创建或读取 openclaw 系统用户。\n请检查 /etc/passwd、/etc/group 是否可写。\n",
1
)
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件安装已在后台启动..." })
return
end
-- 后台执行安装。注意:插件安装不需要释放 Gateway 端口,避免误停正在运行的 Gateway 触发 procd crash-loop。
-- 微信插件安装目录路径 (用于安装后权限修复)
local extensions_dir = install_path .. "/data/.openclaw/extensions"
local install_cmd = string.format(
"( " ..
openclaw_user_runner_cmd() ..
"echo '开始安装微信插件...' > /tmp/openclaw-wechat-install.log; " ..
"echo '安装路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
"echo 'npx 路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
"echo 'Node 版本:' $(%s -v 2>/dev/null || echo 未检测到) >> /tmp/openclaw-wechat-install.log; " ..
wechat_network_probe_cmd(node_bin, "/tmp/openclaw-wechat-install.log") ..
wechat_python3_bootstrap_cmd("/tmp/openclaw-wechat-install.log") ..
"OC_WECHAT_DATA=%s; export OC_WECHAT_DATA; " ..
"if [ -x /usr/libexec/openclaw-permissions.sh ]; then /usr/libexec/openclaw-permissions.sh prepare-workdirs \"$OC_WECHAT_DATA\" >/dev/null 2>&1; " ..
"else mkdir -p \"$OC_WECHAT_DATA/.npm\" \"$OC_WECHAT_DATA/.tmp\" \"$OC_WECHAT_DATA/.openclaw/extensions\"; chown -R openclaw:openclaw \"$OC_WECHAT_DATA/.npm\" \"$OC_WECHAT_DATA/.tmp\" 2>/dev/null; chown openclaw:openclaw \"$OC_WECHAT_DATA/.openclaw\" 2>/dev/null; fi; " ..
"if [ ! -w %s/.npm ] || [ ! -w %s/.tmp ]; then echo '❌ npm cache/tmp 目录不可写' >> /tmp/openclaw-wechat-install.log; echo 1 > /tmp/openclaw-wechat-install.exit; exit 0; fi; " ..
"_oc_as_openclaw 'test -w %s/.npm && test -w %s/.tmp && test -w %s/.openclaw' || { echo '❌ openclaw 用户无法写入 npm cache/tmp/data 目录' >> /tmp/openclaw-wechat-install.log; echo 1 > /tmp/openclaw-wechat-install.exit; exit 0; }; " ..
wechat_openclaw_plugin_install_cmd(install_path, oc_entry, "/tmp/openclaw-wechat-install.log", "/tmp/openclaw-wechat-install.exit") ..
wechat_enable_plugin_config_cmd(install_path, node_bin, "/tmp/openclaw-wechat-install.log") ..
wechat_finalize_plugin_registry_cmd(install_path, oc_entry, "/tmp/openclaw-wechat-install.log", "/tmp/openclaw-wechat-install.exit") ..
-- 关键修复: 安装完成后强制修复插件目录权限 (确保 Gateway 可读取插件)
-- 原因: npx/npm 以 root 身份创建目录,默认权限 700 导致其他用户无法读取
-- 官方 npm generation 由 openclaw 用户维护,必须允许后续升级清理。
"[ -x /usr/libexec/openclaw-permissions.sh ] && /usr/libexec/openclaw-permissions.sh fix-state %s/.openclaw >/dev/null 2>&1; " ..
"if [ $RC -eq 0 ]; then echo '✅ 微信插件安装成功!' >> /tmp/openclaw-wechat-install.log; " ..
"else echo '❌ 安装失败 (exit: '$RC')' >> /tmp/openclaw-wechat-install.log; fi " ..
") & echo $! > /tmp/openclaw-wechat-install.pid",
install_path, npx_bin, node_bin,
shellquote(oc_data),
oc_data, oc_data,
oc_data, oc_data, oc_data,
install_path, oc_data, oc_data, oc_data,
oc_data
)
sys.exec(install_cmd)
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件安装已在后台启动..." })
end
-- ═══════════════════════════════════════════
-- 微信安装日志轮询 API
-- ═══════════════════════════════════════════
function action_wechat_install_log()
local http = require "luci.http"
local sys = require "luci.sys"
local log = ""
local f = io.open("/tmp/openclaw-wechat-install.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
local running = false
local pid_file = io.open("/tmp/openclaw-wechat-install.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-wechat-install.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
local state = "idle"
if running then
state = "running"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
log = log,
state = state,
running = running,
exit_code = exit_code
})
end
-- ═══════════════════════════════════════════
-- 微信登录 API (启动登录流程并获取二维码)
-- ═══════════════════════════════════════════
function action_wechat_login()
local http = require "luci.http"
local sys = require "luci.sys"
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local oc_data = install_path .. "/data"
local oc_entry = ""
-- 查找 openclaw 入口
local search_dirs = {
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}
for _, d in ipairs(search_dirs) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
if oc_entry == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "OpenClaw 未安装" })
return
end
if not ensure_openclaw_user(oc_data) then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "无法创建或读取 openclaw 系统用户,请检查 /etc/passwd、/etc/group 是否可写" })
return
end
-- 清理旧状态和可能的残留进程
sys.exec("kill -9 $(cat /tmp/openclaw-wechat-login.pid 2>/dev/null) 2>/dev/null")
sys.exec("pkill -f 'channels login --channel openclaw-weixin' 2>/dev/null")
sys.exec("rm -f /tmp/openclaw-wechat-qrcode.txt /tmp/openclaw-wechat-login.pid /tmp/openclaw-wechat-login.exit /tmp/openclaw-wechat-restarted")
local wechat_plugin_dir = find_wechat_plugin_dir(install_path)
if not wechat_plugin_dir then
write_wechat_log_and_exit(
"/tmp/openclaw-wechat-qrcode.txt",
"/tmp/openclaw-wechat-login.exit",
"微信插件未安装...\n安装路径: " .. install_path .. "\n未找到包含 openclaw.plugin.json 的微信插件目录。\n请先安装插件或重新安装插件。\n",
1
)
http.prepare_content("application/json")
http.write_json({ status = "error", message = "微信插件未安装或未找到,请先安装/重新安装插件" })
return
end
-- 后台启动登录流程,将二维码输出到文件
local login_cmd = string.format(
"( " ..
openclaw_user_runner_cmd() ..
"echo '正在启动微信登录...' > /tmp/openclaw-wechat-qrcode.txt; " ..
"echo '安装路径: %s' >> /tmp/openclaw-wechat-qrcode.txt; " ..
"echo 'OpenClaw 入口: %s' >> /tmp/openclaw-wechat-qrcode.txt; " ..
"echo '微信插件目录: %s' >> /tmp/openclaw-wechat-qrcode.txt; " ..
"echo 'Node 版本:' $(%s -v 2>/dev/null || echo 未检测到) >> /tmp/openclaw-wechat-qrcode.txt; " ..
"if command -v python3 >/dev/null 2>&1; then echo 'python3: 已安装' >> /tmp/openclaw-wechat-qrcode.txt; else echo '⚠️ python3: 未安装,微信插件可能无法完成配对' >> /tmp/openclaw-wechat-qrcode.txt; fi; " ..
wechat_network_probe_cmd(node_bin, "/tmp/openclaw-wechat-qrcode.txt") ..
"mkdir -p %s/.npm %s/.tmp %s/.openclaw/openclaw-weixin; " ..
"touch %s/.openclaw/openclaw.json 2>/dev/null || true; " ..
"chown -R openclaw:openclaw %s/.npm %s/.tmp %s/.openclaw/openclaw-weixin 2>/dev/null; " ..
"chown openclaw:openclaw %s/.openclaw %s/.openclaw/openclaw.json 2>/dev/null; " ..
"_oc_as_openclaw 'test -w %s/.npm && test -w %s/.tmp && test -w %s/.openclaw && test -w %s/.openclaw/openclaw-weixin && test -w %s/.openclaw/openclaw.json' || { echo '❌ openclaw 用户无法写入微信登录目录,请检查数据目录权限' >> /tmp/openclaw-wechat-qrcode.txt; echo 1 > /tmp/openclaw-wechat-login.exit; exit 0; }; " ..
"RC=0; " ..
wechat_enable_plugin_config_cmd(install_path, node_bin, "/tmp/openclaw-wechat-qrcode.txt", "/tmp/openclaw-wechat-login.exit") ..
wechat_finalize_plugin_registry_cmd(install_path, oc_entry, "/tmp/openclaw-wechat-qrcode.txt", "/tmp/openclaw-wechat-login.exit") ..
"if [ $RC -ne 0 ]; then echo '❌ 微信插件注册失败,无法登录' >> /tmp/openclaw-wechat-qrcode.txt; exit 0; fi; " ..
"cd %s && " ..
"_oc_as_openclaw 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json " ..
"NODE_ICU_DATA=%s/node/share/icu " ..
"NPM_CONFIG_CACHE=%s/.npm npm_config_cache=%s/.npm TMPDIR=%s/.tmp PATH=%s/node/bin:%s/global/bin:$PATH " ..
"%s %s channels login --channel openclaw-weixin' >> /tmp/openclaw-wechat-qrcode.txt 2>&1; " ..
"echo $? > /tmp/openclaw-wechat-login.exit; " ..
") >/dev/null 2>&1 & echo $! > /tmp/openclaw-wechat-login.pid",
install_path, oc_entry, wechat_plugin_dir, node_bin,
oc_data, oc_data, oc_data,
oc_data,
oc_data, oc_data, oc_data,
oc_data, oc_data,
oc_data, oc_data, oc_data, oc_data, oc_data,
install_path,
oc_data, oc_data, oc_data, oc_data,
install_path,
oc_data, oc_data, oc_data,
install_path, install_path,
node_bin, oc_entry
)
sys.exec(login_cmd)
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信登录流程已启动" })
end
-- ═══════════════════════════════════════════
-- 微信登录状态/二维码 API
-- ═══════════════════════════════════════════
function action_wechat_login_status()
local http = require "luci.http"
local sys = require "luci.sys"
-- 读取二维码输出
local qrcode = ""
local f = io.open("/tmp/openclaw-wechat-qrcode.txt", "r")
if f then
qrcode = f:read("*a") or ""
f:close()
end
-- 检查进程状态
local running = false
local pid_file = io.open("/tmp/openclaw-wechat-login.pid", "r")
if pid_file then
local pid = pid_file:read("*a"):gsub("%s+", "")
pid_file:close()
if pid ~= "" then
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
running = (check == "yes")
end
end
local exit_code = -1
if not running then
local exit_file = io.open("/tmp/openclaw-wechat-login.exit", "r")
if exit_file then
local code = exit_file:read("*a"):gsub("%s+", "")
exit_file:close()
exit_code = tonumber(code) or -1
end
end
-- 提取最后生成的二维码 URL;排除前置网络探测中的接口 URL,避免页面误导用户扫码错误链接
local qrcode_url = ""
for url in qrcode:gmatch("https?://[^%s%]%)}\"'<>]+") do
if url:match("liteapp%.weixin%.qq%.com/q/") or url:match("weixin%.qq%.com/q/") then
qrcode_url = url
end
end
if qrcode_url == "" then
for url in qrcode:gmatch("https?://[^%s%]%)}\"'<>]+") do
if not url:match("ilinkai%.weixin%.qq%.com") then
qrcode_url = url
end
end
end
-- 检查是否登录成功。微信插件在 OpenClaw 2026.6.11 中可能已经保存认证,
-- 但随后通过 Gateway 动态启动渠道时报 "invalid channels.start channel" 并返回
-- 非 0;这种情况账号已配对成功,LuCI 不应误判为失败。
local logged_in = qrcode:find("登录成功") ~= nil
or qrcode:find("成功登录") ~= nil
or qrcode:find("Login success") ~= nil
or qrcode:find("Logged in") ~= nil
or qrcode:find("已将此 OpenClaw 连接到微信") ~= nil
or qrcode:find("Local login saved auth for openclaw%-weixin") ~= nil
local state = "idle"
if logged_in then
state = "success"
elseif running and qrcode_url ~= "" then
state = "qrcode"
elseif running then
state = "starting"
elseif exit_code == 0 then
state = "success"
elseif exit_code > 0 then
state = "failed"
end
local error_detail = ""
local message = ""
if state == "failed" then
error_detail = wechat_tail_detail(qrcode, 35)
if error_detail ~= "" then
message = "登录失败,下面是最近日志,请按提示处理"
else
message = "登录失败,请查看 /tmp/openclaw-wechat-qrcode.txt"
end
end
-- 如果刚登录成功,触发一次轻量重启,确保主进程加载微信账号。
-- 不使用完整 /etc/init.d/openclaw restart,避免同时重启 Web PTY 和拉长等待时间。
if state == "success" and not nixio.fs.stat("/tmp/openclaw-wechat-restarted", "type") then
sys.exec("touch /tmp/openclaw-wechat-restarted")
sys.exec("/etc/init.d/openclaw restart_gateway >/dev/null 2>&1 &")
message = "微信登录成功,正在重新加载微信账号"
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
state = state,
qrcode = qrcode,
qrcode_url = qrcode_url,
running = running,
exit_code = exit_code,
logged_in = logged_in,
message = message,
error_detail = error_detail
})
end
-- ═══════════════════════════════════════════
-- 微信插件卸载 API
-- ═══════════════════════════════════════════
function action_wechat_uninstall()
local http = require "luci.http"
local sys = require "luci.sys"
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local oc_data = install_path .. "/data"
-- 删除微信插件目录和账号状态。所有路径都限制在当前 OpenClaw 数据目录下。
local wechat_ext_dir = oc_data .. "/.openclaw/extensions/openclaw-weixin"
local wechat_state_dir = oc_data .. "/.openclaw/openclaw-weixin"
local npm_projects = oc_data .. "/.openclaw/npm/projects"
sys.exec("rm -rf " .. shellquote(wechat_ext_dir) .. " " .. shellquote(wechat_state_dir) .. " 2>/dev/null")
if nixio.fs.stat(npm_projects, "type") then
sys.exec("find " .. shellquote(npm_projects) .. " -path '*/node_modules/@tencent-weixin/openclaw-weixin' -type d -prune -exec rm -rf {} + 2>/dev/null")
sys.exec("find " .. shellquote(npm_projects) .. " -path '*/node_modules/@tencent-weixin/openclaw-weixin-cli' -type d -prune -exec rm -rf {} + 2>/dev/null")
end
-- 从配置中删除微信相关配置
local config_file = oc_data .. "/.openclaw/openclaw.json"
if nixio.fs.stat(config_file, "type") then
local cleanup_js = [[
const fs = require('fs');
const p = process.env.OC_CONFIG;
let d = {};
try { d = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { process.exit(0); }
function drop(o, k) { if (o && typeof o === 'object') delete o[k]; }
function dropChannel(o) { drop(o, 'openclaw-weixin'); drop(o, 'weixin'); }
if (d.plugins && Array.isArray(d.plugins.allow)) {
d.plugins.allow = d.plugins.allow.filter((x) => x !== 'openclaw-weixin' && x !== 'weixin');
}
if (d.plugins) {
dropChannel(d.plugins.installs);
dropChannel(d.plugins.entries);
}
dropChannel(d.channels);
dropChannel(d.channel);
dropChannel(d);
fs.writeFileSync(p, JSON.stringify(d, null, 2));
]]
if nixio.fs.stat(node_bin, "type") then
sys.exec("OC_CONFIG=" .. shellquote(config_file) .. " " .. shellquote(node_bin) .. " -e " .. shellquote(cleanup_js) .. " 2>/dev/null")
else
local cf = io.open(config_file, "r")
local content = ""
if cf then
content = cf:read("*a") or ""
cf:close()
end
content = content:gsub(',?%s*"openclaw%-weixin"%s*:%s*%b{}', "")
content = content:gsub('"openclaw%-weixin"%s*:%s*%b{}%s*,?', "")
local wf = io.open(config_file, "w")
if wf then
wf:write(content)
wf:close()
end
end
sys.exec("chown openclaw:openclaw " .. shellquote(config_file) .. " 2>/dev/null")
end
-- 清理临时文件
sys.exec("rm -f /tmp/openclaw-wechat-*.log /tmp/openclaw-wechat-*.pid /tmp/openclaw-wechat-*.exit /tmp/openclaw-wechat-qrcode.txt")
-- 重启网关让卸载真正生效。
--
-- 不重启会留下登录态残留: 网关进程仍在内存中持有已加载的微信渠道,
-- 会把会话游标(get_updates_buf, 内含账号标识)写回刚被删除的
-- .openclaw/openclaw-weixin/accounts/ 目录,于是目录被重新创建。
-- 实测卸载后该文件 mtime 与卸载时刻相同 —— 是删除后被重建,而非漏删。
--
-- action_wechat_login / action_wechat_logout 本来就会重启网关,
-- 只有卸载遗漏了这一步,属实现不一致。
sys.exec("/etc/init.d/openclaw restart &")
-- 重启是异步的,等待一小段时间让网关放开插件文件句柄后再清理残留状态目录。
sys.exec("(sleep 6; rm -rf " .. shellquote(wechat_state_dir) .. " 2>/dev/null) &")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件已卸载,网关正在重启" })
end
-- ═══════════════════════════════════════════
-- 微信插件检测升级 API
-- ═══════════════════════════════════════════
function action_wechat_check_upgrade()
local http = require "luci.http"
local sys = require "luci.sys"
local install_path = get_install_path()
-- view 是 npm 的子命令, 不是 npx 的。
-- 历史实现用 npx viewnpx 会把 view 当成待执行的包去解析并失败:
-- npm error could not determine executable to run
-- 该错误被 2>/dev/null 吞掉后 latest_version 恒为空,
-- is_newer_version("" , x) 恒为 false —— 升级检测从未真正工作过。
local npm_bin = install_path .. "/node/bin/npm"
local oc_data = install_path .. "/data"
-- 获取当前已安装版本
local current_version = ""
local wechat_ext_dir = find_wechat_plugin_dir(install_path) or (install_path .. "/data/.openclaw/extensions/openclaw-weixin")
local plugin_json = wechat_ext_dir .. "/openclaw.plugin.json"
local pf = io.open(plugin_json, "r")
if pf then
local content = pf:read("*a") or ""
pf:close()
current_version = content:match('"version"%s*:%s*"([^"]+)"') or ""
end
-- 检测最新版本 (npm view)
local latest_version = ""
local check_err = ""
local env_prefix = string.format(
"HOME=%s PATH=%s/node/bin:%s/global/bin:$PATH",
oc_data, install_path, install_path
)
-- 保留 stderr 以便查询失败时能给出可诊断的提示,而不是静默显示"已是最新"
local check_cmd = string.format(
"%s %s view @tencent-weixin/openclaw-weixin version 2>&1",
env_prefix, npm_bin
)
local raw = sys.exec(check_cmd) or ""
-- npm 输出可能夹带告警行,取最后一个形如 x.y.z 的 token
latest_version = raw:match("(%d+%.%d+%.%d+[%w%.%-]*)%s*$") or ""
if latest_version == "" then
check_err = raw:gsub("%s+$", ""):sub(1, 200)
end
local has_upgrade = false
if is_newer_version(latest_version, current_version) then
has_upgrade = true
end
http.prepare_content("application/json")
-- 查询失败时必须显式区分"已是最新"与"查不到"
-- 否则用户永远看到"已是最新版"而不知道检测其实没跑通。
http.write_json({
status = (latest_version ~= "") and "ok" or "error",
current_version = current_version,
latest_version = latest_version,
has_upgrade = has_upgrade,
message = (latest_version ~= "") and "" or
("无法查询最新版本 (请检查网络或 npm 源): " .. check_err)
})
end
-- ═══════════════════════════════════════════
-- 微信插件升级 API (后台执行安装命令)
-- ═══════════════════════════════════════════
function action_wechat_upgrade_plugin()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
-- 使用统一的路径获取函数 (唯一来源: UCI 配置)
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local npx_bin = install_path .. "/node/bin/npx"
local oc_data = install_path .. "/data"
local oc_entry = ""
for _, d in ipairs({
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
-- 清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-wechat-install.log /tmp/openclaw-wechat-install.pid /tmp/openclaw-wechat-install.exit")
-- 升级也必须由官方 CLI 更新 SQLite 插件索引。
if not nixio.fs.stat(node_bin, "type") or oc_entry == "" then
local log_content = "正在升级微信插件...\n" ..
"安装路径: " .. install_path .. "\n" ..
"❌ 错误: Node.js 或 OpenClaw CLI 不存在。\n" ..
"请先检查运行环境是否正常安装。\n" ..
"UCI install_path=" .. (uci:get("openclaw", "main", "install_path") or "未设置")
local f = io.open("/tmp/openclaw-wechat-install.log", "w")
if f then
f:write(log_content)
f:close()
end
local ef = io.open("/tmp/openclaw-wechat-install.exit", "w")
if ef then
ef:write("127")
ef:close()
end
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件升级已在后台启动..." })
return
end
if not ensure_openclaw_user(oc_data) then
write_wechat_log_and_exit(
"/tmp/openclaw-wechat-install.log",
"/tmp/openclaw-wechat-install.exit",
"正在升级微信插件...\n安装路径: " .. install_path .. "\n❌ 错误: 无法创建或读取 openclaw 系统用户。\n请检查 /etc/passwd、/etc/group 是否可写。\n",
1
)
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件升级已在后台启动..." })
return
end
-- 后台执行升级 (其实就是重新安装最新版)。不要释放 Gateway 端口,避免误停正在运行的 Gateway。
-- 微信插件安装目录路径 (用于升级后权限修复)
local extensions_dir = install_path .. "/data/.openclaw/extensions"
local upgrade_cmd = string.format(
"( " ..
openclaw_user_runner_cmd() ..
"echo '正在升级微信插件...' > /tmp/openclaw-wechat-install.log; " ..
"echo '安装路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
"echo 'npx 路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
"echo 'Node 版本:' $(%s -v 2>/dev/null || echo 未检测到) >> /tmp/openclaw-wechat-install.log; " ..
wechat_network_probe_cmd(node_bin, "/tmp/openclaw-wechat-install.log") ..
wechat_python3_bootstrap_cmd("/tmp/openclaw-wechat-install.log") ..
"OC_WECHAT_DATA=%s; export OC_WECHAT_DATA; " ..
"if [ -x /usr/libexec/openclaw-permissions.sh ]; then /usr/libexec/openclaw-permissions.sh prepare-workdirs \"$OC_WECHAT_DATA\" >/dev/null 2>&1; " ..
"else mkdir -p \"$OC_WECHAT_DATA/.npm\" \"$OC_WECHAT_DATA/.tmp\" \"$OC_WECHAT_DATA/.openclaw/extensions\"; chown -R openclaw:openclaw \"$OC_WECHAT_DATA/.npm\" \"$OC_WECHAT_DATA/.tmp\" 2>/dev/null; chown openclaw:openclaw \"$OC_WECHAT_DATA/.openclaw\" 2>/dev/null; fi; " ..
"if [ ! -w %s/.npm ] || [ ! -w %s/.tmp ]; then echo '❌ npm cache/tmp 目录不可写' >> /tmp/openclaw-wechat-install.log; echo 1 > /tmp/openclaw-wechat-install.exit; exit 0; fi; " ..
"_oc_as_openclaw 'test -w %s/.npm && test -w %s/.tmp && test -w %s/.openclaw' || { echo '❌ openclaw 用户无法写入 npm cache/tmp/data 目录' >> /tmp/openclaw-wechat-install.log; echo 1 > /tmp/openclaw-wechat-install.exit; exit 0; }; " ..
wechat_openclaw_plugin_install_cmd(install_path, oc_entry, "/tmp/openclaw-wechat-install.log", "/tmp/openclaw-wechat-install.exit") ..
wechat_enable_plugin_config_cmd(install_path, node_bin, "/tmp/openclaw-wechat-install.log") ..
wechat_finalize_plugin_registry_cmd(install_path, oc_entry, "/tmp/openclaw-wechat-install.log", "/tmp/openclaw-wechat-install.exit") ..
-- 关键修复: 升级完成后强制修复插件目录权限 (确保 Gateway 可读取插件)
-- 官方 npm generation 由 openclaw 用户维护,必须允许后续升级清理。
"[ -x /usr/libexec/openclaw-permissions.sh ] && /usr/libexec/openclaw-permissions.sh fix-state %s/.openclaw >/dev/null 2>&1; " ..
"if [ $RC -eq 0 ]; then echo '✅ 微信插件升级成功!' >> /tmp/openclaw-wechat-install.log; " ..
"else echo '❌ 升级失败 (exit: '$RC')' >> /tmp/openclaw-wechat-install.log; fi " ..
") & echo $! > /tmp/openclaw-wechat-install.pid",
install_path, npx_bin, node_bin,
shellquote(oc_data),
oc_data, oc_data,
oc_data, oc_data, oc_data,
install_path, oc_data, oc_data, oc_data,
oc_data
)
sys.exec(upgrade_cmd)
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "微信插件升级已在后台启动..." })
end
-- ═══════════════════════════════════════════
-- 微信退出/删除账号 API
-- ═══════════════════════════════════════════
function action_wechat_logout()
local http = require "luci.http"
local sys = require "luci.sys"
local account_id = http.formvalue("account")
if not account_id or account_id == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "参数错误:未提供账号 ID" })
return
end
if account_id:match("[`$;&|<>\"']") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "账号 ID 包含非法字符" })
return
end
local install_path = get_install_path()
local node_bin = install_path .. "/node/bin/node"
local oc_data = install_path .. "/data"
local oc_entry = ""
local search_dirs = {
install_path .. "/global/lib/node_modules/openclaw",
install_path .. "/global/node_modules/openclaw",
install_path .. "/node/lib/node_modules/openclaw",
}
for _, d in ipairs(search_dirs) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
if oc_entry == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "OpenClaw 未安装" })
return
end
if not ensure_openclaw_user(oc_data) then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "无法创建或读取 openclaw 系统用户" })
return
end
-- 在后台执行 logout
local logout_cmd = string.format(
openclaw_user_runner_cmd() .. "cd %s && _oc_as_openclaw 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json NODE_ICU_DATA=%s/node/share/icu " ..
"PATH=%s/node/bin:%s/global/bin:$PATH " ..
"%s %s channels logout --channel openclaw-weixin --account \"%s\"'",
oc_data, oc_data, oc_data, oc_data, oc_data, install_path, install_path, install_path, node_bin, oc_entry, account_id
)
sys.exec(logout_cmd .. " >/dev/null 2>&1")
-- 重启服务
sys.exec("/etc/init.d/openclaw restart &")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "已下线账号: " .. account_id })
end