mirror of
https://github.com/hotwa/luci-app-openclaw.git
synced 2026-03-31 04:52:33 +00:00
release: v1.0.14 — 备份管理增强、QQ 机器人、适配 v2026.3.8
This commit is contained in:
@@ -38,6 +38,9 @@ function index()
|
||||
|
||||
-- 插件升级日志 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
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
@@ -67,6 +70,7 @@ function action_status()
|
||||
memory_kb = 0,
|
||||
uptime = "",
|
||||
node_version = "",
|
||||
oc_version = "",
|
||||
plugin_version = "",
|
||||
}
|
||||
|
||||
@@ -86,6 +90,25 @@ function action_status()
|
||||
result.node_version = node_ver
|
||||
end
|
||||
|
||||
-- OpenClaw 版本 (从 package.json 读取)
|
||||
local oc_dirs = {
|
||||
"/opt/openclaw/global/lib/node_modules/openclaw",
|
||||
"/opt/openclaw/global/node_modules/openclaw",
|
||||
"/opt/openclaw/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 = sys.exec("netstat -tlnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0"):gsub("%s+", "")
|
||||
result.gateway_running = (tonumber(gw_check) or 0) > 0
|
||||
@@ -113,6 +136,27 @@ function action_status()
|
||||
if model and model ~= "" then
|
||||
result.active_model = model
|
||||
end
|
||||
|
||||
-- 读取已配置的渠道列表
|
||||
local channels = {}
|
||||
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 和内存
|
||||
@@ -479,3 +523,234 @@ function action_plugin_upgrade_log()
|
||||
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 action = http.formvalue("action") or "create"
|
||||
|
||||
local node_bin = "/opt/openclaw/node/bin/node"
|
||||
local oc_entry = ""
|
||||
|
||||
-- 查找 openclaw 入口
|
||||
local search_dirs = {
|
||||
"/opt/openclaw/global/lib/node_modules/openclaw",
|
||||
"/opt/openclaw/global/node_modules/openclaw",
|
||||
"/opt/openclaw/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=/opt/openclaw/data OPENCLAW_HOME=/opt/openclaw/data " ..
|
||||
"OPENCLAW_STATE_DIR=/opt/openclaw/data/.openclaw " ..
|
||||
"OPENCLAW_CONFIG_PATH=/opt/openclaw/data/.openclaw/openclaw.json " ..
|
||||
"PATH=/opt/openclaw/node/bin:/opt/openclaw/global/bin:$PATH "
|
||||
)
|
||||
|
||||
-- 备份目录 (openclaw backup create 输出到 CWD,需要 cd)
|
||||
local backup_dir = "/opt/openclaw/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 /opt/openclaw/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 config_path = "/opt/openclaw/data/.openclaw/openclaw.json"
|
||||
-- 先备份当前配置
|
||||
sys.exec("cp -f " .. config_path .. " " .. config_path .. ".pre-restore 2>/dev/null")
|
||||
-- 从 tar.gz 中提取 openclaw.json
|
||||
local extract_cmd = "tar -xzf " .. restore_path .. " --wildcards '*/openclaw.json' -O > " .. config_path .. ".tmp 2>/dev/null"
|
||||
sys.exec(extract_cmd)
|
||||
-- 验证提取的文件是否有效 JSON
|
||||
local check = sys.exec(node_bin .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. config_path .. ".tmp','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
|
||||
if check == "OK" then
|
||||
sys.exec("mv -f " .. config_path .. ".tmp " .. config_path)
|
||||
sys.exec("chown openclaw:openclaw " .. config_path .. " 2>/dev/null")
|
||||
-- 重启服务使配置生效
|
||||
sys.exec("/etc/init.d/openclaw restart >/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
|
||||
})
|
||||
else
|
||||
sys.exec("rm -f " .. config_path .. ".tmp")
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "备份文件中的配置无效,恢复已取消" })
|
||||
end
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user