mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-28 03:01:54 +08:00
1854 lines
75 KiB
Lua
1854 lines
75 KiB
Lua
-- 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
|
||
return value == "/opt/openclaw" or value:match("^/mnt/[^/]+/openclaw$") ~= nil or value:match("^/media/[^/]+/openclaw$") ~= nil
|
||
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 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
|
||
|
||
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
|
||
entry({"admin", "services", "openclaw", "service_ctl"}, call("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
|
||
entry({"admin", "services", "openclaw", "uninstall"}, call("action_uninstall"), nil).leaf = true
|
||
|
||
-- 获取网关 Token API (仅认证用户可访问)
|
||
entry({"admin", "services", "openclaw", "get_token"}, call("action_get_token"), nil).leaf = true
|
||
|
||
-- 插件升级 API
|
||
entry({"admin", "services", "openclaw", "plugin_upgrade"}, call("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)
|
||
entry({"admin", "services", "openclaw", "backup"}, call("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
|
||
|
||
-- 确保网关端口可用:检测占用并尝试优雅停止或强制杀死占用进程
|
||
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("openclaw gateway stop >/dev/null 2>&1 || true")
|
||
|
||
-- 查询占用端口的行
|
||
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("openclaw gateway stop >/dev/null 2>&1 || true")
|
||
-- 发送 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 进程存在,说明正在启动中 (gateway 初始化需要数分钟)
|
||
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+", "")
|
||
result.gateway_exit_code = procd_exit
|
||
if procd_exit ~= "" and tonumber(procd_exit) and tonumber(procd_exit) ~= 0 and procd_running ~= "true" then
|
||
result.gateway_failed = true
|
||
elseif procd_pid ~= "" or procd_running == "true" then
|
||
result.gateway_starting = true
|
||
else
|
||
local fallback_pid = sys.exec("pgrep -f 'openclaw.*gateway' 2>/dev/null | head -1"):gsub("%s+", "")
|
||
if fallback_pid ~= "" then
|
||
result.gateway_starting = true
|
||
end
|
||
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
|
||
-- 先完整 stop (确保端口释放),再后台 start
|
||
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
|
||
sys.exec("sleep 2")
|
||
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
|
||
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 -e,init_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 plugin_current ~= "" and plugin_latest ~= "" and plugin_current ~= plugin_latest 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"
|
||
|
||
-- 获取安装路径
|
||
local install_path_uci = uci:get("openclaw", "main", "install_path") or "/opt"
|
||
-- 实际安装路径
|
||
local install_path = install_path_uci .. "/openclaw"
|
||
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) 修复权限
|
||
sys.exec("chown -R openclaw:openclaw " .. oc_data_dir .. " 2>/dev/null")
|
||
|
||
-- 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"
|
||
|
||
-- 清理旧日志和状态
|
||
sys.exec("rm -f /tmp/openclaw-wechat-install.log /tmp/openclaw-wechat-install.pid /tmp/openclaw-wechat-install.exit")
|
||
|
||
-- 校验: 确保 npx 存在 (运行环境已安装)
|
||
if not nixio.fs.stat(npx_bin, "type") then
|
||
-- npx 不存在,运行环境未安装或路径配置错误
|
||
local log_content = "开始安装微信插件...\n" ..
|
||
"安装路径: " .. install_path .. "\n" ..
|
||
"❌ 错误: npx 命令不存在 (" .. npx_bin .. ")\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 sys.exec("command -v python3 2>/dev/null"):gsub("%s+", "") == "" then
|
||
write_wechat_log_and_exit(
|
||
"/tmp/openclaw-wechat-install.log",
|
||
"/tmp/openclaw-wechat-install.exit",
|
||
"开始安装微信插件...\n安装路径: " .. install_path .. "\n❌ 错误: 未检测到 python3。\n请先安装依赖: opkg update && opkg install python3\n",
|
||
127
|
||
)
|
||
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 进程)
|
||
local port = uci:get("openclaw", "main", "port") or "18789"
|
||
ensure_port_free(port)
|
||
-- 微信插件安装目录路径 (用于安装后权限修复)
|
||
local extensions_dir = install_path .. "/data/.openclaw/extensions"
|
||
local install_cmd = string.format(
|
||
"( " ..
|
||
"echo '开始安装微信插件...' > /tmp/openclaw-wechat-install.log; " ..
|
||
"echo '安装路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
|
||
"echo 'npx 路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
|
||
"mkdir -p %s/.npm %s/.tmp %s/.openclaw/extensions; " ..
|
||
"if [ ! -w %s/.openclaw/extensions ]; then chown -R openclaw:openclaw %s/.openclaw/extensions 2>/dev/null; fi; " ..
|
||
"chown -R openclaw:openclaw %s/.npm %s/.tmp %s/.openclaw 2>/dev/null; " ..
|
||
"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; " ..
|
||
"su -s /bin/sh openclaw -c '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; }; " ..
|
||
"cd %s && " ..
|
||
"su -s /bin/sh openclaw -c 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw " ..
|
||
"OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json NPM_CONFIG_CACHE=%s/.npm npm_config_cache=%s/.npm TMPDIR=%s/.tmp " ..
|
||
"PATH=%s/node/bin:%s/global/bin:$PATH " ..
|
||
"%s -y @tencent-weixin/openclaw-weixin-cli install' >> /tmp/openclaw-wechat-install.log 2>&1; " ..
|
||
"RC=$?; echo $RC > /tmp/openclaw-wechat-install.exit; " ..
|
||
-- 关键修复: 安装完成后强制修复插件目录权限 (确保 Gateway 可读取插件)
|
||
-- 原因: npx/npm 以 root 身份创建目录,默认权限 700 导致其他用户无法读取
|
||
-- 注意: 保持 root:root 属主 (OpenClaw v2026.4.9+ 安全要求),仅修复权限模式
|
||
"chown -R root:root %s 2>/dev/null; chmod -R 755 %s 2>/dev/null; " ..
|
||
"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,
|
||
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, oc_data, oc_data, oc_data,
|
||
install_path, install_path, npx_bin,
|
||
extensions_dir, extensions_dir
|
||
)
|
||
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 login_cmd = string.format(
|
||
"( " ..
|
||
"echo '正在启动微信登录...' > /tmp/openclaw-wechat-qrcode.txt; " ..
|
||
"echo '安装路径: %s' >> /tmp/openclaw-wechat-qrcode.txt; " ..
|
||
"mkdir -p %s/.npm %s/.tmp %s/.openclaw; chown -R openclaw:openclaw %s/.npm %s/.tmp %s/.openclaw 2>/dev/null; " ..
|
||
"cd %s && " ..
|
||
"su -s /bin/sh openclaw -c 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json " ..
|
||
"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_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, 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
|
||
local qrcode_url = ""
|
||
for url in qrcode:gmatch("https?://[^\n\r]+") do
|
||
qrcode_url = url
|
||
end -- 检查是否登录成功
|
||
local logged_in = qrcode:find("登录成功") ~= nil or qrcode:find("成功登录") ~= nil or qrcode:find("Login success") ~= nil or qrcode:find("Logged in") ~= 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
|
||
|
||
-- 如果刚登录成功,触发一次重启,确保主进程加载微信
|
||
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 &")
|
||
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
|
||
})
|
||
end
|
||
|
||
-- ═══════════════════════════════════════════
|
||
-- 微信插件卸载 API
|
||
-- ═══════════════════════════════════════════
|
||
function action_wechat_uninstall()
|
||
local http = require "luci.http"
|
||
local sys = require "luci.sys"
|
||
|
||
local install_path = get_install_path()
|
||
|
||
-- 删除微信插件目录
|
||
local wechat_ext_dir = install_path .. "/data/.openclaw/extensions/openclaw-weixin"
|
||
if wechat_ext_dir == install_path .. "/data/.openclaw/extensions/openclaw-weixin" then
|
||
sys.exec("rm -rf " .. shellquote(wechat_ext_dir) .. " 2>/dev/null")
|
||
end
|
||
|
||
-- 从配置中删除微信相关配置
|
||
local config_file = install_path .. "/data/.openclaw/openclaw.json"
|
||
if nixio.fs.stat(config_file, "type") then
|
||
-- 读取配置
|
||
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("rm -f /tmp/openclaw-wechat-*.log /tmp/openclaw-wechat-*.pid /tmp/openclaw-wechat-*.exit /tmp/openclaw-wechat-qrcode.txt")
|
||
|
||
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()
|
||
local npx_bin = install_path .. "/node/bin/npx"
|
||
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 env_prefix = string.format(
|
||
"HOME=%s PATH=%s/node/bin:%s/global/bin:$PATH",
|
||
oc_data, install_path, install_path
|
||
)
|
||
local check_cmd = string.format(
|
||
"%s %s view @tencent-weixin/openclaw-weixin version 2>/dev/null",
|
||
env_prefix, npx_bin
|
||
)
|
||
latest_version = sys.exec(check_cmd):gsub("%s+", "")
|
||
|
||
local has_upgrade = false
|
||
if current_version ~= "" and latest_version ~= "" and current_version ~= latest_version then
|
||
has_upgrade = true
|
||
end
|
||
|
||
http.prepare_content("application/json")
|
||
http.write_json({
|
||
status = "ok",
|
||
current_version = current_version,
|
||
latest_version = latest_version,
|
||
has_upgrade = has_upgrade
|
||
})
|
||
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"
|
||
|
||
-- 清理旧日志和状态
|
||
sys.exec("rm -f /tmp/openclaw-wechat-install.log /tmp/openclaw-wechat-install.pid /tmp/openclaw-wechat-install.exit")
|
||
|
||
-- 校验: 确保 npx 存在 (运行环境已安装)
|
||
if not nixio.fs.stat(npx_bin, "type") then
|
||
local log_content = "正在升级微信插件...\n" ..
|
||
"安装路径: " .. install_path .. "\n" ..
|
||
"❌ 错误: npx 命令不存在 (" .. npx_bin .. ")\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 sys.exec("command -v python3 2>/dev/null"):gsub("%s+", "") == "" then
|
||
write_wechat_log_and_exit(
|
||
"/tmp/openclaw-wechat-install.log",
|
||
"/tmp/openclaw-wechat-install.exit",
|
||
"正在升级微信插件...\n安装路径: " .. install_path .. "\n❌ 错误: 未检测到 python3。\n请先安装依赖: opkg update && opkg install python3\n",
|
||
127
|
||
)
|
||
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 进程)
|
||
local port = uci:get("openclaw", "main", "port") or "18789"
|
||
ensure_port_free(port)
|
||
-- 微信插件安装目录路径 (用于升级后权限修复)
|
||
local extensions_dir = install_path .. "/data/.openclaw/extensions"
|
||
local upgrade_cmd = string.format(
|
||
"( " ..
|
||
"echo '正在升级微信插件...' > /tmp/openclaw-wechat-install.log; " ..
|
||
"echo '安装路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
|
||
"echo 'npx 路径: %s' >> /tmp/openclaw-wechat-install.log; " ..
|
||
"mkdir -p %s/.npm %s/.tmp %s/.openclaw/extensions; " ..
|
||
"if [ ! -w %s/.openclaw/extensions ]; then chown -R openclaw:openclaw %s/.openclaw/extensions 2>/dev/null; fi; " ..
|
||
"chown -R openclaw:openclaw %s/.npm %s/.tmp %s/.openclaw 2>/dev/null; " ..
|
||
"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; " ..
|
||
"su -s /bin/sh openclaw -c '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; }; " ..
|
||
"cd %s && " ..
|
||
"su -s /bin/sh openclaw -c 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw " ..
|
||
"OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json NPM_CONFIG_CACHE=%s/.npm npm_config_cache=%s/.npm TMPDIR=%s/.tmp " ..
|
||
"PATH=%s/node/bin:%s/global/bin:$PATH " ..
|
||
"%s -y @tencent-weixin/openclaw-weixin-cli install' >> /tmp/openclaw-wechat-install.log 2>&1; " ..
|
||
"RC=$?; echo $RC > /tmp/openclaw-wechat-install.exit; " ..
|
||
-- 关键修复: 升级完成后强制修复插件目录权限 (确保 Gateway 可读取插件)
|
||
-- 注意: 保持 root:root 属主 (OpenClaw v2026.4.9+ 安全要求),仅修复权限模式
|
||
"chown -R root:root %s 2>/dev/null; chmod -R 755 %s 2>/dev/null; " ..
|
||
"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,
|
||
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, oc_data, oc_data, oc_data,
|
||
install_path, install_path, npx_bin,
|
||
extensions_dir, extensions_dir
|
||
)
|
||
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(
|
||
"cd %s && su -s /bin/sh openclaw -c 'HOME=%s OPENCLAW_HOME=%s OPENCLAW_STATE_DIR=%s/.openclaw OPENCLAW_CONFIG_PATH=%s/.openclaw/openclaw.json " ..
|
||
"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, 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
|