diff --git a/example/a2a/plg.json b/example/a2a/plg.json index 38beddb..4f64abd 100644 --- a/example/a2a/plg.json +++ b/example/a2a/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "a2a", "name_zh": "A2A 代理通信", "name_en": "A2A Agent Communication", diff --git a/example/a2a/plugin.go b/example/a2a/plugin.go index 4ec7d81..de38389 100644 --- a/example/a2a/plugin.go +++ b/example/a2a/plugin.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" @@ -17,6 +18,7 @@ import ( type Plugin struct { name string sdk *sdk.PluginSDK + srvMu sync.Mutex server *http.Server serverAddr string } @@ -97,7 +99,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // Inbound HTTP server if addr, _ := s.Settings().Get("listen"); addr != nil { if addrStr, ok := addr.(string); ok && addrStr != "" { - p.startServer(addrStr) + if err := p.startServer(addrStr); err != nil { + log.Printf("[%s] start A2A server: %v", p.name, err) + } } } @@ -111,6 +115,8 @@ func (p *Plugin) Stop() error { } func (p *Plugin) stopServer() { + p.srvMu.Lock() + defer p.srvMu.Unlock() if p.server != nil { p.server.Close() p.server = nil @@ -120,7 +126,7 @@ func (p *Plugin) stopServer() { // ---- Inbound HTTP Server ---- -func (p *Plugin) startServer(addr string) { +func (p *Plugin) startServer(addr string) error { mux := http.NewServeMux() mux.HandleFunc("/agent-card", p.handleAgentCard) mux.HandleFunc("/task", p.handleIncomingTask) @@ -128,18 +134,27 @@ func (p *Plugin) startServer(addr string) { listener, err := net.Listen("tcp", addr) if err != nil { - log.Printf("[%s] listen %s: %v", p.name, addr, err) - return + return fmt.Errorf("listen %s: %v", addr, err) } - p.server = &http.Server{Handler: mux} - p.serverAddr = listener.Addr().String() + srv := &http.Server{Handler: mux} + 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, p.serverAddr) - if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed { + 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) { @@ -411,24 +426,21 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) { listen, _ := args["listen"].(string) - if listen == "" { - return "参数 listen 不能为空。设为空字符串可禁用 HTTP 服务。", nil - } + listen = strings.TrimSpace(listen) if err := p.sdk.Settings().Set("listen", listen); err != nil { return fmt.Sprintf("保存配置失败: %v", err), nil } - p.stopServer() - if listen != "" { - p.startServer(listen) + if listen == "" || listen == "off" || listen == "disabled" { + p.stopServer() + return "A2A HTTP 服务已禁用(listen 设为空)", nil } - status := "已启动" - if listen == "" { - status = "已禁用" + if err := p.startServer(listen); err != nil { + return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil } - return fmt.Sprintf("A2A 配置已更新。监听地址: %s (%s)", listen, status), nil + return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil } func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) { @@ -436,23 +448,28 @@ func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) addr, _ := p.sdk.Settings().Get("listen") addrStr, _ := addr.(string) - if addrStr == "" { + if addrStr == "" || addrStr == "off" || addrStr == "disabled" { return "A2A 服务未配置监听地址(listen 为空),无法启动", nil } - p.startServer(addrStr) - if p.server == nil { - return fmt.Sprintf("A2A 服务启动失败,请检查监听地址: %s", addrStr), nil + if err := p.startServer(addrStr); err != nil { + return fmt.Sprintf("A2A 服务启动失败: %v", err), nil } - return fmt.Sprintf("A2A 服务已重启,监听: %s", p.serverAddr), 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 = "未运行" } diff --git a/example/acp/go.mod b/example/acp/go.mod new file mode 100644 index 0000000..4e6d3fa --- /dev/null +++ b/example/acp/go.mod @@ -0,0 +1,7 @@ +module acp + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../../ \ No newline at end of file diff --git a/example/acp/main.go b/example/acp/main.go new file mode 100644 index 0000000..67c3a3c --- /dev/null +++ b/example/acp/main.go @@ -0,0 +1,11 @@ +//go:build !windows || !cgo + +package main + +import ( + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return NewPluginFactory(name, config) +} diff --git a/example/acp/plg.json b/example/acp/plg.json new file mode 100644 index 0000000..52df12c --- /dev/null +++ b/example/acp/plg.json @@ -0,0 +1,15 @@ +{ + "name": "acp", + "name_zh": "ACP 代理通信", + "name_en": "ACP Agent Client Protocol", + "version": "1.0.0", + "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/example/acp/plugin.go b/example/acp/plugin.go new file mode 100644 index 0000000..7fb7d69 --- /dev/null +++ b/example/acp/plugin.go @@ -0,0 +1,528 @@ +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{} +} + +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) + tp := p.name + "_" + + 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 的任务描述"}, + "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"` + 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 + } + + sid := fmt.Sprintf("session_%d", time.Now().UnixNano()) + p.mu.Lock() + p.sessions[sid] = &sessionState{ID: sid} + p.mu.Unlock() + + if p.sdk != nil { + p.sdk.InjectInterruptText(p.name, "acp", + fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, 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": map[string]interface{}{"id": sid}, + }, + }) + + case "session/update": + sid := req.Params.SessionID + p.mu.Lock() + st := p.sessions[sid] + if st != nil && req.Params.Final { + st.Replying = append(st.Replying, map[string]interface{}{ + "type": "reply", "text": "done", + }) + } + 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 + } + 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} + + newBody, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()), + "method": "session/new", + "params": map[string]interface{}{ + "request": map[string]interface{}{"text": prompt}, + }, + }) + + 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, + }, 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/example/ai_image/plg.json b/example/ai_image/plg.json index 0dbbdb7..f2fab0f 100644 --- a/example/ai_image/plg.json +++ b/example/ai_image/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "ai_image", "name_zh": "AI绘图", "name_en": "AI Image", diff --git a/example/bili/plg.json b/example/bili/plg.json index 1459568..37ce886 100644 --- a/example/bili/plg.json +++ b/example/bili/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "bili", "name_zh": "B站视频下载", "name_en": "Bilibili Video Downloader", diff --git a/example/bili/plugin.go b/example/bili/plugin.go index d764917..c49b16b 100644 --- a/example/bili/plugin.go +++ b/example/bili/plugin.go @@ -8,13 +8,15 @@ import ( "os/exec" "path/filepath" "strings" + "time" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) type Plugin struct { - name string - sdk *sdk.PluginSDK + name string + sdk *sdk.PluginSDK + proxy string } func (p *Plugin) Name() string { return p.name } @@ -30,6 +32,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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", @@ -101,7 +114,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro cmd := exec.Command("yt-dlp", ytdlpArgs...) cmd.Stdout = &out cmd.Stderr = &out - cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") + cmd.Env = proxyEnv(p.proxy) if err := cmd.Run(); err != nil { return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) } @@ -171,12 +184,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro 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(outputDir, "%(title)s.%(ext)s"), + "-o", filepath.Join(taskDir, "%(title)s.%(ext)s"), "--no-overwrites", } if format != "" { @@ -184,7 +202,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro } dlArgs = append(dlArgs, url) cmd2 := exec.Command("yt-dlp", dlArgs...) - cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") + cmd2.Env = proxyEnv(p.proxy) var dlOut bytes.Buffer cmd2.Stdout = &dlOut cmd2.Stderr = &dlOut @@ -192,9 +210,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) } - entries, _ := os.ReadDir(outputDir) - var newest string - var newestTime int64 + 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 @@ -203,30 +230,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro if fi == nil { continue } - t := fi.ModTime().Unix() - if t > newestTime { - newestTime = t - newest = e.Name() + if fi.Size() > mainSize { + mainSize = fi.Size() + mainFile = e.Name() } } - if newest == "" { + if mainFile == "" { return map[string]interface{}{ "content": "下载完成,但未找到视频文件", }, nil } - dlPath := filepath.Join(outputDir, newest) - fi, _ := os.Stat(dlPath) - var fileSize int64 - if fi != nil { - fileSize = fi.Size() - } + dlPath := filepath.Join(taskDir, mainFile) return map[string]interface{}{ - "content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath), + "content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath), "file": dlPath, - "filename": newest, + "filename": mainFile, }, nil } +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 { diff --git a/example/browser/plg.json b/example/browser/plg.json index e5a37db..a75e94a 100644 --- a/example/browser/plg.json +++ b/example/browser/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "browser", "name_zh": "浏览器", "name_en": "Browser", diff --git a/example/browser/plugin.go b/example/browser/plugin.go index 383def9..ef0d0d3 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -37,6 +37,7 @@ type Plugin struct { nextID int wg sync.WaitGroup stopCh chan struct{} + stopOnce sync.Once } type BrowserSession struct { @@ -273,7 +274,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { "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 或 jpeg(默认 png)"}, + "format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"}, }, "required": []string{"id"}, }, @@ -356,18 +357,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } func (p *Plugin) Stop() error { - 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) + 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 } @@ -665,6 +668,9 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) 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))) if waitSec > 0 { time.Sleep(time.Duration(waitSec) * time.Second) @@ -766,7 +772,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e return errResult("navigate failed: " + err.Error()), nil } session.currentURL = initURL - p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL)) + p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL)) } log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout) @@ -807,7 +813,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error return errResult("navigate failed: " + err.Error()), nil } s.currentURL = rawURL - p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL)) + p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL)) return map[string]interface{}{"status": "ok", "url": rawURL}, nil } @@ -825,6 +831,9 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err full = v } format := readArg(args, "format", "png") + if format != "png" { + return errResult("仅支持 png 格式"), nil + } var buf []byte var err error if full { @@ -841,7 +850,7 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err "format": format, "size": len(buf), "base64": b64, - "data_uri": fmt.Sprintf("data:image/%s;base64,%s", format, b64), + "data_uri": fmt.Sprintf("data:image/png;base64,%s", b64), }, nil } @@ -1001,9 +1010,9 @@ func (p *Plugin) cleanupLoop() { for id, s := range p.sessions { if time.Since(s.createdAt) >= s.timeout { log.Printf("[%s] cleanup: browser session %s expired", p.name, id) - delete(p.sessions, id) - go s.Close() - p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id)) + delete(p.sessions, id) + s.Close() + p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id)) } } p.mu.Unlock() diff --git a/example/calendar/plugin.go b/example/calendar/plugin.go index 652b657..766006d 100644 --- a/example/calendar/plugin.go +++ b/example/calendar/plugin.go @@ -134,6 +134,18 @@ func readArg[T string | int64 | float64](args map[string]interface{}, key string 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{ @@ -264,12 +276,14 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s - dataHome := os.Getenv("HOME") - if dataHome == "" { - dataHome = "/tmp" + 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.dataDir = filepath.Join(dataHome, ".homeagent", "calendar") - os.MkdirAll(p.dataDir, 0755) p.loadEvents() // 持久化交由 stop handler:内核会在调用 Stop() 之前执行, @@ -414,9 +428,9 @@ func (p *Plugin) checkReminders() { now := time.Now() p.mu.Lock() - defer p.mu.Unlock() changed := false + var injectMsgs []string for i := range p.events { e := &p.events[i] @@ -458,7 +472,7 @@ func (p *Plugin) checkReminders() { if e.Note != "" { msg += fmt.Sprintf("\n📝 %s", e.Note) } - go p.sdk.InjectInterruptText("calendar", "calendar", msg) + injectMsgs = append(injectMsgs, msg) } } @@ -503,6 +517,11 @@ func (p *Plugin) checkReminders() { if changed { p.saveEventsLocked() } + p.mu.Unlock() + + for _, msg := range injectMsgs { + p.sdk.InjectInterruptText("calendar", "calendar", msg) + } } func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent { @@ -713,10 +732,7 @@ func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error note := readArg(args, "note", "") remindStr := readArg(args, "remind_before", "") reminds := parseReminds(remindStr) - lunar := false - if v := readArg(args, "lunar", ""); v == "true" { - lunar = true - } + lunar := readArgBool(args, "lunar") lunarMonth := int(readArg(args, "lunar_month", int64(0))) lunarDay := int(readArg(args, "lunar_day", int64(0))) @@ -918,10 +934,12 @@ func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, er e.Repeat = v } } - if v := readArg(args, "lunar", ""); v == "true" { - e.Lunar = true - } else if v == "false" { - e.Lunar = false + 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) diff --git a/example/editdoc/plg.json b/example/editdoc/plg.json index 116992e..c96fa43 100644 --- a/example/editdoc/plg.json +++ b/example/editdoc/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "editdoc", "name_zh": "文档编辑", "name_en": "Document Editor", diff --git a/example/editdoc/plugin.go b/example/editdoc/plugin.go index aee26f1..6d38d58 100644 --- a/example/editdoc/plugin.go +++ b/example/editdoc/plugin.go @@ -4,15 +4,19 @@ import ( "bytes" "encoding/json" "fmt" + "log" "os" "os/exec" + "path/filepath" "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) type Plugin struct { - name string - sdk *sdk.PluginSDK + name string + sdk *sdk.PluginSDK + scriptPath string + venvPython string } func (p *Plugin) Name() string { return p.name } @@ -20,6 +24,30 @@ 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: "script_path", Default: "", Type: "string", + DisplayName: "编辑脚本路径", + Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py", + Category: p.name, + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "venv_python", Default: "", Type: "string", + DisplayName: "venv Python 解释器", + Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错", + Category: p.name, + }) + + if v, err := s.Settings().Get("script_path"); err == nil { + if str, ok := v.(string); ok { + p.scriptPath = str + } + } + if v, err := s.Settings().Get("venv_python"); err == nil { + if str, ok := v.(string); ok { + p.venvPython = str + } + } s.RegisterTool("edit_document", sdk.ToolDef{ Name: "edit_document", Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。", @@ -80,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e } pyArgsJSON, _ := json.Marshal(pyArgs) - scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py" + scriptPath := p.scriptPath + if scriptPath == "" { + scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py") + log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath) + } if _, err := os.Stat(scriptPath); os.IsNotExist(err) { - return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath) + return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath) } - venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3" - pythonBin := "python3" - if _, err := os.Stat(venvPython); err == nil { - pythonBin = venvPython + if p.venvPython == "" { + return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)") + } + if _, err := os.Stat(p.venvPython); err != nil { + return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython) } var out bytes.Buffer - cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON)) + cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON)) cmd.Stdout = &out if err := cmd.Run(); err != nil { return nil, fmt.Errorf("edit document: %w", err) diff --git a/example/files/plg.json b/example/files/plg.json index 9d76fc0..55d1bf8 100644 --- a/example/files/plg.json +++ b/example/files/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "files", "name_zh": "文件系统", "name_en": "File System", diff --git a/example/files/plugin.go b/example/files/plugin.go index e73b942..a63d756 100644 --- a/example/files/plugin.go +++ b/example/files/plugin.go @@ -27,24 +27,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s s.Settings().RegisterDef(sdk.ConfigDef{ Key: "dir", - Default: "/", + Default: "", Type: "string", DisplayName: "文件系统根目录", - Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)", + Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox),不建议设为 /", Category: "files", }) - dir := getSetting[string](s.Settings(), "dir", "/") + dir := getSetting[string](s.Settings(), "dir", "") if strings.HasPrefix(dir, "~/") { home, _ := os.UserHomeDir() dir = filepath.Join(home, dir[2:]) } + if dir == "" { + dataDir, err := s.Settings().GetCore("core.daemon.data_dir") + base := "." + if err == nil { + if ds, ok := dataDir.(string); ok && ds != "" { + base = ds + } + } + dir = filepath.Join(base, "files_sandbox") + } abs, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("resolve files.dir: %w", err) } + if err := os.MkdirAll(abs, 0755); err != nil { + return fmt.Errorf("mkdir files.dir: %w", err) + } + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } p.filesDir = abs - os.MkdirAll(p.filesDir, 0755) tp := p.name + "_" @@ -143,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) { return "", fmt.Errorf("resolve path: %w", err) } base := filepath.Clean(p.filesDir) - if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { + if !withinSandbox(base, abs) { return "", fmt.Errorf("path outside sandbox: %s", userPath) } - return abs, nil + real, err := evalReal(base, abs) + if err != nil { + return "", err + } + if !withinSandbox(base, real) { + return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath) + } + return real, nil +} + +func withinSandbox(base, abs string) bool { + if base == "/" { + return true + } + return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator)) +} + +func evalReal(base, abs string) (string, error) { + existing := abs + var tail []string + for { + real, err := filepath.EvalSymlinks(existing) + if err == nil { + full := real + for i := len(tail) - 1; i >= 0; i-- { + full = filepath.Join(full, tail[i]) + } + return full, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("resolve path: %w", err) + } + if link, lerr := os.Readlink(existing); lerr == nil { + target := link + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(existing), target) + } + if t, aerr := filepath.Abs(target); aerr == nil { + target = filepath.Clean(t) + } + if !withinSandbox(base, target) { + return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs) + } + } + parent := filepath.Dir(existing) + if parent == existing { + return "", fmt.Errorf("resolve path: %w", err) + } + tail = append(tail, filepath.Base(existing)) + existing = parent + } } // handleRead implements the read tool. diff --git a/example/memo/plugin.go b/example/memo/plugin.go index 70f7323..be080d0 100644 --- a/example/memo/plugin.go +++ b/example/memo/plugin.go @@ -48,13 +48,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s p.tp = p.name + "_" - p.stopCh = make(chan struct{}) dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") if err != nil || dataDirVal == "" { dataDirVal = "." } - dir := fmt.Sprint(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() @@ -97,6 +99,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, 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", @@ -204,18 +218,22 @@ func (p *Plugin) loadMemos() { } func (p *Plugin) saveTodos() { + p.mu.RLock() data, _ := json.MarshalIndent(map[string]interface{}{ "todos": p.todos, "next_id": p.nextTID, }, "", " ") + p.mu.RUnlock() os.WriteFile(p.todoPath, data, 0644) } func (p *Plugin) saveMemos() { + p.mu.RLock() data, _ := json.MarshalIndent(map[string]interface{}{ "memos": p.memos, "next_id": p.nextMID, }, "", " ") + p.mu.RUnlock() os.WriteFile(p.memoPath, data, 0644) } @@ -357,6 +375,33 @@ func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error }, 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) { @@ -443,7 +488,7 @@ func errorResult(msg string) map[string]interface{} { } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { - return &Plugin{name: name}, nil + return &Plugin{name: name, stopCh: make(chan struct{})}, nil } // cleanupData 卸载时清理数据文件(待办 + 备忘) diff --git a/example/music/plg.json b/example/music/plg.json index 07b2e2f..ce361b5 100644 --- a/example/music/plg.json +++ b/example/music/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "music", "name_zh": "音乐搜索", "name_en": "Music Search", diff --git a/example/ocr/plg.json b/example/ocr/plg.json index f71105a..d1e9404 100644 --- a/example/ocr/plg.json +++ b/example/ocr/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "ocr", "name_zh": "OCR 文字识别", "name_en": "OCR Text Recognition", diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 14e82a7..88bdb83 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "crypto/hmac" "encoding/base64" "encoding/binary" "encoding/json" @@ -32,7 +33,7 @@ type ForwardRule struct { } func rconSend(host string, port int, password, cmd string) error { - addr := fmt.Sprintf("%s:%d", host, port) + 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) @@ -66,7 +67,7 @@ func rconPacket(id, typ int32, body string) []byte { b = append(b, 0) // null terminator b = append(b, 0) // padding length := 4 + 4 + len(b) - pkt := make([]byte, 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)) @@ -90,7 +91,8 @@ type Plugin struct { napcatURL string remoteDir string filesDir string - adminID int64 + webhookToken string + adminIDs []int64 botID int64 botNickname string dmPolicy string @@ -119,7 +121,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", 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"}}) @@ -127,13 +129,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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"}) 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"), "/") - p.adminID = getSetting[int64](settings, "admin", 0) + p.adminIDs = parseIDList(getSetting[string](settings, "admin", "")) 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", "")) @@ -279,7 +283,7 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 NoMemory: false, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, }, }, @@ -312,19 +316,19 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 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号(踢人/禁言/设名片等需要)"}, + "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)"}, + "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"}, + "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, @@ -334,12 +338,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 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可选)"}, + "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"}, + "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, }, }, NoMemory: true, @@ -352,12 +356,12 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 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"}, + "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可选)"}, + "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) @@ -454,6 +458,15 @@ type 枚举: text(文字)/ voice(语音转文字后发送)/ image(图 } func (p *Plugin) Stop() error { + p.typingMu.Lock() + for _, st := range p.typingMap { + select { + case <-st.stopCh: + default: + close(st.stopCh) + } + } + p.typingMu.Unlock() if p.srv != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -475,8 +488,8 @@ func (p *Plugin) fetchBotInfo() { return } var info struct { - Status string `json:"status"` - Data *struct { + Status string `json:"status"` + Data *struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` } `json:"data"` @@ -563,6 +576,29 @@ func parseIDSet(raw string) map[int64]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) + } + } + return out +} + +func (p *Plugin) isAdmin(userID int64) bool { + for _, id := range p.adminIDs { + if id == userID { + return true + } + } + return false +} + // isAtBot checks if the message contains an @-mention of the bot. func (p *Plugin) isAtBot(msg interface{}) bool { segments, ok := msg.([]interface{}) @@ -616,7 +652,7 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool { case "allowlist": _, ok := p.groupAllowFrom[groupID] return ok - default: + default: return true } } @@ -628,11 +664,6 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error { return nil } tc := &ctx.ToolCalls[0] - if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" { - if file, ok := tc.Arguments["file"].(string); ok { - tc.Arguments["file"] = p.sensitiveFilter(file) - } - } if tc.Name == p.name+"_group_manage" { cmd, _ := tc.Arguments["command"].(string) if requiresConfirmGroupCommand(cmd) { @@ -679,6 +710,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { 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"` @@ -745,10 +780,22 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { } else { interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool) } - if p.adminID > 0 && evt.UserID == p.adminID { + if p.isAdmin(evt.UserID) { interrupt = "【重要!老大消息】" + interrupt } + if text != "" { + text = stripCQRe.ReplaceAllString(text, "") + text = strings.TrimSpace(text) + } + if text == "" { + w.WriteHeader(http.StatusOK) + return + } + if highRiskRe.MatchString(text) { + interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt + } + if evt.MessageType == "group" && p.sdk != nil { rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]") var rules []ForwardRule @@ -943,7 +990,7 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, payload, _ := args["payload"].(string) rawType, _ := args["type"].(string) meta, _ := args["meta"].(string) - log.Printf("[qq] handleChannelOutput payload=%q type=%s meta=%s", payload, rawType, meta) + log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta) if payload == "" || rawType == "" { return nil, fmt.Errorf("payload 和 type 参数不能为空") } @@ -1108,7 +1155,7 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error if name == "" { name = filepath.Base(filePath) } - name = p.sensitiveFilter(name) + name = sanitizeFilename(name) asImage, _ := args["as_image"].(bool) // copy to remote dir for NapCat container access @@ -1312,7 +1359,7 @@ func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{} } keyword = strings.ToLower(keyword) - gid, groupErr := convInt64(args["group_id"]) + 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 { @@ -1526,20 +1573,11 @@ func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, e // delete friend p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) // kick from groups - if gid, err := convInt64(args["group_id"]); err == nil { - p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) - } else { - grps, _ := p.napcat("get_group_list", map[string]interface{}{}) - if list, ok := grps.([]interface{}); ok { - for _, g := range list { - if m, ok := g.(map[string]interface{}); ok { - if gid, ok := m["group_id"].(float64); ok { - p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true}) - } - } - } - } + 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) @@ -1574,6 +1612,7 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, 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 { @@ -1701,7 +1740,7 @@ func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{} if name == "" { name = filepath.Base(filePath) } - name = p.sensitiveFilter(name) + name = sanitizeFilename(name) data, err := os.ReadFile(filePath) if err != nil { @@ -2000,8 +2039,8 @@ func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, e result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) } return map[string]interface{}{ - "content": result, - "file": path, + "content": result, + "file": path, "truncated": truncated, }, nil } @@ -2218,6 +2257,8 @@ func rawString(v interface{}) (string, bool) { 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 != "" { @@ -2260,9 +2301,3 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e groupPolicy: "open", }, nil } - - - - - - diff --git a/example/rss/plg.json b/example/rss/plg.json index 3e2a1bf..9dced7f 100644 --- a/example/rss/plg.json +++ b/example/rss/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "rss", "name_zh": "RSS订阅", "name_en": "RSS", diff --git a/example/rss/plugin.go b/example/rss/plugin.go index 4a703f1..b30beac 100644 --- a/example/rss/plugin.go +++ b/example/rss/plugin.go @@ -16,6 +16,8 @@ import ( "github.com/mmcdole/gofeed" ) +const injectDedupWindow = 5 * time.Minute + type FeedSub struct { URL string `json:"url"` Title string `json:"title"` @@ -32,7 +34,9 @@ type Plugin struct { 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 } @@ -103,14 +107,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { 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{} - dataHome := os.Getenv("HOME") - if dataHome == "" { - dataHome = "/tmp" + 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.dataDir = filepath.Join(dataHome, ".homeagent", "rss") - os.MkdirAll(p.dataDir, 0755) p.loadData() // 卸载(删除)时清理订阅数据目录;重载不触发 @@ -179,7 +186,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } func (p *Plugin) Stop() error { - close(p.stopCh) + p.stopOnce.Do(func() { close(p.stopCh) }) p.pollTicker.Stop() p.wg.Wait() p.saveData() @@ -251,9 +258,34 @@ func (p *Plugin) checkFeed(sub FeedSub) { return } - var lines []string - lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(newArticles))) + 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") @@ -269,19 +301,6 @@ func (p *Plugin) checkFeed(sub FeedSub) { } p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n")) - - p.mu.Lock() - for _, item := range newArticles { - guid := item.GUID - if guid == "" { - guid = item.Link - } - if guid == "" { - continue - } - p.seenGUIDs[sub.URL+"|"+guid] = true - } - p.mu.Unlock() p.saveData() } @@ -323,6 +342,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro } guidCount := 0 + p.mu.Lock() for _, item := range parsed.Items { guid := item.GUID if guid == "" { @@ -334,6 +354,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro p.seenGUIDs[url+"|"+guid] = true guidCount++ } + p.mu.Unlock() p.mu.Lock() p.feeds = append(p.feeds, sub) @@ -398,7 +419,16 @@ func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) { } func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) { - go p.checkAllFeeds() + 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 } @@ -442,8 +472,16 @@ func (p *Plugin) saveData() { // cleanupData 卸载时清理订阅数据目录(feeds.json 等) func (p *Plugin) cleanupData() { - if p.dataDir != "" { - os.RemoveAll(p.dataDir) + 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) + } } } diff --git a/example/sanitizer/plg.json b/example/sanitizer/plg.json index 6f75765..7265c7d 100644 --- a/example/sanitizer/plg.json +++ b/example/sanitizer/plg.json @@ -1,4 +1,4 @@ -{ +{ "name": "sanitizer", "name_zh": "输出清洗", "name_en": "sanitizer", diff --git a/example/sanitizer/plugin.go b/example/sanitizer/plugin.go index de94b18..000323b 100644 --- a/example/sanitizer/plugin.go +++ b/example/sanitizer/plugin.go @@ -27,7 +27,9 @@ var ( toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```") chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`) multiNewlineRE = regexp.MustCompile(`\n{3,}`) - toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`) + toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`) + placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[::][^}]*\}\}`) + atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`) ) type Plugin struct{} @@ -83,8 +85,12 @@ func cleanToolCallLeakage(content string) string { cleaned = append(cleaned, line) continue } - if toolNameRE.MatchString(trimmed) { - if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") { + if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) { + continue + } + if m := toolNameRE.FindStringIndex(trimmed); m != nil { + rest := trimmed[m[1]:] + if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") { continue } } diff --git a/example/vanblog/go.mod b/example/vanblog/go.mod new file mode 100644 index 0000000..1acf25e --- /dev/null +++ b/example/vanblog/go.mod @@ -0,0 +1,7 @@ +module vanblog-plugin + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../../ diff --git a/example/vanblog/plg.json b/example/vanblog/plg.json new file mode 100644 index 0000000..36b8de0 --- /dev/null +++ b/example/vanblog/plg.json @@ -0,0 +1,11 @@ +{ + "name": "vanblog", + "name_zh": "VanBlog 博客管理", + "name_en": "VanBlog", + "version": "1.0.0", + "description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["blog", "vanblog", "cms"], + "targets": "linux/amd64" +} \ No newline at end of file diff --git a/example/vanblog/plugin.go b/example/vanblog/plugin.go new file mode 100644 index 0000000..4aeebb7 --- /dev/null +++ b/example/vanblog/plugin.go @@ -0,0 +1,1334 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "os" + "strings" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + http *http.Client + baseURL string + token string + resetToken string +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +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(s sdk.SettingsAPI, key string, fallback string) string { + v, err := s.Get(key) + if err != nil || v == nil { + return fallback + } + if sv, ok := v.(string); ok { + return sv + } + 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 readArgBool(args map[string]interface{}, key string) *bool { + if v, ok := args[key]; ok { + if b, ok := v.(bool); ok { + return &b + } + } + return nil +} + +func errResult(msg string) map[string]interface{} { + return map[string]interface{}{"isError": true, "content": msg} +} + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + s.SetAutoRestart(true) + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "url", Default: "https://blog.jianfgit.xyz", Type: "string", + DisplayName: "VanBlog URL", Description: "VanBlog 站点基地址", + Category: "vanblog", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "token", Default: "", Type: "password", + DisplayName: "API Token", Description: "管理员 API Token(长期令牌,从后台 Token 管理创建)", + Category: "vanblog", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "reset_token", Default: "", Type: "password", + DisplayName: "重置密码 Token", Description: "用于 auth/restore 重置管理员密码的特殊 Token", + Category: "vanblog", + }) + + p.http = &http.Client{Timeout: 30 * time.Second} + p.baseURL = strings.TrimRight(getSetting(s.Settings(), "url", "https://blog.jianfgit.xyz"), "/") + p.token = getSetting(s.Settings(), "token", "") + p.resetToken = getSetting(s.Settings(), "reset_token", "") + + if p.token == "" { + if coreVal, err := s.Settings().GetCore("plugin.vanblog.token"); err == nil && coreVal != nil { + if sv, ok := coreVal.(string); ok && sv != "" { + p.token = sv + s.Settings().Set("token", sv) + s.Settings().SetCore("plugin.vanblog.token", nil) + log.Printf("[vanblog] migrated token from core config to plugin config") + } + } + } + + tp := p.name + "_" + + s.RegisterTool(tp+"list_articles", sdk.ToolDef{ + Name: tp + "list_articles", Description: "列出 VanBlog 文章,支持分页和搜索", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "page": map[string]interface{}{"type": "integer", "description": "页码从1开始", "default": 1}, + "pageSize": map[string]interface{}{"type": "integer", "description": "每页条数", "default": 50}, + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词"}, + "category": map[string]interface{}{"type": "string", "description": "按分类筛选"}, + "tag": map[string]interface{}{"type": "string", "description": "按标签筛选"}, + "sort": map[string]interface{}{"type": "string", "description": "排序: newest|oldest"}, + }, + }, + }, p.handleListArticles) + + s.RegisterTool(tp+"get_article", sdk.ToolDef{ + Name: tp + "get_article", Description: "获取单篇文章完整内容", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "文章 ID 或 pathname"}, + }, "required": []string{"id"}, + }, + }, p.handleGetArticle) + + s.RegisterTool(tp+"create_article", sdk.ToolDef{ + Name: tp + "create_article", Description: "新建文章,title 和 category 必填", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "title": map[string]interface{}{"type": "string", "description": "标题"}, + "content": map[string]interface{}{"type": "string", "description": "Markdown 正文"}, + "category": map[string]interface{}{"type": "string", "description": "分类"}, + "tags": map[string]interface{}{"type": "string", "description": "标签逗号分隔"}, + "pinned": map[string]interface{}{"type": "boolean", "description": "置顶"}, + }, "required": []string{"title", "category"}, + }, + }, p.handleCreateArticle) + + s.RegisterTool(tp+"update_article", sdk.ToolDef{ + Name: tp + "update_article", Description: "更新文章,只传需改字段", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "文章 ID"}, + "title": map[string]interface{}{"type": "string", "description": "新标题"}, + "content": map[string]interface{}{"type": "string", "description": "新正文"}, + "category": map[string]interface{}{"type": "string", "description": "新分类"}, + "tags": map[string]interface{}{"type": "string", "description": "新标签逗号分隔"}, + "pinned": map[string]interface{}{"type": "boolean", "description": "置顶"}, + }, "required": []string{"id"}, + }, + }, p.handleUpdateArticle) + + s.RegisterTool(tp+"delete_article", sdk.ToolDef{ + Name: tp + "delete_article", Description: "删除文章(软删除)", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "文章 ID"}, + }, "required": []string{"id"}, + }, + }, p.handleDeleteArticle) + + s.RegisterTool(tp+"search_articles", sdk.ToolDef{ + Name: tp + "search_articles", Description: "按链接搜索文章", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "link": map[string]interface{}{"type": "string", "description": "文章链接"}, + }, "required": []string{"link"}, + }, + }, p.handleSearchArticles) + + s.RegisterTool(tp+"manage_drafts", sdk.ToolDef{ + Name: tp + "manage_drafts", + Description: "管理草稿。命令: list|get|create|update|delete|publish", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|get|create|update|delete|publish"}, + "id": map[string]interface{}{"type": "string", "description": "草稿ID(get/update/delete/publish需要)"}, + "title": map[string]interface{}{"type": "string", "description": "标题(create/update)"}, + "content": map[string]interface{}{"type": "string", "description": "正文(create/update)"}, + "category": map[string]interface{}{"type": "string", "description": "分类(create/update)"}, + "tags": map[string]interface{}{"type": "string", "description": "标签逗号分隔(create/update)"}, + "page": map[string]interface{}{"type": "integer", "description": "页码(list)"}, + "pageSize": map[string]interface{}{"type": "integer", "description": "每页条数(list)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageDrafts) + + s.RegisterTool(tp+"manage_categories", sdk.ToolDef{ + Name: tp + "manage_categories", + Description: "管理分类。命令: list|get|create|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|get|create|update|delete"}, + "name": map[string]interface{}{"type": "string", "description": "分类名(get/create/update/delete需要)"}, + "newName": map[string]interface{}{"type": "string", "description": "新名称(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageCategories) + + s.RegisterTool(tp+"manage_tags", sdk.ToolDef{ + Name: tp + "manage_tags", + Description: "管理标签。命令: list|get|rename|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|get|rename|delete"}, + "name": map[string]interface{}{"type": "string", "description": "标签名"}, + "newName": map[string]interface{}{"type": "string", "description": "新名称(rename)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageTags) + + s.RegisterTool(tp+"manage_images", sdk.ToolDef{ + Name: tp + "manage_images", + Description: "管理图床图片。命令: list|all|upload|scan|export|delete|delete_all", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|all|upload|scan|export|delete|delete_all"}, + "page": map[string]interface{}{"type": "integer", "description": "页码(list)"}, + "pageSize": map[string]interface{}{"type": "integer", "description": "每页条数(list)"}, + "file": map[string]interface{}{"type": "string", "description": "本地文件路径(upload)"}, + "sign": map[string]interface{}{"type": "string", "description": "图片签名(delete)"}, + "type": map[string]interface{}{"type": "string", "description": "图片类型(upload: favicon/watermark/空)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageImages) + + s.RegisterTool(tp+"manage_links", sdk.ToolDef{ + Name: tp + "manage_links", + Description: "管理友情链接。命令: list|create|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|create|update|delete"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + "name": map[string]interface{}{"type": "string", "description": "链接名(delete)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageLinks) + + s.RegisterTool(tp+"manage_social", sdk.ToolDef{ + Name: tp + "manage_social", + Description: "管理社交链接。命令: list|types|create|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|types|create|update|delete"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + "stype": map[string]interface{}{"type": "string", "description": "社交类型(delete)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageSocial) + + s.RegisterTool(tp+"manage_rewards", sdk.ToolDef{ + Name: tp + "manage_rewards", + Description: "管理打赏设置。命令: list|create|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|create|update|delete"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + "name": map[string]interface{}{"type": "string", "description": "打赏项名(delete)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageRewards) + + s.RegisterTool(tp+"manage_pages", sdk.ToolDef{ + Name: tp + "manage_pages", + Description: "管理自定义页面。命令: list|get|create|update|delete|folder|file|upload|create_file|create_folder|update_file", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|get|create|update|delete|folder|file|upload|create_file|create_folder|update_file"}, + "path": map[string]interface{}{"type": "string", "description": "页面路径"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + "folder": map[string]interface{}{"type": "string", "description": "文件夹路径(folder/file相关)"}, + "file": map[string]interface{}{"type": "string", "description": "文件路径(file/upload相关)"}, + "content": map[string]interface{}{"type": "string", "description": "文件内容(create_file/update_file)"}, + }, "required": []string{"command"}, + }, + }, p.handleManagePages) + + s.RegisterTool(tp+"manage_settings", sdk.ToolDef{ + Name: tp + "manage_settings", + Description: "管理站点设置。命令: get_static|set_static|get_waline|set_waline|get_layout|set_layout|get_login|set_login", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get_static|set_static|get_waline|set_waline|get_layout|set_layout|get_login|set_login"}, + "data": map[string]interface{}{"type": "string", "description": "JSON设置数据(set_*命令)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageSettings) + + s.RegisterTool(tp+"manage_about", sdk.ToolDef{ + Name: tp + "manage_about", + Description: "管理关于页。命令: get|update", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get|update"}, + "content": map[string]interface{}{"type": "string", "description": "关于页内容(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageAbout) + + s.RegisterTool(tp+"manage_site", sdk.ToolDef{ + Name: tp + "manage_site", + Description: "管理站点信息。命令: get|update", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get|update"}, + "data": map[string]interface{}{"type": "string", "description": "JSON站点数据(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageSite) + + s.RegisterTool(tp+"manage_menu", sdk.ToolDef{ + Name: tp + "manage_menu", + Description: "管理导航菜单。命令: get|update", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get|update"}, + "data": map[string]interface{}{"type": "string", "description": "JSON菜单数据(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageMenu) + + s.RegisterTool(tp+"get_analysis", sdk.ToolDef{ + Name: tp + "get_analysis", Description: "获取访问统计数据", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "tab": map[string]interface{}{"type": "string", "description": "overview|viewer|article", "default": "overview"}, + }, + }, + }, p.handleGetAnalysis) + + s.RegisterTool(tp+"get_logs", sdk.ToolDef{ + Name: tp + "get_logs", Description: "获取系统日志", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "page": map[string]interface{}{"type": "integer", "description": "页码"}, + "pageSize": map[string]interface{}{"type": "integer", "description": "每页条数"}, + "event": map[string]interface{}{"type": "string", "description": "按事件类型筛选"}, + }, + }, + }, p.handleGetLogs) + + s.RegisterTool(tp+"manage_backup", sdk.ToolDef{ + Name: tp + "manage_backup", + Description: "备份管理。命令: export|import", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "export|import"}, + "file": map[string]interface{}{"type": "string", "description": "备份JSON文件路径(import)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageBackup) + + s.RegisterTool(tp+"manage_caddy", sdk.ToolDef{ + Name: tp + "manage_caddy", + Description: "管理Caddy配置。命令: get_https|set_https|get_log|clear_log|get_config", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get_https|set_https|get_log|clear_log|get_config"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(set_https)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageCaddy) + + s.RegisterTool(tp+"manage_isr", sdk.ToolDef{ + Name: tp + "manage_isr", + Description: "ISR增量渲染。命令: get|trigger|update", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "get|trigger|update"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageISR) + + s.RegisterTool(tp+"manage_pipelines", sdk.ToolDef{ + Name: tp + "manage_pipelines", + Description: "管理流水线。命令: list|get|config|create|trigger|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|get|config|create|trigger|update|delete"}, + "id": map[string]interface{}{"type": "string", "description": "流水线ID(get/trigger/update/delete)"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + }, "required": []string{"command"}, + }, + }, p.handleManagePipelines) + + s.RegisterTool(tp+"manage_collaborators", sdk.ToolDef{ + Name: tp + "manage_collaborators", + Description: "管理协作者。命令: list|list_all|create|update|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|list_all|create|update|delete"}, + "data": map[string]interface{}{"type": "string", "description": "JSON数据(create/update)"}, + "id": map[string]interface{}{"type": "string", "description": "协作者ID(delete)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageCollaborators) + + s.RegisterTool(tp+"manage_tokens", sdk.ToolDef{ + Name: tp + "manage_tokens", + Description: "管理API Token。命令: list|create|delete", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "list|create|delete"}, + "id": map[string]interface{}{"type": "string", "description": "Token ID(delete)"}, + "expiresIn": map[string]interface{}{"type": "integer", "description": "有效期毫秒(create,默认1年)"}, + }, "required": []string{"command"}, + }, + }, p.handleManageTokens) + + s.RegisterTool(tp+"auth", sdk.ToolDef{ + Name: tp + "auth", + Description: "VanBlog认证。命令: login|logout|restore|update", + Parameters: map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "login|logout|restore|update"}, + "username": map[string]interface{}{"type": "string", "description": "用户名(login)"}, + "password": map[string]interface{}{"type": "string", "description": "密码(login/restore/update)"}, + "newName": map[string]interface{}{"type": "string", "description": "新用户名(update)"}, + "newPassword": map[string]interface{}{"type": "string", "description": "新密码(update)"}, + }, "required": []string{"command"}, + }, + }, p.handleAuth) + + s.RegisterTool(tp+"get_meta", sdk.ToolDef{ + Name: tp + "get_meta", Description: "获取VanBlog元信息(版本、站点等)", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, p.handleGetMeta) + + log.Printf("[vanblog] started: %s (url=%s, token=%t)", p.name, p.baseURL, p.token != "") + return nil +} + +func (p *Plugin) Stop() error { return nil } + +func (p *Plugin) do(method, path string, body interface{}) (interface{}, error) { + return p.doRaw(method, path, body, nil) +} + +func (p *Plugin) doRaw(method, path string, body interface{}, headers map[string]string) (interface{}, error) { + url := p.baseURL + path + var reqBody io.Reader + if body != nil { + b, _ := json.Marshal(body) + reqBody = bytes.NewReader(b) + } + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + if p.token != "" { + req.Header.Set("token", p.token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := p.http.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)) + } + var result interface{} + if err := json.Unmarshal(raw, &result); err != nil { + return string(raw), nil + } + return result, nil +} + +func jsonArg(args map[string]interface{}, key string) (map[string]interface{}, bool) { + s := readArg(args, key) + if s == "" { + return nil, false + } + var m map[string]interface{} + if json.Unmarshal([]byte(s), &m) != nil { + return nil, false + } + return m, true +} + +// ======== Article Handlers ======== + +func (p *Plugin) handleListArticles(args map[string]interface{}) (interface{}, error) { + page := readArgInt(args, "page", 1) + pageSize := readArgInt(args, "pageSize", 50) + path := fmt.Sprintf("/api/admin/article?page=%d&pageSize=%d", page, pageSize) + if v := readArg(args, "keyword"); v != "" { + path += "&keyword=" + v + } + if v := readArg(args, "category"); v != "" { + path += "&category=" + v + } + if v := readArg(args, "tag"); v != "" { + path += "&tag=" + v + } + if v := readArg(args, "sort"); v != "" { + path += "&sort=" + v + } + return p.do("GET", path, nil) +} + +func (p *Plugin) handleGetArticle(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("GET", "/api/admin/article/"+id, nil) +} + +func (p *Plugin) handleCreateArticle(args map[string]interface{}) (interface{}, error) { + title := readArg(args, "title") + category := readArg(args, "category") + if title == "" || category == "" { + return errResult("title 和 category 不能为空"), nil + } + body := map[string]interface{}{"title": title, "category": category} + if v := readArg(args, "content"); v != "" { + body["content"] = v + } + if v := readArg(args, "tags"); v != "" { + body["tags"] = strings.Split(v, ",") + } + if v := readArgBool(args, "pinned"); v != nil { + body["pinned"] = *v + } + return p.do("POST", "/api/admin/article", body) +} + +func (p *Plugin) handleUpdateArticle(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + body := map[string]interface{}{} + if v := readArg(args, "title"); v != "" { + body["title"] = v + } + if v := readArg(args, "content"); v != "" { + body["content"] = v + } + if v := readArg(args, "category"); v != "" { + body["category"] = v + } + if v := readArg(args, "tags"); v != "" { + body["tags"] = strings.Split(v, ",") + } + if v := readArgBool(args, "pinned"); v != nil { + body["pinned"] = *v + } + return p.do("PUT", "/api/admin/article/"+id, body) +} + +func (p *Plugin) handleDeleteArticle(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("DELETE", "/api/admin/article/"+id, nil) +} + +func (p *Plugin) handleSearchArticles(args map[string]interface{}) (interface{}, error) { + link := readArg(args, "link") + if link == "" { + return errResult("link 不能为空"), nil + } + return p.do("POST", "/api/admin/article/searchByLink", map[string]interface{}{"link": link}) +} + +// ======== Draft ======== + +func (p *Plugin) handleManageDrafts(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + page := readArgInt(args, "page", 1) + pageSize := readArgInt(args, "pageSize", 50) + return p.do("GET", fmt.Sprintf("/api/admin/draft?page=%d&pageSize=%d", page, pageSize), nil) + case "get": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("GET", "/api/admin/draft/"+id, nil) + case "create": + body := map[string]interface{}{} + if v := readArg(args, "title"); v != "" { + body["title"] = v + } + if v := readArg(args, "content"); v != "" { + body["content"] = v + } + if v := readArg(args, "category"); v != "" { + body["category"] = v + } + if v := readArg(args, "tags"); v != "" { + body["tags"] = strings.Split(v, ",") + } + return p.do("POST", "/api/admin/draft", body) + case "update": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + body := map[string]interface{}{} + if v := readArg(args, "title"); v != "" { + body["title"] = v + } + if v := readArg(args, "content"); v != "" { + body["content"] = v + } + if v := readArg(args, "category"); v != "" { + body["category"] = v + } + if v := readArg(args, "tags"); v != "" { + body["tags"] = strings.Split(v, ",") + } + return p.do("PUT", "/api/admin/draft/"+id, body) + case "delete": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("DELETE", "/api/admin/draft/"+id, nil) + case "publish": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("POST", "/api/admin/draft/publish", map[string]interface{}{"id": id}) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Category ======== + +func (p *Plugin) handleManageCategories(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/category/all", nil) + case "get": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("GET", "/api/admin/category/"+name, nil) + case "create": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("POST", "/api/admin/category", map[string]interface{}{"name": name}) + case "update": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + newName := readArg(args, "newName") + return p.do("PUT", "/api/admin/category/"+name, map[string]interface{}{"name": newName}) + case "delete": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("DELETE", "/api/admin/category/"+name, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Tag ======== + +func (p *Plugin) handleManageTags(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/tag/all", nil) + case "get": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("GET", "/api/admin/tag/"+name, nil) + case "rename": + name := readArg(args, "name") + newName := readArg(args, "newName") + if name == "" || newName == "" { + return errResult("name 和 newName 不能为空"), nil + } + return p.do("PUT", "/api/admin/tag/"+name, map[string]interface{}{"name": newName}) + case "delete": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("DELETE", "/api/admin/tag/"+name, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Image ======== + +func (p *Plugin) handleManageImages(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + page := readArgInt(args, "page", 1) + pageSize := readArgInt(args, "pageSize", 50) + return p.do("GET", fmt.Sprintf("/api/admin/img?page=%d&pageSize=%d", page, pageSize), nil) + case "all": + return p.do("GET", "/api/admin/img/all", nil) + case "upload": + filePath := readArg(args, "file") + if filePath == "" { + return errResult("file 路径不能为空"), nil + } + imgType := readArg(args, "type") + return p.uploadImage(filePath, imgType) + case "scan": + return p.do("POST", "/api/admin/img/scan", nil) + case "export": + return p.do("POST", "/api/admin/img/export", nil) + case "delete": + sign := readArg(args, "sign") + if sign == "" { + return errResult("sign 不能为空"), nil + } + return p.do("DELETE", "/api/admin/img/"+sign, nil) + case "delete_all": + return p.do("DELETE", "/api/admin/img/all/delete", nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +func (p *Plugin) uploadImage(filePath, imgType string) (interface{}, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("打开文件失败: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("image", filePath) + if err != nil { + return nil, fmt.Errorf("创建表单失败: %w", err) + } + if _, err := io.Copy(part, f); err != nil { + return nil, fmt.Errorf("写入文件失败: %w", err) + } + if imgType != "" { + w.WriteField("type", imgType) + } + w.Close() + + req, err := http.NewRequest("POST", p.baseURL+"/api/admin/img/upload", &buf) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("token", p.token) + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := p.http.Do(req) + if err != nil { + return nil, fmt.Errorf("上传失败: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)) + } + var result interface{} + if json.Unmarshal(raw, &result) != nil { + return string(raw), nil + } + return result, nil +} + +// ======== Link ======== + +func (p *Plugin) handleManageLinks(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/meta/link", nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/meta/link", data) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/meta/link", data) + case "delete": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("DELETE", "/api/admin/meta/link/"+name, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Social ======== + +func (p *Plugin) handleManageSocial(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/meta/social", nil) + case "types": + return p.do("GET", "/api/admin/meta/social/types", nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/meta/social", data) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/meta/social", data) + case "delete": + stype := readArg(args, "stype") + if stype == "" { + return errResult("stype 不能为空"), nil + } + return p.do("DELETE", "/api/admin/meta/social/"+stype, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Reward ======== + +func (p *Plugin) handleManageRewards(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/meta/reward", nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/meta/reward", data) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/meta/reward", data) + case "delete": + name := readArg(args, "name") + if name == "" { + return errResult("name 不能为空"), nil + } + return p.do("DELETE", "/api/admin/meta/reward/"+name, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Custom Page ======== + +func (p *Plugin) handleManagePages(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/customPage/all", nil) + case "get": + path := readArg(args, "path") + if path == "" { + return p.do("GET", "/api/admin/customPage", nil) + } + return p.do("GET", "/api/admin/customPage?path="+path, nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/customPage", data) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/customPage", data) + case "delete": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("DELETE", "/api/admin/customPage", data) + case "folder": + folder := readArg(args, "folder") + return p.do("GET", "/api/admin/customPage/folder?folder="+folder, nil) + case "file": + file := readArg(args, "file") + return p.do("GET", "/api/admin/customPage/file?file="+file, nil) + case "upload": + filePath := readArg(args, "file") + if filePath == "" { + return errResult("file 路径不能为空"), nil + } + return p.uploadCustomPageFile(filePath) + case "create_file": + file := readArg(args, "file") + content := readArg(args, "content") + return p.do("POST", "/api/admin/customPage/file", map[string]interface{}{"file": file, "content": content}) + case "create_folder": + folder := readArg(args, "folder") + return p.do("POST", "/api/admin/customPage/folder", map[string]interface{}{"folder": folder}) + case "update_file": + file := readArg(args, "file") + content := readArg(args, "content") + return p.do("PUT", "/api/admin/customPage/file", map[string]interface{}{"file": file, "content": content}) + default: + return errResult("未知命令: " + cmd), nil + } +} + +func (p *Plugin) uploadCustomPageFile(filePath string) (interface{}, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("打开文件失败: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("file", filePath) + if err != nil { + return nil, fmt.Errorf("创建表单失败: %w", err) + } + if _, err := io.Copy(part, f); err != nil { + return nil, fmt.Errorf("写入文件失败: %w", err) + } + w.Close() + + req, err := http.NewRequest("POST", p.baseURL+"/api/admin/customPage/upload", &buf) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("token", p.token) + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := p.http.Do(req) + if err != nil { + return nil, fmt.Errorf("上传失败: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)) + } + var result interface{} + if json.Unmarshal(raw, &result) != nil { + return string(raw), nil + } + return result, nil +} + +// ======== Settings ======== + +func (p *Plugin) handleManageSettings(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get_static": + return p.do("GET", "/api/admin/setting/static", nil) + case "set_static": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/setting/static", data) + case "get_waline": + return p.do("GET", "/api/admin/setting/waline", nil) + case "set_waline": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/setting/waline", data) + case "get_layout": + return p.do("GET", "/api/admin/setting/layout", nil) + case "set_layout": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/setting/layout", data) + case "get_login": + return p.do("GET", "/api/admin/setting/login", nil) + case "set_login": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/setting/login", data) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== About ======== + +func (p *Plugin) handleManageAbout(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get": + return p.do("GET", "/api/admin/meta/about", nil) + case "update": + content := readArg(args, "content") + return p.do("PUT", "/api/admin/meta/about", map[string]interface{}{"content": content}) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Site ======== + +func (p *Plugin) handleManageSite(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get": + return p.do("GET", "/api/admin/meta/site", nil) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/meta/site", data) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Menu ======== + +func (p *Plugin) handleManageMenu(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get": + return p.do("GET", "/api/admin/meta/menu", nil) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/meta/menu", data) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Analysis ======== + +func (p *Plugin) handleGetAnalysis(args map[string]interface{}) (interface{}, error) { + tab := readArg(args, "tab") + if tab == "" { + tab = "overview" + } + return p.do("GET", "/api/admin/analysis?tab="+tab, nil) +} + +// ======== Log ======== + +func (p *Plugin) handleGetLogs(args map[string]interface{}) (interface{}, error) { + page := readArgInt(args, "page", 1) + pageSize := readArgInt(args, "pageSize", 50) + path := fmt.Sprintf("/api/admin/log?page=%d&pageSize=%d", page, pageSize) + if v := readArg(args, "event"); v != "" { + path += "&event=" + v + } + return p.do("GET", path, nil) +} + +// ======== Backup ======== + +func (p *Plugin) handleManageBackup(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "export": + return p.do("GET", "/api/admin/backup/export", nil) + case "import": + filePath := readArg(args, "file") + if filePath == "" { + return errResult("file 路径不能为空"), nil + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("读取备份文件失败: %w", err) + } + var body interface{} + if json.Unmarshal(data, &body) != nil { + return errResult("无效的备份JSON"), nil + } + return p.do("POST", "/api/admin/backup/import", body) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Caddy ======== + +func (p *Plugin) handleManageCaddy(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get_https": + return p.do("GET", "/api/admin/caddy/https", nil) + case "set_https": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/caddy/https", data) + case "get_log": + return p.do("GET", "/api/admin/caddy/log", nil) + case "clear_log": + return p.do("DELETE", "/api/admin/caddy/log", nil) + case "get_config": + return p.do("GET", "/api/admin/caddy/config", nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== ISR ======== + +func (p *Plugin) handleManageISR(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "get": + return p.do("GET", "/api/admin/isr", nil) + case "trigger": + return p.do("POST", "/api/admin/isr", nil) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/isr", data) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Pipeline ======== + +func (p *Plugin) handleManagePipelines(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/pipeline", nil) + case "config": + return p.do("GET", "/api/admin/pipeline/config", nil) + case "get": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("GET", "/api/admin/pipeline/"+id, nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/pipeline", data) + case "trigger": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("POST", "/api/admin/pipeline/trigger/"+id, nil) + case "update": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/pipeline/"+id, data) + case "delete": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("DELETE", "/api/admin/pipeline/"+id, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Collaborator ======== + +func (p *Plugin) handleManageCollaborators(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/collaborator", nil) + case "list_all": + return p.do("GET", "/api/admin/collaborator/list", nil) + case "create": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("POST", "/api/admin/collaborator", data) + case "update": + data, ok := jsonArg(args, "data") + if !ok { + return errResult("data JSON 不能为空"), nil + } + return p.do("PUT", "/api/admin/collaborator", data) + case "delete": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("DELETE", "/api/admin/collaborator/"+id, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Token ======== + +func (p *Plugin) handleManageTokens(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "list": + return p.do("GET", "/api/admin/token", nil) + case "create": + expiresIn := readArgInt(args, "expiresIn", 3153600000) + return p.do("POST", "/api/admin/token", map[string]interface{}{"expiresIn": expiresIn}) + case "delete": + id := readArg(args, "id") + if id == "" { + return errResult("id 不能为空"), nil + } + return p.do("DELETE", "/api/admin/token/"+id, nil) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Auth ======== + +func (p *Plugin) handleAuth(args map[string]interface{}) (interface{}, error) { + cmd := readArg(args, "command") + switch cmd { + case "login": + username := readArg(args, "username") + password := readArg(args, "password") + if username == "" || password == "" { + return errResult("username 和 password 不能为空"), nil + } + result, err := p.do("POST", "/api/admin/auth/login", map[string]interface{}{"username": username, "password": password}) + if err != nil { + return nil, err + } + if m, ok := result.(map[string]interface{}); ok { + if data, ok := m["data"].(map[string]interface{}); ok { + if token, ok := data["token"].(string); ok && token != "" { + p.token = token + p.sdk.Settings().Set("token", token) + } + } + } + return result, nil + case "logout": + return p.do("POST", "/api/admin/auth/logout", nil) + case "restore": + password := readArg(args, "password") + if password == "" { + return errResult("password 不能为空"), nil + } + return p.doRaw("POST", "/api/admin/auth/restore", map[string]interface{}{"password": password}, map[string]string{"token": p.resetToken}) + case "update": + body := map[string]interface{}{} + if v := readArg(args, "newName"); v != "" { + body["name"] = v + } + if v := readArg(args, "newPassword"); v != "" { + body["password"] = v + } + return p.do("PUT", "/api/admin/auth", body) + default: + return errResult("未知命令: " + cmd), nil + } +} + +// ======== Meta ======== + +func (p *Plugin) handleGetMeta(args map[string]interface{}) (interface{}, error) { + return p.do("GET", "/api/admin/meta", nil) +} diff --git a/example/weather/plugin.go b/example/weather/plugin.go index 61c9c05..b4b3f05 100644 --- a/example/weather/plugin.go +++ b/example/weather/plugin.go @@ -5,8 +5,6 @@ import ( "fmt" "io" "net/http" - "os" - "path/filepath" "strconv" "strings" "time" @@ -19,7 +17,6 @@ type Plugin struct { sdk *sdk.PluginSDK client *http.Client defaultLoc string - dataDir string } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -45,16 +42,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } } - dataHome := os.Getenv("HOME") - if dataHome == "" { - dataHome = "/tmp" - } - p.dataDir = filepath.Join(dataHome, ".homeagent", "weather") - os.MkdirAll(p.dataDir, 0755) - - // 卸载(删除)时清理天气缓存目录;重载不触发 - s.RegisterOnRemoveHandler(p.cleanupData) - tp := p.name + "_" s.RegisterTool(tp+"current", sdk.ToolDef{ Name: tp + "current", Description: "Get current weather for a city", @@ -299,15 +286,6 @@ func (p *Plugin) handleCurrent(args map[string]interface{}) (interface{}, error) cc.Humidity, cc.WindspeedKmph, windUnit, cc.Winddir16Point, obsTime) - // 文本记忆:每次查询写入一条历史记录(role=tool 便于追溯) - if p.sdk.TextMemory() != nil { - _ = p.sdk.TextMemory().Append(sdk.TextEvent{ - Role: "tool", - Content: fmt.Sprintf("weather %s: %s", place, desc), - Channel: p.name, - }) - } - return map[string]interface{}{ "content": result, "location": place, @@ -395,7 +373,11 @@ func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error sunset = day.Astronomy[0].Sunset } - line := fmt.Sprintf(" %s %s/%s — %s~%s%s %s", weekday, day.Date[5:], day.Date[8:], minT, maxT, unitStr, desc) + datePart := "" + if len(day.Date) >= 8 { + datePart = day.Date[5:7] + "/" + day.Date[8:] + } + line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc) if precip != "" { line += precip } @@ -428,10 +410,3 @@ func (p *Plugin) handleSetLocation(args map[string]interface{}) (interface{}, er p.defaultLoc = loc return map[string]interface{}{"content": fmt.Sprintf("Default location set to: %s", loc)}, nil } - -// cleanupData 卸载时清理天气缓存目录 -func (p *Plugin) cleanupData() { - if p.dataDir != "" { - os.RemoveAll(p.dataDir) - } -} diff --git a/tools/plugindev/cmd_debug.go b/tools/plugindev/cmd_debug.go index f9a2321..74f0bc2 100644 --- a/tools/plugindev/cmd_debug.go +++ b/tools/plugindev/cmd_debug.go @@ -172,4 +172,4 @@ func debugGo(dir string, replaces []string) { } } -var _ = strings.TrimSpace + diff --git a/tools/plugindev/template_c.go b/tools/plugindev/template_c.go deleted file mode 100644 index e4a5bbe..0000000 --- a/tools/plugindev/template_c.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -// tmplPluginInitC is in templates.go (moved to keep all C ABI together)