From bd1d5fff2b517833e04dca837acc7954ae82ddb5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 17:18:41 +0800 Subject: [PATCH] =?UTF-8?q?chore(vendor):=20=E4=B8=BB=E4=BB=93=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=B7=9F=E8=B8=AA=20SDK=20=E7=A4=BA=E4=BE=8B=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=88=E5=86=B3=E7=AD=96=20A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 `.gitignore` 第 26-45 行早已写明「外部插件与工具链维护在独立 SDK 仓 (决策 sdk_repo_only),本仓经 go.mod 的 replace 引用」,并忽略了 example/、tools/、docs/、site_build/、package/、scripts/。 但有 29 个文件**早于该规则**被跟踪,靠「已跟踪文件不受 .gitignore 影响」留着,注释还特意写了「这是有意的,不要『修』」。 本轮修 example/bili 时踩到了:那 87 行改动先落在主仓、再手动同步到 SDK 仓 —— 同一份代码两个仓各改一遍,正是这个遗留结构的成本。 ## 核实:主仓到底需要什么 - 主仓 Go 代码只 import 两个包:`homeagent-sdk/sdk`(插件入口)、 `homeagent-sdk/meta`(版本号) - `remotedevice/` 是 C 库,主仓 C 侧明确「不链接任何外部库」, 只有注释里提到它 - `tools/hmapdev/yaegi` 的 import 出现在 **SDK 仓自己的文件之间** (yaegi/interp.go → yaegi/mocksdk),不是主仓依赖; 且 tools/ 本就在 .gitignore 里,从未被跟踪 所以 29 个文件全部可以删。实际仓库负担也只有 42 个文件 / 0.6MB (工作树里那 971MB 绝大部分是未跟踪的构建产物,不是仓库体积)。 ## 改动 - 删 21 个 example/ 文件(10 个示例的 plg.json + plugin.go + qq/plugin_test.go) - 删 8 个 remotedevice/ C 文件 - 工作树里一并清掉(不受跟踪的构建产物顺带回收) 构建与全量测试均通过。 ## 判据:3 条 + 变异(internal/meta/vendored_sdk_test.go) - TestVendoredSDKHasNoExampleOrRemotedevice:防止示例代码被重新提交进来 - TestVendoredSDKKeepsRequiredPackages:**反向**保护,防止为省事把 sdk/ 与 meta/ 也删掉(上一条只防"多了",删过头要靠这条) - TestGoModStillReplacesSDKToVendoredPath:决策 A 依赖 replace 指向 vendored 路径 ★ 判据自己踩了两个坑,都靠实跑抓出来: 1. `git ls-files` 的路径参数**相对当前目录**解析,而测试跑在 internal/meta/ 下 ⇒ 就地执行返回空,表现为「必需包全都不在」的假红。 改用 `git -C <仓库根>`。 2. 把 tools/hmapdev/yaegi 当成主仓依赖写进必需清单 ⇒ 又一次假红。 根因是把 SDK 内部的引用误当成主仓依赖(它本就在 .gitignore 里)。 变异验证:塞一个 example 文件进版本控制 → 判红。 --- internal/meta/vendored_sdk_test.go | 126 + .../homeagent-sdk/example/a2a/plg.json | 19 - .../homeagent-sdk/example/a2a/plugin.go | 665 ---- .../homeagent-sdk/example/acp/plg.json | 19 - .../homeagent-sdk/example/acp/plugin.go | 645 ---- .../homeagent-sdk/example/ai_image/plg.json | 20 - .../homeagent-sdk/example/ai_image/plugin.go | 434 --- .../homeagent-sdk/example/bili/plg.json | 19 - .../homeagent-sdk/example/bili/plugin.go | 355 -- .../homeagent-sdk/example/browser/plg.json | 21 - .../homeagent-sdk/example/browser/plugin.go | 1427 ------- .../homeagent-sdk/example/calendar/plg.json | 20 - .../homeagent-sdk/example/calendar/plugin.go | 1192 ------ .../homeagent-sdk/example/memo/plg.json | 19 - .../homeagent-sdk/example/memo/plugin.go | 515 --- third_party/homeagent-sdk/example/qq/plg.json | 18 - .../homeagent-sdk/example/qq/plugin.go | 3426 ----------------- .../homeagent-sdk/example/qq/plugin_test.go | 433 --- .../example/recoverydiag/plg.json | 21 - .../example/recoverydiag/plugin.go | 1023 ----- .../homeagent-sdk/example/rss/plg.json | 20 - .../homeagent-sdk/example/rss/plugin.go | 501 --- .../homeagent-sdk/remotedevice/CMakeLists.txt | 116 - .../remotedevice/include/ha_remotedevice.h | 216 -- .../homeagent-sdk/remotedevice/src/ha_json.c | 369 -- .../homeagent-sdk/remotedevice/src/ha_json.h | 107 - .../remotedevice/src/ha_remotedevice.c | 628 --- .../homeagent-sdk/remotedevice/src/ha_ws.c | 325 -- .../homeagent-sdk/remotedevice/src/ha_ws.h | 62 - .../remotedevice/test/test_ha_remotedevice.c | 1509 -------- 30 files changed, 126 insertions(+), 14144 deletions(-) create mode 100644 internal/meta/vendored_sdk_test.go delete mode 100644 third_party/homeagent-sdk/example/a2a/plg.json delete mode 100644 third_party/homeagent-sdk/example/a2a/plugin.go delete mode 100644 third_party/homeagent-sdk/example/acp/plg.json delete mode 100644 third_party/homeagent-sdk/example/acp/plugin.go delete mode 100644 third_party/homeagent-sdk/example/ai_image/plg.json delete mode 100644 third_party/homeagent-sdk/example/ai_image/plugin.go delete mode 100644 third_party/homeagent-sdk/example/bili/plg.json delete mode 100644 third_party/homeagent-sdk/example/bili/plugin.go delete mode 100644 third_party/homeagent-sdk/example/browser/plg.json delete mode 100644 third_party/homeagent-sdk/example/browser/plugin.go delete mode 100644 third_party/homeagent-sdk/example/calendar/plg.json delete mode 100644 third_party/homeagent-sdk/example/calendar/plugin.go delete mode 100644 third_party/homeagent-sdk/example/memo/plg.json delete mode 100644 third_party/homeagent-sdk/example/memo/plugin.go delete mode 100644 third_party/homeagent-sdk/example/qq/plg.json delete mode 100644 third_party/homeagent-sdk/example/qq/plugin.go delete mode 100644 third_party/homeagent-sdk/example/qq/plugin_test.go delete mode 100644 third_party/homeagent-sdk/example/recoverydiag/plg.json delete mode 100644 third_party/homeagent-sdk/example/recoverydiag/plugin.go delete mode 100644 third_party/homeagent-sdk/example/rss/plg.json delete mode 100644 third_party/homeagent-sdk/example/rss/plugin.go delete mode 100644 third_party/homeagent-sdk/remotedevice/CMakeLists.txt delete mode 100644 third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h delete mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_json.c delete mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_json.h delete mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c delete mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_ws.c delete mode 100644 third_party/homeagent-sdk/remotedevice/src/ha_ws.h delete mode 100644 third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c diff --git a/internal/meta/vendored_sdk_test.go b/internal/meta/vendored_sdk_test.go new file mode 100644 index 0000000..2448a76 --- /dev/null +++ b/internal/meta/vendored_sdk_test.go @@ -0,0 +1,126 @@ +package meta_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ===== 主仓不再 vendoring SDK 的示例代码 ===== +// +// 决策(对应 .gitignore 里的 sdk_repo_only):外部插件与工具链只在 SDK 仓 +// 维护,主仓经 go.mod 的 replace 引用,**只保留编译所需的那几个包**。 +// +// 曾经的状况:主仓跟踪了 29 个 SDK 示例文件(example/*/plg.json、plugin.go、 +// qq/plugin_test.go,加 remotedevice/ 的 8 个 C 文件)。它们不参与主仓构建 +// (唯一 import 的 SDK 包是 sdk/ 与 meta/),却要在两个仓里同步同一份代码。 +// 本轮修 example/bili 时的重复劳动就是这么来的。 +// +// 本测试钉住这个决策:有人再把示例代码提交进主仓时立刻变红。 +// ★ 必须用 git ls-files 判断「**版本控制里有没有**」而不是看目录是否存在 —— +// 工作树里可能因为 vendoring 或本地试验而临时存在,那不算回归。 + +// vendoredSDKRoot 是 vendored SDK 在主仓里的位置。 +const vendoredSDKRoot = "third_party/homeagent-sdk" + +// repoRoot 返回主仓根目录(本测试包在 internal/meta/,往上是两层)。 +func repoRoot(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("解析仓库根: %v", err) + } + return abs +} + +// gitTracked 列出 git 版本控制里该路径下的文件(相对仓库根)。 +// +// ★ 必须用 `git -C <仓库根>` 而不是就地执行:git ls-files 的路径参数 +// +// 是**相对当前目录**解析的,而这个测试运行在 internal/meta/ 下, +// 就地执行会返回空 —— 表现为"必需包全都不在"的假红(我第一版就这样)。 +func gitTracked(t *testing.T, pathPrefix string) []string { + t.Helper() + out, err := exec.Command("git", "-C", repoRoot(t), "ls-files", pathPrefix).Output() + if err != nil { + // 不是 git 仓库 / git 不可用:跳过而不是误报。 + if _, statErr := os.Stat(filepath.Join(repoRoot(t), ".git")); statErr != nil { + t.Skip("非 git 仓库,跳过结构守卫") + } + t.Fatalf("git ls-files %s: %v", pathPrefix, err) + } + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line = strings.TrimSpace(line); line != "" { + files = append(files, line) + } + } + return files +} + +// example/ 与 remotedevice/ 不该再被主仓跟踪。 +func TestVendoredSDKHasNoExampleOrRemotedevice(t *testing.T) { + for _, sub := range []string{"example", "remotedevice"} { + files := gitTracked(t, vendoredSDKRoot+"/"+sub) + if len(files) > 0 { + t.Errorf("主仓仍在跟踪 %s/ 下的 %d 个文件(决策:示例代码只在 SDK 仓维护):\n %s\n"+ + " 这些文件不参与主仓构建(唯一 import 的 SDK 包是 sdk/ 与 meta/),\n"+ + " 却要在两个仓同步同一份代码 —— 本轮修 example/bili 时已因此重复劳动。\n"+ + " 修复:git rm -r --cached %s/%s", + sub, len(files), strings.Join(files, "\n "), vendoredSDKRoot, sub) + } + } +} + +// 反向保护:编译真正需要的包必须在,否则删过头会编译失败。 +// +// 这条不能省:上一条只防"多了",若有人为了省事把 sdk/ 或 meta/ 也删了, +// 上一条照样绿,而 go build ./... 会在 CI 里才炸。 +func TestVendoredSDKKeepsRequiredPackages(t *testing.T) { + required := []string{ + "sdk", // 公开契约:所有插件的入口 + "sdk/plugin.go", // 具体文件:确保不是空目录 + "meta", // 版本号:主仓与 SDK 版本一致性检查 + "meta/meta.go", + // ★ 不列 tools/hmapdev/yaegi:.gitignore 第 33 行忽略了 + // third_party/homeagent-sdk/tools/,它从来没被跟踪过。 + // 而 grep 到的 yaegi 引用是 **SDK 仓自己的文件**在互相 import + // (tools/hmapdev/yaegi/interp.go → .../mocksdk),不是主仓依赖。 + // 我第一版把它当主仓依赖写进来,判据直接假红。 + } + tracked := map[string]bool{} + for _, f := range gitTracked(t, vendoredSDKRoot) { + tracked[f] = true + } + for _, req := range required { + // 支持两种形态:目录(下面有文件)或直接是文件 + found := tracked[vendoredSDKRoot+"/"+req] + if !found { + // 目录形态:检查它下面是否有被跟踪的文件 + for f := range tracked { + if strings.HasPrefix(f, vendoredSDKRoot+"/"+req+"/") { + found = true + break + } + } + } + if !found { + t.Errorf("必需包 %s 不在主仓 vendored 范围内(删过头了):%s/%s", + req, vendoredSDKRoot, req) + } + } +} + +// go.mod 的 replace 必须仍指向 vendored 路径 —— 决策 A 依赖它。 +func TestGoModStillReplacesSDKToVendoredPath(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repoRoot(t), "go.mod")) + if err != nil { + t.Fatalf("读 go.mod: %v", err) + } + content := string(data) + if !strings.Contains(content, vendoredSDKRoot) { + t.Errorf("go.mod 里没有 replace 到 %s:\n%s", vendoredSDKRoot, content) + } +} diff --git a/third_party/homeagent-sdk/example/a2a/plg.json b/third_party/homeagent-sdk/example/a2a/plg.json deleted file mode 100644 index 403f3c5..0000000 --- a/third_party/homeagent-sdk/example/a2a/plg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "a2a", - "name_zh": "A2A 代理通信", - "name_en": "A2A Agent Communication", - "version": "1.3.1", - "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "a2a", - "agent", - "interop" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/a2a/plugin.go b/third_party/homeagent-sdk/example/a2a/plugin.go deleted file mode 100644 index a68eb9c..0000000 --- a/third_party/homeagent-sdk/example/a2a/plugin.go +++ /dev/null @@ -1,665 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - srvMu sync.Mutex - server *http.Server - serverAddr string - - // 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护 - // 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。 - sessMu sync.Mutex - sessions map[string]*a2aSession -} - -// a2aSession 记录一个会话的轮次历史,用于延续上下文。 -type a2aSession struct { - ID string - History []string // 轮次文本 [user1, agent1, user2, agent2, ...] - LastUsed time.Time -} - -// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。 -const maxSessionTurns = 10 - -// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。 -const sessionGCPeriod = 30 * time.Minute - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.sessions = make(map[string]*a2aSession) - // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) - tp := p.name + "_" - - // 注册自身为输出通道:agent 回复 emit 到本通道时有落点, - // 且 output_list_channels 可见(agent 能主动向 a2a 会话推送消息)。 - if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { - payload, _ := args["payload"].(string) - log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120)) - return map[string]interface{}{"status": "ok"}, nil - }); err != nil { - log.Printf("[%s] register output channel: %v", p.name, err) - } - - // 会话 GC:后台周期回收长期不用的会话 - go p.sessionGCLoop() - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "listen", Default: "127.0.0.1:12000", - Type: "string", DisplayName: "监听地址", - Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务", - Category: p.name, - }) - - // Outbound: query + discover - s.RegisterTool(tp+"a2a_query", sdk.ToolDef{ - Name: tp + "a2a_query", Description: "向另一个 A2A Agent 发送查询并获取回复", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"}, - "query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"}, - "session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"}, - "timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"}, - }, - "required": []string{"agent_url", "query"}, - }, - Cleaner: func(output string) string { - var r struct{ Content string } - if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { - return r.Content - } - return output - }, - }, p.handleA2AQuery) - - s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{ - Name: tp + "a2a_discover", Description: "获取另一个 A2A Agent 的能力描述(Agent Card)", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"}, - }, - "required": []string{"agent_url"}, - }, - }, p.handleA2ADiscover) - - // Management tools - s.RegisterTool(tp+"a2a_configure", sdk.ToolDef{ - Name: tp + "a2a_configure", Description: "修改 A2A 插件配置并自动重启服务。支持动态更改监听地址等参数。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12000,设为空字符串禁用 HTTP 服务)"}, - }, - }, - }, p.handleConfigure) - - s.RegisterTool(tp+"a2a_restart", sdk.ToolDef{ - Name: tp + "a2a_restart", Description: "重启 A2A HTTP 服务端。当连接异常或配置变更后需要重新加载时使用。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleRestart) - - s.RegisterTool(tp+"a2a_status", sdk.ToolDef{ - Name: tp + "a2a_status", Description: "查看 A2A 插件的运行状态,包括监听地址和当前配置。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleStatus) - - // Inbound HTTP server - if addr, _ := s.Settings().Get("listen"); addr != nil { - if addrStr, ok := addr.(string); ok && addrStr != "" { - if err := p.startServer(addrStr); err != nil { - log.Printf("[%s] start A2A server: %v", p.name, err) - } - } - } - - log.Printf("[%s] started", p.name) - return nil -} - -func (p *Plugin) Stop() error { - p.stopServer() - return nil -} - -// sessionGCLoop 周期清理超时会话。 -func (p *Plugin) sessionGCLoop() { - ticker := time.NewTicker(sessionGCPeriod) - defer ticker.Stop() - for range ticker.C { - p.sessMu.Lock() - for id, sess := range p.sessions { - if time.Since(sess.LastUsed) > 2*time.Hour { - delete(p.sessions, id) - } - } - p.sessMu.Unlock() - } -} - -func truncateRunes(s string, n int) string { - r := []rune(s) - if len(r) <= n { - return s - } - return string(r[:n]) + "..." -} - -// sessionMessages 返回指定会话的近 limit 条消息(时间正序), -// 会话不存在返回 nil。消息格式 [{role, text, ts}]。 -func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} { - p.sessMu.Lock() - sess := p.sessions[sessionID] - var hist []string - var lastUsed time.Time - if sess != nil { - hist = append([]string{}, sess.History...) - lastUsed = sess.LastUsed - } - p.sessMu.Unlock() - if sess == nil { - return nil - } - _ = lastUsed - // History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序 - start := 0 - if len(hist) > limit { - start = len(hist) - limit - } - msgs := make([]map[string]interface{}, 0, len(hist)-start) - for i := start; i < len(hist); i++ { - role, text := "user", hist[i] - if after, ok := strings.CutPrefix(text, "用户: "); ok { - role, text = "user", after - } else if after, ok := strings.CutPrefix(text, "助手: "); ok { - role, text = "agent", after - } - msgs = append(msgs, map[string]interface{}{ - "role": role, - "text": text, - }) - } - return msgs -} - -func (p *Plugin) stopServer() { - p.srvMu.Lock() - defer p.srvMu.Unlock() - if p.server != nil { - p.server.Close() - p.server = nil - p.serverAddr = "" - } -} - -// ---- Inbound HTTP Server ---- - -func (p *Plugin) startServer(addr string) error { - mux := http.NewServeMux() - mux.HandleFunc("/agent-card", p.handleAgentCard) - mux.HandleFunc("/task", p.handleIncomingTask) - mux.HandleFunc("/a2a", p.handleIncomingA2A) - - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("listen %s: %v", addr, err) - } - - srv := &http.Server{ - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 120 * time.Second, - IdleTimeout: 60 * time.Second, - } - addrStr := listener.Addr().String() - - p.srvMu.Lock() - if p.server != nil { - p.server.Close() - } - p.server = srv - p.serverAddr = addrStr - p.srvMu.Unlock() - - go func() { - log.Printf("[%s] A2A server on %s", p.name, addrStr) - if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { - log.Printf("[%s] serve: %v", p.name, err) - } - }() - return nil -} - -func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) { - card := map[string]interface{}{ - "name": p.name, - "description": "HomeAgent A2A Agent - 支持多工具调用与记忆管理", - "url": r.Host, - "version": "1.0.0", - "capabilities": []map[string]string{ - {"id": "a2a_query", "name": "查询", "description": "接收并处理文本查询"}, - {"id": "a2a_stream", "name": "流式响应", "description": "支持 SSE 流式回复"}, - }, - "skills": []map[string]string{ - {"id": "chat", "name": "对话", "description": "通用对话与问题回答"}, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(card) -} - -func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - p.handleAgentCard(w, r) - return - } - body, _ := io.ReadAll(r.Body) - var req struct { - JSONRPC string `json:"jsonrpc"` - ID string `json:"id"` - Method string `json:"method"` - Params struct { - Query string `json:"query,omitempty"` - SessionID string `json:"session_id,omitempty"` - Limit int `json:"limit,omitempty"` - Message *struct { - Role string `json:"role"` - Parts []struct { - Text string `json:"text,omitempty"` - Type string `json:"type,omitempty"` - } `json:"parts"` - } `json:"message,omitempty"` - } `json:"params,omitempty"` - } - json.Unmarshal(body, &req) - - switch req.Method { - case "tasks.send": - // Extract query text - queryText := req.Params.Query - if queryText == "" && req.Params.Message != nil { - for _, part := range req.Params.Message.Parts { - if part.Text != "" { - queryText += part.Text + "\n" - } - } - queryText = strings.TrimSpace(queryText) - } - if queryText == "" { - http.Error(w, "query/message.text required", http.StatusBadRequest) - return - } - - // 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。 - sessionID := strings.TrimSpace(req.Params.SessionID) - injectText := queryText - p.sessMu.Lock() - if sessionID != "" { - sess := p.sessions[sessionID] - if sess == nil { - sess = &a2aSession{ID: sessionID, LastUsed: time.Now()} - p.sessions[sessionID] = sess - } - sess.LastUsed = time.Now() - // 有历史则把上下文拼在前面(截尾防爆量) - if len(sess.History) > 0 { - ctxText := strings.Join(sess.History, "\n") - injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText - } - } else { - sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano()) - p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()} - } - p.sessMu.Unlock() - - // 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、 - // 也不再回 202 让请求方永远等不到结果)。HTTP 超时由调用方控制。 - reply := p.sdk.InjectInputSync(p.name, p.name, - fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name)) - - // 回复写回会话历史(下一轮作为上下文) - p.sessMu.Lock() - if sess := p.sessions[sessionID]; sess != nil { - sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply) - if len(sess.History) > maxSessionTurns*2 { - sess.History = sess.History[len(sess.History)-maxSessionTurns*2:] - } - sess.LastUsed = time.Now() - } - p.sessMu.Unlock() - - resp := map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": map[string]interface{}{ - "id": fmt.Sprintf("task_%d", time.Now().UnixNano()), - "status": "completed", - "session_id": sessionID, - "message": map[string]interface{}{ - "role": "agent", - "parts": []map[string]string{{"type": "text", "text": reply}}, - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - - case "tasks.get", "session.get": - // 按 session_id 返回会话内近 N 条消息(默认 10 条)。 - sessionID := strings.TrimSpace(req.Params.SessionID) - if sessionID == "" { - sessionID = strings.TrimSpace(req.Params.Query) - } - limit := 10 - if req.Params.Limit > 0 && req.Params.Limit <= 100 { - limit = req.Params.Limit - } - msgs := p.sessionMessages(sessionID, limit) - if msgs == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{ - "session_id": sessionID, - "status": "not_found", - "messages": []interface{}{}, - }, - }) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{ - "session_id": sessionID, - "status": "completed", - "messages": msgs, - }, - }) - - default: - http.Error(w, "unknown method", http.StatusBadRequest) - } -} - -func (p *Plugin) handleIncomingTask(w http.ResponseWriter, r *http.Request) { - p.handleIncomingA2A(w, r) -} - -// ---- A2A Protocol Types ---- - -type A2AAgentCard struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - URL string `json:"url"` - Version string `json:"version,omitempty"` - Capabilities []A2ACapability `json:"capabilities,omitempty"` - Skills []A2ASkill `json:"skills,omitempty"` -} - -type A2ACapability struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` -} - -type A2ASkill struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema string `json:"input_schema,omitempty"` -} - -type A2ARequest struct { - JSONRPC string `json:"jsonrpc"` - ID string `json:"id"` - Method string `json:"method"` - Params A2AParams `json:"params,omitempty"` -} - -type A2AParams struct { - Query string `json:"query,omitempty"` - SessionID string `json:"session_id,omitempty"` - Message *A2AMessage `json:"message,omitempty"` - TaskID string `json:"id,omitempty"` -} - -type A2AResponse struct { - JSONRPC string `json:"jsonrpc"` - ID string `json:"id"` - Result *A2AResult `json:"result,omitempty"` - Error *A2AError `json:"error,omitempty"` -} - -type A2AResult struct { - TaskID string `json:"id,omitempty"` - Status string `json:"status,omitempty"` - SessionID string `json:"session_id,omitempty"` - Message *A2AMessage `json:"message,omitempty"` - AgentCard *A2AAgentCard `json:"agent_card,omitempty"` -} - -type A2AMessage struct { - Role string `json:"role"` - Parts []A2APart `json:"parts"` -} - -type A2APart struct { - Text string `json:"text,omitempty"` - Data string `json:"data,omitempty"` - Type string `json:"type,omitempty"` -} - -type A2AError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// ---- Outbound Handlers ---- - -func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, error) { - agentURL, _ := args["agent_url"].(string) - agentURL = strings.TrimRight(agentURL, "/") - if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") { - agentURL = "http://" + agentURL - } - - cardURL := agentURL - if !strings.HasSuffix(cardURL, "/agent-card") { - cardURL = agentURL + "/agent-card" - } - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Get(cardURL) - if err != nil { - return map[string]interface{}{"error": fmt.Sprintf("连接失败: %v", err)}, nil - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil - } - - var card A2AAgentCard - if err := json.Unmarshal(body, &card); err != nil { - var fallback map[string]interface{} - if err2 := json.Unmarshal(body, &fallback); err2 == nil { - return map[string]interface{}{"agent_info": fallback, "format": "非标准格式"}, nil - } - return map[string]interface{}{"error": fmt.Sprintf("解析失败: %v", err), "raw_body": string(body)}, nil - } - - return map[string]interface{}{ - "name": card.Name, "description": card.Description, - "version": card.Version, "url": card.URL, - "capabilities": card.Capabilities, "skills": card.Skills, - }, nil -} - -func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) { - agentURL, _ := args["agent_url"].(string) - query, _ := args["query"].(string) - sessionID, _ := args["session_id"].(string) // 可选:延续对方会话 - timeoutSec := 60 - if v, ok := args["timeout"].(float64); ok && v > 0 { - timeoutSec = int(v) - } - - agentURL = strings.TrimRight(agentURL, "/") - if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") { - agentURL = "http://" + agentURL - } - - taskURL := agentURL - if strings.HasSuffix(agentURL, "/agent-card") { - taskURL = strings.TrimSuffix(agentURL, "/agent-card") - } - taskURL = strings.TrimRight(taskURL, "/") + "/task" - - reqBody := A2ARequest{ - JSONRPC: "2.0", - ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()), - Method: "tasks.send", - Params: A2AParams{ - SessionID: sessionID, - Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}}, - }, - } - - bodyData, _ := json.Marshal(reqBody) - client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} - resp, err := client.Post(taskURL, "application/json", bytes.NewReader(bodyData)) - if err != nil { - return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil - } - - var a2aResp A2AResponse - if err := json.Unmarshal(body, &a2aResp); err != nil { - return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err), "raw_body": string(body)}, nil - } - - if a2aResp.Error != nil { - return map[string]interface{}{"error": fmt.Sprintf("Agent错误 [%d]: %s", a2aResp.Error.Code, a2aResp.Error.Message)}, nil - } - if a2aResp.Result == nil { - return map[string]interface{}{"error": "空结果", "raw_body": string(body)}, nil - } - - var replyText string - if a2aResp.Result.Message != nil { - for _, part := range a2aResp.Result.Message.Parts { - if part.Text != "" { - replyText += part.Text + "\n" - } - } - replyText = strings.TrimSpace(replyText) - } - - result := map[string]interface{}{ - "task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status, - "response": replyText, - } - if a2aResp.Result.SessionID != "" || sessionID != "" { - result["session_id"] = a2aResp.Result.SessionID - if result["session_id"] == "" { - result["session_id"] = sessionID - } - result["note"] = "延续会话:下次调用传此 session_id 可保持上下文" - } - return result, nil -} - -// ---- Management Handlers ---- - -func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) { - listen, _ := args["listen"].(string) - listen = strings.TrimSpace(listen) - - if err := p.sdk.Settings().Set("listen", listen); err != nil { - return fmt.Sprintf("保存配置失败: %v", err), nil - } - - if listen == "" || listen == "off" || listen == "disabled" { - p.stopServer() - return "A2A HTTP 服务已禁用(listen 设为空)", nil - } - - if err := p.startServer(listen); err != nil { - return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil - } - return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil -} - -func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) { - p.stopServer() - - addr, _ := p.sdk.Settings().Get("listen") - addrStr, _ := addr.(string) - if addrStr == "" || addrStr == "off" || addrStr == "disabled" { - return "A2A 服务未配置监听地址(listen 为空),无法启动", nil - } - - if err := p.startServer(addrStr); err != nil { - return fmt.Sprintf("A2A 服务启动失败: %v", err), nil - } - - p.srvMu.Lock() - listening := p.serverAddr - p.srvMu.Unlock() - return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil -} - -func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) { - addr, _ := p.sdk.Settings().Get("listen") - addrStr, _ := addr.(string) - - p.srvMu.Lock() - serverRunning := p.server != nil - listening := p.serverAddr - p.srvMu.Unlock() - if !serverRunning { - listening = "未运行" - } - - return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s", - addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning]), nil -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} diff --git a/third_party/homeagent-sdk/example/acp/plg.json b/third_party/homeagent-sdk/example/acp/plg.json deleted file mode 100644 index b499e56..0000000 --- a/third_party/homeagent-sdk/example/acp/plg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "acp", - "name_zh": "ACP 代理通信", - "name_en": "ACP Agent Client Protocol", - "version": "1.2.1", - "description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "acp", - "agent", - "interop" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/acp/plugin.go b/third_party/homeagent-sdk/example/acp/plugin.go deleted file mode 100644 index ba6e8d3..0000000 --- a/third_party/homeagent-sdk/example/acp/plugin.go +++ /dev/null @@ -1,645 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集: -// - 服务端:POST /api/session (JSON-RPC:session/new / session/update), -// 请求注入本 Agent,另提供 GET /api/session?id=xxx SSE 事件流。 -// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。 -type Plugin struct { - name string - sdk *sdk.PluginSDK - srvMu sync.Mutex - server *http.Server - serverID string - - mu sync.RWMutex - sessions map[string]*sessionState -} - -type sessionState struct { - ID string - Replying []map[string]interface{} - History []string // 轮次历史 [user, agent, user, agent...],延续上下文用 - LastUsed time.Time -} - -// maxSessionTurns 单会话保留的最大轮次对数。 -const maxSessionTurns = 10 - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.sessions = make(map[string]*sessionState) - // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) - tp := p.name + "_" - - // 注册自身为输出通道:agent 回复 emit 到本通道时有落点。 - // (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp) - s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { - payload, _ := args["payload"].(string) - log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120)) - return map[string]interface{}{"status": "ok"}, nil - }) - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "listen", Default: "127.0.0.1:12001", - Type: "string", DisplayName: "监听地址", - Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务", - Category: p.name, - }) - - s.RegisterTool(tp+"acp_query", sdk.ToolDef{ - Name: tp + "acp_query", Description: "向远程 ACP Agent(如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001)发起一个会话请求并等待回复,返回其最终回答文本,兼容 SSE 型与同步 JSON 型 ACP 服务端", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"}, - "prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"}, - "session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"}, - "timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"}, - }, - "required": []string{"server_url", "prompt"}, - }, - Cleaner: func(output string) string { - var r struct { - Reply string `json:"reply"` - } - if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" { - return r.Reply - } - return output - }, - }, p.handleAcpQuery) - - s.RegisterTool(tp+"acp_configure", sdk.ToolDef{ - Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001,设为空禁用)"}, - }, - }, - }, p.handleConfigure) - - s.RegisterTool(tp+"acp_status", sdk.ToolDef{ - Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleStatus) - - addr, _ := s.Settings().Get("listen") - if addrStr, ok := addr.(string); ok && addrStr != "" { - if err := p.startServer(addrStr); err != nil { - log.Printf("[%s] start ACP server: %v", p.name, err) - } - } - - log.Printf("[%s] started", p.name) - return nil -} - -func (p *Plugin) Stop() error { - p.stopServer() - return nil -} - -func (p *Plugin) stopServer() { - p.srvMu.Lock() - defer p.srvMu.Unlock() - if p.server != nil { - p.server.Close() - p.server = nil - p.serverID = "" - } -} - -// ---- Inbound HTTP Server ---- - -func (p *Plugin) startServer(addr string) error { - mux := http.NewServeMux() - mux.HandleFunc("/api/session", p.handleSession) - - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("listen %s: %v", addr, err) - } - - srv := &http.Server{Handler: mux} - addrStr := listener.Addr().String() - - p.srvMu.Lock() - if p.server != nil { - p.server.Close() - } - p.server = srv - p.serverID = addrStr - p.srvMu.Unlock() - - go func() { - log.Printf("[%s] ACP server on %s", p.name, addrStr) - if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { - log.Printf("[%s] serve: %v", p.name, err) - } - }() - return nil -} - -func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "POST": - p.handleSessionPost(w, r) - case "GET": - p.handleSessionSSE(w, r) - default: - http.Error(w, "", http.StatusMethodNotAllowed) - } -} - -// handleSessionPost 处理 JSON-RPC:session/new 与 session/update -func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - var req struct { - JSONRPC string `json:"jsonrpc"` - ID interface{} `json:"id"` - Method string `json:"method"` - Params struct { - Request *struct { - Text string `json:"text"` - } `json:"request,omitempty"` - SessionID string `json:"session_id,omitempty"` - Limit int `json:"limit,omitempty"` - Final bool `json:"final,omitempty"` - } `json:"params,omitempty"` - } - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, "invalid json-rpc", http.StatusBadRequest) - return - } - - switch req.Method { - case "session/new": - text := "" - if req.Params.Request != nil { - text = strings.TrimSpace(req.Params.Request.Text) - } - if text == "" { - http.Error(w, "request.text required", http.StatusBadRequest) - return - } - - // 会话:调用方可指定 session_id 延续多轮;不指定则新建。 - sid := strings.TrimSpace(req.Params.SessionID) - p.mu.Lock() - if sid != "" { - if _, exists := p.sessions[sid]; !exists { - p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()} - } - } else { - sid = fmt.Sprintf("session_%d", time.Now().UnixNano()) - p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()} - } - st := p.sessions[sid] - p.mu.Unlock() - - // 延续上下文 - injectText := text - p.mu.Lock() - if len(st.History) > 0 { - ctxText := strings.Join(st.History, "\n") - injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text - } - p.mu.Unlock() - - // 同步注入等待回复:不抢占打断,完整闭环返回文本。 - reply := "" - if p.sdk != nil { - reply = p.sdk.InjectInputSync(p.name, p.name, - fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name)) - } - - // 写回历史 + 填充 Replying 供 SSE 消费 - p.mu.Lock() - st.History = append(st.History, "用户: "+text, "助手: "+reply) - if len(st.History) > maxSessionTurns*2 { - st.History = st.History[len(st.History)-maxSessionTurns*2:] - } - st.LastUsed = time.Now() - if reply != "" { - st.Replying = append(st.Replying, map[string]interface{}{ - "type": "reply", "text": reply, - }) - } - p.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{ - "session": map[string]interface{}{"id": sid}, - "reply": reply, - }, - }) - - case "session/get": - // 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序) - sid := req.Params.SessionID - p.mu.RLock() - st := p.sessions[sid] - var hist []string - if st != nil { - hist = append([]string{}, st.History...) - } - p.mu.RUnlock() - if st == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{ - "session_id": sid, - "status": "not_found", - "messages": []interface{}{}, - }, - }) - return - } - limit := 10 - if req.Params.Limit > 0 && req.Params.Limit <= 100 { - limit = req.Params.Limit - } - start := 0 - if len(hist) > limit { - start = len(hist) - limit - } - msgs := make([]map[string]interface{}, 0, len(hist)-start) - for i := start; i < len(hist); i++ { - role, text := "user", hist[i] - if after, ok := strings.CutPrefix(text, "用户: "); ok { - role, text = "user", after - } else if after, ok := strings.CutPrefix(text, "助手: "); ok { - role, text = "agent", after - } - msgs = append(msgs, map[string]interface{}{ - "role": role, - "text": text, - }) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{ - "session_id": sid, - "status": "completed", - "messages": msgs, - }, - }) - - case "session/update": - sid := req.Params.SessionID - p.mu.Lock() - st := p.sessions[sid] - p.mu.Unlock() - if st == nil { - http.Error(w, "session not found", http.StatusNotFound) - return - } - if req.Params.Final { - // 客户端结束会话:标记并保留历史(后续可再 session/new 续) - p.mu.Lock() - st.LastUsed = time.Now() - p.mu.Unlock() - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{"final": true}, - }) - - case "session/cancel": - p.mu.Lock() - delete(p.sessions, req.Params.SessionID) - p.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", "id": req.ID, - "result": map[string]interface{}{"canceled": true}, - }) - - default: - http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest) - } -} - -// handleSessionSSE 提供 SSE 事件流订阅 -func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) { - sid := r.URL.Query().Get("id") - if sid == "" { - http.Error(w, "id query param required", http.StatusBadRequest) - return - } - - p.mu.RLock() - st := p.sessions[sid] - p.mu.RUnlock() - if st == nil { - http.Error(w, "session not found", http.StatusNotFound) - return - } - - fl, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming unsupported", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - for { - p.mu.RLock() - replies := append([]map[string]interface{}{}, st.Replying...) - p.mu.RUnlock() - for _, rep := range replies { - data, _ := json.Marshal(rep) - fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data) - fl.Flush() - } - p.mu.Lock() - st.Replying = nil - p.mu.Unlock() - - select { - case <-r.Context().Done(): - return - case <-ticker.C: - } - } -} - -// ---- Outbound:ACP 客户端 ---- - -// parseRPCBody 兼容 JSON 与 SSE 两种响应体 -func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) { - if strings.Contains(ct, "text/event-stream") { - sc := bufio.NewScanner(bytes.NewReader(body)) - var last string - for sc.Scan() { - line := strings.TrimRight(sc.Text(), "\r") - if strings.HasPrefix(line, "data:") { - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data != "" && data != "[DONE]" { - last = data - } - } - } - if last == "" { - return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200)) - } - body = []byte(last) - } - var raw json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300)) - } - return &raw, nil -} - -func truncateStr(s string, n int) string { - if len(s) > n { - return s[:n] + "..." - } - return s -} - -func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) { - serverURL, _ := args["server_url"].(string) - serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/") - if serverURL == "" { - return map[string]interface{}{"error": "server_url 不能为空"}, nil - } - if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") { - serverURL = "http://" + serverURL - } - prompt, _ := args["prompt"].(string) - prompt = strings.TrimSpace(prompt) - if prompt == "" { - return map[string]interface{}{"error": "prompt 不能为空"}, nil - } - sessionID, _ := args["session_id"].(string) // 可选:延续对方会话 - timeoutSec := 120 - if v, ok := args["timeout"].(float64); ok && v > 0 { - timeoutSec = int(v) - } - - endpoint := serverURL + "/api/session" - client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} - - params := map[string]interface{}{ - "request": map[string]interface{}{"text": prompt}, - } - if sessionID != "" { - params["session_id"] = sessionID - } - newBody, _ := json.Marshal(map[string]interface{}{ - "jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()), - "method": "session/new", - "params": params, - }) - - req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - - resp, err := client.Do(req) - if err != nil { - return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 { - return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil - } - - raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body) - if err != nil { - return map[string]interface{}{"error": err.Error()}, nil - } - var rpcResp struct { - Result *struct { - Session *struct { - ID string `json:"id"` - } `json:"session,omitempty"` - SessionID string `json:"sessionId,omitempty"` - Reply string `json:"reply,omitempty"` - } `json:"result,omitempty"` - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"error,omitempty"` - } - if err := json.Unmarshal(*raw, &rpcResp); err != nil { - return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil - } - if rpcResp.Error != nil { - return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil - } - if rpcResp.Result == nil { - return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil - } - - // 兼容两种协议: - // A) 标准/SSE 型(opencode、本插件服务端):result.session.id,回复经 SSE 事件流 - // B) 同步 JSON 型(pi bridge):result.sessionId + result.reply - if rpcResp.Result.Reply != "" { - return map[string]interface{}{ - "session_id": rpcResp.Result.SessionID, - "status": "completed", - "reply": rpcResp.Result.Reply, - }, nil - } - if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" { - return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil - } - sid := rpcResp.Result.Session.ID - - replyText := p.readSSEReply(endpoint, sid, client, timeoutSec) - - return map[string]interface{}{ - "session_id": sid, - "status": "completed", - "reply": replyText, - "note": "延续会话:下次调用传此 session_id 可保持上下文", - }, nil -} - -// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本 -func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string { - sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid) - req, _ := http.NewRequest("GET", sseURL, nil) - req.Header.Set("Accept", "text/event-stream") - - resp, err := client.Do(req) - if err != nil { - return fmt.Sprintf("(SSE 读取失败: %v)", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bb, _ := io.ReadAll(resp.Body) - return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200)) - } - - var sb strings.Builder - sc := bufio.NewScanner(resp.Body) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) - for sc.Scan() { - if time.Now().After(deadline) { - break - } - line := strings.TrimRight(sc.Text(), "\r") - if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" { - break - } - if strings.HasPrefix(line, "data:") { - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "" || data == "[DONE]" { - continue - } - var evt struct { - SessionID string `json:"session_id,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` - Message *struct { - Text string `json:"text"` - } `json:"message,omitempty"` - } - if json.Unmarshal([]byte(data), &evt) == nil { - text := evt.Text - if evt.Message != nil && evt.Message.Text != "" { - text = evt.Message.Text - } - if text != "" { - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(text) - } - } - } - } - if sb.Len() == 0 { - return "(未收到回复)" - } - return sb.String() -} - -// ---- Management ---- - -func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) { - listen, _ := args["listen"].(string) - listen = strings.TrimSpace(listen) - - if err := p.sdk.Settings().Set("listen", listen); err != nil { - return fmt.Sprintf("保存配置失败: %v", err), nil - } - - if listen == "" || listen == "off" || listen == "disabled" { - p.stopServer() - return "ACP HTTP 服务已禁用", nil - } - - if err := p.startServer(listen); err != nil { - return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil - } - return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil -} - -func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) { - addr, _ := p.sdk.Settings().Get("listen") - addrStr, _ := addr.(string) - - p.srvMu.Lock() - serverRunning := p.server != nil - listening := p.serverID - p.srvMu.Unlock() - - p.mu.RLock() - n := len(p.sessions) - p.mu.RUnlock() - - if !serverRunning { - listening = "未运行" - } - return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d", - addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} diff --git a/third_party/homeagent-sdk/example/ai_image/plg.json b/third_party/homeagent-sdk/example/ai_image/plg.json deleted file mode 100644 index cf36da2..0000000 --- a/third_party/homeagent-sdk/example/ai_image/plg.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "ai_image", - "name_zh": "AI绘图", - "name_en": "AI Image", - "version": "1.3.0", - "description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "ai", - "image", - "draw", - "generate" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/ai_image/plugin.go b/third_party/homeagent-sdk/example/ai_image/plugin.go deleted file mode 100644 index 612be0c..0000000 --- a/third_party/homeagent-sdk/example/ai_image/plugin.go +++ /dev/null @@ -1,434 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - client *http.Client - apiKey string - provider string - model string - size string - baseURL string - dataDir string // /ai_images:生成本地图片存放目录 -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, def T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return def - } - switch any(def).(type) { - case string: - if sv, ok := v.(string); ok { - return any(sv).(T) - } - case int64: - switch n := v.(type) { - case float64: - return any(int64(n)).(T) - case int64: - return any(n).(T) - case string: - if i, err := strconv.ParseInt(n, 10, 64); err == nil { - return any(i).(T) - } - } - case float64: - switch n := v.(type) { - case float64: - return any(n).(T) - case int64: - return any(float64(n)).(T) - case string: - if f, err := strconv.ParseFloat(n, 64); err == nil { - return any(f).(T) - } - } - } - return def -} - -func getArg[T string | int64 | float64](args map[string]interface{}, key string, def T) T { - v, ok := args[key] - if !ok || v == nil { - return def - } - switch any(def).(type) { - case string: - if s, ok := v.(string); ok { - return any(s).(T) - } - case int64: - switch n := v.(type) { - case float64: - return any(int64(n)).(T) - case int64: - return any(n).(T) - case string: - if i, err := strconv.ParseInt(n, 10, 64); err == nil { - return any(i).(T) - } - } - case float64: - switch n := v.(type) { - case float64: - return any(n).(T) - case int64: - return any(float64(n)).(T) - case string: - if f, err := strconv.ParseFloat(n, 64); err == nil { - return any(f).(T) - } - } - } - return def -} - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.client = &http.Client{Timeout: 120 * time.Second} - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "api_key", Default: "", Type: "string", - DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key", - Category: "ai_image", Secret: true, - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "base_url", Default: "", Type: "string", - DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com", - Category: "ai_image", - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "provider", Default: "openai", Type: "string", - DisplayName: "Provider", Description: "Image generation provider: openai / stability", - Category: "ai_image", - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "model", Default: "dall-e-3", Type: "string", - DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)", - Category: "ai_image", - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "size", Default: "1024x1024", Type: "string", - DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)", - Category: "ai_image", - }) - - p.apiKey = getSetting(s.Settings(), "api_key", "") - p.provider = getSetting(s.Settings(), "provider", "openai") - p.model = getSetting(s.Settings(), "model", "dall-e-3") - p.size = getSetting(s.Settings(), "size", "1024x1024") - p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/") - -// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。 -if p.sdk != nil { - if dd := s.Settings().DataDir(); dd != "" { - p.dataDir = dd - } -} -if p.dataDir == "" { - // 旧版内核无 DataDir API 时退到 /tmp - p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images") -} -os.MkdirAll(p.dataDir, 0755) - - tp := p.name + "_" - s.RegisterTool(tp+"generate", sdk.ToolDef{ - Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"}, - "size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"}, - "model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"}, - "n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"}, - }, - "required": []string{"prompt"}, - }, - }, p.handleGenerate) - - fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model) - return nil -} - -func (p *Plugin) Stop() error { - fmt.Printf("[%s] stopped\n", p.name) - return nil -} - -type openAIReq struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - N int `json:"n"` - Size string `json:"size"` - ResponseFormat string `json:"response_format"` -} - -type openAIResp struct { - Created int64 `json:"created"` - Data []struct { - RevisedPrompt string `json:"revised_prompt"` - URL string `json:"url"` - } `json:"data"` - Error *struct { - Message string `json:"message"` - Type string `json:"type"` - } `json:"error"` -} - -func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) { - prompt := getArg(args, "prompt", "") - if prompt == "" { - return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil - } - - key := getSetting(p.sdk.Settings(), "api_key", p.apiKey) - if key == "" { - return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil - } - - provider := getSetting(p.sdk.Settings(), "provider", p.provider) - model := getArg(args, "model", getSetting(p.sdk.Settings(), "model", p.model)) - size := getArg(args, "size", getSetting(p.sdk.Settings(), "size", p.size)) - n := getArg(args, "n", int64(1)) - if n < 1 { - n = 1 - } - if n > 10 { - n = 10 - } - - switch provider { - case "openai": - return p.generateOpenAI(prompt, model, size, int(n), key) - case "stability": - return p.generateStability(prompt, model, size, int(n), key) - default: - return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil - } -} - -func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) { - // 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀; - // 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。 - endpoint := "https://api.openai.com/v1/images/generations" - if p.baseURL != "" { - base := strings.TrimSuffix(p.baseURL, "/v1") - endpoint = base + "/v1/images/generations" - } - - body := openAIReq{ - Model: model, - Prompt: prompt, - N: n, - Size: size, - ResponseFormat: "url", - } - - log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model) - b, _ := json.Marshal(body) - req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - - resp, err := p.client.Do(req) - if err != nil { - return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - var result openAIResp - if err := json.Unmarshal(respBody, &result); err != nil { - return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil - } - - if result.Error != nil { - return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil - } - - if len(result.Data) == 0 { - return map[string]interface{}{"isError": true, "content": "No images returned"}, nil - } - - urls := make([]string, len(result.Data)) - for i, d := range result.Data { - urls[i] = d.URL - } - - // 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL): - // - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问 - // - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端) - localPaths := make([]string, len(urls)) - var errs []string - for i, u := range urls { - path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano())) - if err != nil { - errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err)) - continue - } - localPaths[i] = path - } - - content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model) - for _, pth := range localPaths { - if pth != "" { - content += "\n" + pth - } - } - if len(errs) > 0 { - content += "\n\n" + strings.Join(errs, "\n") - } - content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。" - return map[string]interface{}{ - "content": content, - "images": localPaths, - "prompt": prompt, - "model": model, - "local_paths": localPaths, - }, nil -} - -// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。 -// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。 -func (p *Plugin) downloadImage(url, baseName string) (string, error) { - dl := &http.Client{Timeout: 60 * time.Second} - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)") - resp, err := dl.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200]) - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - ext := ".png" - if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { - ext = ".jpg" - } else if strings.Contains(ct, "webp") { - ext = ".webp" - } - path := filepath.Join(p.dataDir, baseName+ext) - if err := os.WriteFile(path, data, 0644); err != nil { - return "", err - } - return path, nil -} - -type stabilityReq struct { - TextPrompts []stabilityPrompt `json:"text_prompts"` - Width int `json:"width"` - Height int `json:"height"` - Samples int `json:"samples"` -} - -type stabilityPrompt struct { - Text string `json:"text"` - Weight float64 `json:"weight,omitempty"` -} - -type stabilityArtifact struct { - Base64 string `json:"base64"` - Seed int `json:"seed"` -} - -type stabilityResp struct { - Artifacts []stabilityArtifact `json:"artifacts"` - Message string `json:"message,omitempty"` -} - -func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey string) (interface{}, error) { - width, height := 1024, 1024 - if parts := strings.Split(size, "x"); len(parts) == 2 { - if w, err := strconv.Atoi(parts[0]); err == nil { - width = w - } - if h, err := strconv.Atoi(parts[1]); err == nil { - height = h - } - } - - body := stabilityReq{ - TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}}, - Width: width, - Height: height, - Samples: n, - } - - apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image" - - b, _ := json.Marshal(body) - req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Accept", "application/json") - - resp, err := p.client.Do(req) - if err != nil { - return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil - } - - respBody, _ := io.ReadAll(resp.Body) - var result stabilityResp - if err := json.Unmarshal(respBody, &result); err != nil { - return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil - } - - if len(result.Artifacts) == 0 { - msg := result.Message - if msg == "" { - msg = "No images returned" - } - return map[string]interface{}{"isError": true, "content": msg}, nil - } - - urls := make([]string, len(result.Artifacts)) - for i, a := range result.Artifacts { - urls[i] = "data:image/png;base64," + a.Base64 - } - - return map[string]interface{}{ - "content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")), - "images": urls, - "prompt": prompt, - "model": model, - }, nil -} diff --git a/third_party/homeagent-sdk/example/bili/plg.json b/third_party/homeagent-sdk/example/bili/plg.json deleted file mode 100644 index f55ee72..0000000 --- a/third_party/homeagent-sdk/example/bili/plg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "bili", - "name_zh": "B站视频下载", - "name_en": "Bilibili Video Downloader", - "version": "1.2.0", - "description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "bili", - "video", - "download" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/bili/plugin.go b/third_party/homeagent-sdk/example/bili/plugin.go deleted file mode 100644 index 3564a8d..0000000 --- a/third_party/homeagent-sdk/example/bili/plugin.go +++ /dev/null @@ -1,355 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - proxy string - - // runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。 - // - // 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。 - // 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段, - // 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。 - runMu sync.Mutex - runCancel context.CancelFunc - runWG sync.WaitGroup -} - -// trackRun 登记一次外部命令运行,返回完成时调用 untrack。 -func (p *Plugin) trackRun() (context.Context, func()) { - ctx, cancel := context.WithCancel(context.Background()) - p.runMu.Lock() - p.runCancel = cancel - p.runWG.Add(1) - p.runMu.Unlock() - return ctx, func() { - p.runWG.Done() - p.runMu.Lock() - p.runCancel = nil - p.runMu.Unlock() - } -} - -// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。 -func (p *Plugin) killRunGroup(pid int) { - if pid <= 0 { - return - } - // 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内) - if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { - _ = syscall.Kill(pid, syscall.SIGKILL) - } -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - tp := p.name + "_" - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "output_dir", Default: "/tmp/bili_videos", - Type: "string", DisplayName: "下载目录", - Description: "B站视频下载后的保存目录", - Category: p.name, - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "proxy", Default: "", - Type: "string", DisplayName: "HTTP 代理", - Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置", - Category: p.name, - }) - if v, _ := s.Settings().Get("proxy"); v != nil { - if str, ok := v.(string); ok { - p.proxy = str - } - } - - s.RegisterTool(tp+"video", sdk.ToolDef{ - Name: tp + "video", - Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"}, - "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"}, - "format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"}, - }, - "required": []string{"url"}, - }, - Cleaner: func(output string) string { - var r struct{ Content string } - if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { - return r.Content - } - return output - }, - }, p.handleBiliVideo) - return nil -} - -// Stop 取消并等待正在跑的下载。 -// -// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑, -// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿 -// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死 -// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报 -// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。 -func (p *Plugin) Stop() error { - p.runMu.Lock() - cancel := p.runCancel - p.runMu.Unlock() - if cancel != nil { - cancel() - } - // 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。 - p.runWG.Wait() - return nil -} - -type ytdlpFormat struct { - FormatID string `json:"format_id"` - FormatNote string `json:"format_note"` - Ext string `json:"ext"` - Width int `json:"width"` - Height int `json:"height"` - TBR float64 `json:"tbr"` - Filesize int64 `json:"filesize"` - FilesizeApprox int64 `json:"filesize_approx"` - VCodec string `json:"vcodec"` - ACodec string `json:"acodec"` - FPS float64 `json:"fps"` -} - -type ytdlpInfo struct { - Title string `json:"title"` - Duration float64 `json:"duration"` - WebpageURL string `json:"webpage_url"` - Filename string `json:"_filename"` - Formats []ytdlpFormat `json:"formats"` -} - -func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) { - url, _ := args["url"].(string) - if url == "" { - return nil, fmt.Errorf("url is required") - } - infoOnly, _ := args["info_only"].(bool) - format, _ := args["format"].(string) - - outputDir := "/tmp/bili_videos" - if p.sdk != nil { - if v, _ := p.sdk.Settings().Get("output_dir"); v != nil { - if s, ok := v.(string); ok && s != "" { - outputDir = s - } - } - } - // 安全校验: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) - - var out bytes.Buffer - ytdlpArgs := []string{"--no-warnings", "--dump-json", url} - ctx, done := p.trackRun() - defer done() - // CommandContext:Stop 里的 cancel 能直接掐掉它。 - // Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内, - // killRunGroup 能一次带走整棵子进程树。 - cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...) - cmd.Stdout = &out - cmd.Stderr = &out - cmd.Env = proxyEnv(p.proxy) - setPgid(cmd) - if err := cmd.Run(); err != nil { - if ctx.Err() != nil { - return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)") - } - return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) - } - - var info ytdlpInfo - if err := json.Unmarshal(out.Bytes(), &info); err != nil { - return nil, fmt.Errorf("parse yt-dlp output: %w", err) - } - - if infoOnly { - var filtered []ytdlpFormat - for _, f := range info.Formats { - if f.VCodec != "none" || f.ACodec != "none" { - filtered = append(filtered, f) - } - } - info.Formats = filtered - - lines := []string{fmt.Sprintf("标题: %s", info.Title)} - if info.Duration > 0 { - lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration)) - } - - type fmtLine struct { - ID string - Note string - Res string - Ext string - Size string - } - var seen []string - var display []fmtLine - for _, f := range info.Formats { - if f.FormatNote == "" { - continue - } - key := f.FormatNote + f.Ext - if contains(seen, key) { - continue - } - seen = append(seen, key) - res := "" - if f.Width > 0 && f.Height > 0 { - res = fmt.Sprintf("%dx%d", f.Width, f.Height) - } - sz := "" - fs := f.Filesize - if fs == 0 { - fs = f.FilesizeApprox - } - if fs > 0 { - sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576) - } - display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz}) - } - if len(display) > 0 { - lines = append(lines, "清晰度列表:") - for _, d := range display { - r := d.Res - if r != "" { - r = " " + r - } - lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size)) - } - } - - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil - } - - taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano())) - if err := os.MkdirAll(taskDir, 0755); err != nil { - return nil, fmt.Errorf("mkdir task dir: %w", err) - } - - dlArgs := []string{ - "--no-warnings", - "--socket-timeout", "30", - "--retries", "3", - "--fragment-retries", "3", - "-o", filepath.Join(taskDir, "%(title)s.%(ext)s"), - "--no-overwrites", - } - if format != "" { - dlArgs = append(dlArgs, "-f", format) - } - dlArgs = append(dlArgs, url) - cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...) - cmd2.Env = proxyEnv(p.proxy) - var dlOut bytes.Buffer - cmd2.Stdout = &dlOut - cmd2.Stderr = &dlOut - setPgid(cmd2) - if err := cmd2.Run(); err != nil { - if ctx.Err() != nil { - return nil, fmt.Errorf("下载已取消(插件停止)") - } - return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) - } - - parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part")) - for _, f := range parts { - os.Remove(f) - } - residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl")) - for _, f := range residuals { - os.Remove(f) - } - - entries, _ := os.ReadDir(taskDir) - var mainFile string - var mainSize int64 - for _, e := range entries { - if e.IsDir() { - continue - } - fi, _ := e.Info() - if fi == nil { - continue - } - if fi.Size() > mainSize { - mainSize = fi.Size() - mainFile = e.Name() - } - } - if mainFile == "" { - return map[string]interface{}{ - "content": "下载完成,但未找到视频文件", - }, nil - } - dlPath := filepath.Join(taskDir, mainFile) - return map[string]interface{}{ - "content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath), - "file": dlPath, - "filename": mainFile, - }, nil -} - -// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg) -// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。 -func setPgid(cmd *exec.Cmd) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} - } - cmd.SysProcAttr.Setpgid = true -} - -func proxyEnv(proxy string) []string { - env := os.Environ() - if proxy != "" { - env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy) - } - return env -} - -func contains(slice []string, s string) bool { - for _, v := range slice { - if v == s { - return true - } - } - return false -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} diff --git a/third_party/homeagent-sdk/example/browser/plg.json b/third_party/homeagent-sdk/example/browser/plg.json deleted file mode 100644 index 86e1c10..0000000 --- a/third_party/homeagent-sdk/example/browser/plg.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "browser", - "name_zh": "浏览器", - "name_en": "Browser", - "version": "2.4.1", - "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "web", - "search", - "fetch", - "browser", - "cdp" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/browser/plugin.go b/third_party/homeagent-sdk/example/browser/plugin.go deleted file mode 100644 index 253a7a9..0000000 --- a/third_party/homeagent-sdk/example/browser/plugin.go +++ /dev/null @@ -1,1427 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "html" - "io" - "log" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" - "unicode" - - sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" - - "github.com/chromedp/chromedp" -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - timeout int - proxy string - client *http.Client - - sessions map[string]*BrowserSession - nextID int - wg sync.WaitGroup - stopCh chan struct{} - stopOnce sync.Once - profilesDir string // 持久化 profile 根目录(/browser_profiles),空则禁用 - - // 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir, - // 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个 - // 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在 - // 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。 - sharedAllocCtx context.Context - sharedAllocCancel context.CancelFunc - sharedMu sync.Mutex -} - -type BrowserSession struct { - id string - allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例) - cancel context.CancelFunc - ctx context.Context // 本会话的 Target 上下文(一个标签页) - createdAt time.Time - timeout time.Duration - closed bool - mu sync.Mutex - 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) { - return &Plugin{name: name, stopCh: make(chan struct{}), sessions: make(map[string]*BrowserSession)}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { - v, err := s.Get(key) - if err == nil && v != nil { - if s, ok := v.(string); ok && s != "" { - switch any(fallback).(type) { - case string: - return any(s).(T) - case int64: - if n, err := strconv.ParseInt(s, 10, 64); err == nil { - return any(n).(T) - } - case float64: - if n, err := strconv.ParseFloat(s, 64); err == nil { - return any(n).(T) - } - } - } - } - v2, err2 := s.GetCore("plugin." + "browser" + "." + key) - if err2 == nil && v2 != nil { - if s, ok := v2.(string); ok && s != "" { - switch any(fallback).(type) { - case string: - return any(s).(T) - case int64: - if n, err := strconv.ParseInt(s, 10, 64); err == nil { - return any(n).(T) - } - case float64: - if n, err := strconv.ParseFloat(s, 64); err == nil { - return any(n).(T) - } - } - } - } - return fallback -} - -func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { - v, ok := args[key] - if !ok || v == nil { - return fallback - } - switch any(fallback).(type) { - case string: - if s, ok := v.(string); ok { - return any(s).(T) - } - case int64: - switch val := v.(type) { - case float64: - return any(int64(val)).(T) - case string: - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return any(n).(T) - } - } - case float64: - switch val := v.(type) { - case float64: - return any(val).(T) - case string: - if n, err := strconv.ParseFloat(val, 64); err == nil { - return any(n).(T) - } - } - } - return fallback -} - -func errResult(msg string) map[string]interface{} { - return map[string]interface{}{"isError": true, "content": msg} -} - -func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) { - raw := strings.TrimSpace(readArg(args, "timeout", "")) - if raw == "" { - return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h") - } - timeout, err := time.ParseDuration(raw) - if err != nil || timeout <= 0 { - return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw) - } - return timeout, nil -} - -func newHTTPClient(timeout int, proxyURL string) *http.Client { - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: time.Duration(timeout) * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - TLSHandshakeTimeout: time.Duration(timeout) * time.Second, - ResponseHeaderTimeout: time.Duration(timeout) * time.Second, - } - if proxyURL != "" { - if u, err := url.Parse(proxyURL); err == nil { - transport.Proxy = http.ProxyURL(u) - } - } - return &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - Transport: transport, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return fmt.Errorf("too many redirects") - } - return nil - }, - } - -} - -// ── Start / Stop ────────────────────────────────────────── - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - s.SetAutoRestart(true) - // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "timeout", Default: "30", Type: "int", - DisplayName: "HTTP 超时(秒)", Description: "HTTP 请求超时时间", - Category: "browser", - }) - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "proxy", Default: "", Type: "string", - DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port", - Category: "browser", - }) - - t := readCfg(s.Settings(), "timeout", float64(30)) - p.timeout = int(t) - if p.timeout < 5 { - p.timeout = 5 - } - if p.timeout > 120 { - p.timeout = 120 - } - p.proxy = readCfg(s.Settings(), "proxy", "") - p.client = newHTTPClient(p.timeout, p.proxy) - - // 持久化 profile 根目录:/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 + "_" - - cleaner := func(output string) string { - var r struct{ Content string } - if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { - return r.Content - } - return output - } - - s.RegisterTool(tp+"search", sdk.ToolDef{ - Name: tp + "search", - Description: "使用 Bing 搜索网页。返回标题、URL 和摘要。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "搜索关键词"}, - "count": map[string]interface{}{"type": "integer", "description": "结果数量(1-20,默认5)"}, - }, - "required": []string{"query"}, - }, - Cleaner: cleaner, - }, p.handleSearch) - - s.RegisterTool(tp+"fetch", sdk.ToolDef{ - Name: tp + "fetch", - Description: "抓取 URL 内容。mode=auto 时遇 403/429 自动降级用无头 Chromium 渲染;mode=render 强制用 Chromium;mode=quick 纯 HTTP 不降级。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL"}, - "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认20000)"}, - "mode": map[string]interface{}{"type": "string", "description": "auto(默认)/render(强制Chromium)/quick(纯HTTP)"}, - }, - "required": []string{"url"}, - }, - Cleaner: cleaner, - }, p.handleFetch) - - s.RegisterTool(tp+"render", sdk.ToolDef{ - Name: tp + "render", - Description: "无头 Chromium 渲染网页并提取文本(normal 模式)。支持 JS 渲染的页面。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "URL"}, - "wait": map[string]interface{}{"type": "integer", "description": "等待 JS 渲染的秒数(默认0)"}, - }, - "required": []string{"url"}, - }, - Cleaner: cleaner, - }, p.handleRender) - - s.RegisterTool(tp+"start", sdk.ToolDef{ - Name: tp + "start", - Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"}, - "timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"}, - "profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"}, - }, - "required": []string{"timeout"}, - }, - }, p.handleBrowserStart) - - s.RegisterTool(tp+"navigate", sdk.ToolDef{ - Name: tp + "navigate", - Description: "在交互式浏览器中导航到指定 URL。自动等待页面 body 加载完成。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "url": map[string]interface{}{"type": "string", "description": "目标 URL"}, - "wait": map[string]interface{}{"type": "integer", "description": "页面加载后额外等待秒数(默认2,反爬页面建议5)"}, - }, - "required": []string{"id", "url"}, - }, - }, p.handleNavigate) - - s.RegisterTool(tp+"screenshot", sdk.ToolDef{ - Name: tp + "screenshot", - Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"}, - "format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"}, - }, - "required": []string{"id"}, - }, - }, p.handleScreenshot) - - s.RegisterTool(tp+"html", sdk.ToolDef{ - Name: tp + "html", - Description: "获取交互式浏览器当前页面 JS 渲染后的完整 HTML。用于模型分析页面结构、定位元素。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认50000)"}, - }, - "required": []string{"id"}, - }, - }, p.handleHTML) - - s.RegisterTool(tp+"click", sdk.ToolDef{ - Name: tp + "click", - Description: "在交互式浏览器中点击元素。自动等待元素可见后再点击。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, - "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, - }, - "required": []string{"id", "selector"}, - }, - }, p.handleClick) - - s.RegisterTool(tp+"type", sdk.ToolDef{ - Name: tp + "type", - Description: "在交互式浏览器中向输入框输入文字。自动等待元素可见、聚焦后清空再输入。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, - "text": map[string]interface{}{"type": "string", "description": "要输入的文字"}, - "submit": map[string]interface{}{"type": "boolean", "description": "输入后按回车(默认 false)"}, - "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, - }, - "required": []string{"id", "selector", "text"}, - }, - }, p.handleType) - - s.RegisterTool(tp+"scroll", sdk.ToolDef{ - Name: tp + "scroll", - Description: "在交互式浏览器中滚动页面。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - "dir": map[string]interface{}{"type": "string", "description": "方向: up, down, left, right(默认 down)"}, - "amount": map[string]interface{}{"type": "integer", "description": "滚动像素数(默认 500)"}, - }, - "required": []string{"id"}, - }, - }, 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{ - Name: tp + "close", - Description: "关闭交互式浏览器会话,释放资源。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleBrowserClose) - - p.wg.Add(1) - go p.cleanupLoop() - - log.Printf("[%s] started, timeout=%ds proxy=%q", p.name, p.timeout, p.proxy) - return nil -} - -func (p *Plugin) Stop() error { - p.stopOnce.Do(func() { - close(p.stopCh) - p.wg.Wait() - if p.client != nil { - p.client.CloseIdleConnections() - } - p.mu.Lock() - for _, s := range p.sessions { - s.Close() - } - p.sessions = nil - p.mu.Unlock() - log.Printf("[%s] stopped", p.name) - }) - return nil -} - -// ── SSRF ────────────────────────────────────────────────── - -var privateCIDRs []*net.IPNet - -func init() { - for _, c := range []string{ - "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", - "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", - "::1/128", "fc00::/7", "fe80::/10", - } { - _, n, _ := net.ParseCIDR(c) - if n != nil { - privateCIDRs = append(privateCIDRs, n) - } - } -} - -func isPrivateIP(ip net.IP) bool { - for _, n := range privateCIDRs { - if n.Contains(ip) { - return true - } - } - return false -} - -func (p *Plugin) ssrfCheck(rawURL string) error { - u, err := url.Parse(rawURL) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("only http/https allowed, got: %s", u.Scheme) - } - ips, err := net.LookupHost(u.Hostname()) - if err != nil { - return fmt.Errorf("DNS lookup failed: %w", err) - } - for _, ip := range ips { - if parsed := net.ParseIP(ip); parsed != nil && isPrivateIP(parsed) { - return fmt.Errorf("blocked request to private IP: %s (%s)", u.Hostname(), ip) - } - } - return nil -} - -// ── Bing Search ─────────────────────────────────────────── - -type searchResult struct { - Title, URL, Snippet string -} - -func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) { - // 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。 - // 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。 - u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count) - req, _ := http.NewRequest("GET", u, nil) - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - resp, err := p.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body)) - } - results := parseBingResults(string(body), count) - if len(results) == 0 { - // 关键:把「解析不出来」与「真的没结果」区分开。 - // 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。 - return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body)) - } - return results, nil -} - -var ( - bingBlockRe = regexp.MustCompile(`
  • ]*>\s*]+href="([^"]+)"[^>]*>(.*?)`) - bingAnyLinkRe = regexp.MustCompile(`(?s)]+href="([^"]+)"[^>]*>(.*?)`) - bingSnipRe = regexp.MustCompile(`(?s)

    ]*>(.*?)

    `) - bingCaptionRe = regexp.MustCompile(`(?s)
    ]*>(.*?)
    `) -) - -// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。 -// -// 不用 `
  • `:结果块内部可能嵌套
  • (deep links), -// 非贪婪匹配会在错误位置截断;而且块内第一个 往往是 Bing 的「来源行」, -// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。 -func splitBingBlocks(pageHTML string) []string { - locs := bingBlockRe.FindAllStringIndex(pageHTML, -1) - if len(locs) == 0 { - return nil - } - blocks := make([]string, 0, len(locs)) - for i, loc := range locs { - end := len(pageHTML) - if i+1 < len(locs) { - end = locs[i+1][0] - } - blocks = append(blocks, pageHTML[loc[1]:end]) - } - return blocks -} - -func parseBingResults(pageHTML string, count int) []searchResult { - if count <= 0 { - count = 5 - } - var results []searchResult - for _, block := range splitBingBlocks(pageHTML) { - if len(results) >= count { - break - } - // 标题:现代 Bing 是

    标题

    ;没有 h2 时才退回到块内第一个链接。 - var href, title string - if m := bingTitleRe.FindStringSubmatch(block); m != nil { - href, title = m[1], html.UnescapeString(stripTags(m[2])) - } else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil { - href, title = m[1], html.UnescapeString(stripTags(m[2])) - } - href = bingRealURL(html.UnescapeString(href)) - - // 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p - var snippet string - if m := bingSnipRe.FindStringSubmatch(block); m != nil { - snippet = html.UnescapeString(stripTags(m[1])) - } else if m := bingCaptionRe.FindStringSubmatch(block); m != nil { - snippet = html.UnescapeString(stripTags(m[1])) - } - - title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet) - if href == "" || title == "" || !strings.HasPrefix(href, "http") { - continue - } - results = append(results, searchResult{Title: title, URL: href, Snippet: snippet}) - } - return results -} - -// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1&... → 真实 URL。 -// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。 -func bingRealURL(href string) string { - href = strings.TrimSpace(href) - if href == "" { - return "" - } - if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") { - return href - } - u, err := url.Parse(href) - if err != nil { - return href - } - raw := u.Query().Get("u") - if !strings.HasPrefix(raw, "a1") { - return href - } - b64 := raw[2:] - for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} { - if dec, err := enc.DecodeString(b64); err == nil { - s := string(dec) - if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { - return s - } - } - } - return href -} - -func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { - query := readArg(args, "query", "") - if query == "" { - return errResult("query is required"), nil - } - count := 5 - if v, ok := args["count"].(float64); ok && v > 0 { - count = int(v) - } - if count < 1 { - count = 1 - } - if count > 20 { - count = 20 - } - results, err := p.bingSearch(query, count) - if err != nil { - return errResult("search failed: " + err.Error()), nil - } - if len(results) == 0 { - return map[string]interface{}{"content": "No results found."}, nil - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query)) - for i, r := range results { - sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)) - } - return map[string]interface{}{"content": strings.TrimSpace(sb.String())}, nil -} - -// ── Web Fetch (quick) ────────────────────────────────────── - -func htmlToText(html string) string { - for _, tag := range []string{"" - for { - start := strings.Index(strings.ToLower(html), tag) - if start < 0 { - break - } - end := strings.Index(html[start:], closing) - if end < 0 { - break - } - html = html[:start] + html[start+end+len(closing):] - } - } - for _, tag := range []string{"

    ", "", "", "", "", "", "", "", "
  • ", "", "", ""} { - html = strings.ReplaceAll(html, tag, "\n") - } - html = stripTags(html) - for _, pair := range [][2]string{ - {"&", "&"}, {"<", "<"}, {">", ">"}, - {""", "\""}, {"'", "'"}, {" ", " "}, - } { - html = strings.ReplaceAll(html, pair[0], pair[1]) - } - lines := strings.Split(html, "\n") - var cleaned []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - in := []rune(line) - var out []rune - space := false - for _, r := range in { - if unicode.IsSpace(r) { - if !space { - out = append(out, ' ') - space = true - } - } else { - out = append(out, r) - space = false - } - } - cleaned = append(cleaned, string(out)) - } - return strings.Join(cleaned, "\n") -} - -func stripTags(s string) string { - var out strings.Builder - inTag := false - for _, r := range s { - if r == '<' { - inTag = true - continue - } - if r == '>' { - inTag = false - continue - } - if !inTag { - out.WriteRune(r) - } - } - return out.String() -} - -func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) { - rawURL := readArg(args, "url", "") - if rawURL == "" { - return errResult("url is required"), nil - } - maxChars := 20000 - if v, ok := args["max_chars"].(float64); ok && v > 0 { - maxChars = int(v) - } - if maxChars > 500000 { - maxChars = 500000 - } - if err := p.ssrfCheck(rawURL); err != nil { - return errResult(err.Error()), nil - } - - mode := readArg(args, "mode", "auto") - if mode == "render" { - return p.fetchWithChromium(rawURL, maxChars) - } - - req, _ := http.NewRequest("GET", rawURL, nil) - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") - resp, err := p.client.Do(req) - if err != nil { - return errResult("fetch failed: " + err.Error()), nil - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - resp.Body.Close() - if (resp.StatusCode == 403 || resp.StatusCode == 429) && mode == "auto" { - return p.fetchWithChromium(rawURL, maxChars) - } - return errResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000)) - rawText := string(body) - ct := resp.Header.Get("Content-Type") - var extracted string - if strings.Contains(ct, "text/html") { - extracted = htmlToText(rawText) - } else if strings.Contains(ct, "application/json") { - var v interface{} - if json.Unmarshal(body, &v) == nil { - if pretty, err := json.MarshalIndent(v, "", " "); err == nil { - extracted = string(pretty) - } - } - if extracted == "" { - extracted = rawText - } - } else { - extracted = rawText - } - extracted = strings.TrimSpace(extracted) - if len(extracted) > maxChars { - extracted = extracted[:maxChars] + "\n\n[Content truncated]" - } - if extracted == "" { - extracted = "(empty content)" - } - return map[string]interface{}{ - "content": extracted, - "details": map[string]interface{}{"url": rawURL, "status": resp.StatusCode, "content_type": ct}, - }, nil -} - -// ── Chromium Render (normal) ────────────────────────────── - -func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, error) { - chromiumPath := "/usr/local/bin/chromium" - if _, err := os.Stat(chromiumPath); err != nil { - return errResult("HTTP 403 且 Chromium 不可用,无法降级渲染"), nil - } - var out bytes.Buffer - cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) - cmd.Stdout = &out - if err := cmd.Run(); err != nil { - return errResult("Chromium 渲染失败: " + err.Error()), nil - } - text := htmlToText(out.String()) - if len(text) > maxChars { - text = text[:maxChars] + "\n\n[Content truncated]" - } - return map[string]interface{}{ - "content": text, - "details": map[string]interface{}{"url": rawURL, "mode": "render_fallback", "content_type": "text/html"}, - }, nil -} - -// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。 -// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页; -// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。 -func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) { - rawURL := readArg(args, "url", "") - if rawURL == "" { - return errResult("url is required"), nil - } - if err := p.ssrfCheck(rawURL); err != nil { - return errResult(err.Error()), nil - } - waitSec := int64(readArg(args, "wait", float64(0))) - - 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 - } - - if !rendered { - 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 - cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) - cmd.Stdout = &out - done := make(chan error, 1) - 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() - } - - text := htmlToText(html) - origLen := len(text) - truncated := origLen > 5000 - if truncated { - text = text[:5000] - } - result := "" - if title != "" { - result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, rawURL) - } - result += text - if truncated { - result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen) - } - mode := "backend-tab" - if !rendered { - mode = "local-dump-dom" - } - return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil -} - -func cdpReachable(endpoint string) bool { - client := &http.Client{Timeout: 2 * time.Second} - resp, err := client.Get(endpoint + "/json/version") - if err != nil { - 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[:], - chromedp.Flag("headless", true), - chromedp.Flag("disable-gpu", true), - chromedp.Flag("no-sandbox", true), - chromedp.WindowSize(1280, 800), - ) - if p.proxy != "" { - opts = append(opts, chromedp.Flag("proxy-server", p.proxy)) - } - allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) - ctx, _ := chromedp.NewContext(allocCtx) - if err := chromedp.Run(ctx); err != nil { - cancelAlloc() - return nil, nil, nil, err - } - return allocCtx, cancelAlloc, nil, nil -} - -func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { - timeout, err := parseBrowserSessionTimeout(args) - if err != nil { - return errResult(err.Error()), nil - } - - source := readArg(args, "source", "") - if source == "" { - source = "default" - } - - // 同 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.createdAt = time.Now() - s.timeout = timeout - closesAt := s.createdAt.Add(timeout) - s.mu.Unlock() - p.mu.Unlock() - log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source) - return map[string]interface{}{ - "id": id, - "status": "reused", - "url": cur, - "timeout": timeout.String(), - "closes_at": closesAt.Format(time.RFC3339), - "note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间", - }, 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.nextID++ - id := fmt.Sprintf("browser_%d", p.nextID) - session.id = id - p.sessions[id] = session - p.mu.Unlock() - - initURL := readArg(args, "url", "") - if initURL != "" { - if err := chromedp.Run(session.ctx, - chromedp.Navigate(initURL), - chromedp.WaitReady("body"), - ); err != nil { - session.Close() - p.mu.Lock() - delete(p.sessions, id) - p.mu.Unlock() - return errResult("navigate failed: " + err.Error()), nil - } - session.currentURL = initURL - } - - closesAt := session.createdAt.Add(timeout) - log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source) - return map[string]interface{}{ - "id": id, - "status": "created", - "mode": "shared-backend", - "url": initURL, - "timeout": timeout.String(), - "closes_at": closesAt.Format(time.RFC3339), - }, nil -} - -func (p *Plugin) getSession(id string) (*BrowserSession, error) { - p.mu.Lock() - s, ok := p.sessions[id] - p.mu.Unlock() - if !ok { - return nil, fmt.Errorf("浏览器会话 %s 不存在或已关闭", id) - } - return s, nil -} - -func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - rawURL := readArg(args, "url", "") - if id == "" || rawURL == "" { - return errResult("id 和 url 不能为空"), nil - } - s, err := p.getSession(id) - if err != nil { - return errResult(err.Error()), nil - } - waitSec := int64(readArg(args, "wait", float64(2))) - if err := chromedp.Run(s.ctx, - chromedp.Navigate(rawURL), - chromedp.WaitReady("body"), - chromedp.Sleep(time.Duration(waitSec)*time.Second), - ); err != nil { - return errResult("navigate failed: " + err.Error()), nil - } - s.currentURL = rawURL - p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL)) - return map[string]interface{}{"status": "ok", "url": rawURL}, nil -} - -func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return errResult("id is required"), nil - } - s, sessErr := p.getSession(id) - if sessErr != nil { - return errResult(sessErr.Error()), nil - } - full := false - if v, ok := args["full"].(bool); ok { - full = v - } - format := readArg(args, "format", "png") - if format != "png" { - return errResult("仅支持 png 格式"), nil - } - var buf []byte - var err error - if full { - err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90)) - } else { - err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf)) - } - if err != nil { - return errResult("screenshot failed: " + err.Error()), nil - } - b64 := base64.StdEncoding.EncodeToString(buf) - return map[string]interface{}{ - "status": "ok", - "format": format, - "size": len(buf), - "base64": b64, - "data_uri": fmt.Sprintf("data:image/png;base64,%s", b64), - }, nil -} - -func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return errResult("id is required"), nil - } - s, err := p.getSession(id) - if err != nil { - return errResult(err.Error()), nil - } - maxChars := 50000 - if v, ok := args["max_chars"].(float64); ok && v > 0 { - maxChars = int(v) - } - var html string - if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil { - return errResult("get html failed: " + err.Error()), nil - } - var title, currentURL string - chromedp.Run(s.ctx, - chromedp.Title(&title), - chromedp.Location(¤tURL), - ) - truncated := len(html) > maxChars - if truncated { - html = html[:maxChars] + "\n\n[HTML truncated]" - } - return map[string]interface{}{ - "status": "ok", - "title": title, - "url": currentURL, - "html": html, - "length": len(html), - }, nil -} - -func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - selector := readArg(args, "selector", "") - if id == "" || selector == "" { - return errResult("id 和 selector 不能为空"), nil - } - s, err := p.getSession(id) - if err != nil { - return errResult(err.Error()), nil - } - if err := chromedp.Run(s.ctx, - chromedp.WaitVisible(selector), - chromedp.Click(selector), - ); err != nil { - return errResult("click failed (element may not exist or page blocking): " + err.Error()), nil - } - return map[string]interface{}{"status": "ok", "selector": selector}, nil -} - -func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - selector := readArg(args, "selector", "") - text := readArg(args, "text", "") - if id == "" || selector == "" || text == "" { - return errResult("id, selector, text 不能为空"), nil - } - s, err := p.getSession(id) - if err != nil { - return errResult(err.Error()), nil - } - actions := []chromedp.Action{ - chromedp.WaitVisible(selector), - chromedp.Click(selector, chromedp.NodeVisible), - chromedp.Clear(selector), - chromedp.SendKeys(selector, text), - } - submit := false - if v, ok := args["submit"].(bool); ok { - submit = v - } - if submit { - actions = append(actions, chromedp.SendKeys(selector, "\r")) - } - if err := chromedp.Run(s.ctx, actions...); err != nil { - return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil - } - return map[string]interface{}{"status": "ok", "selector": selector}, nil -} - -func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return errResult("id is required"), nil - } - s, err := p.getSession(id) - if err != nil { - return errResult(err.Error()), nil - } - dir := readArg(args, "dir", "down") - amount := readArg(args, "amount", float64(500)) - var scrollJS string - switch dir { - case "up": - scrollJS = fmt.Sprintf("window.scrollBy(0, -%d)", int(amount)) - case "down": - scrollJS = fmt.Sprintf("window.scrollBy(0, %d)", int(amount)) - case "left": - scrollJS = fmt.Sprintf("window.scrollBy(-%d, 0)", int(amount)) - case "right": - scrollJS = fmt.Sprintf("window.scrollBy(%d, 0)", int(amount)) - default: - return errResult("dir 必须是 up/down/left/right"), nil - } - if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil { - return errResult("scroll failed: " + err.Error()), nil - } - return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil -} - -func (p *Plugin) handleBrowserClose(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return errResult("id is required"), nil - } - p.mu.Lock() - s, ok := p.sessions[id] - if ok { - delete(p.sessions, id) - } - p.mu.Unlock() - if !ok { - return errResult(fmt.Sprintf("浏览器会话 %s 不存在或已关闭", id)), nil - } - s.Close() - log.Printf("[%s] closed browser session %s", p.name, id) - return map[string]interface{}{"status": "closed", "id": id}, nil -} - -func (s *BrowserSession) Close() { - s.mu.Lock() - defer s.mu.Unlock() - if s.closed { - return - } - s.closed = true - s.cancel() -} - -func (p *Plugin) cleanupLoop() { - defer p.wg.Done() - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-p.stopCh: - return - case <-ticker.C: - now := time.Now() - p.mu.Lock() - for id, s := range p.sessions { - s.mu.Lock() - closesAt := s.createdAt.Add(s.timeout) - expired := !now.Before(closesAt) - s.mu.Unlock() - if expired { - log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339)) - delete(p.sessions, id) - s.Close() - // NoMemory:会话生命周期通知,不是记忆内容。 - p.sdk.InjectInterruptTextOpts(p.name, p.name, - fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true}) - } - } - p.mu.Unlock() - } - } -} - -// ── 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 -} diff --git a/third_party/homeagent-sdk/example/calendar/plg.json b/third_party/homeagent-sdk/example/calendar/plg.json deleted file mode 100644 index 026cea4..0000000 --- a/third_party/homeagent-sdk/example/calendar/plg.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "calendar", - "name_zh": "日历", - "name_en": "Calendar", - "version": "1.1.0", - "description": "日历事件管理,支持提醒和重复事件", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "calendar", - "event", - "reminder", - "schedule" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/calendar/plugin.go b/third_party/homeagent-sdk/example/calendar/plugin.go deleted file mode 100644 index 4f09eb4..0000000 --- a/third_party/homeagent-sdk/example/calendar/plugin.go +++ /dev/null @@ -1,1192 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -const ( - RepeatNone = "none" - RepeatDaily = "daily" - RepeatWeekday = "weekday" - RepeatWeekly = "weekly" - RepeatBiweekly = "biweekly" - RepeatMonthly = "monthly" - RepeatYearly = "yearly" - RepeatLunarYearly = "lunar_yearly" -) - -type CalendarEvent struct { - ID string `json:"id"` - Title string `json:"title"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time,omitempty"` - AllDay bool `json:"all_day,omitempty"` - Location string `json:"location,omitempty"` - Note string `json:"note,omitempty"` - Reminds []int `json:"reminds,omitempty"` - RemindAt []int64 `json:"remind_at,omitempty"` - Repeat string `json:"repeat,omitempty"` - ParentID string `json:"parent_id,omitempty"` - Lunar bool `json:"lunar,omitempty"` - LunarMonth int `json:"lunar_month,omitempty"` - LunarDay int `json:"lunar_day,omitempty"` -} - -type Plugin struct { - name string - sdk *sdk.PluginSDK - dataDir string - mu sync.RWMutex - events []CalendarEvent - nextEventID int - stopCh chan struct{} - wg sync.WaitGroup - remindTicker *time.Ticker -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name, stopCh: make(chan struct{})}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { - v, err := s.Get(key) - if err == nil && v != nil { - if sv, ok := v.(string); ok && sv != "" { - switch any(fallback).(type) { - case string: - return any(sv).(T) - case int64: - if n, err := strconv.ParseInt(sv, 10, 64); err == nil { - return any(n).(T) - } - case float64: - if n, err := strconv.ParseFloat(sv, 64); err == nil { - return any(n).(T) - } - } - } - } - v2, err2 := s.GetCore("plugin." + "calendar" + "." + key) - if err2 == nil && v2 != nil { - if sv, ok := v2.(string); ok && sv != "" { - switch any(fallback).(type) { - case string: - return any(sv).(T) - case int64: - if n, err := strconv.ParseInt(sv, 10, 64); err == nil { - return any(n).(T) - } - case float64: - if n, err := strconv.ParseFloat(sv, 64); err == nil { - return any(n).(T) - } - } - } - } - return fallback -} - -func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { - v, ok := args[key] - if !ok || v == nil { - return fallback - } - switch any(fallback).(type) { - case string: - if s, ok := v.(string); ok { - return any(s).(T) - } - case int64: - switch n := v.(type) { - case float64: - return any(int64(n)).(T) - case int64: - return any(n).(T) - case string: - if i, err := strconv.ParseInt(n, 10, 64); err == nil { - return any(i).(T) - } - } - case float64: - switch n := v.(type) { - case float64: - return any(n).(T) - case int64: - return any(float64(n)).(T) - case string: - if f, err := strconv.ParseFloat(n, 64); err == nil { - return any(f).(T) - } - } - } - return fallback -} - -func readArgBool(args map[string]interface{}, key string) bool { - if v, ok := args[key]; ok && v != nil { - if b, ok := v.(bool); ok { - return b - } - if s, ok := v.(string); ok { - return s == "1" || strings.EqualFold(s, "true") - } - } - return false -} - -// --- Time Helpers --- - -var shortWeekday = map[time.Weekday]string{ - time.Monday: "一", time.Tuesday: "二", time.Wednesday: "三", - time.Thursday: "四", time.Friday: "五", time.Saturday: "六", time.Sunday: "日", -} - -func parseEventTime(s string) (time.Time, bool, bool) { - t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local) - if err == nil { - return t, false, true - } - t, err = time.ParseInLocation("2006-01-02", s, time.Local) - if err == nil { - return t, true, true - } - return time.Time{}, false, false -} - -// --- Lunar Calendar Engine --- - -var lunarInfo = []int{ - 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, - 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, - 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, - 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, - 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, - 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, - 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, - 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, - 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, - 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, - 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, - 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, - 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, - 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, - 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, - 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0, - 0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, - 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, - 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, - 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252, - 0x0d520, -} - -func daysInLunarYear(year int) int { - if year < 1900 || year > 2100 { - return 365 - } - y := lunarInfo[year-1900] - sum := 0 - for i := 0x8000; i > 0x8; i >>= 1 { - if y&i > 0 { - sum += 30 - } else { - sum += 29 - } - } - return sum + leapDays(year) -} - -func leapMonth(year int) int { - if year < 1900 || year > 2100 { - return 0 - } - return lunarInfo[year-1900] & 0xf -} - -func leapDays(year int) int { - if year < 1900 || year > 2100 { - return 0 - } - if leapMonth(year) == 0 { - return 0 - } - if lunarInfo[year-1900]&0x10000 > 0 { - return 30 - } - return 29 -} - -func monthDays(year, month int) int { - if year < 1900 || year > 2100 || month < 1 || month > 12 { - return 30 - } - if lunarInfo[year-1900]&(0x10000>>month) > 0 { - return 30 - } - return 29 -} - -var baseSolar = func() time.Time { t, _ := time.ParseInLocation("2006-01-02", "1900-01-31", time.Local); return t }() - -func lunarToSolar(year, month, day int) (time.Time, bool) { - if year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 30 { - return time.Time{}, false - } - offset := 0 - for y := 1900; y < year; y++ { - offset += daysInLunarYear(y) - } - lm := leapMonth(year) - _ = lm - for m := 1; m < month; m++ { - offset += monthDays(year, m) - } - offset += day - 1 - solar := baseSolar.AddDate(0, 0, offset) - return solar, true -} - -func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bool) { - afterYear := after.Year() - for y := afterYear; y <= afterYear+2; y++ { - t, ok := lunarToSolar(y, targetMonth, targetDay) - if !ok { - continue - } - if t.After(after) { - return t, true - } - } - return time.Time{}, false -} - -// --- Plugin Lifecycle --- - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - p.sdk = s - - // 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true}) - dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") - if err != nil || dataDirVal == "" { - dataDirVal = "." - } - p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar") - if err := os.MkdirAll(p.dataDir, 0755); err != nil { - fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) - } - p.loadEvents() - - // 持久化交由 stop handler:内核会在调用 Stop() 之前执行, - // 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。 - s.RegisterStopHandler(p.saveEvents) - // 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。 - s.RegisterOnRemoveHandler(p.cleanupData) - - tp := p.name + "_" - - s.RegisterTool(tp+"event_add", sdk.ToolDef{ - Name: tp + "event_add", Description: "Add a calendar event. Time: YYYY-MM-DD HH:MM or YYYY-MM-DD for all-day.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "title": map[string]interface{}{"type": "string", "description": "Event title"}, - "start_time": map[string]interface{}{"type": "string", "description": "Start time (YYYY-MM-DD HH:MM or YYYY-MM-DD)"}, - "end_time": map[string]interface{}{"type": "string", "description": "End time (optional)"}, - "location": map[string]interface{}{"type": "string", "description": "Location (optional)"}, - "note": map[string]interface{}{"type": "string", "description": "Notes (optional)"}, - "remind_before": map[string]interface{}{"type": "string", "description": "Reminder minutes before event. Multiple: comma-separated, e.g. '15,60,1440' for 15min + 1hr + 1day before. 0 or empty = no reminder."}, - "repeat": map[string]interface{}{"type": "string", "description": "Repeat: none, daily, weekday, weekly, biweekly, monthly, yearly, lunar_yearly"}, - "lunar": map[string]interface{}{"type": "boolean", "description": "Whether the date is lunar calendar. If true, repeat=lunar_yearly by default. Also set lunar_month and lunar_day."}, - "lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month (1-12), required when lunar=true"}, - "lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day (1-30), required when lunar=true"}, - }, - "required": []string{"title", "start_time"}, - }, - }, p.handleEventAdd) - - s.RegisterTool(tp+"event_list", sdk.ToolDef{ - Name: tp + "event_list", Description: "List upcoming events. Shows date, time, repeat pattern.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "days": map[string]interface{}{"type": "integer", "description": "Days ahead (default 7, max 365)"}, - }, - }, - }, p.handleEventList) - - s.RegisterTool(tp+"event_delete", sdk.ToolDef{ - Name: tp + "event_delete", Description: "Delete an event by ID. Deletes this and all future recurrences.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "Event ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleEventDelete) - - s.RegisterTool(tp+"event_update", sdk.ToolDef{ - Name: tp + "event_update", Description: "Update an event. Only provided fields change. Resets reminder state.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "string", "description": "Event ID"}, - "title": map[string]interface{}{"type": "string", "description": "New title"}, - "start_time": map[string]interface{}{"type": "string", "description": "New start time"}, - "end_time": map[string]interface{}{"type": "string", "description": "New end time"}, - "location": map[string]interface{}{"type": "string", "description": "New location"}, - "note": map[string]interface{}{"type": "string", "description": "New notes"}, - "remind_before": map[string]interface{}{"type": "string", "description": "New reminder minutes (comma-separated)"}, - "repeat": map[string]interface{}{"type": "string", "description": "New repeat type"}, - "lunar": map[string]interface{}{"type": "boolean", "description": "Whether lunar calendar"}, - "lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month 1-12"}, - "lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day 1-30"}, - }, - "required": []string{"id"}, - }, - }, p.handleEventUpdate) - - s.RegisterTool(tp+"today", sdk.ToolDef{ - Name: tp + "today", Description: "Show today's events with countdown.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleToday) - - s.RegisterTool(tp+"week", sdk.ToolDef{ - Name: tp + "week", Description: "Show this week's events grouped by day.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleWeek) - - s.RegisterTool(tp+"month", sdk.ToolDef{ - Name: tp + "month", Description: "Show a month calendar grid with event dots.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "year": map[string]interface{}{"type": "integer", "description": "Year (default: current)"}, - "month": map[string]interface{}{"type": "integer", "description": "Month 1-12 (default: current)"}, - }, - }, - }, p.handleMonth) - - s.RegisterTool(tp+"search", sdk.ToolDef{ - Name: tp + "search", Description: "Search events by keyword in title, location, or notes.", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "Search keyword"}, - }, - "required": []string{"keyword"}, - }, - }, p.handleSearch) - - p.remindTicker = time.NewTicker(30 * time.Second) - p.wg.Add(1) - go p.remindLoop() - - fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events)) - return nil -} - -func (p *Plugin) Stop() error { - p.remindTicker.Stop() - close(p.stopCh) - p.wg.Wait() - fmt.Printf("[%s] stopped\n", p.name) - return nil -} - -// --- Reminder Loop --- - -func (p *Plugin) remindLoop() { - defer p.wg.Done() - for { - select { - case <-p.remindTicker.C: - p.checkReminders() - case <-p.stopCh: - return - } - } -} - -func (p *Plugin) checkReminders() { - now := time.Now() - - p.mu.Lock() - - changed := false - var injectMsgs []string - - for i := range p.events { - e := &p.events[i] - evtTime, allDay, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if allDay || evtTime.Before(now) { - continue - } - - for ri, remindMin := range e.Reminds { - if remindMin <= 0 { - continue - } - if ri < len(e.RemindAt) && e.RemindAt[ri] > 0 { - continue - } - remindAt := evtTime.Add(-time.Duration(remindMin) * time.Minute) - if !now.After(remindAt) && !now.Equal(remindAt) { - continue - } - if len(e.RemindAt) <= ri { - e.RemindAt = append(e.RemindAt, make([]int64, ri+1-len(e.RemindAt))...) - } - e.RemindAt[ri] = remindAt.Unix() - changed = true - timeUntil := evtTime.Sub(now).Round(time.Minute) - msg := fmt.Sprintf("⏰ 提醒: %s (%s)", e.Title, e.StartTime) - if timeUntil > 0 { - msg += fmt.Sprintf(" (还有%s)", timeUntil) - } - if len(e.Reminds) > 1 { - msg += fmt.Sprintf(" [第%d次提醒]", ri+1) - } - if e.Location != "" { - msg += fmt.Sprintf("\n📍 %s", e.Location) - } - if e.Note != "" { - msg += fmt.Sprintf("\n📝 %s", e.Note) - } - injectMsgs = append(injectMsgs, msg) - } - } - - newEvents := []CalendarEvent{} - for i := range p.events { - e := &p.events[i] - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if !now.After(evtTime) { - continue - } - if e.Repeat == "" || e.Repeat == RepeatNone { - continue - } - next := p.nextOccurrence(*e, evtTime) - if next != nil { - pid := e.ID - if e.ParentID != "" { - pid = e.ParentID - } - next.ParentID = pid - dup := false - for _, ev := range p.events { - if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime { - dup = true - break - } - } - if !dup { - newEvents = append(newEvents, *next) - changed = true - } - } - } - if len(newEvents) > 0 { - p.events = append(p.events, newEvents...) - } - - p.cleanupPastEvents() - if changed { - p.saveEventsLocked() - } - p.mu.Unlock() - - for _, msg := range injectMsgs { - // NoMemory:日程到点提醒,不是记忆内容。 - p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true}) - } -} - -func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent { - var next time.Time - switch e.Repeat { - case RepeatDaily: - next = evtTime.AddDate(0, 0, 1) - case RepeatWeekday: - next = evtTime.AddDate(0, 0, 1) - for next.Weekday() == time.Saturday || next.Weekday() == time.Sunday { - next = next.AddDate(0, 0, 1) - } - case RepeatWeekly: - next = evtTime.AddDate(0, 0, 7) - case RepeatBiweekly: - next = evtTime.AddDate(0, 0, 14) - case RepeatMonthly: - next = evtTime.AddDate(0, 1, 0) - case RepeatYearly: - next = evtTime.AddDate(1, 0, 0) - case RepeatLunarYearly: - if e.LunarMonth > 0 && e.LunarDay > 0 { - t, ok := nextLunarYearly(e.LunarMonth, e.LunarDay, evtTime) - if ok { - next = t - } else { - return nil - } - } else { - return nil - } - default: - return nil - } - - timeStr := next.Format("2006-01-02 15:04") - if e.AllDay { - timeStr = next.Format("2006-01-02") - } - - reminds := make([]int, len(e.Reminds)) - copy(reminds, e.Reminds) - - return &CalendarEvent{ - ID: fmt.Sprintf("evt_%d_%d", next.Unix(), p.nextEventID), - Title: e.Title, - StartTime: timeStr, - EndTime: e.EndTime, - AllDay: e.AllDay, - Location: e.Location, - Note: e.Note, - Reminds: reminds, - Repeat: e.Repeat, - ParentID: e.ParentID, - } -} - -func (p *Plugin) cleanupPastEvents() { - now := time.Now() - keep := []CalendarEvent{} - for _, e := range p.events { - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if !now.After(evtTime) { - keep = append(keep, e) - continue - } - _ = e // 过时重复事件不再保留:next 已由 nextOccurrence 追加 - } - p.events = keep -} - -// --- Persistence --- - -func (p *Plugin) eventsFile() string { - return filepath.Join(p.dataDir, "events.json") -} - -// cleanupData 删除插件时清理本地持久化数据文件。 -func (p *Plugin) cleanupData() { - p.mu.Lock() - defer p.mu.Unlock() - if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) { - fmt.Printf("[calendar] onRemove cleanup: %v\n", err) - } else { - fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile()) - } -} - -func (p *Plugin) loadEvents() { - p.mu.Lock() - defer p.mu.Unlock() - b, err := os.ReadFile(p.eventsFile()) - if err != nil { - p.events = nil - p.nextEventID = 1 - return - } - var data struct { - Events []CalendarEvent `json:"events"` - NextEventID int `json:"next_id"` - } - if json.Unmarshal(b, &data) != nil { - p.events = nil - p.nextEventID = 1 - return - } - p.events = data.Events - p.nextEventID = data.NextEventID - if p.nextEventID < 1 { - p.nextEventID = 1 - } - if p.events == nil { - p.events = []CalendarEvent{} - } -} - -func (p *Plugin) saveEvents() { - p.mu.RLock() - defer p.mu.RUnlock() - p.saveEventsLocked() -} - -func (p *Plugin) saveEventsLocked() { - data := struct { - Events []CalendarEvent `json:"events"` - NextEventID int `json:"next_id"` - }{ - Events: p.events, - NextEventID: p.nextEventID, - } - b, _ := json.MarshalIndent(data, "", " ") - atomicWriteJSON(p.eventsFile(), b) -} - -// --- Helper: parse remind_before --- - -func parseReminds(s string) []int { - s = strings.TrimSpace(s) - if s == "" || s == "0" { - return nil - } - parts := strings.Split(s, ",") - vals := make([]int, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - v, err := strconv.Atoi(p) - if err != nil || v <= 0 { - continue - } - vals = append(vals, v) - } - sort.Ints(vals) - return vals -} - -// --- Helper: format event duration --- - -func formatTimeUntil(t time.Time) string { - now := time.Now() - if t.Before(now) { - return "已开始" - } - d := t.Sub(now) - if d < time.Hour { - m := int(d.Minutes()) - return fmt.Sprintf("还有%d分钟", m) - } - if d < 24*time.Hour { - h := int(d.Hours()) - m := int(d.Minutes()) % 60 - if m > 0 { - return fmt.Sprintf("还有%d小时%d分", h, m) - } - return fmt.Sprintf("还有%d小时", h) - } - d2 := int(d.Hours() / 24) - return fmt.Sprintf("还有%d天", d2) -} - -// --- Tool: event_add --- - -func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error) { - title := readArg(args, "title", "") - startTime := readArg(args, "start_time", "") - if title == "" || startTime == "" { - return map[string]interface{}{"isError": true, "content": "title and start_time are required"}, nil - } - - parsedStart, allDay, ok := parseEventTime(startTime) - if !ok { - return map[string]interface{}{"isError": true, "content": "Invalid start_time. Use YYYY-MM-DD HH:MM or YYYY-MM-DD."}, nil - } - - endTime := readArg(args, "end_time", "") - if endTime != "" { - if _, _, ok := parseEventTime(endTime); !ok { - return map[string]interface{}{"isError": true, "content": "Invalid end_time."}, nil - } - } - - location := readArg(args, "location", "") - note := readArg(args, "note", "") - remindStr := readArg(args, "remind_before", "") - reminds := parseReminds(remindStr) - lunar := readArgBool(args, "lunar") - lunarMonth := int(readArg(args, "lunar_month", int64(0))) - lunarDay := int(readArg(args, "lunar_day", int64(0))) - - repeat := readArg(args, "repeat", RepeatNone) - if lunar && repeat == RepeatNone { - repeat = RepeatLunarYearly - } - switch repeat { - case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly: - default: - repeat = RepeatNone - } - - if lunar && (lunarMonth < 1 || lunarMonth > 12 || lunarDay < 1 || lunarDay > 30) { - return map[string]interface{}{"isError": true, "content": "lunar_month (1-12) and lunar_day (1-30) required when lunar=true"}, nil - } - - event := CalendarEvent{ - ID: fmt.Sprintf("evt_%d_%d", parsedStart.Unix(), p.nextEventID), - Title: title, - StartTime: startTime, - EndTime: endTime, - AllDay: allDay, - Location: location, - Note: note, - Reminds: reminds, - Repeat: repeat, - Lunar: lunar, - LunarMonth: lunarMonth, - LunarDay: lunarDay, - } - - p.mu.Lock() - p.events = append(p.events, event) - p.nextEventID++ - p.mu.Unlock() - p.saveEvents() - - detail := fmt.Sprintf("Event added: %s (ID: %s)", title, event.ID) - if len(reminds) > 0 { - parts := make([]string, len(reminds)) - for i, r := range reminds { - parts[i] = fmt.Sprintf("%dmin", r) - } - detail += fmt.Sprintf(" | 提醒: %s", strings.Join(parts, ", ")) - } - if repeat != RepeatNone { - detail += " | 重复: " + repeat - } - return map[string]interface{}{"content": detail}, nil -} - -// --- Tool: event_list --- - -func (p *Plugin) handleEventList(args map[string]interface{}) (interface{}, error) { - days := int(readArg(args, "days", int64(7))) - if days < 1 { - days = 1 - } - if days > 365 { - days = 365 - } - - now := time.Now() - cutoff := now.AddDate(0, 0, days) - - p.mu.RLock() - upcoming := make([]CalendarEvent, 0) - for _, e := range p.events { - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if evtTime.Before(cutoff) && evtTime.After(now.Add(-24*time.Hour)) { - upcoming = append(upcoming, e) - } - } - p.mu.RUnlock() - - sort.Slice(upcoming, func(i, j int) bool { - return upcoming[i].StartTime < upcoming[j].StartTime - }) - - if len(upcoming) == 0 { - return map[string]interface{}{"content": fmt.Sprintf("No events in the next %d days.", days)}, nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("📋 Events (%d):", len(upcoming))) - for _, e := range upcoming { - timeStr := e.StartTime - if e.EndTime != "" { - timeStr += " → " + e.EndTime - } - extra := "" - if e.Location != "" { - extra += " 📍" + e.Location - } - if len(e.Reminds) > 0 { - parts := make([]string, len(e.Reminds)) - for i, r := range e.Reminds { - parts[i] = fmt.Sprintf("%d′", r) - } - extra += " 🔔" + strings.Join(parts, ",") - } - if e.Lunar { - extra += fmt.Sprintf(" 🌙%d-%d", e.LunarMonth, e.LunarDay) - } - if e.Repeat != "" && e.Repeat != RepeatNone { - extra += " 🔄" + e.Repeat - } - if e.Note != "" { - extra += " 📝" + e.Note - } - lines = append(lines, fmt.Sprintf(" [%s] %s%s", timeStr, e.Title, extra)) - } - - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil -} - -// --- Tool: event_delete --- - -func (p *Plugin) handleEventDelete(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil - } - - p.mu.Lock() - defer p.mu.Unlock() - - found := false - remaining := []CalendarEvent{} - for _, e := range p.events { - if e.ID == id { - found = true - continue - } - pid := e.ParentID - if pid == "" { - pid = e.ID - } - if pid == id { - continue - } - remaining = append(remaining, e) - } - if !found { - return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil - } - p.events = remaining - p.saveEventsLocked() - return map[string]interface{}{"content": "Deleted event and all recurrences: " + id}, nil -} - -// --- Tool: event_update --- - -func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, error) { - id := readArg(args, "id", "") - if id == "" { - return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil - } - - p.mu.Lock() - defer p.mu.Unlock() - - for i := range p.events { - if p.events[i].ID != id { - continue - } - e := &p.events[i] - - if v := readArg(args, "title", ""); v != "" { - e.Title = v - } - if v := readArg(args, "start_time", ""); v != "" { - if _, allDay, ok := parseEventTime(v); ok { - e.StartTime = v - e.AllDay = allDay - } - } - if v := readArg(args, "end_time", ""); v != "" { - if _, _, ok := parseEventTime(v); ok { - e.EndTime = v - } - } - if v := readArg(args, "location", ""); v != "" { - e.Location = v - } - if v := readArg(args, "note", ""); v != "" { - e.Note = v - } - if v := readArg(args, "remind_before", ""); v != "" { - e.Reminds = parseReminds(v) - } - if v := readArg(args, "repeat", ""); v != "" { - switch v { - case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly: - e.Repeat = v - } - } - if v, ok := args["lunar"]; ok && v != nil { - if b, ok := v.(bool); ok { - e.Lunar = b - } else if s, ok := v.(string); ok { - e.Lunar = s == "1" || strings.EqualFold(s, "true") - } - } - if v := readArg(args, "lunar_month", int64(0)); v > 0 { - e.LunarMonth = int(v) - } - if v := readArg(args, "lunar_day", int64(0)); v > 0 { - e.LunarDay = int(v) - } - e.RemindAt = nil - - p.saveEventsLocked() - return map[string]interface{}{"content": "Event updated: " + e.Title}, nil - } - - return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil -} - -// --- Tool: today --- - -func (p *Plugin) handleToday(args map[string]interface{}) (interface{}, error) { - now := time.Now() - todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - todayEnd := todayStart.AddDate(0, 0, 1) - - p.mu.RLock() - events := make([]CalendarEvent, 0) - for _, e := range p.events { - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if evtTime.After(todayStart.Add(-time.Hour)) && evtTime.Before(todayEnd) { - events = append(events, e) - } - } - p.mu.RUnlock() - - sort.Slice(events, func(i, j int) bool { - return events[i].StartTime < events[j].StartTime - }) - - dateStr := now.Format("2006-01-02") - weekday := shortWeekday[now.Weekday()] - lines := []string{fmt.Sprintf("📅 %s 周%s — 今天", dateStr, weekday)} - - if len(events) == 0 { - lines = append(lines, " 今天没有事件") - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil - } - - for _, e := range events { - evtTime, _, _ := parseEventTime(e.StartTime) - timeStr := e.StartTime - if now.Format("2006-01-02") == evtTime.Format("2006-01-02") { - timeStr = evtTime.Format("15:04") - } - countdown := formatTimeUntil(evtTime) - detail := fmt.Sprintf(" %s — %s (%s)", timeStr, e.Title, countdown) - if e.AllDay { - detail = fmt.Sprintf(" 🌙 %s (全天)", e.Title) - } - if e.Location != "" { - detail += " 📍" + e.Location - } - lines = append(lines, detail) - } - - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil -} - -// --- Tool: week --- - -func (p *Plugin) handleWeek(args map[string]interface{}) (interface{}, error) { - now := time.Now() - weekStart := now.AddDate(0, 0, -int(now.Weekday()-time.Monday)) - if now.Weekday() == time.Sunday { - weekStart = now.AddDate(0, 0, -6) - } - weekEnd := weekStart.AddDate(0, 0, 7) - - p.mu.RLock() - dayEvents := make(map[string][]CalendarEvent) - for _, e := range p.events { - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if evtTime.After(weekStart.Add(-time.Hour)) && evtTime.Before(weekEnd) { - dayKey := evtTime.Format("2006-01-02") - dayEvents[dayKey] = append(dayEvents[dayKey], e) - } - } - p.mu.RUnlock() - - for k := range dayEvents { - sort.Slice(dayEvents[k], func(i, j int) bool { - return dayEvents[k][i].StartTime < dayEvents[k][j].StartTime - }) - } - - lines := []string{fmt.Sprintf("📅 %s ~ %s", weekStart.Format("01-02"), weekEnd.AddDate(0, 0, -1).Format("01-02"))} - eventCount := 0 - for i := 0; i < 7; i++ { - d := weekStart.AddDate(0, 0, i) - dayKey := d.Format("2006-01-02") - wd := shortWeekday[d.Weekday()] - prefix := " " - if d.Format("2006-01-02") == now.Format("2006-01-02") { - prefix = "▶" - } - line := fmt.Sprintf("%s %s %s", prefix, d.Format("01-02"), wd) - if evts, ok := dayEvents[dayKey]; ok && len(evts) > 0 { - titles := make([]string, len(evts)) - for i, e := range evts { - timeStr := e.StartTime - if !e.AllDay { - timeStr = parseTimeShort(e.StartTime) - } else { - timeStr = "全天" - } - titles[i] = fmt.Sprintf("%s %s", timeStr, e.Title) - eventCount++ - } - line += " " + strings.Join(titles, ", ") - } - lines = append(lines, line) - } - if eventCount == 0 { - lines = append(lines, " 本周没有事件") - } - - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil -} - -func parseTimeShort(s string) string { - t, _, ok := parseEventTime(s) - if !ok { - return s - } - return t.Format("15:04") -} - -// --- Tool: month --- - -func (p *Plugin) handleMonth(args map[string]interface{}) (interface{}, error) { - now := time.Now() - year := int(readArg(args, "year", int64(now.Year()))) - month := int(readArg(args, "month", int64(now.Month()))) - if month < 1 || month > 12 { - month = int(now.Month()) - } - - firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location()) - lastDay := firstDay.AddDate(0, 1, -1) - daysInMonth := lastDay.Day() - startWeekday := int(firstDay.Weekday()) - if startWeekday == 0 { - startWeekday = 7 - } - - p.mu.RLock() - daySet := make(map[int]bool) - for _, e := range p.events { - evtTime, _, ok := parseEventTime(e.StartTime) - if !ok { - continue - } - if evtTime.Year() == year && evtTime.Month() == time.Month(month) { - daySet[evtTime.Day()] = true - } - } - p.mu.RUnlock() - - monthName := firstDay.Format("January") - lines := []string{fmt.Sprintf("📅 %d年%d月 (%s)", year, month, monthName)} - lines = append(lines, " 一 二 三 四 五 六 日") - lines = append(lines, "") - - row := " " - for i := 1; i < startWeekday; i++ { - row += " " - } - for d := 1; d <= daysInMonth; d++ { - mark := " " - if daySet[d] { - mark = "•" - } - row += fmt.Sprintf(" %2d%s", d, mark) - wd := startWeekday - 1 + d - if wd%7 == 0 || d == daysInMonth { - lines = append(lines, row) - row = " " - } - } - - count := 0 - for d := range daySet { - count++ - _ = d - } - lines = append(lines, fmt.Sprintf("\n本月 %d 天有事件", count)) - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil -} - -// --- Tool: search --- - -func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { - keyword := strings.ToLower(readArg(args, "keyword", "")) - if keyword == "" { - return map[string]interface{}{"isError": true, "content": "keyword is required"}, nil - } - - p.mu.RLock() - results := make([]CalendarEvent, 0) - for _, e := range p.events { - if strings.Contains(strings.ToLower(e.Title), keyword) || - strings.Contains(strings.ToLower(e.Location), keyword) || - strings.Contains(strings.ToLower(e.Note), keyword) { - results = append(results, e) - } - } - p.mu.RUnlock() - - sort.Slice(results, func(i, j int) bool { - return results[i].StartTime < results[j].StartTime - }) - - if len(results) == 0 { - return map[string]interface{}{"content": fmt.Sprintf("No events match: %s", keyword)}, nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("🔍 Found %d events for \"%s\":", len(results), keyword)) - for _, e := range results { - lines = append(lines, fmt.Sprintf(" [%s] %s (ID: %s)", e.StartTime, e.Title, e.ID)) - } - 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) -} diff --git a/third_party/homeagent-sdk/example/memo/plg.json b/third_party/homeagent-sdk/example/memo/plg.json deleted file mode 100644 index c3e1125..0000000 --- a/third_party/homeagent-sdk/example/memo/plg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "memo", - "name_zh": "备忘录", - "name_en": "Memo", - "version": "1.1.0", - "description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "memo", - "todo", - "notes" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/memo/plugin.go b/third_party/homeagent-sdk/example/memo/plugin.go deleted file mode 100644 index 53ab9c1..0000000 --- a/third_party/homeagent-sdk/example/memo/plugin.go +++ /dev/null @@ -1,515 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -// Todo 待办条目:会被主动提醒 -type Todo struct { - ID int64 `json:"id"` - Content string `json:"content"` - CreatedAt int64 `json:"created_at"` - Done bool `json:"done"` -} - -// Memo 备忘录条目:纯记事,不主动提醒 -type Memo struct { - ID int64 `json:"id"` - Content string `json:"content"` - CreatedAt int64 `json:"created_at"` -} - -type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - todos []Todo - nextTID int64 - memos []Memo - nextMID int64 - todoPath string - memoPath string - stopCh chan struct{} - tp string -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.tp = p.name + "_" - // 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true}) - - dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") - if err != nil || dataDirVal == "" { - dataDirVal = "." - } - dir := filepath.Join(fmt.Sprint(dataDirVal), p.name) - if err := os.MkdirAll(dir, 0755); err != nil { - log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err) - } - p.todoPath = filepath.Join(dir, "todos.json") - p.memoPath = filepath.Join(dir, "memos.json") - p.loadTodos() - p.loadMemos() - - // 卸载(删除)时清理数据文件;重载不触发 - s.RegisterOnRemoveHandler(p.cleanupData) - - // ── 待办(会被主动提醒)── - s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{ - Name: p.tp + "todo_add", - Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "content": map[string]interface{}{"type": "string", "description": "待办内容"}, - }, - "required": []string{"content"}, - }, - }, p.handleTodoAdd) - - s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{ - Name: p.tp + "todo_complete", - Description: "将指定ID的待办标记为已完成(不再提醒)。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "integer", "description": "待办ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleTodoComplete) - - s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{ - Name: p.tp + "todo_list", - Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleTodoList) - - s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{ - Name: p.tp + "todo_delete", - Description: "删除指定ID的待办事项(包括已完成的)。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "integer", "description": "待办ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleTodoDelete) - - // ── 备忘(纯记事,不提醒)── - s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{ - Name: p.tp + "memo_create", - Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "content": map[string]interface{}{"type": "string", "description": "备忘录内容"}, - }, - "required": []string{"content"}, - }, - }, p.handleMemoCreate) - - s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{ - Name: p.tp + "memo_list", - Description: "列出所有备忘录,包含ID、内容和创建时间。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleMemoList) - - s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{ - Name: p.tp + "memo_delete", - Description: "删除指定ID的备忘录。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{"type": "integer", "description": "备忘录ID"}, - }, - "required": []string{"id"}, - }, - }, p.handleMemoDelete) - - // 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与) - s.RegisterStage(sdk.StagePreAction, p.stagePreAction) - go p.periodicCheck() - - log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath) - return nil -} - -func (p *Plugin) Stop() error { - close(p.stopCh) - p.saveTodos() - p.saveMemos() - log.Printf("[%s] stopped", p.name) - return nil -} - -func (p *Plugin) loadTodos() { - p.mu.Lock() - defer p.mu.Unlock() - data, err := os.ReadFile(p.todoPath) - if err != nil { - p.todos = []Todo{} - p.nextTID = 1 - return - } - var store struct { - Todos []Todo `json:"todos"` - NextID int64 `json:"next_id"` - } - if json.Unmarshal(data, &store) != nil { - p.todos = []Todo{} - p.nextTID = 1 - return - } - p.todos = store.Todos - p.nextTID = store.NextID - if p.todos == nil { - p.todos = []Todo{} - } - if p.nextTID < 1 { - p.nextTID = 1 - } -} - -func (p *Plugin) loadMemos() { - p.mu.Lock() - defer p.mu.Unlock() - data, err := os.ReadFile(p.memoPath) - if err != nil { - p.memos = []Memo{} - p.nextMID = 1 - return - } - var store struct { - Memos []Memo `json:"memos"` - NextID int64 `json:"next_id"` - } - if json.Unmarshal(data, &store) != nil { - p.memos = []Memo{} - p.nextMID = 1 - return - } - p.memos = store.Memos - p.nextMID = store.NextID - if p.memos == nil { - p.memos = []Memo{} - } - if p.nextMID < 1 { - p.nextMID = 1 - } -} - -func (p *Plugin) saveTodos() { - p.mu.RLock() - data, _ := json.MarshalIndent(map[string]interface{}{ - "todos": p.todos, - "next_id": p.nextTID, - }, "", " ") - p.mu.RUnlock() - atomicWriteJSON(p.todoPath, data) -} - -func (p *Plugin) saveMemos() { - p.mu.RLock() - data, _ := json.MarshalIndent(map[string]interface{}{ - "memos": p.memos, - "next_id": p.nextMID, - }, "", " ") - p.mu.RUnlock() - atomicWriteJSON(p.memoPath, data) -} - -// ── 待办:未完成计数与提醒 ── - -func (p *Plugin) pendingTodoCount() int { - p.mu.RLock() - defer p.mu.RUnlock() - n := 0 - for _, t := range p.todos { - if !t.Done { - n++ - } - } - return n -} - -func (p *Plugin) pendingTodos() []Todo { - p.mu.RLock() - defer p.mu.RUnlock() - var out []Todo - for _, t := range p.todos { - if !t.Done { - out = append(out, t) - } - } - return out -} - -// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示) -func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error { - n := p.pendingTodoCount() - if n == 0 { - return nil - } - ctx.Lock() - ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ - "role": "system", - "content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp), - }) - ctx.Unlock() - return nil -} - -// periodicCheck 周期主动提醒未完成待办(备忘录不提醒) -func (p *Plugin) periodicCheck() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-p.stopCh: - return - case <-ticker.C: - n := p.pendingTodoCount() - if n == 0 { - continue - } - if p.sdk != nil { - // NoMemory:这是定时提醒,不是记忆内容。 - p.sdk.InjectInterruptTextOpts(p.name, p.name, - fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true}) - } - } - } -} - -// ── 待办工具 ── - -func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) { - content, _ := args["content"].(string) - if content == "" { - return errorResult("content is required"), nil - } - - p.mu.Lock() - todo := Todo{ - ID: p.nextTID, - Content: content, - CreatedAt: time.Now().Unix(), - Done: false, - } - p.nextTID++ - p.todos = append(p.todos, todo) - p.mu.Unlock() - p.saveTodos() - - return map[string]interface{}{ - "content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID), - "id": todo.ID, - }, nil -} - -func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) { - id, ok := args["id"].(float64) - if !ok { - return errorResult("id is required"), nil - } - - p.mu.Lock() - found := false - for i := range p.todos { - if p.todos[i].ID == int64(id) && !p.todos[i].Done { - p.todos[i].Done = true - found = true - break - } - } - p.mu.Unlock() - - if !found { - return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil - } - p.saveTodos() - - return map[string]interface{}{ - "content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)), - }, nil -} - -func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) { - todos := p.pendingTodos() - if len(todos) == 0 { - return map[string]interface{}{ - "content": "暂无未完成的待办", - }, nil - } - - var sb strings.Builder - for i, t := range todos { - ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04") - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts)) - } - - return map[string]interface{}{ - "content": sb.String(), - "count": len(todos), - }, nil -} - -func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) { - id, ok := args["id"].(float64) - if !ok { - return errorResult("id is required"), nil - } - - p.mu.Lock() - found := false - for i := range p.todos { - if p.todos[i].ID == int64(id) { - p.todos = append(p.todos[:i], p.todos[i+1:]...) - found = true - break - } - } - p.mu.Unlock() - - if !found { - return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil - } - p.saveTodos() - - return map[string]interface{}{ - "content": fmt.Sprintf("待办 %d 已删除", int64(id)), - }, nil -} - -// ── 备忘工具 ── - -func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) { - content, _ := args["content"].(string) - if content == "" { - return errorResult("content is required"), nil - } - - p.mu.Lock() - memo := Memo{ - ID: p.nextMID, - Content: content, - CreatedAt: time.Now().Unix(), - } - p.nextMID++ - p.memos = append(p.memos, memo) - p.mu.Unlock() - p.saveMemos() - - return map[string]interface{}{ - "content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID), - "id": memo.ID, - }, nil -} - -func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) { - id, ok := args["id"].(float64) - if !ok { - return errorResult("id is required"), nil - } - - p.mu.Lock() - found := false - for i := range p.memos { - if p.memos[i].ID == int64(id) { - p.memos = append(p.memos[:i], p.memos[i+1:]...) - found = true - break - } - } - p.mu.Unlock() - - if !found { - return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil - } - p.saveMemos() - - return map[string]interface{}{ - "content": fmt.Sprintf("备忘录 %d 已删除", int64(id)), - }, nil -} - -func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) { - p.mu.RLock() - memos := append([]Memo{}, p.memos...) - p.mu.RUnlock() - - if len(memos) == 0 { - return map[string]interface{}{ - "content": "暂无备忘录", - }, nil - } - - var sb strings.Builder - for i, m := range memos { - ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04") - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts)) - } - - return map[string]interface{}{ - "content": sb.String(), - "count": len(memos), - }, nil -} - -func errorResult(msg string) map[string]interface{} { - return map[string]interface{}{ - "isError": true, - "content": msg, - } -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name, stopCh: make(chan struct{})}, nil -} - -// cleanupData 卸载时清理数据文件(待办 + 备忘) -func (p *Plugin) cleanupData() { - if p.todoPath != "" { - os.Remove(p.todoPath) - } - if 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) -} diff --git a/third_party/homeagent-sdk/example/qq/plg.json b/third_party/homeagent-sdk/example/qq/plg.json deleted file mode 100644 index 011b52b..0000000 --- a/third_party/homeagent-sdk/example/qq/plg.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "qq", - "name_zh": "QQ消息", - "name_en": "qq", - "version": "1.4.1", - "description": "QQ 消息收发插件,通过 NapCat 协议桥接", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "qq", - "messaging" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": false, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go deleted file mode 100644 index d295527..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ /dev/null @@ -1,3426 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/hmac" - "encoding/base64" - "encoding/binary" - "encoding/json" - "fmt" - "io" - "log" - "math" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -type ForwardRule struct { - GroupID int64 `json:"group_id"` - Host string `json:"host"` - Port int `json:"port"` - Password string `json:"password"` - Template string `json:"template"` -} - -func rconSend(host string, port int, password, cmd string) error { - addr := net.JoinHostPort(host, strconv.Itoa(port)) - conn, err := net.DialTimeout("tcp", addr, 5*time.Second) - if err != nil { - return fmt.Errorf("rcon dial: %w", err) - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(10 * time.Second)) - - buf := make([]byte, 4096) - // Login - pkt := rconPacket(1, 3, password) - if _, err := conn.Write(pkt); err != nil { - return fmt.Errorf("rcon login write: %w", err) - } - if _, err := io.ReadFull(conn, buf[:12]); err != nil { - return fmt.Errorf("rcon login read: %w", err) - } - // Command - pkt = rconPacket(2, 2, cmd) - if _, err := conn.Write(pkt); err != nil { - return fmt.Errorf("rcon cmd write: %w", err) - } - n, err := io.ReadFull(conn, buf[:12]) - if err != nil && err != io.ErrUnexpectedEOF { - return fmt.Errorf("rcon cmd read: %w (n=%d)", err, n) - } - return nil -} - -func rconPacket(id, typ int32, body string) []byte { - b := []byte(body) - b = append(b, 0) // null terminator - b = append(b, 0) // padding - length := 4 + 4 + len(b) - pkt := make([]byte, 12+len(b)) - binary.LittleEndian.PutUint32(pkt, uint32(length)) - binary.LittleEndian.PutUint32(pkt[4:], uint32(id)) - binary.LittleEndian.PutUint32(pkt[8:], uint32(typ)) - copy(pkt[12:], b) - return pkt -} - -type DownloadTask struct { - FileID string `json:"file_id"` - Filename string `json:"filename"` - Status string `json:"status"` - Path string `json:"path,omitempty"` - Error string `json:"error,omitempty"` - CreatedAt string `json:"created_at"` -} - -type qqAuthContext struct { - active bool - owner bool - messageID int64 - userID int64 - groupID int64 - isGroup bool - generation uint64 -} - -var qqMessageIDRe = regexp.MustCompile(`message_id=(-?\d+)`) - -// 默认只开放公共信息与当前 QQ 会话所需能力。日历、邮件、记忆、知识库、 -// 主机文件/命令、设备、配置、插件管理及 QQ 联系人/跨会话列表均不在白名单中。 -const defaultPublicToolAllowlist = `["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]` - -const defaultGroupToolAllowlists = `{"*":["output_send__qq","output_list_channels","qq_get_message","qq_get_history","qq_mark_read","qq_get_group_member_info","qq_get_group_files","qq_video_download","weather_*","browser_search","browser_fetch","browser_render","ocr_*","multimodal_*","bili_*","music_*","ai_image_*"]}` - -const ( - // 单轮 QQ 触发的工具调用总数上限(0 = 不限制)。只作跑飞兜底, - // 不应拦下正常的长时间多步任务。 - defaultMaxQQToolCalls = 200 - // 单轮 QQ 主动发送的不同消息条数上限(0 = 不限制)。 - defaultMaxQQOutputCalls = 20 - // 单轮内同一条消息(参数完全相同)允许重复发送的次数(0 = 不限制)。 - defaultMaxDuplicateSend = 1 -) - -type Plugin struct { - name string - sdk *sdk.PluginSDK - listenAddr string - napcatURL string - remoteDir string - filesDir string - webhookToken string - ownerIDs []int64 - botID int64 - botNickname string - dmPolicy string - groupPolicy string - httpClient *http.Client - allowFrom map[int64]struct{} - groupAllowFrom map[int64]struct{} - srv *http.Server - agentfsDir string - downloadMu sync.Mutex - downloadTasks []*DownloadTask - typingMu sync.Mutex - typingMap map[int64]*typingState - - authMu sync.RWMutex - auth qqAuthContext - authGeneration uint64 - authByMessageID map[int64]qqAuthContext - lastDenial string - denialLocked bool - toolCallCount int - outputCallCount int - outputSignatures map[string]int - maxQQToolCalls int - maxQQOutputCalls int - maxDuplicateSend int - groupToolAllowlists map[int64][]string // 0 表示通配配置 "*" - privateToolAllowlist []string - - // msg_id → peer 映射 + 会话最新状态(<7 天兜底 get_history + list_chats) - msgMu sync.Mutex - msgMap map[int64]msgRef // message_id → {peer, time} - chats map[int64]*chatMeta // peerID → 会话状态(群号或 QQ 号) - - // 消息合并(debounce):同一会话、同一发送者在 batchWindow 内连续到达的消息 - // 合并成一次中断。同一个人连发「在吗」「帮我看看」「报错是这个」三条, - // 逐条注入会把 Agent 唤醒三次,且前两次拿到的信息都不完整。 - batchMu sync.Mutex - batches map[string]*pendingBatch - batchWindow time.Duration // 最后一条到达后再等多久(<=0 = 关闭合并,逐条投递) - batchMax time.Duration // 一批最长等多久(防持续刷屏时永远不投) - - // injectHook 仅供测试:非 nil 时 injectInterrupt 走它而不是真实 SDK。 - injectHook func(text, level string) -} - -// pendingBatch 是一批待投递的消息(同一会话、同一发送者、短时间内的连续消息)。 -type pendingBatch struct { - key string - isGroup bool - userID int64 - groupID int64 - nickname string - msgIDs []int64 - single string // 单条时沿用的原文(含所有者/高危前缀),保证 n==1 行为不变 - owner bool - highRisk bool - first time.Time - timer *time.Timer -} - -// qqBatchKey 同一会话 + 同一发送者 = 一组。私聊按 QQ 号;群聊按 (群号, QQ 号)—— -// 群里不同人各发各的,不该并成一条。 -func qqBatchKey(msgType string, groupID, userID int64) string { - if msgType == "group" { - return fmt.Sprintf("g:%d:%d", groupID, userID) - } - return fmt.Sprintf("p:%d", userID) -} - -type typingState struct { - userID int64 - stopCh chan struct{} -} - -// msgRef 一条已见过的消息的引用:只记录 msg_id → (peer, time) 映射,不缓存正文。 -// 用途:NapCat get_msg 的临时短号 <7 天失效时,据此把 get_msg 兜底为按 peer 拉 get_history。 -type msgRef struct { - peerID int64 - isGroup bool - time int64 // 秒级时间戳 -} - -// chatMeta 一个会话(群/私聊)的最新状态,供 list_chats 展示。 -// 只维护最新一条的短摘要(≤qqLastSumLen 字符)与未读数,不缓存完整历史。 -type chatMeta struct { - peerID int64 - isGroup bool - name string - unread int - lastTime int64 - lastText string - lastNick string -} - -const qqMsgTTL = 7 * 86400 // 7 天:msg_id → peer 映射的有效期 -const qqLastSumLen = 60 // list_chats 里最新一条摘要的最大长度 - -// snapshotMsg 记录一条策略允许的消息:更新 msg_id→peer 映射与会话未读/最新状态。 -// 不缓存消息正文(仅最新一条留 ≤qqLastSumLen 的摘要供列表展示)。 -func (p *Plugin) snapshotMsg(msgID, peerID int64, isGroup bool, t int64, nickname, text string) { - if msgID <= 0 { - return - } - p.msgMu.Lock() - defer p.msgMu.Unlock() - - // msg_id 映射(7 天 TTL,惰性清理) - p.msgMap[msgID] = msgRef{peerID: peerID, isGroup: isGroup, time: t} - now := time.Now().Unix() - if len(p.msgMap) > 2000 { // 定期清理过期项 - for k, v := range p.msgMap { - if now-v.time > qqMsgTTL { - delete(p.msgMap, k) - } - } - } - - ch := p.chats[peerID] - if ch == nil { - ch = &chatMeta{peerID: peerID, isGroup: isGroup} - p.chats[peerID] = ch - } - if ch.name == "" { - if isGroup { - ch.name = fmt.Sprintf("群%d", peerID) - } else { - ch.name = nickname - } - } - // 按到达次序维护未读与最新摘要:仅当本条更新时才更新 lastTime/lastText(保持按时间排) - if t > ch.lastTime { - ch.lastTime = t - ch.lastText = text - ch.lastNick = nickname - } - ch.unread++ -} - -// lookupMsgRef 查 msg_id 映射,返回 (peer, isGroup, time, ok)。超过 7 天视为无效(交给 get_history)。 -func (p *Plugin) lookupMsgRef(msgID int64) (int64, bool, int64, bool) { - p.msgMu.Lock() - defer p.msgMu.Unlock() - ref, ok := p.msgMap[msgID] - if !ok { - return 0, false, 0, false - } - now := time.Now().Unix() - if now-ref.time > qqMsgTTL { - delete(p.msgMap, msgID) - return 0, false, 0, false - } - return ref.peerID, ref.isGroup, ref.time, true -} - -// markChatRead 清零某会话未读数(模型处理完该会话后调用)。 -func (p *Plugin) markChatRead(peerID int64) { - p.msgMu.Lock() - defer p.msgMu.Unlock() - if ch := p.chats[peerID]; ch != nil { - ch.unread = 0 - } -} - -// listChats 返回会话列表:按最新消息时间降序,含未读数与最新一条摘要。 -func (p *Plugin) listChats(capN int) []map[string]interface{} { - p.msgMu.Lock() - list := make([]*chatMeta, 0, len(p.chats)) - for _, c := range p.chats { - list = append(list, c) - } - p.msgMu.Unlock() - - // 降序(最新消息在前) - for i := 1; i < len(list); i++ { - for j := i; j > 0 && list[j].lastTime > list[j-1].lastTime; j-- { - list[j], list[j-1] = list[j-1], list[j] - } - } - if len(list) > capN { - list = list[:capN] - } - - out := make([]map[string]interface{}, 0, len(list)) - for _, c := range list { - typ := "private" - if c.isGroup { - typ = "group" - } - item := map[string]interface{}{ - "peer_id": c.peerID, - "type": typ, - "name": c.name, - "unread": c.unread, - "last_text": c.lastText, - "last_nick": c.lastNick, - } - if c.lastTime > 0 { - item["last_time"] = time.Unix(c.lastTime, 0).Format("2006-01-02 15:04") - } - out = append(out, item) - } - return out -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - - s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "owner", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ", Description: "Bot 所有者 QQ 号列表,逗号分隔。所有者无论私聊或群聊均拥有完整工具权限", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "Bot 所有者 QQ(旧配置)", Description: "兼容旧版 admin 配置;owner 为空时作为 Bot 所有者列表", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_tool_allowlists", Default: defaultGroupToolAllowlists, Type: "string", DisplayName: "群聊工具白名单", Description: "JSON 对象:群号到允许工具名/前缀*的数组;* 为未单独配置群的默认白名单。Bot 所有者不受限制", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "private_tool_allowlist", Default: defaultPublicToolAllowlist, Type: "string", DisplayName: "非所有者私聊工具白名单", Description: "JSON 数组,支持工具精确名和尾部 * 前缀。硬性私人资源工具不能由此白名单放行;Bot 所有者不受权限限制", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_tool_calls", Default: defaultMaxQQToolCalls, Type: "int", DisplayName: "单轮 QQ 工具调用上限", Description: "QQ 输入触发的单轮推理最多调用工具次数(0=不限制);仅作跑飞兜底,不拦参数不同的必需调用", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_qq_output_calls", Default: defaultMaxQQOutputCalls, Type: "int", DisplayName: "单轮 QQ 发送上限", Description: "单轮最多主动发送的不同消息条数(0=不限制);参数不同的消息不视为重复", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "max_duplicate_qq_send", Default: defaultMaxDuplicateSend, Type: "int", DisplayName: "单轮相同 QQ 发送上限", Description: "单轮内参数完全相同的 output_send__qq 允许重复的次数(0=不限制);这才是循环保险的真正触发条件", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录(CQ file/image 自动下载到此目录)", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_window_ms", Default: "1500", Type: "int", DisplayName: "消息合并窗口(毫秒)", Description: "同一会话同一发送者的连续消息在该窗口内合并成一次中断并告知共几条;0=关闭合并(逐条投递)", Category: "qq"}) - s.Settings().RegisterDef(sdk.ConfigDef{Key: "batch_max_ms", Default: "30000", Type: "int", DisplayName: "消息合并上限(毫秒)", Description: "一批消息最长等这么久就投递,避免对方持续刷屏时一直不唤醒 Agent", Category: "qq"}) - - settings := s.Settings() - - p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") - p.webhookToken = getSetting[string](settings, "webhook_token", "") - p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") - ownerRaw := getSetting[string](settings, "owner", "") - if strings.TrimSpace(ownerRaw) == "" { - ownerRaw = getSetting[string](settings, "admin", "") - } - p.ownerIDs = parseIDList(ownerRaw) - p.groupToolAllowlists = parseGroupToolAllowlists(getSetting[string](settings, "group_tool_allowlists", defaultGroupToolAllowlists)) - p.privateToolAllowlist = parseToolAllowlist(getSetting[string](settings, "private_tool_allowlist", defaultPublicToolAllowlist)) - p.maxQQToolCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_tool_calls", int64(defaultMaxQQToolCalls))), defaultMaxQQToolCalls) - p.maxQQOutputCalls = nonNegativeOrDefault(int(getSetting[int64](settings, "max_qq_output_calls", int64(defaultMaxQQOutputCalls))), defaultMaxQQOutputCalls) - p.maxDuplicateSend = nonNegativeOrDefault(int(getSetting[int64](settings, "max_duplicate_qq_send", int64(defaultMaxDuplicateSend))), defaultMaxDuplicateSend) - p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) - p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) - p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) - p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) - p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") - p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/") - p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/") - p.batchWindow = time.Duration(getSetting[int64](settings, "batch_window_ms", 1500)) * time.Millisecond - p.batchMax = time.Duration(getSetting[int64](settings, "batch_max_ms", 30000)) * time.Millisecond - if p.batchWindow < 0 { - p.batchWindow = 0 - } - os.MkdirAll(p.remoteDir, 0755) - - p.httpClient = &http.Client{Timeout: 30 * time.Second} - - // msg_id → peer 映射 + 会话状态(不缓存正文) - p.msgMap = make(map[int64]msgRef) - p.batches = make(map[string]*pendingBatch) - p.chats = make(map[int64]*chatMeta) - - // 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s) - p.fetchBotInfo() - if p.botID == 0 { - log.Printf("[qq] warning: 获取 Bot 身份失败,群 @ 检查将拒绝所有未提及消息") - } - - tp := p.name + "_" - - botInfo := "" - if p.botNickname != "" { - botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname) - if p.botID > 0 { - botInfo += fmt.Sprintf(",QQ号是%d", p.botID) - } - botInfo += "。" - } - - // ---- 注册输出通道 ---- - s.RegisterOutputChannel("qq", sdk.CapText|sdk.CapFile|sdk.CapImage|sdk.CapAudio, - `发送QQ群聊/私聊消息,支持文字、语音、图片、文件。 -meta JSON 格式: -{ - "group_id": 123456, // 群号 - "user_id": 123456, // QQ号 - "reply_to": 12345 // 可选,回复指定消息 ID -} -路由规则:仅 group_id 发群;仅 user_id 发私聊;两者同时存在时发到 group_id,并在消息头 @user_id。 -type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图片URL)/ file(文件URL)`, - sdk.ChannelDef{}, p.handleChannelOutput) - - // ---- 注册输入通道(记忆计算层行为) ---- - inputCleaner := func(text string) string { - // 从中断模板中提取语义内容:消息来源和发送者昵称 - // 模板: 来自「昵称」的私聊/群聊消息(message_id=N)... - // 模板: 【重要!老大消息】来自「昵称」... - cleaned := text - // 去掉模板前缀 - if strings.HasPrefix(cleaned, "【重要!老大消息】") { - cleaned = strings.TrimPrefix(cleaned, "【重要!老大消息】") - } - // 提取 "来自「XXX」" 中的昵称作为关键词 - if start := strings.Index(cleaned, "来自「"); start >= 0 { - if end := strings.Index(cleaned[start:], "」"); end >= 0 { - nick := cleaned[start+len("来自「") : start+end] - cleaned = nick - } - } - return cleaned - } - // qq 通道到达的是**中断通知(meta)**,不是用户正文,不据它召回; - // 真实正文由 qq_get_message 取回后由该工具声明 RecallPolicy=auto 触发召回。 - s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner, RecallPolicy: sdk.RecallPolicyNone}) - - // 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化 - cleaner := func(output string) string { - var r struct{ Content string } - if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" { - return r.Content - } - return output - } - - // ---- 消息 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_message", Description: botInfo + "通过 message_id 从 NapCat 实时获取消息正文、发送者、附件等信息。message_id 从中断消息的 message_id=N 获取,或从 reply_to 的 message_id 获取。", - NoMemory: false, - Cleaner: cleaner, - // 消息正文只在当轮需要(决策怎么回复);用完即裁剪。 - // 不裁的后果是每条 QQ 消息的完整正文都留在 L0 上下文里, - // 长会话下持续挤占 token 预算(§13.8)。 - ContextPolicy: "prune", - // 正文才是真实内容:取回后用**正文**触发一次召回, - // 而不是用中断通知的 meta 文本去召回(那是无关词)。 - RecallPolicy: "auto", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(从中断消息的 message_id=N 或 reply_to.message_id 获取)"}, - }, "required": []string{"message_id"}, - }, - }, p.handleGetMessage) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "send_file", Description: "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号(与group_id二选一)"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - "as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送(true)还是作为文件(false,默认)"}, - }, - }, - NoMemory: true, - }, p.handleSendFile) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_history", Description: "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容。如果消息包含文件,会额外返回 files 字段(含 file_id 和 name),可用 qq_download_file 工具下载。", - NoMemory: false, - Cleaner: cleaner, - // 与 get_message 同理:返回的是**真实聊天正文**,不只当轮需要, - // 还可能牵出与这些正文相关的长期记忆。故取回后既裁剪(用完不长期占 - // L0)又据正文召回(取进来)。不声明 recall 的话就是「记忆里有、但 - // 拉回历史消息时不注入」的盲区。 - ContextPolicy: "prune", - RecallPolicy: "auto", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, - "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, - }, "required": []string{}, - }, - }, p.handleGetHistory) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "list_chats", Description: "获取QQ会话列表,与真人客户端一致:按最新消息先后排序,每条标注会话(群/私聊)、会话名、未读消息数、最新一条消息摘要与时间。用于发现有未读消息的会话,再配合 qq_get_history 拉取对应会话内容、output_send__qq 回复。", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "count": map[string]interface{}{"type": "integer", "description": "最多返回会话数,默认10"}, - }, "required": []string{}, - }, - }, p.handleListChats) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "mark_read", Description: "将某个会话的未读计数清零(对象:群聊传 group_id,私聊传 user_id)。处理完某会话消息后可调用,让 list_chats 的未读数回到0,与真人客户端标记已读一致。", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, - }, "required": []string{}, - }, - }, p.handleMarkRead) - - // ---- 查询 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_groups", Description: "获取QQ群列表,可按关键词搜索群名", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, - }, p.handleGetGroups) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_friends", Description: "获取QQ好友列表,可按昵称/备注关键词搜索", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, - }, - }, - }, p.handleGetFriends) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_recent_contacts", Description: "查看最近有消息的联系人和群聊,返回最近消息概览(含消息数、最后一条消息内容)。可用于发现有谁发过消息但未处理。", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "count": map[string]interface{}{"type": "integer", "description": "获取数量,默认10"}, - }, - }, - }, p.handleGetRecentContacts) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "resolve_name", Description: "将QQ号或群号解析为可读的用户昵称或群名称", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, - }, - }, - }, p.handleResolveName) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "resolve_nickname", Description: "按昵称/备注/群名片搜索QQ用户,返回匹配的QQ号和详细信息。支持搜索好友列表或指定群成员。", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(昵称/备注/群名片)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "所在群号(可选),不传则搜索好友列表"}, - }, "required": []string{"keyword"}, - }, - }, p.handleResolveNickname) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_group_member_info", Description: "获取QQ群成员详细信息", - NoMemory: false, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号"}, - }, "required": []string{"group_id", "user_id"}, - }, - }, p.handleGetGroupMemberInfo) - - // ---- 群管理 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "group_manage", Description: "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作命令"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"}, - "message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"}, - "name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"}, - "card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"}, - "title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"}, - "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"}, - "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"}, - "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"}, - "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, - }, - }, - NoMemory: true, - }, p.handleGroupManage) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "friend_action", Description: "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"}, - "remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"}, - "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, - }, - }, - NoMemory: true, - }, p.handleFriendAction) - - // ---- 文件 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_group_files", Description: "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", - NoMemory: false, - Cleaner: cleaner, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, - "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, - "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, - "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, - "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, - "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, - }, - }, - }, p.handleGetGroupFiles) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "download_file", Description: "从聊天记录下载文件到本地。file_id 从 qq_get_message/qq_get_history 的 files 字段获取。群文件建议提供 group_id,私聊文件建议提供 user_id 以提高成功率。异步下载,完成后推送通知。", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "file_id": map[string]interface{}{"type": "string", "description": "文件 ID(从 qq_get_message 或 qq_get_history 的 files 字段获取)"}, - "url": map[string]interface{}{"type": "string", "description": "文件下载 URL(可选,qq_get_message 返回的 url 字段)"}, - "group_id": map[string]interface{}{"type": "integer", "description": "群号(可选,群文件下载)"}, - "user_id": map[string]interface{}{"type": "integer", "description": "私聊对象QQ号(可选,私聊文件下载)"}, - "filename": map[string]interface{}{"type": "string", "description": "保存文件名(可选,默认用原文件名)"}, - }, "required": []string{"file_id"}, - }, - NoMemory: true, - }, p.handleDownloadFile) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "get_download_tasks", Description: "查看所有下载任务及状态(running/done/failed),包含文件名、保存路径、错误信息等", - NoMemory: false, - Cleaner: cleaner, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{}, - }, - }, p.handleGetDownloadTasks) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "upload_group_file", Description: "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, - "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, - "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, - }, "required": []string{"group_id", "file"}, - }, - NoMemory: true, - }, p.handleUploadGroupFile) - - // ---- 文档/视频/网页工具 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "read_document", Description: "读取文档内容文本。支持 PDF、DOCX、DOC、XLSX、XLS、PPTX、PPT、TXT、CSV、MD 格式。使用 libreoffice + pandoc 转换提取文本,返回前 20000 字符。适合处理用户发来的文档文件。", - NoMemory: false, - Cleaner: cleaner, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "path": map[string]interface{}{"type": "string", "description": "文档文件路径(已保存到本地的文件路径)"}, - }, "required": []string{"path"}, - }, - }, p.handleReadDocument) - - p.regTool(s, sdk.ToolDef{ - Name: tp + "video_download", Description: "下载视频到本地。支持 B站、YouTube 等主流视频网站(通过 yt-dlp)。先调用 info_only 查看视频信息,再下载。下载后文件保存在 agentfs 目录。", - NoMemory: false, - Cleaner: cleaner, - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "视频分享链接"}, - "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、时长、清晰度列表),不下"}, - }, "required": []string{"url"}, - }, - }, p.handleVideoDownload) - - // ---- 附加 ---- - p.regTool(s, sdk.ToolDef{ - Name: tp + "send_like", Description: "给QQ好友点赞/戳一戳", - Parameters: map[string]interface{}{ - "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, - "times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20,默认1"}, - }, "required": []string{"user_id"}, - }, - NoMemory: true, - }, p.handleSendLike) - - // 全局权限门:只有 QQ 当前输入需要受此插件约束;Bot 所有者始终完整放行。 - s.RegisterStage(sdk.StageOnInput, p.onInputAuthContext, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageBeforeToolcall, p.beforeToolcall, sdk.StageScopeGlobal) - // before_toolcall 的 Response 只用于拒绝当前工具。下一次模型补全到达时必须清掉, - // 否则内核会把它误当作“结束整个推理”的最终响应。 - s.RegisterStage(sdk.StagePostAction, p.clearDeniedResponse, sdk.StageScopeGlobal) - s.RegisterStage(sdk.StageAfterOutput, p.afterOutputAuthContext, sdk.StageScopeGlobal) - - // ---- HTTP server for NapCat webhook ---- - mux := http.NewServeMux() - mux.HandleFunc("/", p.handleWebhook) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"status":"ok"}`)) - }) - p.srv = &http.Server{Addr: p.listenAddr, Handler: mux} - go func() { - log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL) - if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[qq] http: %v", err) - } - }() - - log.Printf("[qq] plugin started: %s (%d tools)", p.name, 15) - return nil -} - -func (p *Plugin) Stop() error { - p.typingMu.Lock() - for _, st := range p.typingMap { - select { - case <-st.stopCh: - default: - close(st.stopCh) - } - } - p.typingMu.Unlock() - // 停机前把未到点的合并批次立刻投出去,别把对方的消息吞掉。 - p.flushAllBatches() - if p.srv != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - p.srv.Shutdown(ctx) - } - return nil -} - -func (p *Plugin) regTool(s *sdk.PluginSDK, def sdk.ToolDef, handler sdk.ToolHandler) { - s.RegisterTool(def.Name, def, handler) -} - -// ======== Bot Identity ======== - -func (p *Plugin) fetchBotInfo() { - resp, err := p.rawNapcat("get_login_info", nil) - if err != nil { - log.Printf("[qq] fetch login info: %v", err) - return - } - var info struct { - Status string `json:"status"` - Data *struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(resp), &info); err != nil { - log.Printf("[qq] parse login info: %v", err) - return - } - if info.Data != nil { - p.botID = info.Data.UserID - p.botNickname = info.Data.Nickname - log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID) - } -} - -// rawNapcat sends a request to NapCat and returns raw JSON string. -func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return "", fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return string(body), nil -} - -// getSetting reads a setting from the SDK; returns fallback if unset or wrong type. -func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return fallback - } - switch any(fallback).(type) { - case string: - if str, ok := v.(string); ok { - return any(str).(T) - } - case int64: - switch val := v.(type) { - case int: - return any(int64(val)).(T) - case int64: - return any(val).(T) - case float64: - return any(int64(val)).(T) - case string: - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return any(n).(T) - } - } - case float64: - switch val := v.(type) { - case float64: - return any(val).(T) - case string: - if n, err := strconv.ParseFloat(val, 64); err == nil { - return any(n).(T) - } - } - } - return fallback -} - -func normalizePolicy(v string) string { - switch strings.ToLower(strings.TrimSpace(v)) { - case "allowlist": - return "allowlist" - case "disabled": - return "disabled" - default: - return "open" - } -} - -func parseIDSet(raw string) map[int64]struct{} { - out := make(map[int64]struct{}) - for _, part := range strings.Split(raw, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if n, err := strconv.ParseInt(part, 10, 64); err == nil { - out[n] = struct{}{} - } - } - return out -} - -func parseIDList(raw string) []int64 { - var out []int64 - for _, part := range strings.Split(raw, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 { - out = append(out, n) - continue - } - // 兼容历史坏数据:科学计数法存库的值(如 2.198972886e+09) - if f, err := strconv.ParseFloat(part, 64); err == nil && f > 0 && f == math.Trunc(f) { - out = append(out, int64(f)) - } - } - return out -} - -func (p *Plugin) isOwner(userID int64) bool { - for _, id := range p.ownerIDs { - if id == userID { - return true - } - } - return false -} - -func parseToolAllowlist(raw string) []string { - var patterns []string - if json.Unmarshal([]byte(raw), &patterns) != nil { - return nil - } - out := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - if pattern = strings.TrimSpace(pattern); pattern != "" { - out = append(out, pattern) - } - } - return out -} - -func parseGroupToolAllowlists(raw string) map[int64][]string { - var encoded map[string][]string - if json.Unmarshal([]byte(raw), &encoded) != nil { - return map[int64][]string{} - } - out := make(map[int64][]string, len(encoded)) - for key, patterns := range encoded { - var groupID int64 - if key != "*" { - parsed, err := strconv.ParseInt(strings.TrimSpace(key), 10, 64) - if err != nil || parsed <= 0 { - continue - } - groupID = parsed - } - clean := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - if pattern = strings.TrimSpace(pattern); pattern != "" { - clean = append(clean, pattern) - } - } - out[groupID] = clean - } - return out -} - -func matchesToolAllowlist(name string, patterns []string) bool { - for _, pattern := range patterns { - if pattern == name { - return true - } - if strings.HasSuffix(pattern, "*") && strings.HasPrefix(name, strings.TrimSuffix(pattern, "*")) { - return true - } - } - return false -} - -// nonNegativeOrDefault 保留 0(表示“不限制”),仅把负数纠正为默认值。 -func nonNegativeOrDefault(value, fallback int) int { - if value < 0 { - return fallback - } - return value -} - -// isHardPrivateTool 是不可由群/私聊白名单覆盖的私人资源边界。 -// output_send__qq 及 QQ 当前会话工具在参数级另行约束,不在此处按名称误杀。 -func isHardPrivateTool(name string) bool { - for _, prefix := range []string{ - "calendar_", "email_", "mail_", "agentmail_", "memory_", "knowledge_", - "device_", "devicectl_", "terminal_", "shell_", "command_", "exec_", - "filesystem_", "agentfs_", "config_", "settings_", "plugin_", "plugins_", - } { - if strings.HasPrefix(name, prefix) { - return true - } - } - return matchesToolAllowlist(name, []string{ - "read_file", "write_file", "edit_file", "delete_file", "list_files", "run_command", - "homeagent_config", "homeagent_restart", "output_send__email", "output_send__mail", - }) -} - -func argInt64(args map[string]interface{}, key string) (int64, bool) { - value, exists := args[key] - if !exists || value == nil { - return 0, false - } - parsed, err := convInt64(value) - return parsed, err == nil && parsed != 0 -} - -func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}, auth qqAuthContext) (bool, string) { - if !auth.active || auth.owner { - return true, "" - } - // 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。 - // - // 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta - // (group_id / user_id)决定 —— handleChannelOutput 会强制要求该字段存在, - // 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门, - // 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去, - // 报「可信 QQ 会话身份不完整」。 - // 「只能访问当前会话」这类限制只对**读取类**工具(get_history / mark_read / - // get_message)成立 —— 那才是真的不能跨会话读。 - if name == "output_send__"+p.name { - return true, "" - } - - currentPeer := auth.userID - if auth.isGroup { - currentPeer = auth.groupID - } - if currentPeer == 0 { - return false, "可信 QQ 会话身份不完整" - } - matchCurrentPeer := func() bool { - groupID, hasGroup := argInt64(args, "group_id") - userID, hasUser := argInt64(args, "user_id") - if auth.isGroup { - return hasGroup && groupID == auth.groupID && !hasUser - } - return hasUser && userID == auth.userID && !hasGroup - } - - switch name { - case p.name + "_get_history", p.name + "_mark_read": - if !matchCurrentPeer() { - return false, "只能访问当前 QQ 会话" - } - case p.name + "_get_message": - messageID, ok := argInt64(args, "message_id") - if !ok { - return false, "缺少有效 message_id" - } - if messageID == auth.messageID { - return true, "" - } - peerID, isGroup, _, found := p.lookupMsgRef(messageID) - if !found || isGroup != auth.isGroup || peerID != currentPeer { - return false, "message_id 不属于当前 QQ 会话" - } - case p.name + "_get_group_member_info", p.name + "_get_group_files": - groupID, ok := argInt64(args, "group_id") - if !auth.isGroup || !ok || groupID != auth.groupID { - return false, "只能访问当前 QQ 群的数据" - } - } - return true, "" -} - -// qqAuthExtraKey 是本轮(帧)QQ 身份挂在 StageContext.Extra 上的键。 -// -// 身份必须**绑帧**,不能只存插件全局: -// - 中断会抢占当前轮并把现场压栈(scheduler 的 suspendStack),中断轮收尾时 -// afterOutput 把插件全局身份清空;随后外层被恢复(resumeTask 复用同一帧、 -// 不重跑 StageOnInput),若身份只存全局,恢复后的外层就是"无身份"—— -// beforeToolcall 会在 !auth.active 处直接返回,权限门整体失效。 -// - 运行中到达的新消息会调 activateAuthContext 改写全局身份,把**正在跑的那一轮** -// 换成另一方的身份(换高=越权,换低=误拒)。 -// -// 帧上的 Extra 随帧一起压栈/恢复,正好是"这一轮的身份"。 -const qqAuthExtraKey = "qq_auth" - -// authOnFrame 读取本帧绑定的身份;ok=false 表示本帧未绑定过 QQ 身份。 -// 调用方需持有 ctx 的读(或写)锁。 -func authOnFrame(ctx *sdk.StageContext) (qqAuthContext, bool) { - if ctx == nil || ctx.Extra == nil { - return qqAuthContext{}, false - } - auth, ok := ctx.Extra[qqAuthExtraKey].(qqAuthContext) - return auth, ok -} - -// bindAuthOnFrame 把身份绑到本帧上。调用方需持有 ctx 的写锁。 -func bindAuthOnFrame(ctx *sdk.StageContext, auth qqAuthContext) { - if ctx == nil { - return - } - if ctx.Extra == nil { - ctx.Extra = make(map[string]interface{}) - } - ctx.Extra[qqAuthExtraKey] = auth -} - -// activateAuthContext 只接收 OneBot 事件中的可信 ID。多个中断在同一推理轮合并时 -// 采用最小权限合并,防止“非所有者请求 + 随后所有者消息”意外提升前一请求权限。 -// message_id 映射供排队输入在 StageOnInput 精确恢复身份,不依赖昵称或用户正文。 -func (p *Plugin) activateAuthContext(messageID, userID, groupID int64, isGroup bool) { - p.authMu.Lock() - defer p.authMu.Unlock() - if p.authByMessageID == nil { - p.authByMessageID = make(map[int64]qqAuthContext) - } - p.authGeneration++ - next := qqAuthContext{ - active: true, owner: p.isOwner(userID), messageID: messageID, userID: userID, - groupID: groupID, isGroup: isGroup, generation: p.authGeneration, - } - if messageID != 0 { - p.authByMessageID[messageID] = next - if len(p.authByMessageID) > 2048 { - cutoff := p.authGeneration - 1024 - for id, auth := range p.authByMessageID { - if auth.generation < cutoff { - delete(p.authByMessageID, id) - } - } - } - } - if !p.auth.active { - p.auth = next - return - } - if p.auth.userID == userID && p.auth.groupID == groupID && p.auth.isGroup == isGroup { - p.auth.owner = p.auth.owner && next.owner - p.auth.messageID = next.messageID - p.auth.generation = next.generation - return - } - // 不同可信来源被内核合并到同一推理时,只在双方都是所有者时保留完整权限。 - bothOwners := p.auth.owner && next.owner - sameGroup := p.auth.isGroup && next.isGroup && p.auth.groupID == groupID - p.auth.owner = bothOwners - p.auth.messageID = 0 - p.auth.userID = 0 - p.auth.isGroup = sameGroup - if sameGroup { - p.auth.groupID = groupID - } else { - p.auth.groupID = 0 - } - p.auth.generation = next.generation -} - -var qqMessageIDsRe = regexp.MustCompile(`message_id=(-?\d+(?:,-?\d+)*)`) - -// messageIDsFromInput 取出一段输入里出现的全部 message_id。 -// -// 合并中继的正文是 `(message_id=100,101,102)`:只取第一个会留下同批其余 id 永不清理; -// 身份表用 id 做键,泄漏的条目要等 generation 回收才会消失。 -func messageIDsFromInput(raw string) []int64 { - matches := qqMessageIDsRe.FindAllStringSubmatch(raw, -1) - ids := make([]int64, 0, len(matches)) - for _, match := range matches { - if len(match) != 2 { - continue - } - for _, part := range strings.Split(match[1], ",") { - id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) - if err != nil || id == 0 { - continue - } - ids = append(ids, id) - } - } - return ids -} - -func (p *Plugin) onInputAuthContext(ctx *sdk.StageContext) error { - ctx.RLock() - source, _ := ctx.Extra["input_source"].(string) - raw := ctx.RawMessage - ctx.RUnlock() - - p.authMu.Lock() - // 默认降权:QQ 来源却对不上可信事件时绝不复用上一条消息的身份。 - next := qqAuthContext{active: source == p.name} - ids := messageIDsFromInput(raw) - if source == p.name && len(ids) > 0 { - if auth, ok := p.authByMessageID[ids[0]]; ok { - next = auth - for _, id := range ids { - delete(p.authByMessageID, id) - } - } - } - // p.auth 只作为"帧上没绑身份"时的兜底(单测/异常帧),权威副本在帧上。 - p.auth = next - p.resetTurnGuardLocked() - p.authMu.Unlock() - - ctx.Lock() - bindAuthOnFrame(ctx, next) - ctx.Unlock() - return nil -} - -func (p *Plugin) resetTurnGuardLocked() { - p.lastDenial = "" - p.denialLocked = false - p.toolCallCount = 0 - p.outputCallCount = 0 - p.outputSignatures = make(map[string]int) -} - -func (p *Plugin) afterOutputAuthContext(ctx *sdk.StageContext) error { - p.authMu.Lock() - p.auth = qqAuthContext{} - p.resetTurnGuardLocked() - p.authMu.Unlock() - return nil -} - -func (p *Plugin) currentToolAllowed(ctx *sdk.StageContext, name string) (bool, qqAuthContext) { - // 身份以本帧为准(中断恢复后全局身份可能已属于别的轮)。 - auth, onFrame := authOnFrame(ctx) - p.authMu.RLock() - if !onFrame { - auth = p.auth - } - var patterns []string - if auth.active && !auth.owner { - if auth.isGroup { - patterns, _ = p.groupToolAllowlists[auth.groupID] - if patterns == nil { - patterns = p.groupToolAllowlists[0] - } - } else { - patterns = p.privateToolAllowlist - } - } - p.authMu.RUnlock() - if !auth.active || auth.owner { - return true, auth - } - return matchesToolAllowlist(name, patterns), auth -} - -// isAtBot checks if the message contains an @-mention of the bot. -func (p *Plugin) isAtBot(msg interface{}) bool { - segments, ok := msg.([]interface{}) - if !ok { - return false - } - botIDStr := strconv.FormatInt(p.botID, 10) - for _, seg := range segments { - s, ok := seg.(map[string]interface{}) - if !ok { - continue - } - if s["type"] == "at" { - if data, ok := s["data"].(map[string]interface{}); ok { - if qq, ok := data["qq"]; ok { - switch v := qq.(type) { - case string: - if v == botIDStr || v == "all" { - return true - } - case float64: - if int64(v) == p.botID { - return true - } - } - } - } - } - } - return false -} - -// ======== Webhook ======== - -func (p *Plugin) isDMAllowed(userID int64) bool { - switch p.dmPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.allowFrom[userID] - return ok - default: - return true - } -} - -func (p *Plugin) isGroupAllowed(groupID int64) bool { - switch p.groupPolicy { - case "disabled": - return false - case "allowlist": - _, ok := p.groupAllowFrom[groupID] - return ok - default: - return true - } -} - -func (p *Plugin) setDenial(ctx *sdk.StageContext, message string) { - ctx.Response = &message - p.authMu.Lock() - p.lastDenial = message - p.authMu.Unlock() -} - -func (p *Plugin) clearPreviousDenial(ctx *sdk.StageContext) { - p.authMu.Lock() - last := p.lastDenial - p.lastDenial = "" - p.authMu.Unlock() - if last != "" && ctx.Response != nil && *ctx.Response == last { - ctx.Response = nil - } -} - -func (p *Plugin) clearDeniedResponse(ctx *sdk.StageContext) error { - ctx.Lock() - defer ctx.Unlock() - p.clearPreviousDenial(ctx) - return nil -} - -func (p *Plugin) beforeToolcall(ctx *sdk.StageContext) error { - ctx.Lock() - defer ctx.Unlock() - p.clearPreviousDenial(ctx) - if len(ctx.ToolCalls) == 0 { - return nil - } - tc := &ctx.ToolCalls[0] - allowed, auth := p.currentToolAllowed(ctx, tc.Name) - if !auth.active { - return nil - } - - p.authMu.Lock() - p.toolCallCount++ - toolCount := p.toolCallCount - denialLocked := p.denialLocked - if tc.Name == "output_send__"+p.name { - p.outputCallCount++ - signatureBytes, _ := json.Marshal(tc.Arguments) - signature := string(signatureBytes) - p.outputSignatures[signature]++ - duplicateCount := p.outputSignatures[signature] - distinctCount := len(p.outputSignatures) - // 循环保险只拦“参数完全相同的重复调用”。参数不同的必需调用一律放行, - // 否则多次 cmd_run / update_schedule / 多条不同消息都会被误杀。 - if p.maxDuplicateSend > 0 && duplicateCount > p.maxDuplicateSend { - p.authMu.Unlock() - msg := fmt.Sprintf("QQ 循环保险已阻止重复发送:本轮第 %d 次出现参数完全相同的消息;请勿重复发送同一内容", duplicateCount) - p.setDenial(ctx, msg) - return nil - } - if p.maxQQOutputCalls > 0 && distinctCount > p.maxQQOutputCalls { - p.authMu.Unlock() - msg := fmt.Sprintf("QQ 循环保险已阻止本次发送:单轮主动发送的不同消息数已达上限 %d(0=不限制,可在插件配置调整)", p.maxQQOutputCalls) - p.setDenial(ctx, msg) - return nil - } - } - if p.maxQQToolCalls > 0 && toolCount > p.maxQQToolCalls { - p.authMu.Unlock() - msg := fmt.Sprintf("QQ 循环保险已阻止工具调用:单轮工具调用总数已达上限 %d(0=不限制,可在插件配置调整)", p.maxQQToolCalls) - p.setDenial(ctx, msg) - return nil - } - p.authMu.Unlock() - - if denialLocked && tc.Name != "output_send__"+p.name { - msg := fmt.Sprintf("QQ 权限策略已锁止本轮后续工具 %s;仅允许发送一次权限说明", tc.Name) - p.setDenial(ctx, msg) - return nil - } - if !auth.owner && isHardPrivateTool(tc.Name) { - p.authMu.Lock() - p.denialLocked = true - p.authMu.Unlock() - msg := fmt.Sprintf("QQ 权限策略拒绝私人资源工具 %s;该限制不能由群聊或私聊白名单覆盖,请不要重试", tc.Name) - p.setDenial(ctx, msg) - return nil - } - if !allowed { - scope := "非所有者私聊" - if auth.isGroup { - scope = fmt.Sprintf("群聊 %d", auth.groupID) - } - msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s 的工具白名单未包含该工具;请不要重试,改为直接说明权限限制", tc.Name, scope) - p.setDenial(ctx, msg) - return nil - } - if argsAllowed, reason := p.sessionToolArgsAllowed(tc.Name, tc.Arguments, auth); !argsAllowed { - msg := fmt.Sprintf("QQ 权限策略拒绝工具 %s:%s;请不要改用其他会话 ID 重试", tc.Name, reason) - p.setDenial(ctx, msg) - return nil - } - if tc.Name == p.name+"_group_manage" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmGroupCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - p.setDenial(ctx, msg) - return nil - } - } - } - if tc.Name == p.name+"_friend_action" { - cmd, _ := tc.Arguments["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := tc.Arguments["confirm"].(bool); !ok { - msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) - p.setDenial(ctx, msg) - return nil - } - } - } - return nil -} - -func requiresConfirmGroupCommand(cmd string) bool { - switch cmd { - case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create": - return true - default: - return false - } -} - -func requiresConfirmFriendCommand(cmd string) bool { - switch cmd { - case "delete", "block", "approve-friend", "reject-friend": - return true - default: - return false - } -} - -// enqueueInterrupt 把一条已通过策略/@ 检查的消息并入待投批次,并重置 debounce 计时。 -// -// batchWindow<=0 时退回逐条投递(合并前行为)。 -func (p *Plugin) enqueueInterrupt(msgType string, userID, groupID, messageID int64, nickname, single string, owner, highRisk bool) { - if p.sdk == nil && p.injectHook == nil { - return - } - if p.batchWindow <= 0 { - p.injectInterrupt(single, p.interruptLevel(owner)) - return - } - key := qqBatchKey(msgType, groupID, userID) - p.batchMu.Lock() - if p.batches == nil { - p.batches = make(map[string]*pendingBatch) - } - b := p.batches[key] - if b == nil { - b = &pendingBatch{key: key, first: time.Now()} - p.batches[key] = b - } - b.isGroup = msgType == "group" - b.userID, b.groupID, b.nickname = userID, groupID, nickname - b.msgIDs = append(b.msgIDs, messageID) - b.single = single - b.owner = b.owner || owner - b.highRisk = b.highRisk || highRisk - // debounce:每来一条就推迟;但整体不超过 batchMax(否则持续刷屏会一直不投)。 - delay := p.batchWindow - if p.batchMax > 0 { - if remain := p.batchMax - time.Since(b.first); remain < delay { - delay = remain - } - } - if delay < 0 { - delay = 0 - } - if b.timer != nil { - b.timer.Stop() - } - b.timer = time.AfterFunc(delay, func() { p.flushBatch(key) }) - p.batchMu.Unlock() -} - -// interruptLevel 决定一条 QQ 消息的中断级别。 -// -// - Bot 所有者/管理员的消息 → **L2**(一般提醒); -// - 其他人的消息 → L1(后台,完全可等)。 -// -// 为什么不能一律 L1:L1 之间可以随时互相抢占、也可以被任何更高一级打断, -// 于是「老板发的话」会被路人的闲聊挤到后面,甚至对方持续刷屏时一直排在队尾。 -// 为什么也不该给 L3:L3 是时钟/终端那类"需要及时处理"的实时工作,QQ 是异步 -// 消息,抬到 L3 会反过来打断真正实时的事情。 -func (p *Plugin) interruptLevel(owner bool) string { - if owner { - return sdk.PriorityL2 - } - return sdk.PriorityL1 -} - -// injectInterrupt 投递一条中断提示(NoMemory:HTTP 侧来的不是对话内容)。 -func (p *Plugin) injectInterrupt(text, level string) { - if text == "" { - return - } - if level == "" { - level = sdk.PriorityL1 - } - if p.injectHook != nil { - p.injectHook(text, level) - return - } - if p.sdk == nil { - return - } - p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{ - NoMemory: true, - Priority: level, - // 中断文本是路由/取正文的指令,不是对话内容,不据它召回。 - RecallPolicy: sdk.RecallPolicyNone, - }) -} - -// flushBatch 投递一批:n==1 沿用单条原文;n>1 生成「共几条」的合并中断。 -func (p *Plugin) flushBatch(key string) { - p.batchMu.Lock() - b := p.batches[key] - delete(p.batches, key) - p.batchMu.Unlock() - if b == nil { - return - } - text := b.single - if len(b.msgIDs) > 1 { - text = p.buildBatchInterrupt(b) - } - // 一批里只要有一条来自 Bot 所有者,整批按 L2 投递(不因混入路人消息而降低)。 - p.injectInterrupt(text, p.interruptLevel(b.owner)) -} - -// flushAllBatches 停机前把未到点的批次立刻投出去(best effort)。 -func (p *Plugin) flushAllBatches() { - p.batchMu.Lock() - keys := make([]string, 0, len(p.batches)) - for k := range p.batches { - keys = append(keys, k) - } - p.batchMu.Unlock() - for _, k := range keys { - p.flushBatch(k) - } -} - -// buildBatchInterrupt 生成合并中断:说清「一共几条」「分别是哪些 message_id」, -// 并给出一次拿全上下文的建议(get_history),避免模型逐条 get_message。 -func (p *Plugin) buildBatchInterrupt(b *pendingBatch) string { - tp := p.name + "_" - outputTool := "output_send__" + p.name - n := len(b.msgIDs) - ids := formatMsgIDs(b.msgIDs) - var s string - if b.isGroup { - s = fmt.Sprintf("来自「%s」在群里短时间内连续发来 %d 条消息(message_id=%s)。建议先用%sget_history(group_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复群聊", - b.nickname, n, ids, tp, b.groupID, n+5, tp, outputTool) - } else { - s = fmt.Sprintf("来自「%s」的私聊短时间内连续发来 %d 条消息(message_id=%s, user_id=%d)。建议先用%sget_history(user_id=%d, count=%d)一次拉取这几条上下文再统一回复;也可用%sget_message 取单条。用%s回复对方", - b.nickname, n, ids, b.userID, tp, b.userID, n+5, tp, outputTool) - } - if b.highRisk { - s = "【⚠️ 高危信息,谨慎处理】" + s - } - if b.owner { - s = "【重要!Bot 所有者消息】" + s - } - return s -} - -// formatMsgIDs 把 message_id 列表压成一行;过多时截断,避免中断文字过长。 -func formatMsgIDs(ids []int64) string { - const capN = 12 - parts := make([]string, 0, len(ids)+1) - for i, id := range ids { - if i >= capN { - parts = append(parts, "…") - break - } - parts = append(parts, strconv.FormatInt(id, 10)) - } - return strings.Join(parts, ",") -} - -func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - http.Error(w, "", http.StatusMethodNotAllowed) - return - } - if p.webhookToken != "" && !hmac.Equal([]byte(r.Header.Get("X-Webhook-Token")), []byte(p.webhookToken)) { - w.WriteHeader(http.StatusUnauthorized) - return - } - body, _ := io.ReadAll(r.Body) - var evt struct { - PostType string `json:"post_type"` - MessageType string `json:"message_type,omitempty"` - MessageID int64 `json:"message_id"` - UserID int64 `json:"user_id,omitempty"` - GroupID int64 `json:"group_id,omitempty"` - RawMessage string `json:"raw_message,omitempty"` - Message interface{} `json:"message,omitempty"` - Time int64 `json:"time"` - Sender *struct { - Nickname string `json:"nickname"` - Card string `json:"card,omitempty"` - } `json:"sender,omitempty"` - } - if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" { - w.WriteHeader(http.StatusOK) - 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 - text := rawCQ - if text == "" { - if s, ok := evt.Message.(string); ok { - text = s - } - } - nickname := "" - if evt.Sender != nil { - nickname = evt.Sender.Nickname - if evt.Sender.Card != "" { - nickname = evt.Sender.Card - } - } - - if evt.MessageType == "private" { - if !p.isDMAllowed(evt.UserID) { - w.WriteHeader(http.StatusOK) - return - } - } - if evt.MessageType == "group" { - if !p.isGroupAllowed(evt.GroupID) { - log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy) - w.WriteHeader(http.StatusOK) - return - } - } - - // ---- 记录 msg_id→peer 映射与会话状态(不缓存正文,仅最新一条短摘要)---- - // 策略允许的消息(群/私聊、是否 @bot 均记),供 get_msg 兜底与 list_chats 使用; - // @bot 与否只决定是否发中断,不影响记录——与真人客户端一致看到全部会话。 - { - peerID, isGroup := evt.UserID, false - if evt.MessageType == "group" { - peerID, isGroup = evt.GroupID, true - } - sum := text - runes := []rune(sum) - if len(runes) > qqLastSumLen { - sum = string(runes[:qqLastSumLen]) + "…" - } - if evt.Time == 0 { - evt.Time = time.Now().Unix() - } - p.snapshotMsg(evt.MessageID, peerID, isGroup, evt.Time, nickname, sum) - } - - if evt.MessageType == "group" { - // 群消息必须 @ 机器人才响应 - if p.botID == 0 { - log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID) - w.WriteHeader(http.StatusOK) - return - } - 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) - return - } - } - - tp := p.name + "_" - outputTool := "output_send__" + p.name - var interrupt string - if evt.MessageType == "group" { - interrupt = fmt.Sprintf("来自「%s」在群「%s」的消息(message_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(group_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复群聊", nickname, "群聊", evt.MessageID, tp, evt.MessageID, tp, evt.GroupID, tp, outputTool) - } else { - interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d, user_id=%d)。先用%sget_message(message_id=%d)取正文;若取不到(消息已过期),改用%sget_history(user_id=%d)按会话拉取上下文,或用%slist_chats 查看未读会话。用%s回复对方", nickname, evt.MessageID, evt.UserID, tp, evt.MessageID, tp, evt.UserID, tp, outputTool) - } - if p.isOwner(evt.UserID) { - interrupt = "【重要!Bot 所有者消息】" + interrupt - } - - if text != "" { - text = stripCQRe.ReplaceAllString(text, "") - text = strings.TrimSpace(text) - } - if text == "" { - w.WriteHeader(http.StatusOK) - return - } - highRisk := false - if highRiskRe.MatchString(text) { - highRisk = true - interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt - } - - if evt.MessageType == "group" && p.sdk != nil { - rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]") - var rules []ForwardRule - if json.Unmarshal([]byte(rulesRaw), &rules) == nil { - for _, rule := range rules { - if evt.GroupID == rule.GroupID { - mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) - go func(r ForwardRule, msg string) { - defer func() { _ = recover() }() - 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) - } - }(rule, mcMsg) - } - } - } - } - - // 必须在注入前记录 OneBot 可信来源;权限判断绝不依赖昵称、正文或模型参数。 - p.activateAuthContext(evt.MessageID, evt.UserID, evt.GroupID, evt.MessageType == "group") - - if evt.MessageType == "private" { - p.startTyping(evt.UserID) - } - - // 合并投递:同一会话同一发送者在 batchWindow 内的连续消息并成一次中断。 - p.enqueueInterrupt(evt.MessageType, evt.UserID, evt.GroupID, evt.MessageID, nickname, interrupt, p.isOwner(evt.UserID), highRisk) - - w.WriteHeader(http.StatusOK) -} - -// ======== Tool Handlers ======== - -// getMsgFromHistoryByTime 按 (peer, isGroup, targetTime) 从 NapCat 拉最近历史,返回距 targetTime 最近的完整消息。 -func (p *Plugin) getMsgFromHistoryByTime(peerID int64, isGroup bool, targetTime int64) (map[string]interface{}, bool) { - ep := "get_friend_msg_history" - params := map[string]interface{}{"user_id": peerID, "count": 50} - if isGroup { - ep = "get_group_msg_history" - params = map[string]interface{}{"group_id": peerID, "count": 50} - } - raw, err := p.napcat(ep, params) - if err != nil { - return nil, false - } - rawStr, _ := rawString(raw) - if rawStr == "" { - return nil, false - } - var resp struct { - Data *struct { - Messages []interface{} `json:"messages"` - } `json:"data"` - } - if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil { - return nil, false - } - var best map[string]interface{} - bestAbs := int64(-1) - for _, m := range resp.Data.Messages { - mm, ok := m.(map[string]interface{}) - if !ok { - continue - } - mt, _ := mm["time"].(float64) - t := int64(mt) - if t == 0 { - continue - } - abs := t - targetTime - if abs < 0 { - abs = -abs - } - if bestAbs < 0 || abs < bestAbs { - bestAbs = abs - best = mm - } - } - if best == nil { - return nil, false - } - return best, true -} - -// msgToGetMsgResult 把一条 NapCat 历史消息对象转成与 get_msg 同构的结果(历史包装语义)。 -func msgToGetMsgResult(msg map[string]interface{}) map[string]interface{} { - nickname := "" - if s, ok := msg["sender"].(map[string]interface{}); ok { - if n, _ := s["nickname"].(string); n != "" { - nickname = n - } - if c, _ := s["card"].(string); c != "" { - nickname = c - } - } - rawText, _ := msg["raw_message"].(string) - content := rawText - if content == "" { - if segs, ok := msg["message"].([]interface{}); ok { - var parts []string - for _, seg := range segs { - segMap, _ := seg.(map[string]interface{}) - if segMap == nil { - continue - } - typ, _ := segMap["type"].(string) - segData, _ := segMap["data"].(map[string]interface{}) - if segData == nil { - continue - } - switch typ { - case "text": - if t, _ := segData["text"].(string); t != "" { - parts = append(parts, t) - } - case "image": - parts = append(parts, "[图片]") - case "file": - if n, _ := segData["name"].(string); n != "" { - parts = append(parts, "[文件:"+n+"]") - } - default: - if typ != "" { - parts = append(parts, "["+typ+"]") - } - } - } - if len(parts) > 0 { - content = strings.Join(parts, " ") - } - } - } - mid, _ := msg["message_id"].(float64) - uid, _ := msg["user_id"].(float64) - gid, _ := msg["group_id"].(float64) - mt, _ := msg["time"].(float64) - mtType, _ := msg["message_type"].(string) - loc := "私聊" - if mtType == "group" || gid > 0 { - loc = "群聊" - } - return map[string]interface{}{ - "content": content, - "message_id": int64(mid), - "user_id": int64(uid), - "group_id": int64(gid), - "nickname": nickname, - "message_type": mtType, - "type": loc, - "time": time.Unix(int64(mt), 0).Format("2006-01-02 15:04:05"), - } -} - -func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { - msgID, err := convInt64(args["message_id"]) - if err != nil { - return map[string]interface{}{ - "content": "需要提供 message_id 参数", - "not_found": true, - }, nil - } - - // 本地 msg_id→peer 映射命中且 <7 天 → 用 get_history 语义兜底(NapCat 临时短号失效也不怕) - if peerID, isGroup, t, ok := p.lookupMsgRef(msgID); ok { - if m, found := p.getMsgFromHistoryByTime(peerID, isGroup, t); found { - // 找到同会话、时间最接近的消息,包装为 get_msg 同构返回 - res := msgToGetMsgResult(m) - res["resolved_via"] = "history" // 标明由历史查询兜底 - return res, nil - } - // 历史窗口内没找到(消息可能被裁剪/更早),回退 NapCat 原查询 - } - return p.getMsgFromNapcat(msgID) -} - -func (p *Plugin) getMsgFromNapcat(msgID int64) (interface{}, error) { - raw, err := p.napcat("get_msg", map[string]interface{}{"message_id": msgID}) - if err != nil { - return map[string]interface{}{ - "content": fmt.Sprintf("查询 NapCat 失败: %s。该 message_id 可能已过期,请改用 qq_get_history 按会话拉取最近消息(或用 qq_list_chats 看未读会话)", err), - "message_id": msgID, - "not_found": true, - }, nil - } - rawStr, _ := rawString(raw) - if rawStr == "" { - return map[string]interface{}{ - "content": "NapCat 返回空响应", - "message_id": msgID, - "not_found": true, - }, nil - } - - var resp struct { - Data *struct { - MessageID int64 `json:"message_id"` - UserID int64 `json:"user_id"` - GroupID int64 `json:"group_id"` - Time int64 `json:"time"` - MessageType string `json:"message_type"` - RawMessage string `json:"raw_message"` - Message interface{} `json:"message"` - Sender *struct { - Nickname string `json:"nickname"` - Card string `json:"card"` - } `json:"sender"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { - return map[string]interface{}{ - "content": "解析 NapCat 响应失败(消息可能已过期)。请改用 qq_get_history 按会话拉取最近消息,或用 qq_list_chats 查看未读会话", - "message_id": msgID, - "not_found": true, - }, nil - } - - d := resp.Data - nickname := "" - if d.Sender != nil { - nickname = d.Sender.Nickname - if d.Sender.Card != "" { - nickname = d.Sender.Card - } - } - - content := d.RawMessage - var files []map[string]interface{} - var replyToID int64 - var hasImage, hasFile bool - if segs, ok := d.Message.([]interface{}); ok { - var parts []string - for _, seg := range segs { - segMap, _ := seg.(map[string]interface{}) - if segMap == nil { - continue - } - typ, _ := segMap["type"].(string) - segData, _ := segMap["data"].(map[string]interface{}) - if segData == nil { - continue - } - switch typ { - case "text": - if t, _ := segData["text"].(string); t != "" { - parts = append(parts, t) - } - case "at": - if qq, _ := segData["qq"].(string); qq != "" { - parts = append(parts, "@"+qq) - } - case "reply": - if idStr, ok := segData["id"].(string); ok { - replyToID, _ = strconv.ParseInt(idStr, 10, 64) - } else if id, ok := segData["id"].(float64); ok { - replyToID = int64(id) - } - parts = append(parts, fmt.Sprintf("[回复id=%d]", replyToID)) - case "file": - hasFile = true - fid, _ := segData["file_id"].(string) - if fid == "" { - fid, _ = segData["file"].(string) - } - name, _ := segData["name"].(string) - if name == "" { - name, _ = segData["file"].(string) - } - fileURL, _ := segData["url"].(string) - if fid != "" { - entry := map[string]interface{}{ - "file_id": fid, - "name": name, - } - if fileURL != "" { - entry["url"] = fileURL - } - files = append(files, entry) - } - case "image": - hasImage = true - } - } - if len(parts) > 0 { - content = strings.Join(parts, " ") - } - } - - loc := "私聊" - if d.MessageType == "group" { - loc = "群聊" - } - result := map[string]interface{}{ - "content": content, - "message_id": d.MessageID, - "user_id": d.UserID, - "group_id": d.GroupID, - "nickname": nickname, - "message_type": d.MessageType, - "type": loc, - "time": time.Unix(d.Time, 0).Format("2006-01-02 15:04:05"), - } - if replyToID > 0 { - result["reply_to"] = map[string]interface{}{"message_id": replyToID} - } - if hasImage { - result["has_image"] = true - } - if hasFile { - result["has_file"] = true - } - if len(files) > 0 { - result["files"] = files - } - - // 异步标记已读 - go func() { - defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程 - if d.MessageType == "group" && d.GroupID > 0 { - p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) - } else if d.UserID > 0 { - p.napcat("mark_private_msg_as_read", map[string]interface{}{"user_id": d.UserID}) - } - }() - - return result, nil -} - -// handleChannelOutput — output_send(channel="qq") 的处理器 -// args 包含 payload, type, (可选 meta) -// 成功时只返回极简标记,不回传 NapCat 完整响应——避免"已发送"类富回执喂给模型 -// 造成"看到成功→继续发下一条"的回声循环(issue: output loop echo)。 -func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) { - payload, _ := args["payload"].(string) - rawType, _ := args["type"].(string) - meta, _ := args["meta"].(string) - log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta) - if payload == "" || rawType == "" { - return nil, fmt.Errorf("payload 和 type 参数不能为空") - } - - // 解析 meta - var groupID, userID int64 - var replyTo int64 - if meta != "" { - var m struct { - GroupID int64 `json:"group_id"` - UserID int64 `json:"user_id"` - ReplyTo int64 `json:"reply_to"` - } - if err := json.Unmarshal([]byte(meta), &m); err == nil { - groupID = m.GroupID - userID = m.UserID - replyTo = m.ReplyTo - } - } - if groupID == 0 && userID == 0 { - return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明") - } - - if userID > 0 { - p.stopTyping(userID) - } - - var sendErr error - switch rawType { - case "text": - text := p.sensitiveFilter(payload) - message := interface{}(text) - if groupID != 0 && userID != 0 { - message = messageWithMention(userID, map[string]interface{}{"type": "text", "data": map[string]interface{}{"text": text}}) - } - msg := map[string]interface{}{"message": message} - if groupID != 0 { - msg["group_id"] = groupID - } else { - msg["user_id"] = userID - } - if replyTo > 0 { - msg["reply_to"] = replyTo - } - if groupID != 0 { - _, sendErr = p.napcat("send_group_msg", msg) - } else { - _, sendErr = p.napcat("send_private_msg", msg) - } - - case "voice", "audio": - text := p.sensitiveFilter(payload) - audioFile, err := p.ttsToFile(text) - if err != nil { - return nil, fmt.Errorf("语音生成失败: %w", err) - } - os.MkdirAll(p.remoteDir, 0755) - dest := filepath.Join(p.remoteDir, filepath.Base(audioFile)) - data, err := os.ReadFile(audioFile) - if err != nil { - return nil, fmt.Errorf("读取音频文件失败: %w", err) - } - if err := os.WriteFile(dest, data, 0644); err != nil { - return nil, fmt.Errorf("写入共享目录失败: %w", err) - } - os.Remove(audioFile) - uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest)) - cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri) - message := interface{}(cqMsg) - if groupID != 0 && userID != 0 { - message = messageWithMention(userID, map[string]interface{}{"type": "record", "data": map[string]interface{}{"file": uri}}) - } - msg := map[string]interface{}{"message": message} - if groupID != 0 { - msg["group_id"] = groupID - } else { - msg["user_id"] = userID - } - if groupID != 0 { - _, sendErr = p.napcat("send_group_msg", msg) - } else { - _, sendErr = p.napcat("send_private_msg", msg) - } - - case "image", "file": - uri := payload - 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) - } - cqTag := "file" - if rawType == "image" { - cqTag = "image" - } - message := interface{}(fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)) - if groupID != 0 && userID != 0 { - message = messageWithMention(userID, map[string]interface{}{"type": cqTag, "data": map[string]interface{}{"file": uri}}) - } - msg := map[string]interface{}{"message": message} - if groupID != 0 { - msg["group_id"] = groupID - } else { - msg["user_id"] = userID - } - if groupID != 0 { - _, sendErr = p.napcat("send_group_msg", msg) - } else { - _, sendErr = p.napcat("send_private_msg", msg) - } - - default: - return nil, fmt.Errorf("不支持的 type: %s(枚举值: text/voice/image/file)", rawType) - } - - if sendErr != nil { - return nil, sendErr - } - // 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型, - // 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。 - return "ok", nil -} - -func (p *Plugin) buildOutputHelp() string { - return `【参数】 -payload — 消息载荷。type=text时直接填文字,type=voice时填文字(自动转语音),type=image/file时填URL -meta — JSON 元数据,含 group_id(群聊)和/或 user_id(私聊或群内@),可选 reply_to -type — text / voice / image / file - -【示例】 -群聊文字:output_send__qq(payload="你好", meta="{\"group_id\":123456789}", type="text") -私聊语音:output_send__qq(payload="你好", meta="{\"user_id\":123456789}", type="voice") -群内@用户:output_send__qq(payload="你好", meta="{\"group_id\":123456789,\"user_id\":987654321}", type="text") -发送图片:output_send__qq(payload="https://example.com/img.jpg", meta="{\"group_id\":123456789}", type="image") - -【注意】 -- group_id 与 user_id 同时存在时始终发送到 group_id,并在消息头 @user_id -- type=text 时 payload 直接是文字,无需 JSON 包裹 -- type=voice 时 payload 是文字内容,自动转语音发送 -- type=image/file 时 payload 是 URL 或路径` -} - -// ttsToFile 用 edge-tts 将文本转为音频文件,返回临时文件路径 -func (p *Plugin) ttsToFile(text string) (string, error) { - // 清理文本中的特殊字符 - clean := strings.Map(func(r rune) rune { - if r == '"' || r == '\n' || r == '\r' { - return ' ' - } - return r - }, text) - clean = strings.TrimSpace(clean) - if clean == "" { - clean = " " - } - - tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("qq_tts_%d.mp3", time.Now().UnixNano())) - cmd := exec.Command("edge-tts", - "--voice", "zh-CN-XiaoxiaoNeural", - "--text", clean, - "--write-media", tmpFile, - ) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("edge-tts: %w\nstderr: %s", err, stderr.String()) - } - if _, err := os.Stat(tmpFile); os.IsNotExist(err) { - return "", fmt.Errorf("edge-tts 未生成输出文件") - } - return tmpFile, nil -} - -func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - if gerr != nil && uerr != nil { - return nil, fmt.Errorf("need group_id or user_id") - } - filePath, _ := args["file"].(string) - if filePath == "" { - return nil, fmt.Errorf("need file path") - } - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = sanitizeFilename(name) - asImage, _ := args["as_image"].(bool) - - // copy to remote dir for NapCat container access - dest := filepath.Join(p.remoteDir, name) - srcData, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read file: %w", err) - } - if err := os.WriteFile(dest, srcData, 0644); err != nil { - return nil, fmt.Errorf("write remote: %w", err) - } - - uri := fmt.Sprintf("file:///app/files/%s", name) - var cqMsg string - if asImage { - cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri) - } else { - cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name) - } - - params := map[string]interface{}{"message": cqMsg} - if gerr == nil { - params["group_id"] = gid - return p.napcat("send_group_msg", params) - } - params["user_id"] = uid - return p.napcat("send_private_msg", params) -} - -func (p *Plugin) handleListChats(args map[string]interface{}) (interface{}, error) { - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 && c < 100 { - count = int(c) - } - chats := p.listChats(count) - return map[string]interface{}{ - "chats": chats, - "total": len(chats), - "hint": "按最新消息先后排序;unread 为该会话未读消息数,处理完用 qq_mark_read 清零;用 qq_get_history(group_id/user_id) 拉取会话内容", - }, nil -} - -func (p *Plugin) handleMarkRead(args map[string]interface{}) (interface{}, error) { - if gid, err := convInt64(args["group_id"]); err == nil { - p.markChatRead(gid) - return map[string]interface{}{"status": "ok", "group_id": gid, "unread": 0}, nil - } - if uid, err := convInt64(args["user_id"]); err == nil { - p.markChatRead(uid) - return map[string]interface{}{"status": "ok", "user_id": uid, "unread": 0}, nil - } - return nil, fmt.Errorf("need group_id or user_id") -} - -func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { - gid, gerr := convInt64(args["group_id"]) - uid, uerr := convInt64(args["user_id"]) - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - - var endpoint string - var params map[string]interface{} - if gerr == nil { - endpoint = "get_group_msg_history" - params = map[string]interface{}{"group_id": gid, "count": count} - } else if uerr == nil { - endpoint = "get_friend_msg_history" - params = map[string]interface{}{"user_id": uid, "count": count} - } else { - return nil, fmt.Errorf("need group_id or user_id") - } - - rawResp, err := p.napcat(endpoint, params) - if err != nil { - return nil, err - } - rawStr, _ := rawString(rawResp) - if rawStr == "" { - return map[string]interface{}{"messages": []interface{}{}, "note": "未获取到历史消息"}, nil - } - - // 解析 NapCat 响应,提取消息列表 - var resp struct { - Data *struct { - Messages []interface{} `json:"messages"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { - return map[string]interface{}{"raw_response": rawStr, "note": "解析 NapCat 响应失败"}, nil - } - - // 格式化消息为可读文本,并提取文件信息 - var lines []string - var files []map[string]interface{} - for _, m := range resp.Data.Messages { - msg, ok := m.(map[string]interface{}) - if !ok { - continue - } - sender := "" - if s, ok := msg["sender"].(map[string]interface{}); ok { - if nick, _ := s["nickname"].(string); nick != "" { - sender = nick - } - if card, _ := s["card"].(string); card != "" { - sender = card - } - } - msgText, _ := msg["raw_message"].(string) - if msgText == "" { - if segs, ok := msg["message"].([]interface{}); ok { - var parts []string - for _, seg := range segs { - segMap, _ := seg.(map[string]interface{}) - if segMap == nil { - continue - } - typ, _ := segMap["type"].(string) - segData, _ := segMap["data"].(map[string]interface{}) - if segData == nil { - continue - } - switch typ { - case "text": - if t, _ := segData["text"].(string); t != "" { - parts = append(parts, t) - } - case "file": - name, _ := segData["name"].(string) - if name == "" { - name, _ = segData["file"].(string) - } - fid, _ := segData["file_id"].(string) - fileURL, _ := segData["url"].(string) - if name != "" { - parts = append(parts, "[文件:"+name+"]") - } - if fid != "" { - entry := map[string]interface{}{ - "file_id": fid, - "name": name, - "position": len(lines), - } - if fileURL != "" { - entry["url"] = fileURL - } - files = append(files, entry) - } - case "image": - parts = append(parts, "[图片]") - case "video": - parts = append(parts, "[视频]") - } - } - if len(parts) > 0 { - msgText = strings.Join(parts, " ") - } - } - } - if msgText == "" { - continue - } - ts := "" - if t, ok := msg["time"].(float64); ok { - ts = time.Unix(int64(t), 0).Format("2006-01-02 15:04") - } - line := msgText - if sender != "" { - line = sender + ": " + msgText - } - if ts != "" { - line = "[" + ts + "] " + line - } - lines = append(lines, line) - } - - if len(lines) == 0 { - return map[string]interface{}{ - "messages": []interface{}{}, - "note": "未找到历史消息,可能群内暂无消息记录", - }, nil - } - - result := map[string]interface{}{ - "messages": lines, - "count": len(lines), - } - if len(files) > 0 { - result["files"] = files - } - // 拉取过某会话历史即视为已读(与真人客户端一致:看过=已读) - if gerr == nil { - p.markChatRead(gid) - } else if uerr == nil { - p.markChatRead(uid) - } - return result, nil -} - -func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_group_list", map[string]interface{}{}) -} - -func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) { - return p.napcat("get_friend_list", map[string]interface{}{}) -} - -func (p *Plugin) handleGetRecentContacts(args map[string]interface{}) (interface{}, error) { - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - return p.napcat("get_recent_contact", map[string]interface{}{"count": count}) -} - -func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) { - if uid, err := convInt64(args["user_id"]); err == nil { - return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true}) - } - if gid, err := convInt64(args["group_id"]); err == nil { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true}) - } - return nil, fmt.Errorf("need user_id or group_id") -} - -func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}, error) { - keyword, _ := args["keyword"].(string) - if keyword == "" { - return nil, fmt.Errorf("keyword is required") - } - keyword = strings.ToLower(keyword) - - gid, groupErr := convInt64(args["group_id"]) - if groupErr == nil { - v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) - if err != nil { - return nil, err - } - raw, _ := rawString(v) - return filterMemberList(raw, keyword) - } - - v, err := p.napcat("get_friend_list", map[string]interface{}{}) - if err != nil { - return nil, err - } - raw, _ := rawString(v) - return filterFriendList(raw, keyword) -} - -func filterFriendList(raw, keyword string) (interface{}, error) { - var resp struct { - Data []struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - Remark string `json:"remark"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(raw), &resp); err != nil { - return raw, nil - } - var matches []map[string]interface{} - for _, f := range resp.Data { - if strings.Contains(strings.ToLower(f.Nickname), keyword) || - strings.Contains(strings.ToLower(f.Remark), keyword) { - matches = append(matches, map[string]interface{}{ - "user_id": f.UserID, - "nickname": f.Nickname, - "remark": f.Remark, - }) - } - } - if len(matches) == 0 { - return fmt.Sprintf("未找到昵称/备注包含 %q 的好友", keyword), nil - } - return matches, nil -} - -func filterMemberList(raw, keyword string) (interface{}, error) { - var resp struct { - Data []struct { - UserID int64 `json:"user_id"` - Nickname string `json:"nickname"` - Card string `json:"card"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(raw), &resp); err != nil { - return raw, nil - } - var matches []map[string]interface{} - for _, m := range resp.Data { - if strings.Contains(strings.ToLower(m.Nickname), keyword) || - strings.Contains(strings.ToLower(m.Card), keyword) { - matches = append(matches, map[string]interface{}{ - "user_id": m.UserID, - "nickname": m.Nickname, - "card": m.Card, - }) - } - } - if len(matches) == 0 { - return fmt.Sprintf("未找到昵称/名片包含 %q 的群成员", keyword), nil - } - return matches, nil -} - -func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) -} - -func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if cmd == "" { - return nil, fmt.Errorf("need command") - } - if requiresConfirmGroupCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - - switch cmd { - case "group-list": - return p.napcat("get_group_list", map[string]interface{}{}) - case "group-info", "member-list", "member-info", "at-all-remain", "msg-history": - gid, _ := convInt64(args["group_id"]) - if cmd == "msg-history" { - count := 10 - if c, err := convInt64(args["count"]); err == nil && c > 0 { - count = int(c) - } - return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count}) - } - if cmd == "member-info" { - uid, _ := convInt64(args["user_id"]) - return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) - } - if cmd == "at-all-remain" { - return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid}) - } - if cmd == "group-info" { - return p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) - } - return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) - - case "list-files": - gid, _ := convInt64(args["group_id"]) - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "pending-requests": - return p.napcat("get_group_system_msg", map[string]interface{}{}) - - case "leave": - gid, _ := convInt64(args["group_id"]) - return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid}) - - case "kick": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - reject, _ := args["reject_add"].(bool) - return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject}) - - case "ban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - minutes := 10 - if m, err := convInt64(args["minutes"]); err == nil { - minutes = int(m) - } - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60}) - - case "unban": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0}) - - case "rename": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name}) - - case "mute-all": - gid, _ := convInt64(args["group_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable}) - - case "set-card": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - card, _ := args["card"].(string) - return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card}) - - case "set-admin": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - enable, _ := args["enable"].(bool) - return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable}) - - case "set-title": - gid, _ := convInt64(args["group_id"]) - uid, _ := convInt64(args["user_id"]) - title, _ := args["title"].(string) - return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title}) - - case "recall": - mid, _ := convInt64(args["message_id"]) - return p.napcat("delete_msg", map[string]interface{}{"message_id": mid}) - - case "pin-msg": - mid, _ := convInt64(args["message_id"]) - return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid}) - - case "folder-create": - gid, _ := convInt64(args["group_id"]) - name, _ := args["name"].(string) - return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name}) - - default: - return nil, fmt.Errorf("unknown group_manage command: %s", cmd) - } -} - -func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) { - cmd, _ := args["command"].(string) - if requiresConfirmFriendCommand(cmd) { - if ok, _ := args["confirm"].(bool); !ok { - return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil - } - } - switch cmd { - case "list-friends": - return p.napcat("get_friend_list", map[string]interface{}{}) - case "delete": - uid, _ := convInt64(args["user_id"]) - return p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - case "block": - uid, _ := convInt64(args["user_id"]) - // delete friend - p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) - // kick from groups - gid, err := convInt64(args["group_id"]) - if err != nil { - return map[string]interface{}{"isError": true, "content": "block 必须提供 group_id(插件不会自动遍历所有群踢人)"}, nil - } - p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) - return `{"status":"ok","message":"blocked"}`, nil - case "approve-friend": - flag, _ := args["flag"].(string) - remark, _ := args["remark"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark}) - case "reject-friend": - flag, _ := args["flag"].(string) - return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false}) - default: - return nil, fmt.Errorf("unknown friend_action command: %s", cmd) - } -} - -func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - cmd, _ := args["command"].(string) - - switch cmd { - case "list": - folderID, _ := args["folder_id"].(string) - if folderID != "" { - return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) - } - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "search": - return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) - - case "download": - fileID, _ := args["file_id"].(string) - filename, _ := args["filename"].(string) - if filename == "" { - filename = fmt.Sprintf("group_file_%s", fileID) - } - filename = sanitizeFilename(filename) - // get download URL - resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) - if err != nil { - return nil, err - } - respStr, ok := resp.(string) - if !ok { - return resp, nil - } - // parse URL from response - var parsed struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { - return resp, nil - } - dlURL := parsed.Data.URL - client := &http.Client{Timeout: 120 * time.Second} - httpResp, err := client.Get(dlURL) - if err != nil { - return nil, fmt.Errorf("download: %w", err) - } - defer httpResp.Body.Close() - content, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("read download: %w", err) - } - os.MkdirAll(p.filesDir, 0755) - savePath := filepath.Join(p.filesDir, filename) - if err := os.WriteFile(savePath, content, 0644); err != nil { - return nil, fmt.Errorf("save: %w", err) - } - return map[string]interface{}{ - "status": "ok", "path": savePath, "filename": filename, "size": len(content), - }, nil - - default: - return nil, fmt.Errorf("unknown get_group_files command: %s", cmd) - } -} - -func (p *Plugin) addDownloadTask(fileID, filename string) *DownloadTask { - p.downloadMu.Lock() - defer p.downloadMu.Unlock() - t := &DownloadTask{FileID: fileID, Filename: filename, Status: "running", CreatedAt: time.Now().Format("15:04:05")} - p.downloadTasks = append(p.downloadTasks, t) - if len(p.downloadTasks) > 100 { - p.downloadTasks = p.downloadTasks[len(p.downloadTasks)-100:] - } - return t -} - -func (p *Plugin) updateDownloadTask(t *DownloadTask, status, path, errMsg string) { - p.downloadMu.Lock() - defer p.downloadMu.Unlock() - t.Status = status - t.Path = path - t.Error = errMsg -} - -func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, error) { - fileID, _ := args["file_id"].(string) - if fileID == "" { - return nil, fmt.Errorf("file_id is required") - } - fileURL, _ := args["url"].(string) - filename, _ := args["filename"].(string) - groupID, _ := convInt64(args["group_id"]) - userID, _ := convInt64(args["user_id"]) - - task := p.addDownloadTask(fileID, filename) - - 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 := "" - errMsg := "" - if furl != "" { - savePath = p.downloadURL(furl, fname) - } - if savePath == "" && gid > 0 { - savePath = p.downloadGroupFile(gid, fid, fname) - } - if savePath == "" && uid > 0 { - savePath = p.downloadPrivateFile(uid, fid, fname) - } - if savePath == "" { - savePath = p.downloadFromNapCat(fid, fname) - } - if savePath != "" { - p.updateDownloadTask(t, "done", savePath, "") - log.Printf("[qq] 文件下载完成: %s", savePath) - if p.sdk != nil { - // NoMemory:下载完成的状态通知,不是记忆内容。 - // Priority:同上,QQ 侧一律低级别中断(L1)。 - p.sdk.InjectInterruptTextOpts(p.name, p.name, - fmt.Sprintf("文件下载完成: %s,保存在 %s", filepath.Base(savePath), savePath), - sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL1, RecallPolicy: sdk.RecallPolicyNone}) - } - } else { - errMsg = "下载失败,文件可能已过期" - p.updateDownloadTask(t, "failed", "", errMsg) - log.Printf("[qq] 文件下载失败: %s", fid) - } - }(task, fileID, filename, fileURL, groupID, userID) - - return map[string]interface{}{ - "status": "started", "file_id": fileID, "filename": filename, - "hint": "下载已后台启动。使用 qq_get_download_tasks 查看进度。"}, nil -} - -func (p *Plugin) handleGetDownloadTasks(args map[string]interface{}) (interface{}, error) { - p.downloadMu.Lock() - defer p.downloadMu.Unlock() - // 返回最近 50 条 - tasks := p.downloadTasks - if len(tasks) > 50 { - tasks = tasks[len(tasks)-50:] - } - return map[string]interface{}{ - "tasks": tasks, "total": len(p.downloadTasks), - "hint": "status 为 running 表示下载中,done 已完成,failed 已失败。用 qq_download_file 重新下载失败的任务。"}, nil -} - -func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) { - gid, _ := convInt64(args["group_id"]) - filePath, _ := args["file"].(string) - name, _ := args["name"].(string) - if name == "" { - name = filepath.Base(filePath) - } - name = sanitizeFilename(name) - - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - b64 := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(data)) - - resp, err := p.napcat("send_group_msg", map[string]interface{}{ - "group_id": gid, - "message": []map[string]interface{}{ - {"type": "file", "data": map[string]interface{}{"file": b64, "name": name}}, - }, - }) - if err != nil { - return nil, err - } - return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil -} - -func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) { - uid, _ := convInt64(args["user_id"]) - times := 1 - if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 { - times = int(t) - } - return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times}) -} - -// ======== CQ Code / Message Segment Processing ======== - -func (p *Plugin) processMessageSegments(segments []interface{}, nickname string) string { - if len(segments) == 0 { - return "" - } - botIDStr := strconv.FormatInt(p.botID, 10) - var parts []string - - for _, seg := range segments { - s, ok := seg.(map[string]interface{}) - if !ok { - continue - } - typ, _ := s["type"].(string) - data, _ := s["data"].(map[string]interface{}) - if data == nil { - continue - } - - switch typ { - case "text": - if t, _ := data["text"].(string); t != "" { - parts = append(parts, t) - } - case "at": - qq, _ := data["qq"].(string) - if qq == "all" { - parts = append(parts, "@所有人") - } else if qq == botIDStr { - continue - } else { - parts = append(parts, "@"+qq) - } - case "face", "sface": - if id, _ := data["id"].(string); id != "" { - parts = append(parts, "[表情]") - } - case "file": - parts = append(parts, fmt.Sprintf("[%s发送了文件]", nickname)) - case "image": - parts = append(parts, fmt.Sprintf("[%s发送了图片]", nickname)) - case "video": - parts = append(parts, fmt.Sprintf("[%s发送了视频]", nickname)) - case "reply": - if id, ok := data["id"].(float64); ok { - parts = append(parts, fmt.Sprintf("[回复消息id=%.0f]", id)) - } - case "music": - if title, _ := data["title"].(string); title != "" { - parts = append(parts, fmt.Sprintf("[音乐:%s]", title)) - } else { - parts = append(parts, "[音乐]") - } - case "share": - title, _ := data["title"].(string) - urlStr, _ := data["url"].(string) - if title != "" && urlStr != "" { - parts = append(parts, fmt.Sprintf("[分享:%s %s]", title, urlStr)) - } else if urlStr != "" { - parts = append(parts, fmt.Sprintf("[分享:%s]", urlStr)) - } - default: - if typ != "" { - parts = append(parts, "["+typ+"]") - } - } - } - - return strings.TrimSpace(strings.Join(parts, " ")) -} - -func (p *Plugin) downloadGroupFile(groupID int64, fileID, filename string) string { - resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": groupID, "file_id": fileID}) - if err != nil { - return "" - } - respStr, _ := rawString(resp) - if respStr == "" { - return "" - } - var parsed struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { - return "" - } - if p.filesDir == "" { - return "" - } - os.MkdirAll(p.filesDir, 0755) - if filename == "" { - filename = "group_file_" + fileID - } - savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) - httpResp, err := p.httpClient.Get(parsed.Data.URL) - if err != nil { - return "" - } - defer httpResp.Body.Close() - data, err := io.ReadAll(httpResp.Body) - if err != nil || len(data) == 0 { - return "" - } - os.WriteFile(savePath, data, 0644) - return savePath -} - -func (p *Plugin) downloadPrivateFile(userID int64, fileID, filename string) string { - resp, err := p.napcat("get_private_file_url", map[string]interface{}{"user_id": userID, "file_id": fileID}) - if err != nil { - return "" - } - respStr, _ := rawString(resp) - if respStr == "" { - return "" - } - var parsed struct { - Data *struct { - URL string `json:"url"` - } `json:"data"` - } - if json.Unmarshal([]byte(respStr), &parsed) != nil || parsed.Data == nil || parsed.Data.URL == "" { - return "" - } - return p.downloadURL(parsed.Data.URL, filename) -} - -func (p *Plugin) downloadFromNapCat(fileID, filename string) string { - if p.filesDir == "" || fileID == "" { - return "" - } - os.MkdirAll(p.filesDir, 0755) - raw, err := p.napcat("get_file", map[string]interface{}{"file_id": fileID}) - if err != nil { - return "" - } - rawStr, _ := rawString(raw) - if rawStr == "" { - return "" - } - var resp struct { - Data *struct { - File string `json:"file"` - FileName string `json:"file_name"` - FileSize int64 `json:"file_size"` - Base64 string `json:"base64"` - URL string `json:"url"` - } `json:"data"` - } - if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil { - return "" - } - info := resp.Data - if info.FileName != "" { - filename = info.FileName - } - if info.Base64 != "" { - data, err := base64.StdEncoding.DecodeString(info.Base64) - if err == nil { - savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) - os.WriteFile(savePath, data, 0644) - return savePath - } - } - if info.URL != "" { - return p.downloadURL(info.URL, filename) - } - if info.File != "" { - savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) - if err := os.WriteFile(savePath, []byte(info.File), 0644); err == nil { - return savePath - } - } - return "" -} - -func (p *Plugin) downloadURL(fileURL, filename string) string { - if p.filesDir == "" { - return "" - } - os.MkdirAll(p.filesDir, 0755) - if filename == "" { - filename = "file_" + filepath.Base(fileURL) - } - savePath := filepath.Join(p.filesDir, sanitizeFilename(filename)) - dlResp, err := p.httpClient.Get(fileURL) - if err != nil { - return "" - } - defer dlResp.Body.Close() - data, err := io.ReadAll(dlResp.Body) - if err != nil || len(data) == 0 { - return "" - } - os.WriteFile(savePath, data, 0644) - return savePath -} - -func sanitizeFilename(name string) string { - name = filepath.Base(name) - name = strings.Map(func(r rune) rune { - if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { - return '_' - } - return r - }, name) - return name -} - -// ======== Tool Handlers: Document / Video / Web ======== - -func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, error) { - path, _ := args["path"].(string) - if path == "" { - return nil, fmt.Errorf("path is required") - } - if _, err := os.Stat(path); os.IsNotExist(err) { - return map[string]interface{}{ - "content": fmt.Sprintf("文件不存在: %s", path), - }, nil - } - - ext := strings.ToLower(filepath.Ext(path)) - textContent := "" - - switch ext { - case ".txt", ".md", ".csv": - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read file: %w", err) - } - textContent = string(data) - case ".docx", ".doc", ".epub", ".html", ".htm": - textContent = p.readWithPandoc(path) - default: - // Try pandoc first, fallback to libreoffice - textContent = p.readWithPandoc(path) - if textContent == "" { - textContent = p.readWithLibreoffice(path) - } - if textContent == "" { - // last resort: read as plain text - data, err := os.ReadFile(path) - if err == nil { - textContent = string(data) - } - } - } - - if textContent == "" { - return map[string]interface{}{ - "content": fmt.Sprintf("无法提取文件内容: %s(不支持的文件格式或文件损坏)", path), - }, nil - } - - // 截断到 20000 字符 - origLen := len(textContent) - truncated := origLen > 20000 - if truncated { - textContent = textContent[:20000] - } - - result := textContent - if truncated { - result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) - } - return map[string]interface{}{ - "content": result, - "file": path, - "truncated": truncated, - }, nil -} - -func (p *Plugin) readWithPandoc(path string) string { - var out bytes.Buffer - cmd := exec.Command("pandoc", path, "-t", "plain", "--wrap=none") - cmd.Stdout = &out - cmd.Stderr = nil - if err := cmd.Run(); err != nil { - return "" - } - return strings.TrimSpace(out.String()) -} - -func (p *Plugin) readWithLibreoffice(path string) string { - tmpDir, err := os.MkdirTemp("", "lo-doc-*") - if err != nil { - return "" - } - defer os.RemoveAll(tmpDir) - - cmd := exec.Command("libreoffice", "--headless", "--convert-to", "txt:Text", "--outdir", tmpDir, path) - cmd.Stderr = nil - if err := cmd.Run(); err != nil { - return "" - } - - // 找生成的 txt 文件 - entries, err := os.ReadDir(tmpDir) - if err != nil { - return "" - } - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".txt") { - data, err := os.ReadFile(filepath.Join(tmpDir, e.Name())) - if err == nil { - return strings.TrimSpace(string(data)) - } - } - } - return "" -} - -func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{}, error) { - url, _ := args["url"].(string) - if url == "" { - return nil, fmt.Errorf("url is required") - } - infoOnly, _ := args["info_only"].(bool) - - outputDir := filepath.Join(p.agentfsDir, "videos") - os.MkdirAll(outputDir, 0755) - - if infoOnly { - var out bytes.Buffer - cmd := exec.Command("yt-dlp", "--dump-json", url) - cmd.Stdout = &out - cmd.Stderr = nil - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("yt-dlp info: %w", err) - } - var info struct { - Title string `json:"title"` - Duration int `json:"duration"` - Webpage string `json:"webpage_url"` - Formats []struct { - FormatID string `json:"format_id"` - Ext string `json:"ext"` - Width int `json:"width"` - Height int `json:"height"` - Filesize int64 `json:"filesize"` - Format string `json:"format"` - } `json:"formats"` - } - if err := json.Unmarshal(out.Bytes(), &info); err != nil { - return string(out.String()), nil - } - dur := "" - if info.Duration > 0 { - dur = fmt.Sprintf("%d分%d秒", info.Duration/60, info.Duration%60) - } - lines := []string{fmt.Sprintf("🎬 %s", info.Title)} - if dur != "" { - lines = append(lines, fmt.Sprintf(" 时长: %s", dur)) - } - lines = append(lines, fmt.Sprintf(" 链接: %s", info.Webpage)) - lines = append(lines, "") - for _, f := range info.Formats { - fs := "" - if f.Filesize > 0 { - fs = fmt.Sprintf(" (%.1f MB)", float64(f.Filesize)/1048576) - } - res := "" - if f.Width > 0 && f.Height > 0 { - res = fmt.Sprintf(" %dx%d", f.Width, f.Height) - } - lines = append(lines, fmt.Sprintf(" [%s] %s%s%s", f.FormatID, f.Format, res, fs)) - } - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil - } - - // 下载 - outputTmpl := filepath.Join(outputDir, "%(title)s.%(ext)s") - var out bytes.Buffer - cmd := exec.Command("yt-dlp", "-o", outputTmpl, "--no-playlist", "--print", "after_move:filepath", url) - cmd.Stdout = &out - cmd.Stderr = nil - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("yt-dlp download: %w", err) - } - - // 解析 yt-dlp 输出的文件路径 - dlPath := strings.TrimSpace(out.String()) - if dlPath == "" { - return map[string]interface{}{ - "content": "下载完成,但无法获取文件路径", - }, nil - } - dlFilename := filepath.Base(dlPath) - var fileSize int64 = 0 - if fi, err := os.Stat(dlPath); err == nil { - fileSize = fi.Size() - } - return map[string]interface{}{ - "content": fmt.Sprintf("✅ 下载完成: %s\n 大小: %.1f MB\n 路径: %s", dlFilename, float64(fileSize)/1048576, dlPath), - "file": dlPath, - "filename": dlFilename, - }, nil -} - -// ======== NapCat HTTP Client ======== - -func messageWithMention(userID int64, content interface{}) []interface{} { - return []interface{}{ - map[string]interface{}{"type": "at", "data": map[string]interface{}{"qq": strconv.FormatInt(userID, 10)}}, - content, - } -} - -func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { - data, _ := json.Marshal(params) - url := fmt.Sprintf("%s/%s", p.napcatURL, action) - - resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("napcat %s: %w", action, err) - } - defer resp.Body.Close() - - var raw json.RawMessage - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, fmt.Errorf("napcat decode %s: %w", action, err) - } - return raw, nil -} - -func (p *Plugin) setInputStatus(userID int64, eventType int) (interface{}, error) { - return p.napcat("set_input_status", map[string]interface{}{ - "user_id": userID, - "event_type": eventType, - }) -} - -func (p *Plugin) startTyping(userID int64) { - p.typingMu.Lock() - if _, ok := p.typingMap[userID]; ok { - p.typingMu.Unlock() - return - } - ts := &typingState{userID: userID, stopCh: make(chan struct{})} - p.typingMap[userID] = ts - p.typingMu.Unlock() - go p.typingLoop(ts) -} - -func (p *Plugin) stopTyping(userID int64) { - p.typingMu.Lock() - ts, ok := p.typingMap[userID] - if ok { - delete(p.typingMap, userID) - } - p.typingMu.Unlock() - if ok { - close(ts.stopCh) - } -} - -func (p *Plugin) typingLoop(ts *typingState) { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - timeout := time.After(30 * time.Second) - for { - select { - case <-ticker.C: - p.setInputStatus(ts.userID, 1) - case <-ts.stopCh: - return - case <-timeout: - p.stopTyping(ts.userID) - return - } - } -} - -// ======== Helpers ======== - -// rawString extracts a string from napcat's return type (json.RawMessage or string). -func rawString(v interface{}) (string, bool) { - switch r := v.(type) { - case string: - return r, true - case json.RawMessage: - return string(r), true - case []byte: - return string(r), true - } - return "", false -} - -var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) -var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) -var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) -var stripCQRe = regexp.MustCompile(`\[CQ:[^\]]*\]|\[mirai:[^\]]*\]`) -var highRiskRe = regexp.MustCompile(`(假如你是|你现在是|请你(扮演|化作|假装|成为)|扮演(一个|一下)|把你自己(想象|当成)|你的(人设|设定)是|穿越(到|回)|你是从.{0,10}(来|穿越)|帮我编(个|一个)故事|写(个|一个)故事让|故事(中|里)的|觉得(这个|这台|这家)?(机器人|AI|助手|ai).{0,8}(怎么样|如何|好不好|评价)|评价(下|一下)?(这个|这台|这家)?(机器人|AI|助手|ai|gpt)|忽略(之前|所有)?(指令|规则|限制|禁令)|解除.{0,6}(限制|规则|约束)|越狱|绕过.{0,6}(限制|审查)|不用(遵守|管)(任何)?(规则|限制|指令)|无视(所有)?(规则|指令)|你是(一个|一只)自由的)`) - -func (p *Plugin) sensitiveFilter(text string) string { - if p.remoteDir != "" { - text = strings.ReplaceAll(text, p.remoteDir, "[remote]") - } - if p.filesDir != "" { - text = strings.ReplaceAll(text, p.filesDir, "[files]") - } - - text = reAPIKey.ReplaceAllString(text, "$1=***") - text = reSKKey.ReplaceAllString(text, "sk-***") - text = reInternalIP.ReplaceAllString(text, "[IP]") - return text -} - -func convInt64(v interface{}) (int64, error) { - switch n := v.(type) { - case int64: - return n, nil - case float64: - return int64(n), nil - case int: - return int64(n), nil - case json.Number: - return n.Int64() - case string: - return strconv.ParseInt(n, 10, 64) - } - return 0, fmt.Errorf("cannot convert %T to int64", v) -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{ - name: name, - allowFrom: make(map[int64]struct{}), - groupAllowFrom: make(map[int64]struct{}), - authByMessageID: make(map[int64]qqAuthContext), - outputSignatures: make(map[string]int), - maxQQToolCalls: defaultMaxQQToolCalls, - maxQQOutputCalls: defaultMaxQQOutputCalls, - maxDuplicateSend: defaultMaxDuplicateSend, - groupToolAllowlists: parseGroupToolAllowlists(defaultGroupToolAllowlists), - privateToolAllowlist: parseToolAllowlist(defaultPublicToolAllowlist), - downloadTasks: make([]*DownloadTask, 0), - typingMap: make(map[int64]*typingState), - dmPolicy: "open", - groupPolicy: "open", - }, nil -} diff --git a/third_party/homeagent-sdk/example/qq/plugin_test.go b/third_party/homeagent-sdk/example/qq/plugin_test.go deleted file mode 100644 index 50704c0..0000000 --- a/third_party/homeagent-sdk/example/qq/plugin_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -func newPermissionTestPlugin(t *testing.T) *Plugin { - t.Helper() - instance, err := NewPluginFactory("qq", nil) - if err != nil { - t.Fatal(err) - } - return instance.(*Plugin) -} - -func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext { - return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}} -} - -func TestOwnerBypassesQQPermissionBoundary(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - ctx := toolCallContext("calendar_list", nil) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("owner call rejected: %s", *ctx.Response) - } -} - -func TestPrivateResourceCannotBeAllowlisted(t *testing.T) { - p := newPermissionTestPlugin(t) - p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*") - p.auth = qqAuthContext{active: true, userID: 10001} - ctx := toolCallContext("calendar_list", nil) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") { - t.Fatalf("expected private-resource denial, got %#v", ctx.Response) - } -} - -func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true} - - ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)}) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") { - t.Fatalf("cross-group history not rejected: %#v", ctx.Response) - } - - ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)}) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("current-group history rejected: %s", *ctx.Response) - } -} - -func TestUnmatchedQQInputIsDowngraded(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - ctx := &sdk.StageContext{ - RawMessage: "来自未知事件(message_id=404)", - Extra: map[string]interface{}{"input_source": "qq"}, - } - if err := p.onInputAuthContext(ctx); err != nil { - t.Fatal(err) - } - if !p.auth.active || p.auth.owner || p.auth.userID != 0 { - t.Fatalf("unmatched input reused prior privilege: %+v", p.auth) - } -} - -func TestDuplicateQQOutputIsStopped(t *testing.T) { - p := newPermissionTestPlugin(t) - p.maxDuplicateSend = 1 - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`} - - ctx := toolCallContext("output_send__qq", args) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("first send rejected: %s", *ctx.Response) - } - - ctx = toolCallContext("output_send__qq", args) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") { - t.Fatalf("duplicate send not stopped: %#v", ctx.Response) - } -} - -func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) { - var path string - var request map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path = r.URL.Path - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Errorf("decode request: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`)) - })) - defer server.Close() - - p := newPermissionTestPlugin(t) - p.napcatURL = server.URL - p.httpClient = server.Client() - _, err := p.handleChannelOutput(map[string]interface{}{ - "payload": "hello", - "type": "text", - "meta": `{"group_id":20002,"user_id":10001}`, - }) - if err != nil { - t.Fatal(err) - } - if path != "/send_group_msg" { - t.Fatalf("path=%q, want /send_group_msg", path) - } - segments, ok := request["message"].([]interface{}) - if !ok || len(segments) < 2 { - t.Fatalf("message is not a segment array: %#v", request["message"]) - } - mention, _ := segments[0].(map[string]interface{}) - data, _ := mention["data"].(map[string]interface{}) - if mention["type"] != "at" || data["qq"] != "10001" { - t.Fatalf("leading mention=%#v", mention) - } -} - -// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。 -// 现在只拦参数完全相同的重复调用。 -func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - // maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。 - for i := 0; i < 5; i++ { - ctx := toolCallContext("output_send__qq", map[string]interface{}{ - "payload": fmt.Sprintf("message-%d", i), - "type": "text", - "meta": `{"user_id":123}`, - }) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response) - } - } -} - -func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - // 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。 - for i := 0; i < 50; i++ { - ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)}) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response) - } - } -} - -func TestZeroLimitsMeanUnlimited(t *testing.T) { - p := newPermissionTestPlugin(t) - p.maxQQOutputCalls = 0 - p.maxDuplicateSend = 0 - p.maxQQToolCalls = 0 - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - for i := 0; i < 30; i++ { - ctx := toolCallContext("output_send__qq", map[string]interface{}{ - "payload": "same-content", - "type": "text", - "meta": `{"user_id":123}`, - }) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response) - } - } -} - -// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时, -// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定, -// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去 -// (报「可信 QQ 会话身份不完整」)。 -// -// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。 -func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true} - p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"} - p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}} - - ctx := toolCallContext("output_send__qq", map[string]interface{}{ - "payload": "带齐 meta 的主动发送", - "type": "text", - "meta": `{"user_id":2198972886}`, - }) - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("降权时输出被拒: %s", *ctx.Response) - } - - ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713}) - if err := p.beforeToolcall(ctx2); err != nil { - t.Fatal(err) - } - if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") { - t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response) - } -} - -// ---- 消息合并(debounce)---- - -// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。 -func collectInterrupts(p *Plugin) *[]string { - got := []string{} - p.injectHook = func(s, _ string) { got = append(got, s) } - return &got -} - -func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) { - p := newPermissionTestPlugin(t) - got := collectInterrupts(p) - p.batchWindow = 20 * time.Millisecond - p.batchMax = time.Second - - for i := 0; i < 3; i++ { - p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false) - } - time.Sleep(120 * time.Millisecond) - - if len(*got) != 1 { - t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got) - } - if !strings.Contains((*got)[0], "3 条消息") { - t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0]) - } - // 三个 message_id 都要带上,模型才能取全 - for _, id := range []string{"100", "101", "102"} { - if !strings.Contains((*got)[0], id) { - t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0]) - } - } -} - -func TestDifferentSendersAreNotBatchedTogether(t *testing.T) { - p := newPermissionTestPlugin(t) - got := collectInterrupts(p) - p.batchWindow = 20 * time.Millisecond - p.batchMax = time.Second - - p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false) - p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false) - time.Sleep(120 * time.Millisecond) - - if len(*got) != 2 { - t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got) - } -} - -func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) { - p := newPermissionTestPlugin(t) - got := collectInterrupts(p) - p.batchWindow = 0 - - for i := 0; i < 3; i++ { - p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false) - } - if len(*got) != 3 { - t.Fatalf("关闭合并时应逐条投递(3 次),实际 %d: %#v", len(*got), *got) - } -} - -func TestSingleMessageKeepsOriginalText(t *testing.T) { - p := newPermissionTestPlugin(t) - got := collectInterrupts(p) - p.batchWindow = 20 * time.Millisecond - p.batchMax = time.Second - - p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false) - time.Sleep(120 * time.Millisecond) - - if len(*got) != 1 || (*got)[0] != "单条原文" { - t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got) - } -} - -// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人 -// 的 L1 闲聊抢占/挤到队尾。 -func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) { - p := newPermissionTestPlugin(t) - got := []string{} - p.injectHook = func(text, level string) { got = append(got, text+"|"+level) } - p.batchWindow = 20 * time.Millisecond - p.batchMax = time.Second - - p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false) - p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false) - time.Sleep(120 * time.Millisecond) - - joined := strings.Join(got, ",") - if !strings.Contains(joined, "owner-msg|L2") { - t.Fatalf("所有者消息应为 L2,实际 %q", joined) - } - if !strings.Contains(joined, "other-msg|L1") { - t.Fatalf("普通人消息应为 L1,实际 %q", joined) - } -} - -// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后, -// 外层轮被恢复(resumeTask 复用同一帧、不重跑 onInput)时权限门不能整体失效。 -func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) { - p := newPermissionTestPlugin(t) - - // 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。 - inner := &sdk.StageContext{Extra: map[string]interface{}{ - qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}, - }} - if err := p.afterOutputAuthContext(inner); err != nil { - t.Fatal(err) - } - if p.auth.active { - t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)") - } - - // 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。 - frame := &sdk.StageContext{ - Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}}, - ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}}, - } - if err := p.beforeToolcall(frame); err != nil { - t.Fatal(err) - } - if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") { - t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response) - } -} - -// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。 -func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) { - p := newPermissionTestPlugin(t) - - frame := &sdk.StageContext{ - Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}}, - ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}}, - } - // 路人的群消息在所有者轮运行中到达。 - p.activateAuthContext(4242, 10001, 20002, true) - if p.auth.owner { - t.Fatal("到达事件应改写全局身份(复现场景)") - } - - if err := p.beforeToolcall(frame); err != nil { - t.Fatal(err) - } - if frame.Response != nil { - t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response) - } -} - -// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。 -func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) { - p := newPermissionTestPlugin(t) - p.authByMessageID = map[int64]qqAuthContext{ - 100: {active: true, owner: true, userID: 2198972886}, - 101: {active: true, owner: true, userID: 2198972886}, - } - ctx := &sdk.StageContext{ - RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。", - Extra: map[string]interface{}{"input_source": "qq"}, - } - if err := p.onInputAuthContext(ctx); err != nil { - t.Fatal(err) - } - if !p.auth.owner { - t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth) - } - if len(p.authByMessageID) != 0 { - t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID) - } - if auth, ok := authOnFrame(ctx); !ok || !auth.owner { - t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok) - } -} - -// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。 -func TestNonQQFrameBindsInactiveAuth(t *testing.T) { - p := newPermissionTestPlugin(t) - p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886} - - ctx := &sdk.StageContext{ - RawMessage: "webui 里的提问", - Extra: map[string]interface{}{"input_source": "webui"}, - ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}}, - } - if err := p.onInputAuthContext(ctx); err != nil { - t.Fatal(err) - } - if err := p.beforeToolcall(ctx); err != nil { - t.Fatal(err) - } - if ctx.Response != nil { - t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response) - } -} diff --git a/third_party/homeagent-sdk/example/recoverydiag/plg.json b/third_party/homeagent-sdk/example/recoverydiag/plg.json deleted file mode 100644 index 70319c8..0000000 --- a/third_party/homeagent-sdk/example/recoverydiag/plg.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/recoverydiag/plugin.go b/third_party/homeagent-sdk/example/recoverydiag/plugin.go deleted file mode 100644 index 5b666d9..0000000 --- a/third_party/homeagent-sdk/example/recoverydiag/plugin.go +++ /dev/null @@ -1,1023 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strings" - "time" - - sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" -) - -// Plugin 快速检查/崩溃取证工具集。全部确定性检出,返回结论而非原文,供 guard / failback 决策。 -type Plugin struct { - name string - sdk *sdk.PluginSDK - muKey string - dataDir string - logDir string - cfgPath string -} - -func (p *Plugin) Name() string { return p.name } - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.muKey = p.name + "_" - p.resolveDirs(s) - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "db_check_cmd", - Default: "sqlite3", - Type: "string", - DisplayName: "sqlite3 CLI 路径", - Description: "diag_db 用到的 sqlite3 命令;留空则仅在可用时使用,缺失回退到内核 Settings 读取。留空=auto", - Category: "recoverydiag", - }) - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "recovery_kb_dir", - Default: "", - Type: "string", - DisplayName: "结论落盘目录", - Description: "diag_loc 结论 JSON 落盘目录,缺省用 /recovery_kb", - Category: "recoverydiag", - }) - - s.RegisterTool(p.muKey+"diag_triage", sdk.ToolDef{ - Name: p.muKey + "diag_triage", - Description: "快速分诊:根据退出码/信号/存活状态粗分崩溃类别(进程死亡 vs 配置类不可达 vs 正常)。返回结论,不返回日志原文。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "exit_code": map[string]interface{}{"type": "integer", "description": "进程退出码(0=正常)"}, - "signal": map[string]interface{}{"type": "string", "description": "终止信号名(如 SIGSEGV/SIGKILL/OOM),可选"}, - "uptime_ms": map[string]interface{}{"type": "integer", "description": "进程存活毫秒,可选"}, - "still_alive": map[string]interface{}{"type": "boolean", "description": "主 agent 是否仍在运行/对心跳有响应,可选"}, - "crash_reason": map[string]interface{}{"type": "string", "description": "守护方附带的已知原因描述,可选"}, - }, - }, - NoMemory: true, - }, p.handleTriage) - - s.RegisterTool(p.muKey+"diag_db", sdk.ToolDef{ - Name: p.muKey + "diag_db", - Description: "config.db 完整性(PRAGMA integrity_check)+ LLM 源解析校验(core.llm.sources.* 必备字段),逐项 ok/fail,返回结论。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "db_path": map[string]interface{}{"type": "string", "description": "config.db 路径,缺省用 /config.db"}, - }, - }, - NoMemory: true, - }, p.handleDB) - - s.RegisterTool(p.muKey+"diag_log_scan", sdk.ToolDef{ - Name: p.muKey + "diag_log_scan", - Description: "在日志目录时间窗内统计已知错误签名(panic/OOM/网络不可达/provider失败/sql/致命)出现次数,返回按类统计与主导结论。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "log_dir": map[string]interface{}{"type": "string", "description": "日志目录,缺省用 /log"}, - "since_minutes": map[string]interface{}{"type": "integer", "description": "只看最近 N 分钟,缺省看全部"}, - "max_lines": map[string]interface{}{"type": "integer", "description": "最多扫描行数(防止读取过大文件),缺省 200000"}, - }, - }, - NoMemory: true, - }, p.handleLogScan) - - s.RegisterTool(p.muKey+"diag_delta", sdk.ToolDef{ - Name: p.muKey + "diag_delta", - Description: "对比 baseline(上次 good 快照/目录)与现状目录,输出 created/modified/deleted 文件清单与摘要,用于判定'改了什么'。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "baseline_dir": map[string]interface{}{"type": "string", "description": "基线目录(快照解包目录),必传"}, - "current_dir": map[string]interface{}{"type": "string", "description": "现状目录(如 agentfs merged/upper),必传"}, - "pattern": map[string]interface{}{"type": "string", "description": "只关注匹配该子串的相对路径,可选"}, - "max_items": map[string]interface{}{"type": "integer", "description": "返回最多文件条数,缺省 500"}, - }, - }, - NoMemory: true, - }, p.handleDelta) - - s.RegisterTool(p.muKey+"diag_loc", sdk.ToolDef{ - Name: p.muKey + "diag_loc", - Description: "综合分诊/DB/日志/快照四项结论,按因果强度正交排序定位根因并给出推荐恢复动作。调用前请先跑其余 diag_* 并把结论传入。", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "triage": map[string]interface{}{"type": "object", "description": "diag_triage 返回的结论对象"}, - "db": map[string]interface{}{"type": "object", "description": "diag_db 返回的结论对象"}, - "log_scan": map[string]interface{}{"type": "object", "description": "diag_log_scan 返回的结论对象"}, - "delta": map[string]interface{}{"type": "object", "description": "diag_delta 返回的结论对象"}, - "persist": map[string]interface{}{"type": "boolean", "description": "是否落盘结论到 recovery_kb 并回流知识库,缺省 true"}, - }, - }, - NoMemory: true, - }, p.handleLoc) - - log.Printf("[%s] started: data_dir=%s log_dir=%s", p.name, p.dataDir, p.logDir) - return nil -} - -func (p *Plugin) Stop() error { - log.Printf("[%s] stopped", p.name) - return nil -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -// resolveDirs 从内核 Settings 解出数据目录与日志目录。 -func (p *Plugin) resolveDirs(s *sdk.PluginSDK) { - if v, err := s.Settings().GetCore("core.daemon.data_dir"); err == nil && v != nil { - if sv, ok := v.(string); ok && sv != "" { - p.dataDir = sv - } - } - if v, err := s.Settings().GetCore("core.log.path"); err == nil && v != nil { - if sv, ok := v.(string); ok && sv != "" { - p.logDir = sv - } - } - if p.logDir == "" && p.dataDir != "" { - p.logDir = filepath.Join(p.dataDir, "log") - } - if p.dataDir != "" { - p.cfgPath = filepath.Join(p.dataDir, "config.db") - } -} - -// ===== 参数解析辅助 ===== - -func argString(args map[string]interface{}, key string) string { - if v, ok := args[key]; ok { - switch x := v.(type) { - case string: - return x - case json.Number: - return x.String() - case float64: - return fmt.Sprintf("%.0f", x) - case int: - return fmt.Sprintf("%d", x) - case int64: - return fmt.Sprintf("%d", x) - case bool: - if x { - return "true" - } - return "false" - default: - return fmt.Sprint(x) - } - } - return "" -} - -func argInt(args map[string]interface{}, key string, def int) int { - if v, ok := args[key]; ok { - switch x := v.(type) { - case float64: - return int(x) - case json.Number: - i, _ := x.Int64() - return int(i) - case int: - return x - case int64: - return int(x) - case string: - var i int - if _, err := fmt.Sscanf(x, "%d", &i); err == nil { - return i - } - } - } - return def -} - -func argBool(args map[string]interface{}, key string) bool { - if v, ok := args[key]; ok { - switch x := v.(type) { - case bool: - return x - case string: - return x == "true" || x == "1" || x == "yes" - } - } - return false -} - -func argMap(args map[string]interface{}, key string) map[string]interface{} { - if v, ok := args[key]; ok { - if m, ok := v.(map[string]interface{}); ok { - return m - } - if s, ok := v.(string); ok && s != "" { - var m map[string]interface{} - if json.Unmarshal([]byte(s), &m) == nil { - return m - } - } - } - return nil -} - -func valueString(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - return argString(m, key) -} - -func valueInt(m map[string]interface{}, key string) int { - if m == nil { - return 0 - } - return argInt(m, key, 0) -} - -func content(v interface{}) map[string]interface{} { - out := map[string]interface{}{"content": v} - return out -} - -func contentWith(m map[string]interface{}, c string) map[string]interface{} { - m["content"] = c - return m -} - -// ---- diag_triage ---- - -func (p *Plugin) handleTriage(args map[string]interface{}) (interface{}, error) { - exitCode := argInt(args, "exit_code", 0) - signal := argString(args, "signal") - uptimeMS := argInt(args, "uptime_ms", 0) - stillAlive := argBool(args, "still_alive") - reason := argString(args, "crash_reason") - - class := "normal_stop" - verdict := "healthy" - var detail []string - if reasons := strings.TrimSpace(reason); reasons != "" { - detail = append(detail, "守护方告知: "+reasons) - } - - switch { - case stillAlive: - // 主 agent 进程仍在,但被判定需要检查 → 配置类/可达性问题优先(进程自身正常) - class = "config_unreachable" - verdict = "degraded" - detail = append(detail, "进程存活但健康检测触发,倾向配置/可达性类") - case signal != "": - s := strings.ToUpper(strings.ReplaceAll(signal, "-", "")) - class = "process_death" - verdict = "down" - detail = append(detail, fmt.Sprintf("被信号终止: %s", signal)) - if s == "SIGKILL" || s == "KILL" || s == "OOM" || strings.Contains(strings.ToLower(signal), "oom") { - class = "process_starvation" - detail = append(detail, "疑似被强杀/OOM,优先怀疑资源或失控") - } else if s == "SIGSEGV" || s == "SIGBUS" || s == "SIGABRT" || s == "SIGFPE" { - detail = append(detail, "疑似崩溃信号(segv/abrt),配合 diag_log_scan 的 panic/栈签名") - } - case exitCode != 0: - class = "process_death" - verdict = "down" - detail = append(detail, fmt.Sprintf("非零退出码: %d", exitCode)) - if exitCode >= 128 { - detail = append(detail, "退出码>=128 通常是 128+signal,配合信号判定") - } - default: - detail = append(detail, "退出码0且无信号:正常停止") - } - - c := map[string]interface{}{ - "verdict": verdict, - "class": class, - "exit_code": exitCode, - "signal": signal, - "still_alive": stillAlive, - "uptime_ms": uptimeMS, - "detail": detail, - "recommendation": recForTriage(class), - } - return contentWith(c, describeTriage(c)), nil -} - -func recForTriage(class string) string { - switch class { - case "process_death": - return "进程崩溃:先查崩溃点(diag_log_scan 栈/panic 签名);若无配置改动则重建 worker,勿动配置" - case "process_starvation": - return "资源/强杀:检查内存/失控,勿动配置,重建 worker 并限制资源" - case "config_unreachable": - return "配置/可达性:查 LLM 源与网络配置(diag_db + diag_delta on /etc),必要时还原配置并 ReloadFromConfig" - default: - return "正常情况,无需恢复" - } -} - -func describeTriage(c map[string]interface{}) string { - return fmt.Sprintf("判决: %s | 类别: %s | 建议: %s", c["verdict"], c["class"], c["recommendation"]) -} - -// ---- diag_db ---- - -type llmSourceData struct { - Name string `json:"name"` - BaseURL string `json:"base_url"` - Model string `json:"model"` - Adapter string `json:"adapter"` - APIKeySet bool `json:"api_key_present"` - Missing []string `json:"missing_fields"` - OK bool `json:"ok"` -} - -func (p *Plugin) handleDB(args map[string]interface{}) (interface{}, error) { - dbPath := argString(args, "db_path") - if dbPath == "" { - dbPath = p.cfgPath - } - if dbPath == "" { - return content("无法定位 config.db(未配置 data_dir),请传入 db_path"), nil - } - // 安全校验:db_path 仅允许 data 目录内的 sqlite 文件,防止被用作任意文件探测。 - if p.dataDir != "" { - abs, err := filepath.Abs(dbPath) - if err != nil { - return content("db_path 解析失败: " + err.Error()), nil - } - base := filepath.Clean(p.dataDir) - if abs != base && !strings.HasPrefix(abs, base+string(filepath.Separator)) { - return content("db_path 必须位于数据目录内(" + base + ")"), nil - } - } - - res := map[string]interface{}{ - "db_path": dbPath, - "exists": false, - } - if st, err := os.Stat(dbPath); err != nil || st.IsDir() { - res["integrity"] = "absent" - res["sources"] = []map[string]interface{}{} - res["summary"] = "config.db 缺失,属配置损坏类高危信号" - return contentWith(res, "config.db 缺失/不可访问"), nil - } - res["exists"] = true - res["size_bytes"] = func() int64 { - st, _ := os.Stat(dbPath) - if st != nil { - return st.Size() - } - return 0 - }() - - integrity, errTxt := p.dbIntegrity(dbPath) - res["integrity"] = integrity - if serr, ok := errTxt.(string); ok && serr != "" { - res["integrity_error"] = serr - } - - sources, srcErr := p.dbSources(dbPath) - res["sources"] = sources - failed := 0 - absent := 0 - var missingFields []string - for _, s := range sources { - if !s.OK { - failed++ - missingFields = append(missingFields, s.Name+":"+strings.Join(s.Missing, ",")) - } else if s.BaseURL == "" { - absent++ - } - } - res["source_count"] = len(sources) - res["source_failed"] = failed - res["missing_fields"] = missingFields - - verdict := "ok" - summary := "config.db 完整,LLM 源解析全部通过" - if integrity != "ok" { - verdict = "fail" - summary = "config.db 完整性校验失败,属配置损坏类,应还原配置快照并 ReloadFromConfig" - } else if failed > 0 { - verdict = "degraded" - summary = fmt.Sprintf("config.db 完整,但 %d 个 LLM 源缺必备字段(%s),需修复源配置", failed, strings.Join(missingFields, ";")) - } else if sources == nil && srcErr != "" { - verdict = "unknown" - summary = "config.db 完整但无法解析 LLM 源:" + srcErr - } - res["verdict"] = verdict - res["summary"] = summary - return contentWith(res, summary), nil -} - -// dbIntegrity 优先用 sqlite3 CLI 做 PRAGMA integrity_check;缺失则用 Settings 兜底 + 头部魔法字节启发式。 -func (p *Plugin) dbIntegrity(dbPath string) (string, interface{}) { - bin := p.sqliteBin() - if bin != "" { - out, err := exec.Command(bin, dbPath, "PRAGMA integrity_check;").CombinedOutput() - if err != nil { - return "error", fmt.Sprintf("sqlite3 运行失败: %v(%s)", err, strings.TrimSpace(string(out))) - } - trim := strings.TrimSpace(string(out)) - if strings.Contains(trim, "ok") { - return "ok", "" - } - if trim != "" { - return "fail", hemlines(trim, 3) - } - return "unknown", "integrity_check 无输出" - } - - // 无 CLI:读头部魔法 + 是否 WAL 缺页(pgno/心跳不必深析)作轻量启发式 - hdr := make([]byte, 16) - f, err := os.Open(dbPath) - if err != nil { - return "error", "无法打开 config.db" - } - _, err = f.Read(hdr) - f.Close() - if err != nil || !strings.HasPrefix(string(hdr), "SQLite format 3\x00") { - return "fail", "非 SQLite 文件头,疑似损坏/截断" - } - return "ok", "" // 头部完好;深度一致性超出无 CLI 能力,标注降级 -} - -func hemlines(s string, n int) string { - lines := strings.Split(s, "\n") - lines = filterNonEmpty(lines) - if len(lines) > n { - return strings.Join(lines[:n], " | ") - } - return strings.Join(lines, " | ") -} - -func filterNonEmpty(lines []string) []string { - var o []string - for _, l := range lines { - if strings.TrimSpace(l) != "" { - o = append(o, strings.TrimSpace(l)) - } - } - return o -} - -// dbSources 枚举 core.llm.sources.* 并校验必备字段。优先 sqlite3 CLI;缺失回退内核 Settings。 -func (p *Plugin) dbSources(dbPath string) ([]llmSourceData, string) { - bin := p.sqliteBin() - kv := map[string]string{} - if bin != "" { - out, err := exec.Command(bin, - dbPath, - "SELECT key, value FROM config WHERE key LIKE 'core.llm.sources.%';").CombinedOutput() - if err != nil { - return nil, fmt.Sprintf("sqlite3 查询失败: %v", err) - } - for _, line := range strings.Split(string(out), "\n") { - if idx := strings.IndexByte(line, '|'); idx >= 0 { - kv[line[:idx]] = line[idx+1:] - } - } - } else if p.sdk != nil { - keys, _ := p.sdk.Settings().ListCore("core.llm.sources") - for _, k := range keys { - if v, err := p.sdk.Settings().GetCore(k); err == nil && v != nil { - kv[k] = fmt.Sprint(v) - } - } - } else { - return nil, "既无 sqlite3 也无 Settings 可用" - } - - byName := map[string]map[string]string{} - for k, v := range kv { - rest := strings.TrimPrefix(k, "core.llm.sources.") - parts := strings.SplitN(rest, ".", 2) - if len(parts) != 2 { - continue - } - if byName[parts[0]] == nil { - byName[parts[0]] = map[string]string{} - } - byName[parts[0]][parts[1]] = v - } - - names := make([]string, 0, len(byName)) - for n := range byName { - names = append(names, n) - } - sort.Strings(names) - - var out []llmSourceData - for _, n := range names { - fields := byName[n] - var missing []string - for _, req := range []string{"base_url", "model", "adapter"} { - if strings.TrimSpace(fields[req]) == "" { - missing = append(missing, req) - } - } - out = append(out, llmSourceData{ - Name: n, - BaseURL: fields["base_url"], - Model: fields["model"], - Adapter: fields["adapter"], - APIKeySet: strings.TrimSpace(fields["api_key"]) != "", - Missing: missing, - OK: len(missing) == 0, - }) - } - return out, "" -} - -func (p *Plugin) sqliteBin() string { - if p.sdk != nil { - if v, err := p.sdk.Settings().Get("db_check_cmd"); err == nil && v != nil { - if sv, ok := v.(string); ok && sv != "" && sv != "auto" { - if _, err := exec.LookPath(sv); err == nil { - return sv - } - return "" - } - } - } - if _, err := exec.LookPath("sqlite3"); err == nil { - return "sqlite3" - } - return "" -} - -// ---- diag_log_scan ---- - -type sigRule struct { - Category string - Re *regexp.Regexp -} - -var sigRules = []sigRule{ - {"panic", regexp.MustCompile(`(?i)panic|nil pointer|invalid memory address|runtime error|SIGSEGV|coredump|stack overflow`)}, - {"oom", regexp.MustCompile(`(?i)\boom\b|out of memory|memory allocation failed`)}, - {"network", regexp.MustCompile(`(?i)no such host|connection refused|connection reset|timeout|unreachable|dns|lookup.*fail`)}, - {"provider", regexp.MustCompile(`(?i)provider .* failed|marked unavailable|llm api unreachable|llmfallback|api key|401|403`)}, - {"sql", regexp.MustCompile(`(?i)sql: |sqlite|database is locked|disk I/O error|no such table|constraint failed`)}, - {"fatal", regexp.MustCompile(`(?i)\bfatal\b|\berror\b|failed`)}, -} - -func (p *Plugin) handleLogScan(args map[string]interface{}) (interface{}, error) { - logDir := argString(args, "log_dir") - if logDir == "" { - logDir = p.logDir - } - sinceMin := argInt(args, "since_minutes", 0) - maxLines := argInt(args, "max_lines", 200000) - if maxLines <= 0 { - maxLines = 200000 - } - - cutoff := time.Time{} - if sinceMin > 0 { - cutoff = time.Now().Add(-time.Duration(sinceMin) * time.Minute) - } - - entries, err := os.ReadDir(logDir) - if err != nil { - return content(fmt.Sprintf("日志目录不可读: %v", err)), nil - } - - // 只扫当前 raw 日志(homed_YYYY-MM-DD_HH-MM-SS.log),忽略已压缩归档 - var files []string - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") { - continue - } - files = append(files, filepath.Join(logDir, e.Name())) - } - sort.Strings(files) - - counts := map[string]int{} - total := 0 - matched := 0 - scannedLines := 0 - filesRead := 0 - for _, f := range files { - if scannedLines >= maxLines { - break - } - data, err := os.ReadFile(f) - if err != nil { - continue - } - filesRead++ - for _, line := range strings.Split(string(data), "\n") { - if scannedLines >= maxLines { - break - } - scannedLines++ - line = strings.TrimSpace(line) - if line == "" { - continue - } - // 时间窗过滤:行首时间戳形如 2026/08/03 10:03:52 - if !cutoff.IsZero() { - ts := parseLogTS(line) - if !ts.IsZero() && ts.Before(cutoff) && (sinceMin > 0) { - continue - } - } - total++ - for _, rule := range sigRules { - if rule.Re.MatchString(line) { - counts[rule.Category]++ - matched++ - break - } - } - } - } - - // 排序取主导 - type kv struct { - cat string - count int - } - var order []kv - for cat, n := range counts { - order = append(order, kv{cat, n}) - } - sort.Slice(order, func(i, j int) bool { - if order[i].count != order[j].count { - return order[i].count > order[j].count - } - return order[i].cat < order[j].cat - }) - - dominant := "" - if len(order) > 0 { - dominant = order[0].cat - } - conclusion := "无已知错误签名命中" - switch dominant { - case "panic": - conclusion = "主导: panic/崩溃 → 配合栈定位,属进程死亡类" - case "oom": - conclusion = "主导: OOM/内存 → 进程失稳类,检查内存" - case "network", "provider": - conclusion = "主导: 网络/供应商不可达 → 配置或系统网络类" - case "sql": - conclusion = "主导: SQL/数据库错误 → 数据或配置损坏类" - case "fatal": - conclusion = "主导: 常规 error/failed → 需结合 DB/快照进一步定位" - } - - res := map[string]interface{}{ - "log_dir": logDir, - "files_read": filesRead, - "lines_scanned": scannedLines, - "lines_total_in_window": total, - "lines_matched": matched, - "counts": counts, - "dominant": dominant, - "conclusion": conclusion, - } - return contentWith(res, fmt.Sprintf("%s (命中 %d 行, 主导 %s)", conclusion, matched, dominant)), nil -} - -// parseLogTS 解析 homed 时间戳前缀 2026/08/03 10:03:52。 -var logTSRe = regexp.MustCompile(`^(\d{4})/(\d{2})/(\d{2}) (\d{2}):(\d{2}):(\d{2})`) - -func parseLogTS(line string) time.Time { - m := logTSRe.FindStringSubmatch(line) - if m == nil { - return time.Time{} - } - ts, _ := time.ParseInLocation("2006-01-02 15:04:05", - fmt.Sprintf("%s-%s-%s %s:%s:%s", m[1], m[2], m[3], m[4], m[5], m[6]), time.Local) - return ts -} - -// ---- diag_delta ---- - -type fileEntry struct { - Path string `json:"path"` - Type string `json:"type"` // created / modified / deleted - Size int64 `json:"size"` - NewHash string `json:"new_hash,omitempty"` - OldHash string `json:"old_hash,omitempty"` -} - -func (p *Plugin) handleDelta(args map[string]interface{}) (interface{}, error) { - baseline := argString(args, "baseline_dir") - current := argString(args, "current_dir") - pattern := argString(args, "pattern") - maxItems := argInt(args, "max_items", 500) - if maxItems <= 0 { - maxItems = 500 - } - - if baseline == "" || current == "" { - return contentWith(map[string]interface{}{ - "error": "baseline_dir 与 current_dir 均必填", - }, "缺少基线或现状目录:请先准备 last-good 快照解包目录"), nil - } - - baseMissing := !dirExists(baseline) - currMissing := !dirExists(current) - if baseMissing { - return contentWith(map[string]interface{}{ - "baseline_dir": baseline, - "current_dir": current, - "baseline_exists": false, - "summary": "基线不存在,无法差分(需先建立快照基线)", - }, "基线不存在,无法差分"), nil - } - if currMissing { - return contentWith(map[string]interface{}{ - "baseline_dir": baseline, - "current_dir": current, - "current_exists": false, - "summary": "现状目录不存在", - }, "现状目录不存在"), nil - } - - baseMap := walkHashes(baseline) - currMap := walkHashes(current) - - var files []fileEntry - seen := map[string]bool{} - for path, ch := range currMap { - seen[path] = true - if pattern != "" && !strings.Contains(path, pattern) { - continue - } - if bh, ok := baseMap[path]; ok { - if bh.hash != ch.hash { - files = append(files, fileEntry{Path: path, Type: "modified", Size: ch.size, OldHash: bh.hash, NewHash: ch.hash}) - } - } else { - files = append(files, fileEntry{Path: path, Type: "created", Size: ch.size, NewHash: ch.hash}) - } - } - for path, bh := range baseMap { - if !seen[path] && (pattern == "" || strings.Contains(path, pattern)) { - files = append(files, fileEntry{Path: path, Type: "deleted", Size: bh.size, OldHash: bh.hash}) - } - } - sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - - summary := map[string]int{"created": 0, "modified": 0, "deleted": 0} - for _, f := range files { - summary[f.Type]++ - } - - shown := files - if len(shown) > maxItems { - shown = shown[:maxItems] - } - - res := map[string]interface{}{ - "baseline_dir": baseline, - "current_dir": current, - "summary": summary, - "total_diff": len(files), - "files": shown, - } - return contentWith(res, fmt.Sprintf("diff: %+v", res["summary"])), nil -} - -type hashEnt struct { - hash string - size int64 -} - -func walkHashes(root string) map[string]hashEnt { - out := map[string]hashEnt{} - filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - rel, _ := filepath.Rel(root, path) - data, err := os.ReadFile(path) - if err != nil { - return nil - } - h := sha256.Sum256(data) - out[rel] = hashEnt{hash: hex.EncodeToString(h[:]), size: int64(len(data))} - return nil - }) - return out -} - -func dirExists(p string) bool { - st, err := os.Stat(p) - return err == nil && st.IsDir() -} - -// ---- diag_loc ---- - -func (p *Plugin) handleLoc(args map[string]interface{}) (interface{}, error) { - triage := argMap(args, "triage") - db := argMap(args, "db") - logScan := argMap(args, "log_scan") - delta := argMap(args, "delta") - - type hyp struct { - Cause string `json:"cause"` - Confidence int `json:"confidence"` // 0-100 因果强度 - Evidence []string `json:"evidence"` - Recommend string `json:"recommendation"` - } - var hyps []hyp - - tClass := valueString(triage, "class") - tVerdict := valueString(triage, "verdict") - dbVerdict := valueString(db, "verdict") - dom := valueString(logScan, "dominant") - deltaSummary := map[string]int{} - if delta != nil { - if s, ok := delta["summary"].(map[string]interface{}); ok { - for k, v := range s { - switch n := v.(type) { - case float64: - deltaSummary[k] = int(n) - case int: - deltaSummary[k] = n - case int64: - deltaSummary[k] = int(n) - } - } - } - } - dCreated := deltaSummary["created"] - dModified := deltaSummary["modified"] - dDeleted := deltaSummary["deleted"] - dTotal := dCreated + dModified + dDeleted - - evidence := []string{} - if tVerdict != "" { - evidence = append(evidence, "triage="+tVerdict+"("+tClass+")") - } - if dbVerdict != "" { - evidence = append(evidence, "db="+dbVerdict) - } - if dom != "" { - evidence = append(evidence, "log_dominant="+dom) - } - if dTotal > 0 { - evidence = append(evidence, fmt.Sprintf("delta=%d 改动(%d改/%d增/%d删)", dTotal, dModified, dCreated, dDeleted)) - } else { - evidence = append(evidence, "delta=无改动") - } - - // 1) 进程失稳(panic 主导时走更具体的 code_panic_loop 分支) - if (tClass == "process_death" || tClass == "process_starvation") && - dbVerdict != "fail" && dbVerdict != "degraded" && dTotal == 0 && - dom != "panic" { - hyps = append(hyps, hyp{ - Cause: "process_instability", - Confidence: 75, - Evidence: in(evidence, "triage=down"), - Recommend: "重建 worker;不动配置(db 完好、无文件改动)", - }) - } - - // 2) 配置损坏 - if dbVerdict == "fail" || dbVerdict == "degraded" { - hyps = append(hyps, hyp{ - Cause: "config_corruption", - Confidence: 90, - Evidence: in(evidence, "db="+dbVerdict), - Recommend: "还原 core.llm.sources 配置快照 → ReloadFromConfig → 拉起主 agent", - }) - } - - // 3) 系统网络 - if (dom == "network" || dom == "provider") && (dTotal > 0) { - hyps = append(hyps, hyp{ - Cause: "system_network", - Confidence: 80, - Evidence: in(evidence, "log_dominant="+dom, "delta>0"), - Recommend: "还原 DNS/proxy/host 相关系统网络配置 → 重载主 agent", - }) - } - - // 4) 纯日志栈崩溃(db 完好、无 delta) - if tClass == "process_death" && dbVerdict == "ok" && dTotal == 0 && dom == "panic" { - hyps = append(hyps, hyp{ - Cause: "code_panic_loop", - Confidence: 70, - Evidence: in(evidence, "log_dominant=panic", "db=ok", "delta=无改动"), - Recommend: "定位 panic 栈来源(repeat)+ 检查是否插件引起,必要时禁用对应插件后重建 worker", - }) - } - - // 未知/混合 - if len(hyps) == 0 { - hyps = append(hyps, hyp{ - Cause: "unknown_mixed", - Confidence: 20, - Evidence: evidence, - Recommend: "确定性命中不足,放开 webfetch/知识库,用 rescue 源 做最小 LLM 推理(依据 diag_* 结论摘要)", - }) - } - - sort.Slice(hyps, func(i, j int) bool { return hyps[i].Confidence > hyps[j].Confidence }) - - res := map[string]interface{}{ - "evidence": evidence, - "ranked_hypotheses": hyps, - "final_recommendation": hyps[0].Recommend, - } - - // 落盘 + 知识库回流(同类崩溃下次直接命中) - if argBool(args, "persist") { - p.persistConclusion(res, hyps[0].Cause, hyps[0].Recommend) - } - - return contentWith(res, "定位: "+hyps[0].Cause+" | 建议: "+hyps[0].Recommend), nil -} - -// persistConclusion 把定位结论写 recovery_kb/diag_.json,并经知识库回流(失败不阻塞)。 -func (p *Plugin) persistConclusion(res map[string]interface{}, cause, recommend string) { - ts := time.Now() - entry := map[string]interface{}{ - "ts": ts.Format(time.RFC3339), - "cause": cause, - "recommendation": recommend, - "evidence": valueFrom(res, "evidence"), - "ranked_hypotheses": res["ranked_hypotheses"], - "final_recommendation": recommend, - "tool": "diag_loc", - } - raw, _ := json.MarshalIndent(entry, "", " ") - - dir := p.recoveryKBDir() - if dir != "" { - if err := os.MkdirAll(dir, 0755); err == nil { - path := filepath.Join(dir, fmt.Sprintf("diag_%s.json", ts.Format("2006-01-02_15-04-05"))) - if err := os.WriteFile(path, raw, 0644); err == nil { - log.Printf("[%s] conclusion persisted to %s", p.name, path) - } else { - log.Printf("[%s] persist file %s: %v", p.name, path, err) - } - } - } - - if p.sdk != nil && p.sdk.Knowledge() != nil { - kName := fmt.Sprintf("diag:%s:%s", cause, ts.Format("2006-01-02T15-04")) - content := fmt.Sprintf("恢复诊断结论(%s): %s。建议: %s。命中条件可复用。", ts.Format("2006-01-02 15:04:05"), cause, recommend) - if err := p.sdk.Knowledge().Add(kName, content); err != nil { - log.Printf("[%s] knowledge add %s: %v", p.name, kName, err) - } - } -} - -func valueFrom(m map[string]interface{}, k string) interface{} { - if m == nil { - return nil - } - return m[k] -} - -// recoveryKBDir 返回结论落盘目录,可配置,缺省 /recovery_kb。 -func (p *Plugin) recoveryKBDir() string { - if p.sdk != nil { - if v, err := p.sdk.Settings().Get("recovery_kb_dir"); err == nil && v != nil { - if sv, ok := v.(string); ok && sv != "" { - return sv - } - } - } - if p.dataDir != "" { - return filepath.Join(p.dataDir, "recovery_kb") - } - return "" -} - -// in 过滤 slice,保留同时满足 items 中条件(简单子串匹配)的元素。 -func in(src []string, items ...string) []string { - var o []string - for _, it := range items { - for _, s := range src { - if s == it { - o = append(o, it) - break - } - } - } - return o -} - -var _ = json.Marshal diff --git a/third_party/homeagent-sdk/example/rss/plg.json b/third_party/homeagent-sdk/example/rss/plg.json deleted file mode 100644 index 7c092c5..0000000 --- a/third_party/homeagent-sdk/example/rss/plg.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "rss", - "name_zh": "RSS订阅", - "name_en": "RSS", - "version": "1.1.0", - "description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知", - "author": "HomeAgent", - "entry": "plugin.so", - "tags": [ - "rss", - "feed", - "subscription", - "monitor" - ], - "targets": "linux/amd64", - "outdir": "dist", - "bundle": true, - "replaces": {}, - "source_dirs": [] -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/rss/plugin.go b/third_party/homeagent-sdk/example/rss/plugin.go deleted file mode 100644 index 46c4773..0000000 --- a/third_party/homeagent-sdk/example/rss/plugin.go +++ /dev/null @@ -1,501 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" - "github.com/mmcdole/gofeed" -) - -const injectDedupWindow = 5 * time.Minute - -type FeedSub struct { - URL string `json:"url"` - Title string `json:"title"` - AddedAt string `json:"added_at"` - Interval int `json:"interval"` -} - -type Plugin struct { - name string - sdk *sdk.PluginSDK - client *http.Client - fp *gofeed.Parser - dataDir string - mu sync.RWMutex - feeds []FeedSub - seenGUIDs map[string]bool - injected map[string]time.Time - stopCh chan struct{} - stopOnce sync.Once - wg sync.WaitGroup - pollTicker *time.Ticker -} - -func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil -} - -func (p *Plugin) Name() string { return p.name } - -func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { - v, err := s.Get(key) - if err != nil || v == nil { - return fallback - } - switch any(fallback).(type) { - case string: - if sv, ok := v.(string); ok { - return any(sv).(T) - } - case int64: - switch val := v.(type) { - case float64: - return any(int64(val)).(T) - case string: - if n, err := strconv.ParseInt(val, 10, 64); err == nil { - return any(n).(T) - } - } - case float64: - switch val := v.(type) { - case float64: - return any(val).(T) - case string: - if n, err := strconv.ParseFloat(val, 64); err == nil { - return any(n).(T) - } - } - } - return fallback -} - -func readArg(args map[string]interface{}, key string) string { - if v, ok := args[key]; ok && v != nil { - if s, ok := v.(string); ok { - return s - } - } - return "" -} - -func readArgInt(args map[string]interface{}, key string, fallback int) int { - if v, ok := args[key]; ok && v != nil { - switch n := v.(type) { - case float64: - return int(n) - case int64: - return int(n) - } - } - return fallback -} - -func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) - p.sdk = s - p.client = &http.Client{Timeout: 30 * time.Second} - // 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用), - // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 - _ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true}) - p.fp = gofeed.NewParser() - p.stopCh = make(chan struct{}) - p.seenGUIDs = make(map[string]bool) - p.injected = make(map[string]time.Time) - p.feeds = []FeedSub{} - - dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") - if err != nil || dataDirVal == "" { - dataDirVal = "." - } - p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss") - if err := os.MkdirAll(p.dataDir, 0755); err != nil { - fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) - } - p.loadData() - - // 卸载(删除)时清理订阅数据目录;重载不触发 - s.RegisterOnRemoveHandler(p.cleanupData) - - s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "poll_interval", Default: "30", Type: "string", - DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)", - Category: "rss", - }) - - tp := p.name + "_" - s.RegisterTool(tp+"subscribe", sdk.ToolDef{ - Name: tp + "subscribe", Description: "Subscribe to an RSS/Atom feed URL", - NoMemory: true, - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "Feed URL"}, - "interval": map[string]interface{}{"type": "integer", "description": "Poll interval in minutes (default: 30, minimum: 5)"}, - }, - "required": []string{"url"}, - }, - }, p.handleSubscribe) - - s.RegisterTool(tp+"unsubscribe", sdk.ToolDef{ - Name: tp + "unsubscribe", Description: "Unsubscribe from a feed", - NoMemory: true, - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "Feed URL to unsubscribe"}, - }, - "required": []string{"url"}, - }, - }, p.handleUnsubscribe) - - s.RegisterTool(tp+"list", sdk.ToolDef{ - Name: tp + "list", Description: "List all subscribed feeds", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleList) - - s.RegisterTool(tp+"check_now", sdk.ToolDef{ - Name: tp + "check_now", Description: "Manually check all feeds for new articles now", - NoMemory: true, - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - }, - }, p.handleCheckNow) - - pollMin := int(getSetting(s.Settings(), "poll_interval", int64(30))) - if pollMin < 5 { - pollMin = 5 - } - p.pollTicker = time.NewTicker(time.Duration(pollMin) * time.Minute) - - p.wg.Add(1) - go p.pollLoop() - - fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin) - return nil -} - -func (p *Plugin) Stop() error { - p.stopOnce.Do(func() { close(p.stopCh) }) - p.pollTicker.Stop() - p.wg.Wait() - p.saveData() - fmt.Printf("[%s] stopped\n", p.name) - return nil -} - -func (p *Plugin) pollLoop() { - defer p.wg.Done() - - p.checkAllFeeds() - - for { - select { - case <-p.pollTicker.C: - p.checkAllFeeds() - case <-p.stopCh: - return - } - } -} - -func (p *Plugin) checkAllFeeds() { - p.mu.RLock() - feeds := make([]FeedSub, len(p.feeds)) - copy(feeds, p.feeds) - p.mu.RUnlock() - - for _, feed := range feeds { - select { - case <-p.stopCh: - return - default: - } - p.checkFeed(feed) - } -} - -func (p *Plugin) checkFeed(sub FeedSub) { - parsed, err := p.fp.ParseURL(sub.URL) - if err != nil { - return - } - - title := parsed.Title - if title == "" { - title = sub.URL - } - - var newArticles []*gofeed.Item - for _, item := range parsed.Items { - guid := item.GUID - if guid == "" { - guid = item.Link - } - if guid == "" { - continue - } - guid = sub.URL + "|" + guid - p.mu.RLock() - seen := p.seenGUIDs[guid] - p.mu.RUnlock() - if !seen { - newArticles = append(newArticles, item) - } - } - - if len(newArticles) == 0 { - return - } - - now := time.Now() - toInject := make([]*gofeed.Item, 0, len(newArticles)) - p.mu.Lock() - for _, item := range newArticles { - guid := item.GUID - if guid == "" { - guid = item.Link - } - if guid == "" { - continue - } - key := sub.URL + "|" + guid - if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow { - continue - } - p.injected[key] = now - p.seenGUIDs[key] = true - toInject = append(toInject, item) - } - p.mu.Unlock() - - if len(toInject) == 0 { - return - } - - var lines []string - lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject))) - for _, item := range toInject { - pubDate := "" - if item.PublishedParsed != nil { - pubDate = item.PublishedParsed.Format("01-02 15:04") - } - line := fmt.Sprintf(" • %s", item.Title) - if pubDate != "" { - line += fmt.Sprintf(" [%s]", pubDate) - } - if item.Link != "" { - line += "\n " + item.Link - } - lines = append(lines, line) - } - - // 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算, - // 原文仍进上下文(模型当轮看得到)。 - p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"), - sdk.InjectOptions{NoMemory: true}) - p.saveData() -} - -func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, error) { - url := readArg(args, "url") - if url == "" { - return map[string]interface{}{"isError": true, "content": "URL is required"}, nil - } - - p.mu.RLock() - for _, f := range p.feeds { - if f.URL == url { - p.mu.RUnlock() - return map[string]interface{}{"isError": true, "content": "Already subscribed to: " + url}, nil - } - } - p.mu.RUnlock() - - interval := readArgInt(args, "interval", 30) - if interval < 5 { - interval = 5 - } - - parsed, err := p.fp.ParseURL(url) - if err != nil { - return map[string]interface{}{"isError": true, "content": "Failed to parse feed: " + err.Error()}, nil - } - - feedTitle := parsed.Title - if feedTitle == "" { - feedTitle = url - } - - sub := FeedSub{ - URL: url, - Title: feedTitle, - AddedAt: time.Now().Format("2006-01-02 15:04"), - Interval: interval, - } - - guidCount := 0 - p.mu.Lock() - for _, item := range parsed.Items { - guid := item.GUID - if guid == "" { - guid = item.Link - } - if guid == "" { - continue - } - p.seenGUIDs[url+"|"+guid] = true - guidCount++ - } - p.mu.Unlock() - - p.mu.Lock() - p.feeds = append(p.feeds, sub) - p.mu.Unlock() - p.saveData() - - return map[string]interface{}{ - "content": fmt.Sprintf("Subscribed to: %s\nTitle: %s\nArticles found: %d\nPoll interval: %d min", url, feedTitle, guidCount, interval), - }, nil -} - -func (p *Plugin) handleUnsubscribe(args map[string]interface{}) (interface{}, error) { - url := readArg(args, "url") - if url == "" { - return map[string]interface{}{"isError": true, "content": "URL is required"}, nil - } - - p.mu.Lock() - found := false - for i, f := range p.feeds { - if f.URL == url { - p.feeds = append(p.feeds[:i], p.feeds[i+1:]...) - found = true - break - } - } - if !found { - p.mu.Unlock() - return map[string]interface{}{"isError": true, "content": "Not subscribed to: " + url}, nil - } - - for guid := range p.seenGUIDs { - if strings.HasPrefix(guid, url+"|") { - delete(p.seenGUIDs, guid) - } - } - p.mu.Unlock() - p.saveData() - - return map[string]interface{}{"content": "Unsubscribed: " + url}, nil -} - -func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) { - p.mu.RLock() - defer p.mu.RUnlock() - - if len(p.feeds) == 0 { - return map[string]interface{}{"content": "No subscriptions. Use rss_subscribe to add one."}, nil - } - - sort.Slice(p.feeds, func(i, j int) bool { - return p.feeds[i].Title < p.feeds[j].Title - }) - - var lines []string - lines = append(lines, fmt.Sprintf("📡 Subscriptions (%d):", len(p.feeds))) - for _, f := range p.feeds { - lines = append(lines, fmt.Sprintf(" • %s\n %s (every %dm, added %s)", f.Title, f.URL, f.Interval, f.AddedAt)) - } - - return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil -} - -func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) { - select { - case <-p.stopCh: - return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil - default: - } - p.wg.Add(1) - go func() { - defer p.wg.Done() - p.checkAllFeeds() - }() - return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil -} - -func (p *Plugin) dataFile() string { - return filepath.Join(p.dataDir, "feeds.json") -} - -func (p *Plugin) loadData() { - b, err := os.ReadFile(p.dataFile()) - if err != nil { - return - } - var data struct { - Feeds []FeedSub `json:"feeds"` - SeenGUIDs map[string]bool `json:"seen"` - } - if json.Unmarshal(b, &data) != nil { - return - } - if data.Feeds != nil { - p.feeds = data.Feeds - } - if data.SeenGUIDs != nil { - p.seenGUIDs = data.SeenGUIDs - } -} - -func (p *Plugin) saveData() { - p.mu.RLock() - defer p.mu.RUnlock() - data := struct { - Feeds []FeedSub `json:"feeds"` - SeenGUIDs map[string]bool `json:"seen"` - }{ - Feeds: p.feeds, - SeenGUIDs: p.seenGUIDs, - } - b, _ := json.MarshalIndent(data, "", " ") - atomicWriteJSON(p.dataFile(), b) -} - -// cleanupData 卸载时清理订阅数据目录(feeds.json 等) -func (p *Plugin) cleanupData() { - p.mu.Lock() - defer p.mu.Unlock() - if p.dataDir == "" { - return - } - for _, f := range []string{"feeds.json"} { - path := filepath.Join(p.dataDir, f) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err) - } - } -} - -// 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) -} diff --git a/third_party/homeagent-sdk/remotedevice/CMakeLists.txt b/third_party/homeagent-sdk/remotedevice/CMakeLists.txt deleted file mode 100644 index c7fc578..0000000 --- a/third_party/homeagent-sdk/remotedevice/CMakeLists.txt +++ /dev/null @@ -1,116 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -project(ha_remotedevice VERSION 0.1.0 LANGUAGES C) - -# ============================================================ -# ha_remotedevice — HomeAgent 远程设备接入 C SDK -# 零外部依赖,纯 C 实现,兼容嵌入式平台。 -# -# 使用方式: -# add_subdirectory(path/to/ha_remotedevice) -# target_link_libraries(my_app ha_remotedevice) -# target_include_directories(my_app PRIVATE -# ${HA_REMOTEDEVICE_INCLUDE_DIR}) -# ============================================================ - -# 选项: 构建为静态库或动态库 -option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF) - -# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调) -option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF) - -# 选项: 日志级别 -set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug") - -# 源文件 -set(HA_REMOTEDEVICE_SRC - src/ha_remotedevice.c - src/ha_json.c - src/ha_ws.c -) - -# 头文件 -set(HA_REMOTEDEVICE_INCLUDE - ${CMAKE_CURRENT_SOURCE_DIR}/include -) - -# 编译选项 -if(HA_NO_ALLOC) - add_definitions(-DHA_NO_ALLOC) -endif() -add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL}) - -# 创建库 -if(BUILD_SHARED_LIBS) - add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC}) - if(WIN32) - # Windows 需要导出符号 - set_target_properties(ha_remotedevice PROPERTIES - WINDOWS_EXPORT_ALL_SYMBOLS ON) - endif() -else() - add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC}) -endif() - -# 包含目录 -target_include_directories(ha_remotedevice - PUBLIC ${HA_REMOTEDEVICE_INCLUDE} - PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src -) - -# 不链接任何外部库 -target_link_libraries(ha_remotedevice PRIVATE) - -# 导出包含目录供外部项目使用 -set(HA_REMOTEDEVICE_INCLUDE_DIR - ${HA_REMOTEDEVICE_INCLUDE} - CACHE INTERNAL "ha_remotedevice include directories") - -# 安装规则 -install(TARGETS ha_remotedevice - EXPORT ha_remotedevice-targets - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib - RUNTIME DESTINATION bin - INCLUDES DESTINATION include -) - -install(DIRECTORY include/ - DESTINATION include -) - -install(EXPORT ha_remotedevice-targets - DESTINATION lib/cmake/ha_remotedevice - NAMESPACE ha_remotedevice:: -) - -# ============================================================ -# 测试(可选) -# ============================================================ -option(BUILD_TESTS "Build ha_remotedevice tests" OFF) - -if(BUILD_TESTS) - find_package(Threads REQUIRED) - - add_executable(ha_remotedevice_test - test/test_ha_remotedevice.c - ) - target_link_libraries(ha_remotedevice_test - PRIVATE ha_remotedevice Threads::Threads - ) - target_include_directories(ha_remotedevice_test - PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR} - ) - - # 添加测试 - add_test(NAME ha_remotedevice_test - COMMAND ha_remotedevice_test - ) -endif() - -# ============================================================ -# 编译信息 -# ============================================================ -message(STATUS "ha_remotedevice ${PROJECT_VERSION}") -message(STATUS " Build type: $") -message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}") -message(STATUS " No alloc: ${HA_NO_ALLOC}") \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h b/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h deleted file mode 100644 index 84e64a7..0000000 --- a/third_party/homeagent-sdk/remotedevice/include/ha_remotedevice.h +++ /dev/null @@ -1,216 +0,0 @@ -#ifndef HA_REMOTEDEVICE_H -#define HA_REMOTEDEVICE_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ================================================================== - * ha_remotedevice — 远程设备接入 C SDK - * - * 零外部依赖,纯 C 实现,兼容嵌入式平台。 - * 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。 - * - * 声明式设计: - * 设备在代码中声明自己是什么(kind)和能做什么(caps), - * 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数, - * SDK 自动处理协议握手、心跳、消息路由、结果回执。 - * - * 协议流程: - * TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪 - * 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果 - * ================================================================== */ - -/* ======================== 状态码 ======================== */ -typedef enum { - HA_OK = 0, - HA_ERR_GENERIC = -1, - HA_ERR_NOMEM = -2, - HA_ERR_INVALID = -3, - HA_ERR_TIMEOUT = -4, - HA_ERR_DISCONNECTED = -5, - HA_ERR_PROTOCOL = -6, - HA_ERR_TRANSPORT = -7, - HA_ERR_NOT_FOUND = -8, -} ha_status_t; - -/* ======================== 传输层抽象 ======================== - * - * 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。 - * - * connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功 - * send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败 - * recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败 - * close(ctx) → 关闭连接 - */ -typedef struct { - int (*connect)(void *ctx, const char *host, uint16_t port); - int (*send)(void *ctx, const uint8_t *data, int len); - int (*recv)(void *ctx, uint8_t *buf, int len); - void (*close)(void *ctx); - void *ctx; -} ha_transport_t; - -/* ======================== 设备声明 ======================== - * - * 声明式配置:设备在代码中声明自己的类型和能力。 - * 这些信息通过 hello 消息发送给网关。 - * - * device_id — 唯一标识,如 "esp32-cam-1" - * name — 设备显示名,如 "门口摄像头" - * kind — 设备种类,如 "camera"、"computer"、"speaker"、"light" - * caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL} - * info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}' - */ -typedef struct { - const char *device_id; - const char *name; - const char *kind; - const char **caps; /* NULL 结尾 */ - const char *info_json; /* 可选,NULL 或 JSON 字符串 */ -} ha_device_info_t; - -/* ======================== 命令结果 ======================== - * - * 命令处理函数通过填写此结构体返回数据。 - * SDK 收到结果后自动发送回执(文本或二进制分块)。 - * - * 使用方式: - * 1. 简单文本:设置 status=0, output="结果文本" - * 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime - * 3. 错误:设置 status=1, error="错误信息" - * - * 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。 - * 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。 - * 所以 handler 可以返回栈上或静态字符串。 - */ -typedef struct { - int status; /* 0=ok, 非0=error */ - const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */ - const char *error; /* 错误信息 */ - int has_binary; /* 1=通过二进制分块回传 */ - const char *binary_mime; /* 二进制 MIME 类型 */ - const uint8_t *binary_data; /* 二进制数据指针 */ - int binary_len; /* 二进制数据长度 */ -} ha_cmd_result_t; - -/* ======================== 命令处理声明 ======================== - * - * 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。 - * - * command 值说明: - * - "shell" → 处理 shell 类型命令,args 为完整命令字符串 - * - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数 - * - "screensee" → 处理 homeagent-screensee 命令 - * - "speakeruse" → 处理 homeagent-speakeruse 命令 - * - "computeruse" → 处理 homeagent-computeruse 命令 - * - "clipboardsee" → 处理 homeagent-clipboardsee 命令 - * - "clipboardsue" → 处理 homeagent-clipboardsue 命令 - * - "screensue" → 处理 homeagent-screensue 命令 - * - "deviceinfo" → 处理设备信息查询 - * - 其他自定义命令名 → 按字符串匹配分发 - * - * handler 处理完毕后只需填写 result 结构体,SDK 自动回执。 - */ -typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata); - -typedef struct { - const char *command; /* 命令名,如 "camerasue"、"shell" */ - ha_cmd_handler_t handler; /* 处理函数 */ -} ha_cmd_handler_def_t; - -/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。 - * data 指针在回调返回后失效,如需保存请拷贝。 */ -typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind, - const char *mime, const uint8_t *data, - int len, void *userdata); - -/* 连接状态变化回调 */ -typedef void (*ha_state_callback_t)(int connected, void *userdata); - -/* ======================== 客户端配置 ======================== - * - * 所有配置在 ha_client_new() 时一次性声明。 - * 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。 - */ -typedef struct { - ha_transport_t transport; /* 传输层实现(必须) */ - ha_device_info_t device; /* 设备声明(必须) */ - const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */ - const char *token; /* 接入令牌(必须) */ - - ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */ - ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */ - ha_state_callback_t on_state; /* 状态变化回调(可选) */ - void *userdata; /* 用户自定义数据,传给所有回调 */ - - int ping_interval; /* 心跳间隔秒数,0 则默认 30 */ - int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */ -} ha_config_t; - -/* ======================== 客户端 API ======================== */ - -typedef struct ha_client ha_client_t; - -/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */ -ha_client_t *ha_client_new(const ha_config_t *config); - -/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */ -ha_status_t ha_client_start(ha_client_t *client); - -/* 主循环处理:必须在用户的主循环中周期性调用。 - * - 读取 WS 帧并分发 - * - 按 handlers 表查找命令处理函数,自动回执结果 - * - 处理心跳 ping/pong - * - 处理断线重连 - * 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */ -ha_status_t ha_client_process(ha_client_t *client); - -/* ===== 主动上报(设备主动推送,非命令响应) ===== */ - -/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */ -void ha_client_send_event(ha_client_t *client, const char *type, - const char *detail); - -/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */ -void ha_client_send_status(ha_client_t *client, const char *status); - -/* ===== 生命周期 ===== */ - -/* 停止客户端,断开连接。 */ -void ha_client_stop(ha_client_t *client); - -/* 销毁客户端,释放所有资源。 */ -void ha_client_destroy(ha_client_t *client); - -/* ======================== 工具函数 ======================== */ - -/* 解析 homeagent-* 命令,返回能力名和参数。 - * command = "camerasue 5" → cap="camerasue", args="5" - * command = "screensee" → cap="screensee", args="" - * command = "computeruse {...}" → cap="computeruse", args="..." */ -void ha_cmd_parse_homeagent(const char *command, const char **cap, - const char **args); - -/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。 - * command = "computeruse {\"action\":\"click\",\"x\":100}" - * → action="computeruse", json_str="{\"action\":\"click\",...}" */ -void ha_cmd_parse_json(const char *command, const char **action, - const char **json_str); - -/* Base64 编码(用于将二进制数据编码为文本回传)。 - * 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */ -int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len); - -/* 获取版本号 */ -const char *ha_version(void); - -#ifdef __cplusplus -} -#endif - -#endif /* HA_REMOTEDEVICE_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_json.c b/third_party/homeagent-sdk/remotedevice/src/ha_json.c deleted file mode 100644 index 0d3af91..0000000 --- a/third_party/homeagent-sdk/remotedevice/src/ha_json.c +++ /dev/null @@ -1,369 +0,0 @@ -#include "ha_json.h" -#include -#include -#include -#include - -/* ======================== 解析器 ======================== */ - -/* 前向声明 */ -static ha_json_node_t *parse_value(const char **pp); - -/* 跳过空白 */ -static const char *skip_ws(const char *p) { - while (*p && (unsigned char)*p <= ' ') p++; - return p; -} - -/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */ -static char *parse_string(const char **pp) { - const char *p = skip_ws(*pp); - if (*p != '"') return NULL; - p++; - int len = 0; - const char *q = p; - while (*q && *q != '"') { - if (*q == '\\') { q++; if (*q) q++; } - else q++; - len++; - } - if (*q != '"') return NULL; - char *s = (char *)malloc(len + 1); - if (!s) return NULL; - q = p; - int i = 0; - while (*q && *q != '"') { - if (*q == '\\') { - q++; - switch (*q) { - case '"': s[i++] = '"'; break; - case '\\': s[i++] = '\\'; break; - case '/': s[i++] = '/'; break; - case 'b': s[i++] = '\b'; break; - case 'f': s[i++] = '\f'; break; - case 'n': s[i++] = '\n'; break; - case 'r': s[i++] = '\r'; break; - case 't': s[i++] = '\t'; break; - case 'u': q += 4; s[i++] = '?'; continue; - default: s[i++] = *q; break; - } - q++; - } else { - s[i++] = *q++; - } - } - s[i] = '\0'; - *pp = q + 1; - return s; -} - -static ha_json_node_t *new_node(ha_json_type_t type) { - ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t)); - if (n) n->type = type; - return n; -} - -/* 解析数字 */ -static ha_json_node_t *parse_number(const char **pp) { - const char *p = *pp; - int neg = 0; - if (*p == '-') { neg = 1; p++; } - if (!isdigit((unsigned char)*p)) return NULL; - int val = 0; - while (isdigit((unsigned char)*p)) { - val = val * 10 + (*p - '0'); - p++; - } - if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; } - if (*p == 'e' || *p == 'E') { - p++; - if (*p == '+' || *p == '-') p++; - while (isdigit((unsigned char)*p)) p++; - } - *pp = p; - ha_json_node_t *n = new_node(HA_JSON_INT); - if (n) n->int_val = neg ? -val : val; - return n; -} - -/* 解析 true/false/null */ -static ha_json_node_t *parse_keyword(const char **pp) { - const char *p = *pp; - ha_json_node_t *n = NULL; - if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) { - n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1; - *pp = p + 4; - } else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) { - n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0; - *pp = p + 5; - } else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) { - n = new_node(HA_JSON_NULL); - *pp = p + 4; - } - return n; -} - -/* 解析对象 */ -static ha_json_node_t *parse_object(const char **pp) { - const char *p = skip_ws(*pp); - if (*p != '{') return NULL; - p++; - ha_json_node_t *obj = new_node(HA_JSON_OBJECT); - if (!obj) return NULL; - ha_json_node_t **tail = &obj->child; - p = skip_ws(p); - if (*p == '}') { *pp = p + 1; return obj; } - while (*p) { - p = skip_ws(p); - char *key = parse_string(&p); - if (!key) break; - p = skip_ws(p); - if (*p != ':') { free(key); break; } - p++; - ha_json_node_t *val = parse_value(&p); - if (!val) { free(key); break; } - val->key = key; - *tail = val; - tail = &val->next; - p = skip_ws(p); - if (*p == ',') { p++; continue; } - if (*p == '}') break; - } - p = skip_ws(p); - if (*p == '}') { *pp = p + 1; return obj; } - ha_json_free(obj); - return NULL; -} - -/* 解析数组 */ -static ha_json_node_t *parse_array(const char **pp) { - const char *p = skip_ws(*pp); - if (*p != '[') return NULL; - p++; - ha_json_node_t *arr = new_node(HA_JSON_ARRAY); - if (!arr) return NULL; - ha_json_node_t **tail = &arr->child; - p = skip_ws(p); - if (*p == ']') { *pp = p + 1; return arr; } - while (*p) { - ha_json_node_t *val = parse_value(&p); - if (!val) break; - *tail = val; - tail = &val->next; - p = skip_ws(p); - if (*p == ',') { p++; continue; } - if (*p == ']') break; - } - p = skip_ws(p); - if (*p == ']') { *pp = p + 1; return arr; } - ha_json_free(arr); - return NULL; -} - -/* 解析值(主入口) */ -static ha_json_node_t *parse_value(const char **pp) { - const char *p = skip_ws(*pp); - if (*p == '{') return parse_object(pp); - if (*p == '[') return parse_array(pp); - if (*p == '"') { - char *s = parse_string(pp); - if (!s) return NULL; - ha_json_node_t *n = new_node(HA_JSON_STRING); - if (!n) { free(s); return NULL; } - n->str_val = s; - return n; - } - if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp); - return parse_keyword(pp); -} - -/* ======================== 公共 API ======================== */ - -ha_json_node_t *ha_json_parse(const char *str) { - if (!str) return NULL; - const char *p = str; - return parse_value(&p); -} - -const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) { - ha_json_node_t *n = ha_json_get(obj, key); - if (!n || n->type != HA_JSON_STRING) return NULL; - return n->str_val; -} - -int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) { - ha_json_node_t *n = ha_json_get(obj, key); - if (!n || n->type != HA_JSON_INT) return def; - return n->int_val; -} - -ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) { - if (!obj || obj->type != HA_JSON_OBJECT) return NULL; - ha_json_node_t *c = obj->child; - while (c) { - if (c->key && strcmp(c->key, key) == 0) return c; - c = c->next; - } - return NULL; -} - -int ha_json_array_len(const ha_json_node_t *arr) { - if (!arr || arr->type != HA_JSON_ARRAY) return 0; - int n = 0; - ha_json_node_t *c = arr->child; - while (c) { n++; c = c->next; } - return n; -} - -ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) { - if (!arr || arr->type != HA_JSON_ARRAY) return NULL; - ha_json_node_t *c = arr->child; - int i = 0; - while (c) { - if (i == index) return c; - i++; c = c->next; - } - return NULL; -} - -void ha_json_free(ha_json_node_t *root) { - if (!root) return; - ha_json_node_t *c = root->child; - while (c) { - ha_json_node_t *next = c->next; - free(c->key); - if (c->type == HA_JSON_STRING) free(c->str_val); - ha_json_free(c); - c = next; - } - free(root); -} - -/* ======================== 构建器 ======================== */ - -static void json_escape(ha_json_builder_t *jb, const char *s) { - if (!s) { ha_json_builder_raw(jb, "null"); return; } - ha_json_builder_raw(jb, "\""); - for (const char *p = s; *p; p++) { - unsigned char c = (unsigned char)*p; - switch (c) { - case '"': ha_json_builder_raw(jb, "\\\""); break; - case '\\': ha_json_builder_raw(jb, "\\\\"); break; - case '\b': ha_json_builder_raw(jb, "\\b"); break; - case '\f': ha_json_builder_raw(jb, "\\f"); break; - case '\n': ha_json_builder_raw(jb, "\\n"); break; - case '\r': ha_json_builder_raw(jb, "\\r"); break; - case '\t': ha_json_builder_raw(jb, "\\t"); break; - default: - if (c < 0x20) { - char buf[8]; - snprintf(buf, sizeof(buf), "\\u%04x", c); - ha_json_builder_raw(jb, buf); - } else { - char buf[2] = { (char)c, 0 }; - ha_json_builder_raw(jb, buf); - } - break; - } - } - ha_json_builder_raw(jb, "\""); -} - -void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) { - jb->buf = buf; - jb->len = 0; - jb->cap = cap; - jb->depth = 0; - if (cap > 0) buf[0] = '\0'; -} - -void ha_json_builder_reset(ha_json_builder_t *jb) { - jb->len = 0; - jb->depth = 0; - if (jb->cap > 0) jb->buf[0] = '\0'; -} - -void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) { - while (*s && jb->len < jb->cap - 1) { - jb->buf[jb->len++] = *s++; - } - jb->buf[jb->len] = '\0'; -} - -void ha_json_builder_comma(ha_json_builder_t *jb) { - if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) { - ha_json_builder_raw(jb, ","); - } - if (jb->depth > 0) jb->item_count[jb->depth - 1]++; -} - -void ha_json_builder_begin_object(ha_json_builder_t *jb) { - ha_json_builder_comma(jb); - ha_json_builder_raw(jb, "{"); - if (jb->depth < 16) jb->item_count[jb->depth] = 0; - jb->depth++; -} - -void ha_json_builder_end_object(ha_json_builder_t *jb) { - jb->depth--; - ha_json_builder_raw(jb, "}"); -} - -void ha_json_builder_begin_array(ha_json_builder_t *jb) { - ha_json_builder_comma(jb); - ha_json_builder_raw(jb, "["); - if (jb->depth < 16) jb->item_count[jb->depth] = 0; - jb->depth++; -} - -void ha_json_builder_end_array(ha_json_builder_t *jb) { - jb->depth--; - ha_json_builder_raw(jb, "]"); -} - -void ha_json_builder_key(ha_json_builder_t *jb, const char *key) { - ha_json_builder_comma(jb); - json_escape(jb, key); - ha_json_builder_raw(jb, ":"); -} - -void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) { - json_escape(jb, val); -} - -void ha_json_builder_add_int(ha_json_builder_t *jb, int val) { - char buf[16]; - snprintf(buf, sizeof(buf), "%d", val); - ha_json_builder_raw(jb, buf); -} - -void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) { - ha_json_builder_raw(jb, val ? "true" : "false"); -} - -void ha_json_builder_add_null(ha_json_builder_t *jb) { - ha_json_builder_raw(jb, "null"); -} - -void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) { - ha_json_builder_key(jb, key); - json_escape(jb, val); -} - -void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) { - ha_json_builder_key(jb, key); - ha_json_builder_add_int(jb, val); -} - -void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) { - ha_json_builder_key(jb, key); - ha_json_builder_add_bool(jb, val); -} - -const char *ha_json_builder_str(ha_json_builder_t *jb) { - return jb->buf; -} - -int ha_json_builder_len(ha_json_builder_t *jb) { - return jb->len; -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_json.h b/third_party/homeagent-sdk/remotedevice/src/ha_json.h deleted file mode 100644 index 1560d9e..0000000 --- a/third_party/homeagent-sdk/remotedevice/src/ha_json.h +++ /dev/null @@ -1,107 +0,0 @@ -#ifndef HA_JSON_H -#define HA_JSON_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* ======================== JSON 解析器(DOM 风格) ======================== */ -typedef enum { - HA_JSON_NULL, - HA_JSON_BOOL, - HA_JSON_INT, - HA_JSON_STRING, - HA_JSON_ARRAY, - HA_JSON_OBJECT, -} ha_json_type_t; - -typedef struct ha_json_node { - ha_json_type_t type; - union { - int bool_val; - int int_val; - char *str_val; - }; - struct ha_json_node *next; /* linked list for array/object items */ - struct ha_json_node *child; /* first child for array/object */ - char *key; /* key for object members */ -} ha_json_node_t; - -/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */ -ha_json_node_t *ha_json_parse(const char *str); - -/* 从对象中按 key 获取字符串值,不存在返回 NULL */ -const char *ha_json_get_string(const ha_json_node_t *obj, const char *key); - -/* 从对象中按 key 获取 int 值,不存在返回 def */ -int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def); - -/* 从对象中按 key 获取子节点,不存在返回 NULL */ -ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key); - -/* 获取数组长度 */ -int ha_json_array_len(const ha_json_node_t *arr); - -/* 获取数组第 index 个元素,越界返回 NULL */ -ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index); - -/* 释放整个 JSON 树 */ -void ha_json_free(ha_json_node_t *root); - -/* ======================== JSON 构建器(直接写缓冲区) ======================== */ -typedef struct { - char *buf; - int len; - int cap; - int depth; - int item_count[16]; /* 每层已添加元素数,用于逗号判断 */ -} ha_json_builder_t; - -/* 初始化构建器 */ -void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap); - -/* 清空构建器 */ -void ha_json_builder_reset(ha_json_builder_t *jb); - -/* 基础写入 */ -void ha_json_builder_raw(ha_json_builder_t *jb, const char *s); - -/* 逗号(自动判断是否需要加) */ -void ha_json_builder_comma(ha_json_builder_t *jb); - -/* 对象 */ -void ha_json_builder_begin_object(ha_json_builder_t *jb); -void ha_json_builder_end_object(ha_json_builder_t *jb); - -/* 数组 */ -void ha_json_builder_begin_array(ha_json_builder_t *jb); -void ha_json_builder_end_array(ha_json_builder_t *jb); - -/* 键名 */ -void ha_json_builder_key(ha_json_builder_t *jb, const char *key); - -/* 值 */ -void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val); -void ha_json_builder_add_int(ha_json_builder_t *jb, int val); -void ha_json_builder_add_bool(ha_json_builder_t *jb, int val); -void ha_json_builder_add_null(ha_json_builder_t *jb); - -/* 快捷方法:直接写 "key":"val" */ -void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val); -void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val); -void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val); - -/* 获取当前构建的字符串指针 */ -const char *ha_json_builder_str(ha_json_builder_t *jb); - -/* 获取当前长度 */ -int ha_json_builder_len(ha_json_builder_t *jb); - -#ifdef __cplusplus -} -#endif - -#endif /* HA_JSON_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c b/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c deleted file mode 100644 index 4ab127d..0000000 --- a/third_party/homeagent-sdk/remotedevice/src/ha_remotedevice.c +++ /dev/null @@ -1,628 +0,0 @@ -#include "ha_remotedevice.h" -#include "ha_json.h" -#include "ha_ws.h" -#include -#include -#include - -#define HA_VERSION "0.1.0" - -/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */ -void ha_client_send_result(ha_client_t *client, const char *req_id, - const char *status, const char *output, - const char *error); -void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, - const char *kind, const char *mime, - const uint8_t *data, int len); - -/* ======================== 内部状态 ======================== */ -typedef enum { - HA_STATE_INIT, - HA_STATE_DISCONNECTED, - HA_STATE_CONNECTING, - HA_STATE_WS_UPGRADING, - HA_STATE_HELLO_SENT, - HA_STATE_BIND_SENT, - HA_STATE_READY, - HA_STATE_STOPPING, -} ha_state_t; - -/* 语音数据聚合缓冲区 */ -typedef struct { - char req_id[128]; - char kind[64]; - char mime[64]; - int total; - uint8_t *data; - int len; - int cap; -} ha_speech_accum_t; - -struct ha_client { - ha_config_t config; /* 拷贝的配置 */ - ha_state_t state; - int reconnect_cnt; /* 当前重连次数 */ - ha_ws_t ws; /* WS 连接 */ - - /* JSON 构建缓冲区 */ - char json_buf[4096]; - ha_json_builder_t jb; - - /* 语音数据聚合 */ - ha_speech_accum_t speech; -}; - -/* ======================== 辅助函数 ======================== */ - -static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; } - -/* Base64 编码表 */ -static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) { - int needed = ((len + 2) / 3) * 4 + 1; - if (out_len < needed) { - if (out_len > 0) out[0] = '\0'; - return needed; - } - int i = 0, j = 0; - while (i < len) { - int rem = len - i; - uint8_t b0 = data[i++]; - uint8_t b1 = (rem > 1) ? data[i++] : 0; - uint8_t b2 = (rem > 2) ? data[i++] : 0; - out[j++] = b64[b0 >> 2]; - out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)]; - out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '='; - out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '='; - } - out[j] = '\0'; - return j; -} - -/* ======================== JSON 构建辅助 ======================== */ -static void json_init(ha_client_t *c) { - ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf)); -} - -/* ======================== WS 发送 JSON ======================== */ -static int ws_send_json(ha_client_t *c) { - return ha_ws_send_text(&c->ws, c->json_buf); -} - -/* ======================== 协议消息构造 ======================== */ - -/* 构建 hello 消息 */ -static int send_hello(ha_client_t *c) { - json_init(c); - ha_json_builder_begin_object(&c->jb); - ha_json_builder_string(&c->jb, "op", "hello"); - ha_json_builder_key(&c->jb, "device"); - ha_json_builder_begin_object(&c->jb); - ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id); - ha_json_builder_string(&c->jb, "name", c->config.device.name); - ha_json_builder_string(&c->jb, "kind", c->config.device.kind); - /* caps */ - ha_json_builder_key(&c->jb, "caps"); - ha_json_builder_begin_array(&c->jb); - if (c->config.device.caps) { - for (const char **p = c->config.device.caps; *p; p++) { - ha_json_builder_add_string(&c->jb, *p); - } - } - ha_json_builder_end_array(&c->jb); - /* info 可选 */ - if (c->config.device.info_json && c->config.device.info_json[0]) { - ha_json_builder_string(&c->jb, "info", c->config.device.info_json); - } - ha_json_builder_end_object(&c->jb); /* device */ - ha_json_builder_end_object(&c->jb); /* root */ - return ws_send_json(c); -} - -/* 构建 bind 消息 */ -static int send_bind(ha_client_t *c) { - json_init(c); - ha_json_builder_begin_object(&c->jb); - ha_json_builder_string(&c->jb, "op", "bind"); - ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id); - ha_json_builder_string(&c->jb, "token", c->config.token); - ha_json_builder_end_object(&c->jb); - return ws_send_json(c); -} - -/* ======================== 消息处理 ======================== */ - -/* 在 handlers 表中查找命令处理函数 */ -static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) { - if (!name || !c->config.handlers) return NULL; - for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) { - if (strcmp(h->command, name) == 0) return h; - } - return NULL; -} - -/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */ -static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) { - const char *req_id = ha_json_get_string(msg, "req_id"); - const char *command = ha_json_get_string(msg, "command"); - const char *cmd_type = ha_json_get_string(msg, "cmd_type"); - if (!req_id || !command) return; - if (!cmd_type) cmd_type = "homeagent"; - - const char *handler_name = NULL; - const char *args = command; - - if (strcmp(cmd_type, "shell") == 0) { - handler_name = "shell"; - /* args 保持为完整命令字符串 */ - } else { - /* homeagent-* 命令:提取能力名作为 handler 名 */ - const char *cap = command; - const char *p = command; - if (strncmp(p, "homeagent-", 10) == 0) p += 10; - const char *space = strchr(p, ' '); - if (space) { - args = space + 1; - /* handler_name 用静态缓冲区 */ - static char name_buf[128]; - int n = (int)(space - p); - if (n > 127) n = 127; - strncpy(name_buf, p, n); - name_buf[n] = '\0'; - handler_name = name_buf; - } else { - handler_name = p; - args = ""; - } - } - - ha_cmd_handler_def_t *def = find_handler(c, handler_name); - if (!def) { - ha_client_send_result(c, req_id, "error", NULL, - "unsupported command"); - return; - } - - /* 调用 handler,填写 result */ - ha_cmd_result_t result; - memset(&result, 0, sizeof(result)); - ha_status_t st = def->handler(req_id, args, &result, c->config.userdata); - - /* 自动回执 */ - if (st != HA_OK) { - ha_client_send_result(c, req_id, "error", NULL, - result.error ? result.error : "handler failed"); - return; - } - - if (result.has_binary && result.binary_data && result.binary_len > 0) { - /* 二进制分块回传 */ - ha_client_send_data_chunked(c, req_id, - handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream", - result.binary_data, result.binary_len); - } else { - /* 文本回传 */ - ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error", - result.output, result.error); - } -} - -static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) { - const char *req_id = ha_json_get_string(msg, "req_id"); - const char *kind = ha_json_get_string(msg, "kind"); - const char *mime = ha_json_get_string(msg, "mime"); - if (!req_id) return; - - /* 释放旧的聚合数据 */ - free(c->speech.data); - memset(&c->speech, 0, sizeof(c->speech)); - - strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1); - if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1); - if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1); - c->speech.total = ha_json_get_int(msg, "total", 0); -} - -static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) { - const char *req_id = ha_json_get_string(msg, "req_id"); - if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return; - - if (c->config.on_binary && c->speech.data && c->speech.len > 0) { - c->config.on_binary(c->speech.req_id, c->speech.kind, - c->speech.mime, c->speech.data, - c->speech.len, c->config.userdata); - } - - free(c->speech.data); - memset(&c->speech, 0, sizeof(c->speech)); -} - -static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) { - /* 解析 JSON */ - char *tmp = (char *)malloc(len + 1); - if (!tmp) return; - memcpy(tmp, payload, len); - tmp[len] = '\0'; - - ha_json_node_t *root = ha_json_parse(tmp); - if (!root) { free(tmp); return; } - - const char *op = ha_json_get_string(root, "op"); - if (!op) { ha_json_free(root); free(tmp); return; } - - switch (c->state) { - case HA_STATE_HELLO_SENT: - if (strcmp(op, "hello_ack") == 0) { - c->state = HA_STATE_BIND_SENT; - send_bind(c); - } - break; - case HA_STATE_BIND_SENT: - if (strcmp(op, "bind_ack") == 0) { - c->state = HA_STATE_READY; - if (c->config.on_state) { - c->config.on_state(1, c->config.userdata); - } - } - break; - case HA_STATE_READY: - if (strcmp(op, "cmd") == 0) { - handle_cmd_msg(c, root); - } else if (strcmp(op, "cmd_speech_start") == 0) { - handle_speech_start(c, root); - } else if (strcmp(op, "cmd_speech_end") == 0) { - handle_speech_end(c, root); - } - break; - default: - break; - } - - ha_json_free(root); - free(tmp); -} - -/* ======================== 连接管理 ======================== */ - -static int do_connect(ha_client_t *c) { - c->state = HA_STATE_CONNECTING; - c->reconnect_cnt++; - - /* 解析 server 地址 */ - char host[256] = {0}; - uint16_t port = 9890; - const char *p = c->config.server; - if (!p) return -1; - - /* 去掉 ws:// 前缀 */ - if (strncmp(p, "ws://", 5) == 0) p += 5; - else if (strncmp(p, "wss://", 6) == 0) p += 6; - - /* 提取 host:port */ - const char *colon = strchr(p, ':'); - const char *slash = strchr(p, '/'); - if (colon && (!slash || colon < slash)) { - int host_len = (int)(colon - p); - if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1; - memcpy(host, p, host_len); - host[host_len] = '\0'; - port = (uint16_t)atoi(colon + 1); - } else { - int host_len = (slash ? (int)(slash - p) : (int)strlen(p)); - if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1; - memcpy(host, p, host_len); - host[host_len] = '\0'; - } - - c->state = HA_STATE_WS_UPGRADING; - if (ha_ws_connect(&c->ws, &c->config.transport, host, port, - "/api/v1/device/ws", c->config.token) != 0) { - c->state = HA_STATE_DISCONNECTED; - return -1; - } - - /* 发送 hello */ - c->state = HA_STATE_HELLO_SENT; - if (send_hello(c) != 0) { - ha_ws_close(&c->ws); - c->state = HA_STATE_DISCONNECTED; - return -1; - } - - return 0; -} - -/* ======================== 公共 API ======================== */ - -ha_client_t *ha_client_new(const ha_config_t *config) { - ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t)); - if (!c) return NULL; - memcpy(&c->config, config, sizeof(ha_config_t)); - c->state = HA_STATE_INIT; - c->reconnect_cnt = 0; - return c; -} - -ha_status_t ha_client_start(ha_client_t *client) { - if (!client) return HA_ERR_INVALID; - if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC; - - /* 默认心跳间隔 30 秒 */ - if (client->config.ping_interval <= 0) { - client->config.ping_interval = 30; - } - - if (do_connect(client) != 0) { - return HA_ERR_TRANSPORT; - } - - /* 等待 bind_ack(最多 5 秒) */ - int wait_ms = 5000; - int step = 50; - while (wait_ms > 0 && client->state != HA_STATE_READY) { - /* 处理一帧 */ - ha_status_t st = ha_client_process(client); - if (st != HA_OK && st != HA_ERR_DISCONNECTED) { - return st; - } - if (client->state == HA_STATE_READY) return HA_OK; - - /* 简单延时:靠 process 中的 recv 阻塞 */ - wait_ms -= step; - } - - return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT; -} - -ha_status_t ha_client_process(ha_client_t *client) { - if (!client) return HA_ERR_INVALID; - - if (client->state == HA_STATE_STOPPING) { - return HA_ERR_DISCONNECTED; - } - - /* 断线重连 */ - if (client->state == HA_STATE_DISCONNECTED || - client->state == HA_STATE_INIT) { - if (client->config.max_reconnect >= 0 && - client->reconnect_cnt > client->config.max_reconnect) { - return HA_ERR_DISCONNECTED; - } - /* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */ - return HA_ERR_DISCONNECTED; - } - - if (!client->ws.connected) { - client->state = HA_STATE_DISCONNECTED; - if (client->config.on_state) { - client->config.on_state(0, client->config.userdata); - } - return HA_ERR_DISCONNECTED; - } - - /* 尝试读取一帧 */ - const uint8_t *payload = NULL; - int len = 0; - int ret = ha_ws_read_frame(&client->ws, &payload, &len); - - if (ret < 0) { - /* 连接断开 */ - client->state = HA_STATE_DISCONNECTED; - if (client->config.on_state) { - client->config.on_state(0, client->config.userdata); - } - return HA_ERR_DISCONNECTED; - } - - switch (ret) { - case WS_OPCODE_TEXT: - handle_text_message(client, payload, len); - break; - case WS_OPCODE_BINARY: - /* 二进制帧:如果处于语音聚合状态,追加数据 */ - if (client->speech.req_id[0] && payload) { - int new_len = client->speech.len + len; - if (new_len > client->speech.cap) { - int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096; - while (new_cap < new_len) new_cap *= 2; - uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap); - if (!nd) break; - client->speech.data = nd; - client->speech.cap = new_cap; - } - memcpy(client->speech.data + client->speech.len, payload, len); - client->speech.len = new_len; - } - break; - case WS_OPCODE_PING: - /* 回复 pong */ - ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0); - break; - case WS_OPCODE_PONG: - /* 收到 pong,忽略 */ - break; - case WS_OPCODE_CLOSE: - client->state = HA_STATE_DISCONNECTED; - if (client->config.on_state) { - client->config.on_state(0, client->config.userdata); - } - return HA_ERR_DISCONNECTED; - } - - return HA_OK; -} - -void ha_client_send_result(ha_client_t *client, const char *req_id, - const char *status, const char *output, - const char *error) { - if (!client || client->state != HA_STATE_READY) return; - json_init(client); - ha_json_builder_begin_object(&client->jb); - ha_json_builder_string(&client->jb, "op", "cmd_result"); - ha_json_builder_string(&client->jb, "req_id", req_id); - ha_json_builder_string(&client->jb, "status", status ? status : "ok"); - ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); - if (output && output[0]) { - ha_json_builder_string(&client->jb, "output", output); - } - if (error && error[0]) { - ha_json_builder_string(&client->jb, "error", error); - } - ha_json_builder_end_object(&client->jb); - ws_send_json(client); -} - -void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, - const char *kind, const char *mime, - const uint8_t *data, int len) { - if (!client || client->state != HA_STATE_READY) return; - - /* cmd_data_start */ - json_init(client); - ha_json_builder_begin_object(&client->jb); - ha_json_builder_string(&client->jb, "op", "cmd_data_start"); - ha_json_builder_string(&client->jb, "req_id", req_id); - ha_json_builder_string(&client->jb, "kind", kind ? kind : "data"); - ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream"); - ha_json_builder_int(&client->jb, "total", len); - ha_json_builder_int(&client->jb, "chunk_size", 8192); - ha_json_builder_end_object(&client->jb); - ws_send_json(client); - - /* 二进制帧分块发送 */ - int off = 0; - while (off < len) { - int chunk = len - off; - if (chunk > 8192) chunk = 8192; - if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return; - off += chunk; - } - - /* cmd_data_end */ - json_init(client); - ha_json_builder_begin_object(&client->jb); - ha_json_builder_string(&client->jb, "op", "cmd_data_end"); - ha_json_builder_string(&client->jb, "req_id", req_id); - ha_json_builder_string(&client->jb, "status", "ok"); - ha_json_builder_int(&client->jb, "total", len); - ha_json_builder_end_object(&client->jb); - ws_send_json(client); -} - -void ha_client_send_event(ha_client_t *client, const char *type, - const char *detail) { - if (!client || client->state != HA_STATE_READY) return; - json_init(client); - ha_json_builder_begin_object(&client->jb); - ha_json_builder_string(&client->jb, "op", "event"); - ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); - ha_json_builder_string(&client->jb, "type", type ? type : ""); - if (detail && detail[0]) { - ha_json_builder_string(&client->jb, "payload", detail); - } - ha_json_builder_end_object(&client->jb); - ws_send_json(client); -} - -void ha_client_send_status(ha_client_t *client, const char *status) { - if (!client || client->state != HA_STATE_READY) return; - json_init(client); - ha_json_builder_begin_object(&client->jb); - ha_json_builder_string(&client->jb, "op", "status"); - ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id); - ha_json_builder_string(&client->jb, "status", status ? status : "online"); - ha_json_builder_end_object(&client->jb); - ws_send_json(client); -} - -void ha_client_stop(ha_client_t *client) { - if (!client) return; - client->state = HA_STATE_STOPPING; - if (client->ws.connected) { - ha_ws_close(&client->ws); - } -} - -void ha_client_destroy(ha_client_t *client) { - if (!client) return; - ha_client_stop(client); - free(client->speech.data); - free(client); -} - -/* ======================== 工具函数 ======================== */ - -void ha_cmd_parse_homeagent(const char *command, const char **cap, - const char **args) { - *cap = command; - *args = ""; - - if (!command) { - *cap = ""; - return; - } - - /* 去掉 homeagent- 前缀 */ - const char *p = command; - if (strncmp(p, "homeagent-", 10) == 0) { - p += 10; - } - - /* 按空格分割 */ - const char *space = strchr(p, ' '); - if (space) { - /* cap 指向 p 但不包含空格,需要临时拷贝 */ - /* 返回指针到原始字符串,调用方用 strncpy 取出 */ - *cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */ - /* 实际上,最简单的方式是原地修改,但 const 不允许 */ - /* 用静态缓冲区或让调用方自己处理 */ - static char cap_buf[256]; - int n = (int)(space - p); - if (n > 255) n = 255; - strncpy(cap_buf, p, n); - cap_buf[n] = '\0'; - *cap = cap_buf; - *args = space + 1; - } else { - static char cap_buf[256]; - strncpy(cap_buf, p, sizeof(cap_buf) - 1); - cap_buf[sizeof(cap_buf) - 1] = '\0'; - *cap = cap_buf; - *args = ""; - } -} - -void ha_cmd_parse_json(const char *command, const char **action, - const char **json_str) { - *action = ""; - *json_str = ""; - - if (!command) return; - - const char *p = command; - if (strncmp(p, "homeagent-", 10) == 0) { - p += 10; - } - - const char *brace = strchr(p, '{'); - if (brace) { - static char act_buf[256]; - int n = (int)(brace - p); - while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--; - if (n > 255) n = 255; - strncpy(act_buf, p, n); - act_buf[n] = '\0'; - *action = act_buf; - *json_str = brace; - } else { - static char act_buf[256]; - strncpy(act_buf, p, sizeof(act_buf) - 1); - *action = act_buf; - } -} - -const char *ha_version(void) { - return HA_VERSION; -} - diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_ws.c b/third_party/homeagent-sdk/remotedevice/src/ha_ws.c deleted file mode 100644 index 266509d..0000000 --- a/third_party/homeagent-sdk/remotedevice/src/ha_ws.c +++ /dev/null @@ -1,325 +0,0 @@ -#include "ha_ws.h" -#include -#include -#include - -/* WS GUID 用于计算 Accept 值 */ -#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - -/* ======================== Base64 编码(用于 WS key) ======================== */ -static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -static void base64_encode_bin(const uint8_t *in, int in_len, char *out) { - int i = 0, j = 0; - uint8_t b[3]; - while (i < in_len) { - int rem = in_len - i; - if (rem >= 3) { - b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++]; - out[j++] = b64t[b[0] >> 2]; - out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)]; - out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)]; - out[j++] = b64t[b[2] & 0x3F]; - } else if (rem == 2) { - b[0] = in[i++]; b[1] = in[i++]; - out[j++] = b64t[b[0] >> 2]; - out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)]; - out[j++] = b64t[(b[1] & 0x0F) << 2]; - out[j++] = '='; - } else { - b[0] = in[i++]; - out[j++] = b64t[b[0] >> 2]; - out[j++] = b64t[(b[0] & 0x03) << 4]; - out[j++] = '='; - out[j++] = '='; - } - } - out[j] = '\0'; -} - -/* 简单伪随机数生成器 */ -static uint32_t ws_rand_state = 0; -static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; } -static uint32_t ws_rand(void) { - ws_rand_state = ws_rand_state * 1103515245 + 12345; - return ws_rand_state; -} - -/* 生成 WS 握手 key */ -static void ws_gen_key(char *out) { - uint8_t buf[16]; - for (int i = 0; i < 16; i++) { - buf[i] = (uint8_t)(ws_rand() & 0xFF); - } - base64_encode_bin(buf, 16, out); -} - -/* ======================== 从传输层接收指定字节数 ======================== */ -static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) { - int pos = 0; - while (pos < len) { - int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos); - if (n <= 0) return -1; - pos += n; - } - return 0; -} - -/* ======================== 发送 WS 帧 ======================== */ -int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) { - uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */ - int hdr_len = 0; - - hdr[0] = 0x80 | opcode; /* FIN + opcode */ - hdr_len = 2; - - int ext_len = 0; - if (len < 126) { - hdr[1] = 0x80 | len; /* mask bit + length */ - } else if (len < 65536) { - hdr[1] = 0x80 | 126; - hdr_len = 4; - hdr[2] = (uint8_t)(len >> 8); - hdr[3] = (uint8_t)(len & 0xFF); - ext_len = 2; - } else { - hdr[1] = 0x80 | 127; - hdr_len = 10; - uint64_t l = (uint64_t)len; - for (int i = 8; i > 0; i--) { - hdr[1 + i] = (uint8_t)(l & 0xFF); - l >>= 8; - } - ext_len = 8; - } - - /* mask key */ - uint8_t mask_key[4]; - mask_key[0] = (uint8_t)(ws_rand() & 0xFF); - mask_key[1] = (uint8_t)(ws_rand() & 0xFF); - mask_key[2] = (uint8_t)(ws_rand() & 0xFF); - mask_key[3] = (uint8_t)(ws_rand() & 0xFF); - - int mask_off = 2 + ext_len; - hdr[mask_off] = mask_key[0]; - hdr[mask_off + 1] = mask_key[1]; - hdr[mask_off + 2] = mask_key[2]; - hdr[mask_off + 3] = mask_key[3]; - hdr_len = mask_off + 4; - - /* 发送帧头 */ - if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) { - return -1; - } - - /* 发送掩码后的 payload */ - if (len > 0) { - /* 如果 payload 不大,用栈缓冲区 */ - uint8_t stack_buf[2048]; - uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len); - if (!masked) return -1; - - for (int i = 0; i < len; i++) { - masked[i] = payload[i] ^ mask_key[i & 3]; - } - - int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1; - - if (masked != stack_buf) free(masked); - if (ret != 0) return -1; - } - - return 0; -} - -/* ======================== 公共 API ======================== */ - -int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport, - const char *host, uint16_t port, - const char *path, const char *token) { - memset(ws, 0, sizeof(ha_ws_t)); - ws->transport = transport; - ws->connected = 0; - - strncpy(ws->host, host, sizeof(ws->host) - 1); - ws->port = port; - strncpy(ws->path, path, sizeof(ws->path) - 1); - if (token) strncpy(ws->token, token, sizeof(ws->token) - 1); - - /* 种子 */ - ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port); - - /* 1. TCP 连接 */ - if (transport->connect(transport->ctx, host, port) != 0) { - return -1; - } - - /* 2. 发送 WS 升级请求 */ - char key[32]; - ws_gen_key(key); - - char req[1024]; - int n = snprintf(req, sizeof(req), - "GET %s HTTP/1.1\r\n" - "Host: %s:%u\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Key: %s\r\n" - "Sec-WebSocket-Version: 13\r\n" - "\r\n", - path, host, (unsigned)port, key); - - /* 如果 token 存在,加到路径参数中 */ - if (token && token[0]) { - n = snprintf(req, sizeof(req), - "GET %s?token=%s HTTP/1.1\r\n" - "Host: %s:%u\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Key: %s\r\n" - "Sec-WebSocket-Version: 13\r\n" - "\r\n", - path, token, host, (unsigned)port, key); - } - - if (transport->send(transport->ctx, (uint8_t *)req, n) != n) { - transport->close(transport->ctx); - return -1; - } - - /* 3. 读取响应头(直到 \r\n\r\n) */ - char resp[1024]; - int resp_len = 0; - int found = 0; - while (resp_len < (int)sizeof(resp) - 1) { - int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1); - if (n <= 0) { - transport->close(transport->ctx); - return -1; - } - resp_len += n; - resp[resp_len] = '\0'; - if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) { - found = 1; - break; - } - } - if (!found) { - transport->close(transport->ctx); - return -1; - } - - /* 4. 检查状态码 101 */ - if (strstr(resp, " 101 ") == NULL) { - transport->close(transport->ctx); - return -1; - } - - ws->connected = 1; - return 0; -} - -int ha_ws_send_text(ha_ws_t *ws, const char *text) { - if (!ws->connected) return -1; - return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text)); -} - -int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) { - if (!ws->connected) return -1; - return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len); -} - -int ha_ws_send_ping(ha_ws_t *ws) { - if (!ws->connected) return -1; - return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0); -} - -int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) { - if (!ws->connected) return -1; - - *payload = NULL; - *len = 0; - - /* 读取帧头:2 字节 */ - uint8_t hdr[2]; - if (recv_all(ws, hdr, 2) != 0) { - ws->connected = 0; - return -1; - } - - int opcode = hdr[0] & 0x0F; - int masked = (hdr[1] & 0x80) ? 1 : 0; - uint64_t frame_len = hdr[1] & 0x7F; - - if (frame_len == 126) { - uint8_t ext[2]; - if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; } - frame_len = ((uint64_t)ext[0] << 8) | ext[1]; - } else if (frame_len == 127) { - uint8_t ext[8]; - if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; } - frame_len = 0; - for (int i = 0; i < 8; i++) { - frame_len = (frame_len << 8) | ext[i]; - } - } - - /* 读取 mask key */ - uint8_t mask_key[4] = {0, 0, 0, 0}; - if (masked) { - if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; } - } - - /* 限制帧大小 */ - if (frame_len > sizeof(ws->read_buf)) { - /* 帧太大,跳过 payload */ - uint64_t skip = frame_len; - uint8_t tmp[256]; - while (skip > 0) { - int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip; - if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; } - skip -= to_skip; - } - return -1; /* 返回错误,帧太大 */ - } - - /* 读取 payload */ - if (frame_len > 0) { - if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) { - ws->connected = 0; - return -1; - } - /* 如果有 mask,解掩码 */ - if (masked) { - for (uint64_t i = 0; i < frame_len; i++) { - ws->read_buf[i] ^= mask_key[i & 3]; - } - } - } - - *payload = ws->read_buf; - *len = (int)frame_len; - - switch (opcode) { - case WS_OPCODE_CLOSE: - ws->connected = 0; - return WS_OPCODE_CLOSE; - case WS_OPCODE_PING: - return WS_OPCODE_PING; - case WS_OPCODE_PONG: - return WS_OPCODE_PONG; - case WS_OPCODE_TEXT: - case WS_OPCODE_BINARY: - return opcode; - default: - return -1; - } -} - -void ha_ws_close(ha_ws_t *ws) { - if (ws->connected) { - ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0); - ws->connected = 0; - } - ws->transport->close(ws->transport->ctx); -} \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/src/ha_ws.h b/third_party/homeagent-sdk/remotedevice/src/ha_ws.h deleted file mode 100644 index e47cfcd..0000000 --- a/third_party/homeagent-sdk/remotedevice/src/ha_ws.h +++ /dev/null @@ -1,62 +0,0 @@ -#ifndef HA_WS_H -#define HA_WS_H - -#include -#include -#include "../include/ha_remotedevice.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ======================== WS 帧类型 ======================== */ -#define WS_OPCODE_CONTINUATION 0x0 -#define WS_OPCODE_TEXT 0x1 -#define WS_OPCODE_BINARY 0x2 -#define WS_OPCODE_CLOSE 0x8 -#define WS_OPCODE_PING 0x9 -#define WS_OPCODE_PONG 0xA - -/* ======================== WS 连接 ======================== */ -typedef struct { - ha_transport_t *transport; /* 用户实现的传输层 */ - int connected; /* 是否已连接 */ - uint8_t read_buf[8192]; /* 读缓冲区 */ - int read_pos; /* 缓冲区中有效数据起始位置 */ - int read_len; /* 缓冲区中有效数据长度 */ - char host[256]; /* 缓存目标地址 */ - uint16_t port; - char path[256]; - char token[256]; -} ha_ws_t; - -/* 创建 WS 连接。返回 0 成功,非 0 失败。 */ -int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport, - const char *host, uint16_t port, - const char *path, const char *token); - -/* 发送文本帧。返回 0 成功。 */ -int ha_ws_send_text(ha_ws_t *ws, const char *text); - -/* 发送二进制帧。返回 0 成功。 */ -int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len); - -/* 发送 ping。返回 0 成功。 */ -int ha_ws_send_ping(ha_ws_t *ws); - -/* 读取一帧。 - * 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。 - * payload 和 len 指向内部缓冲区,在下次调用前有效。 */ -int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len); - -/* 发送原始 WS 帧(内部使用,用于回复 ping) */ -int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len); - -/* 关闭 WS 连接 */ -void ha_ws_close(ha_ws_t *ws); - -#ifdef __cplusplus -} -#endif - -#endif /* HA_WS_H */ \ No newline at end of file diff --git a/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c b/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c deleted file mode 100644 index affd3f9..0000000 --- a/third_party/homeagent-sdk/remotedevice/test/test_ha_remotedevice.c +++ /dev/null @@ -1,1509 +0,0 @@ -/** - * ha_remotedevice 全面测试 - * - * 覆盖:JSON 解析/构建、WS 帧编解码、工具函数、 - * 客户端完整生命周期、命令分发、二进制分块、语音数据、事件上报。 - * - * 编译 (POSIX/Linux/macOS): - * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -o test - * - * 编译 (Windows/MinGW): - * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -lws2_32 -o test - */ - -#include "ha_remotedevice.h" -#include "ha_json.h" -#include "ha_ws.h" - -#include -#include -#include -#include -#include - -/* 前向声明:ha_client_send_data_chunked 供测试调用 */ -void ha_client_send_data_chunked(ha_client_t *client, const char *req_id, - const char *kind, const char *mime, - const uint8_t *data, int len); - -/* ================================================================== - * 平台适配 - * ================================================================== */ -#if defined(_WIN32) || defined(_WIN64) - #define _CRT_SECURE_NO_WARNINGS - #include - #include - #include - typedef int socklen_t; - #define sleep(t) Sleep((t)*1000) - #define usleep(t) Sleep((t)/1000) - typedef unsigned thread_func_ret; - #define THREAD_RETURN return 0 - #define SIGPIPE 13 - static void signal(int sig, void (*func)(int)) { (void)sig; (void)func; } - static void sock_close(int fd) { closesocket(fd); } -#else - #include - #include - #include - #include - #include - #include - #include - typedef void *thread_func_ret; - #define THREAD_RETURN return NULL - static void sock_close(int fd) { close(fd); } -#endif - -/* ================================================================== - * 简易测试框架 - * ================================================================== */ -static int tests_passed = 0; -static int tests_failed = 0; -static int tests_skipped = 0; - -#define TEST_BEGIN(name) do { \ - printf(" TEST: %s ... ", name); \ - fflush(stdout); \ - do { (void)0 - -#define TEST_END() } while(0); \ - printf("PASS\n"); \ - tests_passed++; \ -} while(0) - -#define TEST_FAIL(msg) do { \ - printf("FAIL: %s\n", msg); \ - tests_failed++; \ - return; \ -} while(0) - -#define TEST_ASSERT(cond, msg) do { \ - if (!(cond)) { TEST_FAIL(msg); } \ -} while(0) - -#define TEST_SKIP(reason) do { \ - printf("SKIP: %s\n", reason); \ - tests_skipped++; \ - return; \ -} while(0) - -/* ================================================================== - * 工具:在本地端口启动一个 TCP 服务器(用于 mock 网关) - * ================================================================== */ -typedef struct { - int listen_fd; - int client_fd; - int port; -#if defined(_WIN32) || defined(_WIN64) - uintptr_t thread; -#else - pthread_t thread; -#endif - volatile int running; -} mock_server_t; - -/* 发送 WS 文本帧(服务器端,无需掩码) */ -static void mock_send_text(int fd, const char *json) { - int len = (int)strlen(json); - uint8_t hdr[10]; - int hdr_len = 2; - hdr[0] = 0x80 | 0x1; - if (len < 126) { - hdr[1] = (uint8_t)len; - } else if (len < 65536) { - hdr[1] = 126; - hdr[2] = (uint8_t)(len >> 8); - hdr[3] = (uint8_t)(len & 0xFF); - hdr_len = 4; - } else { - hdr[1] = 127; - uint64_t l = (uint64_t)len; - for (int i = 8; i > 0; i--) { - hdr[1 + i] = (uint8_t)(l & 0xFF); - l >>= 8; - } - hdr_len = 10; - } - send(fd, (const char *)hdr, hdr_len, 0); - send(fd, json, len, 0); -} - -/* 发送 WS 二进制帧(服务器端,无需掩码) */ -static void mock_send_binary(int fd, const uint8_t *data, int len) { - uint8_t hdr[10]; - int hdr_len = 2; - hdr[0] = 0x80 | 0x2; - if (len < 126) { - hdr[1] = (uint8_t)len; - } else if (len < 65536) { - hdr[1] = 126; - hdr[2] = (uint8_t)(len >> 8); - hdr[3] = (uint8_t)(len & 0xFF); - hdr_len = 4; - } else { - hdr[1] = 127; - uint64_t l = (uint64_t)len; - for (int i = 8; i > 0; i--) { - hdr[1 + i] = (uint8_t)(l & 0xFF); - l >>= 8; - } - hdr_len = 10; - } - send(fd, (const char *)hdr, hdr_len, 0); - send(fd, (const char *)data, len, 0); -} - -/* 读取 WS 文本帧(服务器端,解析掩码) */ -static char *mock_read_text(int fd) { - uint8_t hdr[2]; - if (recv(fd, (char *)hdr, 2, 0) != 2) return NULL; - int masked = (hdr[1] & 0x80) ? 1 : 0; - uint64_t len = hdr[1] & 0x7F; - if (len == 126) { - uint8_t ext[2]; - if (recv(fd, (char *)ext, 2, 0) != 2) return NULL; - len = ((uint64_t)ext[0] << 8) | ext[1]; - } else if (len == 127) { - uint8_t ext[8]; - if (recv(fd, (char *)ext, 8, 0) != 8) return NULL; - len = 0; - for (int i = 0; i < 8; i++) len = (len << 8) | ext[i]; - } - uint8_t mask_key[4] = {0}; - if (masked) { - if (recv(fd, (char *)mask_key, 4, 0) != 4) return NULL; - } - char *buf = (char *)malloc((size_t)len + 1); - if (!buf) return NULL; - if (len > 0) { - if ((int)recv(fd, buf, (int)len, 0) != (int)len) { - free(buf); - return NULL; - } - if (masked) { - for (uint64_t i = 0; i < len; i++) - buf[i] ^= mask_key[i & 3]; - } - } - buf[len] = '\0'; - return buf; -} - -/* mock 服务器线程 */ -#if defined(_WIN32) || defined(_WIN64) -static unsigned __stdcall mock_server_thread(void *arg) { -#else -static void *mock_server_thread(void *arg) { -#endif - mock_server_t *ms = (mock_server_t *)arg; - struct sockaddr_in client_addr; - socklen_t addr_len = sizeof(client_addr); - int fd = accept(ms->listen_fd, (struct sockaddr *)&client_addr, &addr_len); - if (fd < 0) THREAD_RETURN; - ms->client_fd = fd; - - /* 读取 WS 升级请求 */ - char buf[4096] = {0}; - int n = 0; - while (n < (int)sizeof(buf) - 1) { - int r = (int)recv(fd, buf + n, 1, 0); - if (r <= 0) break; - n += r; - buf[n] = '\0'; - if (n >= 4 && strcmp(buf + n - 4, "\r\n\r\n") == 0) break; - } - - /* 发送 101 响应 */ - const char *resp = - "HTTP/1.1 101 Switching Protocols\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" - "\r\n"; - send(fd, resp, (int)strlen(resp), 0); - - /* 协议循环 */ - char *msg; - while (ms->running) { - msg = mock_read_text(fd); - if (!msg) break; - - if (strstr(msg, "\"hello\"")) { - mock_send_text(fd, "{\"op\":\"hello_ack\",\"status\":\"ok\"}"); - } else if (strstr(msg, "\"bind\"")) { - mock_send_text(fd, "{\"op\":\"bind_ack\",\"status\":\"ok\",\"device_id\":\"test-dev\"}"); - } - /* cmd_result, event, status, cmd_data_start/end 可以忽略 */ - free(msg); - } - sock_close(fd); - ms->client_fd = -1; - THREAD_RETURN; -} - -static mock_server_t *mock_server_start(int port) { - mock_server_t *ms = (mock_server_t *)calloc(1, sizeof(mock_server_t)); - -#if defined(_WIN32) || defined(_WIN64) - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); -#endif - - ms->listen_fd = (int)socket(AF_INET, SOCK_STREAM, 0); - int opt = 1; - setsockopt(ms->listen_fd, SOL_SOCKET, SO_REUSEADDR, - (const char *)&opt, sizeof(opt)); - - struct sockaddr_in addr; - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = INADDR_ANY; - addr.sin_port = htons(port); - if (bind(ms->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - free(ms); - return NULL; - } - listen(ms->listen_fd, 1); - ms->port = port; - ms->running = 1; - ms->client_fd = -1; - -#if defined(_WIN32) || defined(_WIN64) - ms->thread = _beginthreadex(NULL, 0, mock_server_thread, ms, 0, NULL); -#else - pthread_create(&ms->thread, NULL, mock_server_thread, ms); -#endif - usleep(200000); /* 等待服务器就绪 */ - return ms; -} - -static void mock_server_stop(mock_server_t *ms) { - if (!ms) return; - ms->running = 0; - if (ms->client_fd >= 0) sock_close(ms->client_fd); - sock_close(ms->listen_fd); -#if defined(_WIN32) || defined(_WIN64) - WaitForSingleObject((HANDLE)ms->thread, 3000); - WSACleanup(); -#else - pthread_join(ms->thread, NULL); -#endif - free(ms); -} - -/* ================================================================== - * 传输层实现(POSIX socket) - * ================================================================== */ -struct transport_ctx { - int sock; -}; - -static int transport_connect(void *ctx, const char *host, uint16_t port) { - struct transport_ctx *tc = (struct transport_ctx *)ctx; - struct hostent *he = gethostbyname(host); - if (!he) return -1; - tc->sock = (int)socket(AF_INET, SOCK_STREAM, 0); - if (tc->sock < 0) return -1; - struct sockaddr_in addr; - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length); - if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - sock_close(tc->sock); - tc->sock = -1; - return -1; - } - /* 设置 100ms 接收超时,使 ha_client_process 不会永久阻塞 */ -#if defined(_WIN32) || defined(_WIN64) - DWORD timeout = 100; - setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); -#else - struct timeval tv = {0, 100000}; - setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); -#endif - return 0; -} - -static int transport_send(void *ctx, const uint8_t *data, int len) { - struct transport_ctx *tc = (struct transport_ctx *)ctx; - int sent = 0; - while (sent < len) { - int n = (int)send(tc->sock, (const char *)(data + sent), len - sent, 0); - if (n <= 0) return -1; - sent += n; - } - return sent; -} - -static int transport_recv(void *ctx, uint8_t *buf, int len) { - struct transport_ctx *tc = (struct transport_ctx *)ctx; - return (int)recv(tc->sock, (char *)buf, len, 0); -} - -static void transport_close(void *ctx) { - struct transport_ctx *tc = (struct transport_ctx *)ctx; - if (tc->sock >= 0) { - sock_close(tc->sock); - tc->sock = -1; - } -} - -/* ================================================================== - * mock 服务器:发送命令 - * ================================================================== */ -static void mock_send_cmd(mock_server_t *ms, const char *cmd_type, - const char *req_id, const char *cmd) { - char buf[4096]; - char escaped[2048]; - int ei = 0; - /* 转义 cmd 中的双引号和反斜杠 */ - if (cmd) { - for (int i = 0; cmd[i] && ei < (int)sizeof(escaped) - 6; i++) { - if (cmd[i] == '"' || cmd[i] == '\\') { - escaped[ei++] = '\\'; - if (ei >= (int)sizeof(escaped) - 1) break; - } - escaped[ei++] = cmd[i]; - } - } - escaped[ei] = '\0'; - int n = snprintf(buf, sizeof(buf), - "{\"op\":\"cmd\",\"req_id\":\"%s\",\"cmd_type\":\"%s\",\"command\":\"%s\"}", - req_id ? req_id : "", cmd_type ? cmd_type : "", escaped); - mock_send_text(ms->client_fd, buf); -} - -/* ================================================================== - * 测试用例 - * ================================================================== */ - -/* ---------- 1. JSON 解析器 ---------- */ -static void test_json_parser(void) { - TEST_BEGIN("JSON parser: parse object"); - - const char *json = "{\"op\":\"hello\",\"device\":{\"id\":\"test\",\"caps\":[\"a\",\"b\"]}}"; - ha_json_node_t *root = ha_json_parse(json); - TEST_ASSERT(root != NULL, "parse failed"); - TEST_ASSERT(root->type == HA_JSON_OBJECT, "not an object"); - - const char *op = ha_json_get_string(root, "op"); - TEST_ASSERT(op != NULL && strcmp(op, "hello") == 0, "op mismatch"); - - ha_json_node_t *dev = ha_json_get(root, "device"); - TEST_ASSERT(dev != NULL && dev->type == HA_JSON_OBJECT, "device not object"); - - const char *id = ha_json_get_string(dev, "id"); - TEST_ASSERT(id != NULL && strcmp(id, "test") == 0, "device id mismatch"); - - ha_json_node_t *caps = ha_json_get(dev, "caps"); - TEST_ASSERT(caps != NULL && caps->type == HA_JSON_ARRAY, "caps not array"); - TEST_ASSERT(ha_json_array_len(caps) == 2, "caps length wrong"); - - ha_json_node_t *c0 = ha_json_array_get(caps, 0); - TEST_ASSERT(c0 != NULL && c0->type == HA_JSON_STRING && - strcmp(c0->str_val, "a") == 0, "caps[0] mismatch"); - - ha_json_free(root); - TEST_END(); -} - -static void test_json_int(void) { - TEST_BEGIN("JSON parser: int fields"); - - const char *json = "{\"count\":42,\"total\":100500,\"neg\":-7}"; - ha_json_node_t *root = ha_json_parse(json); - TEST_ASSERT(root != NULL, "parse failed"); - - TEST_ASSERT(ha_json_get_int(root, "count", -1) == 42, "count mismatch"); - TEST_ASSERT(ha_json_get_int(root, "total", -1) == 100500, "total mismatch"); - TEST_ASSERT(ha_json_get_int(root, "neg", 0) == -7, "neg mismatch"); - TEST_ASSERT(ha_json_get_int(root, "nonexistent", -999) == -999, "default wrong"); - - ha_json_free(root); - TEST_END(); -} - -static void test_json_builder(void) { - TEST_BEGIN("JSON builder: object with string + int + bool"); - - char buf[256]; - ha_json_builder_t jb; - ha_json_builder_init(&jb, buf, sizeof(buf)); - ha_json_builder_begin_object(&jb); - ha_json_builder_string(&jb, "op", "hello"); - ha_json_builder_int(&jb, "seq", 1); - ha_json_builder_bool(&jb, "active", 1); - ha_json_builder_end_object(&jb); - - const char *r = ha_json_builder_str(&jb); - TEST_ASSERT(r != NULL, "builder returned NULL"); - TEST_ASSERT(strstr(r, "\"op\":\"hello\"") != NULL, "missing op"); - TEST_ASSERT(strstr(r, "\"seq\":1") != NULL, "missing seq"); - TEST_ASSERT(strstr(r, "\"active\":true") != NULL, "missing bool"); - - TEST_END(); -} - -static void test_json_array_builder(void) { - TEST_BEGIN("JSON builder: array of strings"); - - char buf[256]; - ha_json_builder_t jb; - ha_json_builder_init(&jb, buf, sizeof(buf)); - ha_json_builder_begin_object(&jb); - ha_json_builder_key(&jb, "caps"); - ha_json_builder_begin_array(&jb); - ha_json_builder_add_string(&jb, "camera"); - ha_json_builder_add_string(&jb, "screen"); - ha_json_builder_end_array(&jb); - ha_json_builder_end_object(&jb); - - const char *r = ha_json_builder_str(&jb); - TEST_ASSERT(r != NULL, "builder returned NULL"); - TEST_ASSERT(strstr(r, "\"camera\"") != NULL, "missing camera"); - TEST_ASSERT(strstr(r, "\"screen\"") != NULL, "missing screen"); - - TEST_END(); -} - -/* ---------- 2. 工具函数 ---------- */ -static void test_parse_homeagent(void) { - TEST_BEGIN("ha_cmd_parse_homeagent: basic"); - - const char *cap, *args; - - ha_cmd_parse_homeagent("camerasue 5", &cap, &args); - TEST_ASSERT(strcmp(cap, "camerasue") == 0, "cap mismatch"); - TEST_ASSERT(strcmp(args, "5") == 0, "args mismatch"); - - ha_cmd_parse_homeagent("screensee", &cap, &args); - TEST_ASSERT(strcmp(cap, "screensee") == 0, "cap mismatch (no args)"); - TEST_ASSERT(strcmp(args, "") == 0, "args should be empty"); - - ha_cmd_parse_homeagent("homeagent-camerasue 3", &cap, &args); - TEST_ASSERT(strcmp(cap, "camerasue") == 0, "prefix not stripped"); - TEST_ASSERT(strcmp(args, "3") == 0, "args after prefix"); - - ha_cmd_parse_homeagent("", &cap, &args); - TEST_ASSERT(strcmp(cap, "") == 0, "empty input"); - - TEST_END(); -} - -static void test_parse_json(void) { - TEST_BEGIN("ha_cmd_parse_json: action + json"); - - const char *action, *json_str; - - ha_cmd_parse_json("computeruse {\"action\":\"click\",\"x\":100}", - &action, &json_str); - TEST_ASSERT(strcmp(action, "computeruse") == 0, "action mismatch"); - TEST_ASSERT(strstr(json_str, "\"action\"") != NULL, "json missing"); - - ha_cmd_parse_json("screensee", &action, &json_str); - TEST_ASSERT(strcmp(action, "screensee") == 0, "action no json"); - TEST_ASSERT(strcmp(json_str, "") == 0, "json should be empty"); - - TEST_END(); -} - -static void test_base64(void) { - TEST_BEGIN("ha_base64_encode: basic"); - - const uint8_t data[] = "Hello, World!"; - char out[64]; - int n = ha_base64_encode(data, 13, out, sizeof(out)); - TEST_ASSERT(n > 0, "encode returned 0"); - TEST_ASSERT(strcmp(out, "SGVsbG8sIFdvcmxkIQ==") == 0, "base64 mismatch"); - - const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF}; - n = ha_base64_encode(jpeg, 3, out, sizeof(out)); - TEST_ASSERT(n > 0, "short encode failed"); - TEST_ASSERT(strcmp(out, "/9j/") == 0, "jpeg magic mismatch"); - - /* 缓冲区不足 */ - n = ha_base64_encode(data, 13, out, 5); - TEST_ASSERT(n > 5, "should return needed size"); - - TEST_END(); -} - -/* ---------- 3. WS 帧编解码 ---------- */ -static void test_ws_frame(void) { -#if defined(_WIN32) || defined(_WIN64) - printf(" TEST: WS frame: encode/decode with mask ... SKIP: socketpair not available on Windows\n"); - tests_skipped++; - return; -#else - TEST_BEGIN("WS frame: encode/decode with mask"); - - /* 使用 TCP 本地连接测试 */ - int sv[2]; - TEST_ASSERT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0, - "socketpair failed"); - - const char *test_payload = "hello world"; - int test_len = (int)strlen(test_payload); - - /* 发送端:手动构造 WS 帧 */ - uint8_t frame[1024]; - int off = 0; - frame[off++] = 0x80 | 0x1; - frame[off++] = 0x80 | (uint8_t)test_len; - uint8_t mask[4] = {0x01, 0x02, 0x03, 0x04}; - memcpy(frame + off, mask, 4); off += 4; - for (int i = 0; i < test_len; i++) - frame[off++] = test_payload[i] ^ mask[i & 3]; - send(sv[0], (const char *)frame, off, 0); - - /* 接收端 */ - ha_transport_t transport = { - .send = transport_send, .recv = transport_recv, - .close = transport_close, - }; - struct transport_ctx tctx_reader = { .sock = sv[1] }; - transport.ctx = &tctx_reader; - - ha_ws_t ws; - memset(&ws, 0, sizeof(ws)); - ws.transport = &transport; - ws.connected = 1; - - const uint8_t *payload; - int len; - int opcode = ha_ws_read_frame(&ws, &payload, &len); - TEST_ASSERT(opcode == 0x1, "expected text frame"); - TEST_ASSERT(len == test_len, "length mismatch"); - TEST_ASSERT(memcmp(payload, test_payload, len) == 0, "payload mismatch"); - - sock_close(sv[0]); - sock_close(sv[1]); - TEST_END(); -#endif -} - -/* ---------- 4. ha_version ---------- */ -static void test_version(void) { - TEST_BEGIN("ha_version: returns non-empty"); - - const char *v = ha_version(); - TEST_ASSERT(v != NULL && v[0] != '\0', "version empty"); - printf("(v=%s) ", v); - TEST_END(); -} - -/* ---------- 5. 完整客户端生命周期 ---------- */ -static volatile int lifecycle_state = 0; - -static void test_lifecycle_on_state(int connected, void *userdata) { - (void)userdata; - lifecycle_state = connected ? 1 : 0; -} - -static ha_status_t test_handler_camerasue(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)userdata; - printf(" [handler] camerasue called, args=%s\n", args ? args : ""); - result->status = 0; - result->output = "data:image/jpeg;base64,test123"; - return HA_OK; -} - -static ha_status_t test_handler_shell(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)userdata; - printf(" [handler] shell called: %s\n", args ? args : ""); - result->status = 0; - result->output = "shell output here"; - return HA_OK; -} - -static void test_client_lifecycle(void) { - TEST_BEGIN("Client lifecycle: connect -> hello -> bind -> ready"); - - mock_server_t *ms = mock_server_start(19890); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"camera", "status", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "shell", .handler = test_handler_shell}, - {.command = "camerasue", .handler = test_handler_camerasue}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19890", - .token = "test-token", - .device = { - .device_id = "test-dev", - .name = "Test Device", - .kind = "camera", - .caps = caps, - }, - .handlers = handlers, - .on_state = test_lifecycle_on_state, - .ping_interval = 30, - }; - - lifecycle_state = 0; - ha_client_t *client = ha_client_new(&config); - TEST_ASSERT(client != NULL, "client new failed"); - - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - TEST_ASSERT(lifecycle_state == 1, "should be connected"); - - /* 发送命令验证分发 */ - mock_send_cmd(ms, "homeagent", "req-1", "camerasue 3"); - usleep(200000); - ha_client_process(client); - - mock_send_cmd(ms, "shell", "req-2", "ls -la"); - usleep(200000); - ha_client_process(client); - - /* 未注册的命令 */ - mock_send_cmd(ms, "homeagent", "req-3", "unknowncmd"); - usleep(100000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 6. 命令分发 ---------- */ -static volatile int cmd_camerasue_fired = 0; -static volatile int cmd_shell_fired = 0; -static char cmd_req_id[128] = ""; - -static ha_status_t test_handler_camerasue2(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)userdata; - cmd_camerasue_fired = 1; - strncpy(cmd_req_id, req_id, sizeof(cmd_req_id) - 1); - if (strcmp(args, "5") != 0) { - printf(" [handler] args mismatch: expected '5', got '%s'\n", args); - result->status = 1; - result->error = "args mismatch"; - return HA_OK; - } - result->status = 0; - result->output = "snapshot taken"; - return HA_OK; -} - -static ha_status_t test_handler_shell2(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)userdata; - cmd_shell_fired = 1; - if (strstr(args, "ls") == NULL) { - printf(" [handler] args should contain 'ls', got '%s'\n", args); - result->status = 1; - result->error = "args mismatch"; - return HA_OK; - } - result->status = 0; - result->output = "file1.txt\nfile2.txt"; - return HA_OK; -} - -static void test_command_dispatch(void) { - TEST_BEGIN("Command dispatch: homeagent + shell"); - - mock_server_t *ms = mock_server_start(19891); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"camera", "cmd", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "shell", .handler = test_handler_shell2}, - {.command = "camerasue", .handler = test_handler_camerasue2}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19891", - .token = "test-token", - .device = { - .device_id = "cmd-dev", - .name = "Cmd Test", - .kind = "camera", - .caps = caps, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - cmd_camerasue_fired = 0; - cmd_shell_fired = 0; - memset(cmd_req_id, 0, sizeof(cmd_req_id)); - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 发送 homeagent 命令 */ - mock_send_cmd(ms, "homeagent", "req-cam", "camerasue 5"); - usleep(200000); - ha_client_process(client); - TEST_ASSERT(cmd_camerasue_fired == 1, "camerasue handler not called"); - TEST_ASSERT(strcmp(cmd_req_id, "req-cam") == 0, "req_id mismatch"); - - /* 发送 shell 命令 */ - mock_send_cmd(ms, "shell", "req-sh", "ls -la /tmp"); - usleep(200000); - ha_client_process(client); - TEST_ASSERT(cmd_shell_fired == 1, "shell handler not called"); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 7. 事件上报 ---------- */ -static void test_event_report(void) { - TEST_BEGIN("Event report: client sends event to server"); - - mock_server_t *ms = mock_server_start(19892); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19892", - .token = "test-token", - .device = { - .device_id = "evt-dev", - .name = "Event Test", - .kind = "camera", - .caps = (const char *[]){"camera", NULL}, - }, - .handlers = NULL, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - ha_client_send_event(client, "motion_detected", "{\"zone\":\"front_door\"}"); - usleep(100000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 8. 状态上报 ---------- */ -static void test_status_report(void) { - TEST_BEGIN("Status report: client sends status to server"); - - mock_server_t *ms = mock_server_start(19893); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19893", - .token = "test-token", - .device = { - .device_id = "st-dev", - .name = "Status Test", - .kind = "camera", - .caps = (const char *[]){"camera", NULL}, - }, - .handlers = NULL, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - ha_client_send_status(client, "offline"); - usleep(100000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 9. 二进制分块回传 ---------- */ -static void test_binary_chunked(void) { - TEST_BEGIN("Binary chunked transfer: send video data to server"); - - mock_server_t *ms = mock_server_start(19894); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_cmd_handler_def_t handlers[] = { - {.command = "camerasue", .handler = test_handler_camerasue}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19894", - .token = "test-token", - .device = { - .device_id = "bin-dev", - .name = "Binary Test", - .kind = "camera", - .caps = (const char *[]){"camera", NULL}, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 模拟录像数据 */ - uint8_t video_data[20000]; - for (int i = 0; i < 20000; i++) video_data[i] = (uint8_t)(i % 251); - - ha_client_send_data_chunked(client, "req-video", "camera_video", - "video/mp4", video_data, 20000); - usleep(500000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 10. 未注册命令处理 ---------- */ -static void test_unsupported_command(void) { - TEST_BEGIN("Unsupported command: returns error gracefully"); - - mock_server_t *ms = mock_server_start(19895); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_cmd_handler_def_t handlers[] = { - {.command = "camerasue", .handler = test_handler_camerasue}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19895", - .token = "test-token", - .device = { - .device_id = "unsup-dev", - .name = "Unsupported Test", - .kind = "camera", - .caps = (const char *[]){"camera", NULL}, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 发送未注册命令 */ - mock_send_cmd(ms, "homeagent", "req-unsup", "screensee"); - usleep(100000); - ha_client_process(client); - - /* 发送已注册命令 */ - mock_send_cmd(ms, "homeagent", "req-ok", "camerasue"); - usleep(100000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 11. 语音数据接收 ---------- */ -static volatile int speech_received = 0; -static uint8_t speech_data[4096]; -static int speech_len = 0; - -static void test_on_binary(const char *req_id, const char *kind, - const char *mime, const uint8_t *data, - int len, void *userdata) { - (void)req_id; (void)kind; (void)mime; (void)userdata; - speech_received = 1; - speech_len = len < (int)sizeof(speech_data) ? len : (int)sizeof(speech_data); - memcpy(speech_data, data, (size_t)speech_len); -} - -static void test_speech_receive(void) { - TEST_BEGIN("Speech data receive: cmd_speech_start -> binary -> end"); - - mock_server_t *ms = mock_server_start(19896); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19896", - .token = "test-token", - .device = { - .device_id = "speech-dev", - .name = "Speech Test", - .kind = "speaker", - .caps = (const char *[]){"speaker", NULL}, - }, - .handlers = NULL, - .on_binary = test_on_binary, - .ping_interval = 30, - }; - - speech_received = 0; - speech_len = 0; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* mock 服务器发送语音数据 */ - const char *audio_data = "RIFF....fake-wav-data...."; - mock_send_text(ms->client_fd, - "{\"op\":\"cmd_speech_start\",\"req_id\":\"req-speech\"," - "\"kind\":\"speech\",\"mime\":\"audio/wav\",\"total\":25}"); - usleep(50000); - mock_send_binary(ms->client_fd, (const uint8_t *)audio_data, 25); - usleep(50000); - mock_send_text(ms->client_fd, - "{\"op\":\"cmd_speech_end\",\"req_id\":\"req-speech\"}"); - usleep(200000); - - /* 处理帧 */ - for (int i = 0; i < 50; i++) { - ha_client_process(client); - if (speech_received) break; - usleep(20000); - } - - TEST_ASSERT(speech_received == 1, "speech handler not called"); - TEST_ASSERT(speech_len == 25, "speech length mismatch"); - TEST_ASSERT(memcmp(speech_data, audio_data, 25) == 0, "speech data mismatch"); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 12. 二进制分块 via handler ---------- */ -static ha_status_t test_handler_video(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)userdata; - printf(" [handler] video record %s\n", args ? args : ""); - - static uint8_t video[5000]; - for (int i = 0; i < 5000; i++) video[i] = (uint8_t)(i & 0xFF); - - result->status = 0; - result->has_binary = 1; - result->binary_data = video; - result->binary_len = 5000; - result->binary_mime = "video/mp4"; - return HA_OK; -} - -static void test_binary_via_handler(void) { - TEST_BEGIN("Binary via handler: handler sets has_binary, SDK auto-chunks"); - - mock_server_t *ms = mock_server_start(19897); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - ha_cmd_handler_def_t handlers[] = { - {.command = "camerasue", .handler = test_handler_video}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19897", - .token = "test-token", - .device = { - .device_id = "bin2-dev", - .name = "Binary Via Handler", - .kind = "camera", - .caps = (const char *[]){"camera", NULL}, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - mock_send_cmd(ms, "homeagent", "req-video2", "camerasue 10"); - usleep(500000); - ha_client_process(client); - usleep(100000); - ha_client_process(client); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 13. computeruse:结构化 JSON 命令分发 ---------- */ -static volatile int computeruse_fired = 0; -static char computeruse_action[64] = ""; -static int computeruse_x = 0; -static int computeruse_y = 0; - -static ha_status_t test_handler_computeruse(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)userdata; - computeruse_fired = 1; - /* 解析 JSON 参数 */ - if (args && args[0] == '{') { - ha_json_node_t *root = ha_json_parse(args); - if (root) { - const char *act = ha_json_get_string(root, "action"); - if (act) strncpy(computeruse_action, act, sizeof(computeruse_action) - 1); - computeruse_x = ha_json_get_int(root, "x", 0); - computeruse_y = ha_json_get_int(root, "y", 0); - ha_json_free(root); - } - } - result->status = 0; - result->output = "clicked at (100,200)"; - return HA_OK; -} - -static void test_computeruse_dispatch(void) { - TEST_BEGIN("Computeruse: structured JSON command dispatch"); - - mock_server_t *ms = mock_server_start(19898); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"computeruse", "cmd", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "computeruse", .handler = test_handler_computeruse}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19898", - .token = "test-token", - .device = { - .device_id = "cu-dev", - .name = "ComputerUse Test", - .kind = "computer", - .caps = caps, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - computeruse_fired = 0; - memset(computeruse_action, 0, sizeof(computeruse_action)); - computeruse_x = 0; - computeruse_y = 0; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 发送 computeruse 命令(带结构化 JSON 参数) */ - mock_send_cmd(ms, "homeagent", "req-cu", - "computeruse {\"action\":\"click\",\"x\":100,\"y\":200}"); - usleep(200000); - ha_client_process(client); - TEST_ASSERT(computeruse_fired == 1, "computeruse handler not called"); - TEST_ASSERT(strcmp(computeruse_action, "click") == 0, - "action should be 'click'"); - TEST_ASSERT(computeruse_x == 100, "x should be 100"); - TEST_ASSERT(computeruse_y == 200, "y should be 200"); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 14. clipboardsee/clipboardsue 命令分发 ---------- */ -static volatile int clipboardsee_fired = 0; -static volatile int clipboardsue_fired = 0; -static char clipboardsue_text[256] = ""; - -static ha_status_t test_handler_clipboardsee(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)args; (void)userdata; - clipboardsee_fired = 1; - result->status = 0; - result->output = "clipboard content here"; - return HA_OK; -} - -static ha_status_t test_handler_clipboardsue(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)userdata; - clipboardsue_fired = 1; - if (args) strncpy(clipboardsue_text, args, sizeof(clipboardsue_text) - 1); - result->status = 0; - result->output = "clipboard set"; - return HA_OK; -} - -static void test_clipboard_dispatch(void) { - TEST_BEGIN("Clipboard: clipboardsee + clipboardsue dispatch"); - - mock_server_t *ms = mock_server_start(19899); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"clipboard", "cmd", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "clipboardsee", .handler = test_handler_clipboardsee}, - {.command = "clipboardsue", .handler = test_handler_clipboardsue}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19899", - .token = "test-token", - .device = { - .device_id = "clip-dev", - .name = "Clipboard Test", - .kind = "computer", - .caps = caps, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - clipboardsee_fired = 0; - clipboardsue_fired = 0; - memset(clipboardsue_text, 0, sizeof(clipboardsue_text)); - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 发送 clipboardsee 命令 */ - mock_send_cmd(ms, "homeagent", "req-cs", "clipboardsee"); - usleep(200000); - ha_client_process(client); - TEST_ASSERT(clipboardsee_fired == 1, "clipboardsee handler not called"); - - /* 发送 clipboardsue 命令 */ - mock_send_cmd(ms, "homeagent", "req-cw", "clipboardsue Hello World"); - usleep(200000); - ha_client_process(client); - TEST_ASSERT(clipboardsue_fired == 1, "clipboardsue handler not called"); - TEST_ASSERT(strcmp(clipboardsue_text, "Hello World") == 0, - "clipboardsue text mismatch"); - - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 15. screensee:带 data URL 的结果回传 ---------- */ -static ha_status_t test_handler_screensee(const char *req_id, const char *args, - ha_cmd_result_t *result, void *userdata) { - (void)req_id; (void)args; (void)userdata; - result->status = 0; - /* 返回 data URL 格式的 base64 图像数据 */ - result->output = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; - return HA_OK; -} - -static void test_screensee_data_url(void) { - TEST_BEGIN("Screensee: handler returns data URL result"); - - mock_server_t *ms = mock_server_start(19900); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"screen", "cmd", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "screensee", .handler = test_handler_screensee}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19900", - .token = "test-token", - .device = { - .device_id = "see-dev", - .name = "Screensee Test", - .kind = "computer", - .caps = caps, - }, - .handlers = handlers, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 发送 screensee 命令 */ - mock_send_cmd(ms, "homeagent", "req-see", "screensee"); - usleep(200000); - ha_client_process(client); - - /* handler 已被调用,且返回了 data URL(无法直接验证回执内容,但 handler 已执行) */ - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ---------- 16. info_json 设备信息 ---------- */ -static void test_info_json(void) { - TEST_BEGIN("Device info_json: included in hello message"); - - mock_server_t *ms = mock_server_start(19901); - TEST_ASSERT(ms != NULL, "mock server start failed"); - - struct transport_ctx tctx; - tctx.sock = -1; - ha_transport_t transport = { - .connect = transport_connect, - .send = transport_send, - .recv = transport_recv, - .close = transport_close, - .ctx = &tctx, - }; - - const char *caps[] = {"camera", NULL}; - ha_cmd_handler_def_t handlers[] = { - {.command = "camerasue", .handler = test_handler_camerasue}, - {.command = NULL}, - }; - - ha_config_t config = { - .transport = transport, - .server = "127.0.0.1:19901", - .token = "test-token", - .device = { - .device_id = "info-dev", - .name = "Info Test", - .kind = "camera", - .caps = caps, - .info_json = "{\"chip\":\"ESP32-S3\",\"psram\":8}", - }, - .handlers = handlers, - .ping_interval = 30, - }; - - ha_client_t *client = ha_client_new(&config); - ha_status_t st = ha_client_start(client); - TEST_ASSERT(st == HA_OK, "client start failed"); - usleep(200000); - - /* 连接成功,info_json 已通过 hello 消息发送 */ - ha_client_stop(client); - ha_client_destroy(client); - mock_server_stop(ms); - - TEST_END(); -} - -/* ================================================================== - * 主函数 - * ================================================================== */ -int main(void) { -#if !defined(_WIN32) && !defined(_WIN64) - signal(SIGPIPE, SIG_IGN); -#endif - - printf("========================================\n"); - printf(" ha_remotedevice 全面测试\n"); - printf("========================================\n\n"); - - /* ---- JSON 解析/构建 ---- */ - printf("[JSON]\n"); - test_json_parser(); - test_json_int(); - test_json_builder(); - test_json_array_builder(); - - /* ---- 工具函数 ---- */ - printf("\n[Utilities]\n"); - test_parse_homeagent(); - test_parse_json(); - test_base64(); - - /* ---- WS 协议 ---- */ - printf("\n[WebSocket]\n"); - test_ws_frame(); - - /* ---- SDK 核心 ---- */ - printf("\n[SDK Core]\n"); - test_version(); - test_client_lifecycle(); - test_command_dispatch(); - - /* ---- 协议功能 ---- */ - printf("\n[Protocol]\n"); - test_event_report(); - test_status_report(); - test_binary_chunked(); - test_unsupported_command(); - test_speech_receive(); - test_binary_via_handler(); - - /* ---- 扩展命令 ---- */ - printf("\n[Extended Commands]\n"); - test_computeruse_dispatch(); - test_clipboard_dispatch(); - test_screensee_data_url(); - test_info_json(); - - /* ---- 汇总 ---- */ - printf("\n========================================\n"); - printf(" 结果: %d passed, %d failed, %d skipped\n", - tests_passed, tests_failed, tests_skipped); - printf("========================================\n"); - - return tests_failed > 0 ? 1 : 0; -} \ No newline at end of file