mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
fix(examples): 全插件安全审查修复(qq/a2a/memo/calendar/rss/browser/bili/recoverydiag)
审查发现并修复 7 项问题: - P1 qq: downloadURL 裸 http.Get 无超时 → 120s client - P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s - P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等) - P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内 - P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename) - P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover - P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程 recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复 属生产代码,故取消忽略并入库。 全部经 plugindev 重打包升版安装验证 config_kept=true。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,4 +30,3 @@ z_entry.c
|
|||||||
|
|
||||||
# plugindev binary in tools/
|
# plugindev binary in tools/
|
||||||
tools/plugindev/plugindev
|
tools/plugindev/plugindev
|
||||||
example/recoverydiag/
|
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["a2a", "agent", "interop"],
|
"tags": [
|
||||||
|
"a2a",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -137,7 +137,12 @@ func (p *Plugin) startServer(addr string) error {
|
|||||||
return fmt.Errorf("listen %s: %v", addr, err)
|
return fmt.Errorf("listen %s: %v", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux}
|
srv := &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 120 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
addrStr := listener.Addr().String()
|
addrStr := listener.Addr().String()
|
||||||
|
|
||||||
p.srvMu.Lock()
|
p.srvMu.Lock()
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
"name": "bili",
|
"name": "bili",
|
||||||
"name_zh": "B站视频下载",
|
"name_zh": "B站视频下载",
|
||||||
"name_en": "Bilibili Video Downloader",
|
"name_en": "Bilibili Video Downloader",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["bili", "video", "download"],
|
"tags": [
|
||||||
|
"bili",
|
||||||
|
"video",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
|
||||||
|
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
|
||||||
|
outputDir = filepath.Clean(outputDir)
|
||||||
|
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
|
||||||
|
if outputDir == forbidden {
|
||||||
|
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
os.MkdirAll(outputDir, 0755)
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|||||||
@ -2,11 +2,17 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.0.0",
|
"version": "2.3.0",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
"tags": [
|
||||||
|
"web",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"browser",
|
||||||
|
"cdp"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -33,23 +34,49 @@ type Plugin struct {
|
|||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
sessions map[string]*BrowserSession
|
sessions map[string]*BrowserSession
|
||||||
nextID int
|
nextID int
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
|
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
||||||
|
|
||||||
|
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
||||||
|
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||||
|
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||||
|
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||||
|
sharedAllocCtx context.Context
|
||||||
|
sharedAllocCancel context.CancelFunc
|
||||||
|
sharedMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserSession struct {
|
type BrowserSession struct {
|
||||||
id string
|
id string
|
||||||
allocCtx context.Context
|
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ctx context.Context
|
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentURL string
|
currentURL string
|
||||||
|
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||||
|
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||||
|
sessionKey string // 共享模式下的复用键(agent 来源标识,同 key 复用同一标签页)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_,防路径穿越。
|
||||||
|
func sanitizeProfileName(name string) string {
|
||||||
|
var b []byte
|
||||||
|
for _, c := range []byte(name) {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b) == 0 || string(b) == "." || string(b) == ".." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.proxy = readCfg(s.Settings(), "proxy", "")
|
p.proxy = readCfg(s.Settings(), "proxy", "")
|
||||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
|
// 持久化 profile 根目录:<data>/browser_profiles
|
||||||
|
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||||||
|
if s2, ok := dd.(string); ok && s2 != "" {
|
||||||
|
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
cleaner := func(output string) string {
|
cleaner := func(output string) string {
|
||||||
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||||
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
},
|
},
|
||||||
}, p.handleScroll)
|
}, p.handleScroll)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||||
|
Name: tp + "install",
|
||||||
|
Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleBrowserInstall)
|
||||||
|
|
||||||
s.RegisterTool(tp+"close", sdk.ToolDef{
|
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||||
Name: tp + "close",
|
Name: tp + "close",
|
||||||
Description: "关闭交互式浏览器会话,释放资源。",
|
Description: "关闭交互式浏览器会话,释放资源。",
|
||||||
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||||
|
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||||
|
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||||
rawURL := readArg(args, "url", "")
|
rawURL := readArg(args, "url", "")
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||||
if waitSec > 0 {
|
|
||||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
var title, html string
|
||||||
|
rendered := false
|
||||||
|
|
||||||
|
ok, needInstall, _ := p.ensureBackend()
|
||||||
|
if ok {
|
||||||
|
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
|
defer remoteCancel()
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
|
defer tabCancel()
|
||||||
|
actions := []chromedp.Action{
|
||||||
|
chromedp.Navigate(rawURL),
|
||||||
|
chromedp.WaitReady("body"),
|
||||||
|
}
|
||||||
|
if waitSec > 0 {
|
||||||
|
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
|
||||||
|
}
|
||||||
|
actions = append(actions,
|
||||||
|
chromedp.Title(&title),
|
||||||
|
chromedp.OuterHTML("html", &html),
|
||||||
|
)
|
||||||
|
// 整体限时 30s,防慢页拖死工具
|
||||||
|
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
|
||||||
|
defer rcancel()
|
||||||
|
if err := chromedp.Run(rctx, actions...); err == nil {
|
||||||
|
rendered = true
|
||||||
|
} else {
|
||||||
|
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
|
||||||
|
}
|
||||||
|
} else if needInstall {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "browser backend not installed",
|
||||||
|
"need_install": true,
|
||||||
|
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
var html string
|
|
||||||
chromiumPath := "/usr/local/bin/chromium"
|
if !rendered {
|
||||||
if _, err := os.Stat(chromiumPath); err == nil {
|
chromiumPath := "/usr/local/bin/chromium"
|
||||||
|
if _, err := os.Stat(chromiumPath); err != nil {
|
||||||
|
if _, e2 := exec.LookPath("chromium"); e2 == nil {
|
||||||
|
chromiumPath = "chromium"
|
||||||
|
} else {
|
||||||
|
return errResult("no chromium available"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
if err := cmd.Run(); err != nil {
|
done := make(chan error, 1)
|
||||||
return errResult("chromium: " + err.Error()), nil
|
go func() { done <- cmd.Run() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return errResult("chromium: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
<-done // 回收子进程避免僵尸
|
||||||
|
return errResult("chromium dump-dom timeout (30s)"), nil
|
||||||
}
|
}
|
||||||
html = out.String()
|
html = out.String()
|
||||||
} else {
|
|
||||||
resp, err := http.Get(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return errResult("http get: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
html = string(body)
|
|
||||||
}
|
|
||||||
title := ""
|
|
||||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
|
||||||
title = m[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
text := htmlToText(html)
|
text := htmlToText(html)
|
||||||
origLen := len(text)
|
origLen := len(text)
|
||||||
truncated := origLen > 5000
|
truncated := origLen > 5000
|
||||||
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
if truncated {
|
if truncated {
|
||||||
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"content": result, "title": title}, nil
|
mode := "backend-tab"
|
||||||
|
if !rendered {
|
||||||
|
mode = "local-dump-dom"
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interactive Browser Session (CDP) ─────────────────────
|
func cdpReachable(endpoint string) bool {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
resp, err := client.Get(endpoint + "/json/version")
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
timeout = 10 * time.Minute
|
return false
|
||||||
}
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
|
||||||
|
func systemdUnitInstalled() bool {
|
||||||
|
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
|
||||||
|
return err == nil && len(out) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSystemdUnit 尝试 systemctl start(单元已安装但未运行时用)。
|
||||||
|
func startSystemdUnit() error {
|
||||||
|
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。
|
||||||
|
const cdpEndpoint = "http://127.0.0.1:9222"
|
||||||
|
|
||||||
|
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
|
||||||
|
// 返回 (ok, needInstall, err)。
|
||||||
|
func (p *Plugin) ensureBackend() (bool, bool, error) {
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
if systemdUnitInstalled() {
|
||||||
|
if err := startSystemdUnit(); err == nil {
|
||||||
|
// 等待 CDP 就绪(chromium 启动 ~1-3s)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false, fmt.Errorf("browser backend service installed but failed to start")
|
||||||
|
}
|
||||||
|
return false, true, nil // 未安装
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
||||||
|
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
tabCancel()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tabCtx, tabCancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localSpawnFailback 本地拉起一次性 Chromium(离线机器无法装 systemd 服务的兜底)。
|
||||||
|
// 用临时 profile,登录态不跨会话保留——仅保证功能可用。
|
||||||
|
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
|
||||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
chromedp.Flag("headless", true),
|
chromedp.Flag("headless", true),
|
||||||
chromedp.Flag("disable-gpu", true),
|
chromedp.Flag("disable-gpu", true),
|
||||||
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
if p.proxy != "" {
|
if p.proxy != "" {
|
||||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||||
}
|
}
|
||||||
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
||||||
ctx, _ := chromedp.NewContext(allocCtx)
|
ctx, _ := chromedp.NewContext(allocCtx)
|
||||||
|
|
||||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
|
||||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
|
||||||
if err := chromedp.Run(ctx); err != nil {
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
cancel()
|
cancelAlloc()
|
||||||
return errResult("browser init failed: " + err.Error()), nil
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return allocCtx, cancelAlloc, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||||
|
timeoutStr := readArg(args, "timeout", "10m")
|
||||||
|
timeout, err := time.ParseDuration(timeoutStr)
|
||||||
|
if err != nil {
|
||||||
|
timeout = 10 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &BrowserSession{
|
source := readArg(args, "source", "")
|
||||||
allocCtx: allocCtx,
|
if source == "" {
|
||||||
cancel: cancel,
|
source = "default"
|
||||||
ctx: ctx,
|
}
|
||||||
createdAt: time.Now(),
|
|
||||||
timeout: timeout,
|
// 同 source 复用已有标签页
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, s := range p.sessions {
|
||||||
|
if s.shared && s.sessionKey == source && !s.closed {
|
||||||
|
s.mu.Lock()
|
||||||
|
id := s.id
|
||||||
|
cur := s.currentURL
|
||||||
|
s.mu.Unlock()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"status": "reused",
|
||||||
|
"url": cur,
|
||||||
|
"note": "已复用本来源的现有标签页(登录态全机共享)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var session *BrowserSession
|
||||||
|
|
||||||
|
// 路径一:systemd 托管的共享后端(主路径)
|
||||||
|
ok, needInstall, berr := p.ensureBackend()
|
||||||
|
if ok {
|
||||||
|
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
|
probe, _ := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(probe); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("connect to browser backend failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("open tab failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
session = &BrowserSession{
|
||||||
|
allocCtx: remoteCtx,
|
||||||
|
cancel: tabCancel,
|
||||||
|
ctx: tabCtx,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
timeout: timeout,
|
||||||
|
shared: true,
|
||||||
|
sessionKey: source,
|
||||||
|
}
|
||||||
|
} else if needInstall {
|
||||||
|
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
|
||||||
|
"需要本机有 chromium 二进制(apt install chromium 或等价命令)," +
|
||||||
|
"插件会注册 homeagent-browser.service 并启动。" +
|
||||||
|
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "backend not installed",
|
||||||
|
"need_install": true,
|
||||||
|
"guide": guide,
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
return errResult("browser backend error: " + berr.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
|
|
||||||
initURL := readArg(args, "url", "")
|
initURL := readArg(args, "url", "")
|
||||||
if initURL != "" {
|
if initURL != "" {
|
||||||
if err := chromedp.Run(ctx,
|
if err := chromedp.Run(session.ctx,
|
||||||
chromedp.Navigate(initURL),
|
chromedp.Navigate(initURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
return errResult("navigate failed: " + err.Error()), nil
|
return errResult("navigate failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
session.currentURL = initURL
|
session.currentURL = initURL
|
||||||
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout)
|
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
|
"mode": "shared-backend",
|
||||||
"url": initURL,
|
"url": initURL,
|
||||||
"timeout": timeout.String(),
|
"timeout": timeout.String(),
|
||||||
}, nil
|
}, nil
|
||||||
@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── browser_install:安装 systemd 托管的共享浏览器后端 ──────────
|
||||||
|
|
||||||
|
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
|
||||||
|
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
|
||||||
|
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测 chromium 二进制
|
||||||
|
chromePath := ""
|
||||||
|
for _, c := range []string{
|
||||||
|
"/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
chromePath = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
|
||||||
|
chromePath = out
|
||||||
|
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
|
||||||
|
chromePath = out
|
||||||
|
}
|
||||||
|
if chromePath == "" {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "chromium binary not found",
|
||||||
|
"hint": "请先安装 chromium:apt install chromium 或等价命令,然后重试 browser_install",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
profileDir := ""
|
||||||
|
if p.profilesDir != "" {
|
||||||
|
profileDir = filepath.Join(p.profilesDir, "shared")
|
||||||
|
os.MkdirAll(profileDir, 0755)
|
||||||
|
} else {
|
||||||
|
// profilesDir 未注入(无 data_dir),退到 /var/lib/homeagent-browser
|
||||||
|
profileDir = "/var/lib/homeagent-browser"
|
||||||
|
os.MkdirAll(profileDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := fmt.Sprintf(`[Unit]
|
||||||
|
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`, chromePath, profileDir)
|
||||||
|
|
||||||
|
unitPath := "/etc/systemd/system/homeagent-browser.service"
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "write unit failed (need root): " + err.Error(),
|
||||||
|
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds)",
|
||||||
|
"manual_cmds": []string{
|
||||||
|
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
|
||||||
|
"sudo systemctl daemon-reload",
|
||||||
|
"sudo systemctl enable --now homeagent-browser.service",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
for _, cmd := range [][]string{
|
||||||
|
{"systemctl", "daemon-reload"},
|
||||||
|
{"systemctl", "enable", "--now", "homeagent-browser.service"},
|
||||||
|
} {
|
||||||
|
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 等待 CDP 就绪
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
guide := "共享浏览器后端已就绪(CDP " + cdpEndpoint + ")。\n" +
|
||||||
|
"全机共享说明:本机所有 agent(HomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
|
||||||
|
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
|
||||||
|
"- HomeAgent 内部:browser_start 即自动连接本后端\n" +
|
||||||
|
"- 其他 agent:让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect)\n" +
|
||||||
|
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
|
||||||
|
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "installed",
|
||||||
|
"endpoint": cdpEndpoint,
|
||||||
|
"chrome": chromePath,
|
||||||
|
"profile": profileDir,
|
||||||
|
"guide": guide,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,16 @@
|
|||||||
"name": "calendar",
|
"name": "calendar",
|
||||||
"name_zh": "日历",
|
"name_zh": "日历",
|
||||||
"name_en": "Calendar",
|
"name_en": "Calendar",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "日历事件管理,支持提醒和重复事件",
|
"description": "日历事件管理,支持提醒和重复事件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
"tags": [
|
||||||
|
"calendar",
|
||||||
|
"event",
|
||||||
|
"reminder",
|
||||||
|
"schedule"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
|
|||||||
NextEventID: p.nextEventID,
|
NextEventID: p.nextEventID,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.eventsFile(), b, 0644)
|
atomicWriteJSON(p.eventsFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: parse remind_before ---
|
// --- Helper: parse remind_before ---
|
||||||
@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
|
|||||||
}
|
}
|
||||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
"name": "memo",
|
"name": "memo",
|
||||||
"name_zh": "备忘录",
|
"name_zh": "备忘录",
|
||||||
"name_en": "Memo",
|
"name_en": "Memo",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["memo", "todo", "notes"],
|
"tags": [
|
||||||
|
"memo",
|
||||||
|
"todo",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
|
|||||||
"next_id": p.nextTID,
|
"next_id": p.nextTID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
os.WriteFile(p.todoPath, data, 0644)
|
atomicWriteJSON(p.todoPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) saveMemos() {
|
func (p *Plugin) saveMemos() {
|
||||||
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
|
|||||||
"next_id": p.nextMID,
|
"next_id": p.nextMID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
os.WriteFile(p.memoPath, data, 0644)
|
atomicWriteJSON(p.memoPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 待办:未完成计数与提醒 ──
|
// ── 待办:未完成计数与提醒 ──
|
||||||
@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() {
|
|||||||
os.Remove(p.memoPath)
|
os.Remove(p.memoPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,14 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["qq", "messaging"],
|
"tags": [
|
||||||
|
"qq",
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": false,
|
"bundle": false,
|
||||||
|
|||||||
@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃)
|
||||||
|
if evt.MessageType == "group" {
|
||||||
|
log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s",
|
||||||
|
evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage)
|
||||||
|
}
|
||||||
|
|
||||||
rawCQ := evt.RawMessage
|
rawCQ := evt.RawMessage
|
||||||
text := rawCQ
|
text := rawCQ
|
||||||
@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if evt.MessageType == "group" {
|
if evt.MessageType == "group" {
|
||||||
if !p.isGroupAllowed(evt.GroupID) {
|
if !p.isGroupAllowed(evt.GroupID) {
|
||||||
|
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !p.isAtBot(evt.Message) {
|
if !p.isAtBot(evt.Message) {
|
||||||
|
// 诊断:@ 解析失败时打印 at 段原文与 botID,定位漏报问题
|
||||||
|
log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)",
|
||||||
|
evt.GroupID, evt.UserID, p.botID, rawCQ)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
if evt.GroupID == rule.GroupID {
|
if evt.GroupID == rule.GroupID {
|
||||||
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
||||||
go func(r ForwardRule, msg string) {
|
go func(r ForwardRule, msg string) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
||||||
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
||||||
}
|
}
|
||||||
@ -980,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
|
|||||||
|
|
||||||
// 异步标记已读
|
// 异步标记已读
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
|
||||||
if d.MessageType == "group" && d.GroupID > 0 {
|
if d.MessageType == "group" && d.GroupID > 0 {
|
||||||
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
||||||
} else if d.UserID > 0 {
|
} else if d.UserID > 0 {
|
||||||
@ -1070,20 +1081,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
return p.napcat("send_private_msg", msg)
|
||||||
|
|
||||||
case "image":
|
case "image", "file":
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
|
// 收敛到 output 通道:payload 支持本地路径或 http(s) URL。
|
||||||
if groupID != 0 {
|
// 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式),
|
||||||
msg["group_id"] = groupID
|
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
|
||||||
} else {
|
uri := payload
|
||||||
msg["user_id"] = userID
|
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
|
||||||
|
!strings.HasPrefix(payload, "file://") {
|
||||||
|
if _, err := os.Stat(payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload)
|
||||||
|
}
|
||||||
|
os.MkdirAll(p.remoteDir, 0755)
|
||||||
|
dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload)))
|
||||||
|
data, err := os.ReadFile(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入共享目录失败: %w", err)
|
||||||
|
}
|
||||||
|
uri = "file:///app/files/" + filepath.Base(dest)
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
cqTag := "file"
|
||||||
return p.napcat("send_group_msg", msg)
|
if rawType == "image" {
|
||||||
|
cqTag = "image"
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
|
||||||
|
|
||||||
case "file":
|
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
|
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
dlURL := parsed.Data.URL
|
dlURL := parsed.Data.URL
|
||||||
httpResp, err := http.Get(dlURL)
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
httpResp, err := client.Get(dlURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download: %w", err)
|
return nil, fmt.Errorf("download: %w", err)
|
||||||
}
|
}
|
||||||
@ -1693,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
|||||||
task := p.addDownloadTask(fileID, filename)
|
task := p.addDownloadTask(fileID, filename)
|
||||||
|
|
||||||
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[qq] download task %s panic: %v", fid, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
savePath := ""
|
savePath := ""
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if furl != "" {
|
if furl != "" {
|
||||||
|
|||||||
153
example/recoverydiag/diag_test.go
Normal file
153
example/recoverydiag/diag_test.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var realCfg = "/home/newqqagent/config.db"
|
||||||
|
var realLog = "/home/newqqagent/log"
|
||||||
|
|
||||||
|
func TestDiagTriage(t *testing.T) {
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]interface{}
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
|
||||||
|
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
|
||||||
|
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
|
||||||
|
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
|
||||||
|
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
r, _ := p.handleTriage(c.args)
|
||||||
|
m, ok := r.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: not a map", c.name)
|
||||||
|
}
|
||||||
|
if got, _ := m["class"].(string); got != c.want {
|
||||||
|
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagDB(t *testing.T) {
|
||||||
|
if _, err := os.Stat(realCfg); err != nil {
|
||||||
|
t.Skip("config.db not present, skipping")
|
||||||
|
}
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleDB: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
|
||||||
|
if m["integrity"] != "ok" {
|
||||||
|
t.Errorf("integrity = %v, want ok", m["integrity"])
|
||||||
|
}
|
||||||
|
if m["source_count"] == 0 {
|
||||||
|
t.Errorf("source_count == 0, expected LLM sources")
|
||||||
|
}
|
||||||
|
if got, _ := m["source_failed"].(int); got != 0 {
|
||||||
|
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLogScan(t *testing.T) {
|
||||||
|
if _, err := os.Stat(realLog); err != nil {
|
||||||
|
t.Skip("log dir not present, skipping")
|
||||||
|
}
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleLogScan(map[string]interface{}{
|
||||||
|
"log_dir": realLog,
|
||||||
|
"since_minutes": 60 * 24 * 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleLogScan: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagDelta(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
cur := t.TempDir()
|
||||||
|
sub := filepath.Join(base, "sub")
|
||||||
|
os.MkdirAll(sub, 0755)
|
||||||
|
|
||||||
|
// modified: same path, different content
|
||||||
|
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
|
||||||
|
// created
|
||||||
|
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
|
||||||
|
// deleted
|
||||||
|
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
|
||||||
|
// unchanged
|
||||||
|
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
|
||||||
|
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleDelta: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
sum := m["summary"].(map[string]int)
|
||||||
|
t.Logf("summary=%v total=%v", sum, m["total_diff"])
|
||||||
|
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
|
||||||
|
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLoc(t *testing.T) {
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, _ := p.handleLoc(map[string]interface{}{
|
||||||
|
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||||
|
"db": map[string]interface{}{"verdict": "ok"},
|
||||||
|
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||||
|
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||||
|
})
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
var dec map[string]interface{}
|
||||||
|
json.Unmarshal(raw, &dec)
|
||||||
|
hs := dec["ranked_hypotheses"].([]interface{})
|
||||||
|
if len(hs) == 0 {
|
||||||
|
t.Fatal("no hypotheses")
|
||||||
|
}
|
||||||
|
top := hs[0].(map[string]interface{})
|
||||||
|
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
|
||||||
|
if top["cause"] != "code_panic_loop" {
|
||||||
|
t.Errorf("expected code_panic_loop, got %v", top["cause"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLocPersist(t *testing.T) {
|
||||||
|
kb := filepath.Join(t.TempDir(), "recovery_kb")
|
||||||
|
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"persist": true,
|
||||||
|
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||||
|
"db": map[string]interface{}{"verdict": "ok"},
|
||||||
|
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||||
|
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||||
|
}
|
||||||
|
if _, err := p.handleLoc(args); err != nil {
|
||||||
|
t.Fatalf("handleLoc: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(kb)
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
|
||||||
|
if !strings.Contains(string(data), `"cause"`) {
|
||||||
|
t.Errorf("persisted file missing cause field: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
example/recoverydiag/plg.json
Normal file
21
example/recoverydiag/plg.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "recoverydiag",
|
||||||
|
"name_zh": "恢复诊断",
|
||||||
|
"name_en": "Recovery Diagnostics",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"diag",
|
||||||
|
"recovery",
|
||||||
|
"diagnostics",
|
||||||
|
"triage",
|
||||||
|
"failback"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
1023
example/recoverydiag/plugin.go
Normal file
1023
example/recoverydiag/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,11 +2,16 @@
|
|||||||
"name": "rss",
|
"name": "rss",
|
||||||
"name_zh": "RSS订阅",
|
"name_zh": "RSS订阅",
|
||||||
"name_en": "RSS",
|
"name_en": "RSS",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
"tags": [
|
||||||
|
"rss",
|
||||||
|
"feed",
|
||||||
|
"subscription",
|
||||||
|
"monitor"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
|
|||||||
SeenGUIDs: p.seenGUIDs,
|
SeenGUIDs: p.seenGUIDs,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.dataFile(), b, 0644)
|
atomicWriteJSON(p.dataFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||||
@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user