mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-02 15:23:57 +00:00
fix(plugins): 全插件审查修复(qq/a2a/memo/calendar/rss/browser)
17 个生产插件全量审查:编译/vet 全过、无硬编码密钥、内核 executeToolCall 有 panic recover + 超时兜底。发现并修复: P1 qq: downloadURL 裸 http.Get 无超时 → 120s client(挂起泄漏) P2 a2a: inbound http.Server 无超时 → Read 30s/Write 120s/Idle 60s (慢速连接占用 goroutine) P5 memo/calendar/rss: 数据持久化直写 → atomicWriteJSON temp+rename (崩溃截断 JSON 丢全部数据) P6 qq: 3 处后台 goroutine(已读标记/rcon转发/下载任务)加 recover (工具调用外 panic 会带崩 homed 进程) P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程 已知可接受项:bili output_dir 用户可控(本机单用户)、recoverydiag db_path 可读任意 sqlite(诊断工具固有权限,argv 传参无注入)。 全部经 plugindev 重打包 v+0.1 安装验证 config_kept=true。
This commit is contained in:
19
third_party/homeagent-sdk/example/a2a/plg.json
vendored
Normal file
19
third_party/homeagent-sdk/example/a2a/plg.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "a2a",
|
||||||
|
"name_zh": "A2A 代理通信",
|
||||||
|
"name_en": "A2A Agent Communication",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"a2a",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
488
third_party/homeagent-sdk/example/a2a/plugin.go
vendored
Normal file
488
third_party/homeagent-sdk/example/a2a/plugin.go
vendored
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
srvMu sync.Mutex
|
||||||
|
server *http.Server
|
||||||
|
serverAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "listen", Default: "127.0.0.1:12000",
|
||||||
|
Type: "string", DisplayName: "监听地址",
|
||||||
|
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||||
|
Category: p.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Outbound: query + discover
|
||||||
|
s.RegisterTool(tp+"a2a_query", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_query", Description: "向另一个 A2A Agent 发送查询并获取回复",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
|
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||||
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
|
},
|
||||||
|
"required": []string{"agent_url", "query"},
|
||||||
|
},
|
||||||
|
Cleaner: func(output string) string {
|
||||||
|
var r struct{ Content string }
|
||||||
|
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
|
||||||
|
return r.Content
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
},
|
||||||
|
}, p.handleA2AQuery)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_discover", Description: "获取另一个 A2A Agent 的能力描述(Agent Card)",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
|
},
|
||||||
|
"required": []string{"agent_url"},
|
||||||
|
},
|
||||||
|
}, p.handleA2ADiscover)
|
||||||
|
|
||||||
|
// Management tools
|
||||||
|
s.RegisterTool(tp+"a2a_configure", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_configure", Description: "修改 A2A 插件配置并自动重启服务。支持动态更改监听地址等参数。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12000,设为空字符串禁用 HTTP 服务)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, p.handleConfigure)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"a2a_restart", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_restart", Description: "重启 A2A HTTP 服务端。当连接异常或配置变更后需要重新加载时使用。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleRestart)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"a2a_status", sdk.ToolDef{
|
||||||
|
Name: tp + "a2a_status", Description: "查看 A2A 插件的运行状态,包括监听地址和当前配置。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleStatus)
|
||||||
|
|
||||||
|
// Inbound HTTP server
|
||||||
|
if addr, _ := s.Settings().Get("listen"); addr != nil {
|
||||||
|
if addrStr, ok := addr.(string); ok && addrStr != "" {
|
||||||
|
if err := p.startServer(addrStr); err != nil {
|
||||||
|
log.Printf("[%s] start A2A server: %v", p.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] started", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.stopServer()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) stopServer() {
|
||||||
|
p.srvMu.Lock()
|
||||||
|
defer p.srvMu.Unlock()
|
||||||
|
if p.server != nil {
|
||||||
|
p.server.Close()
|
||||||
|
p.server = nil
|
||||||
|
p.serverAddr = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inbound HTTP Server ----
|
||||||
|
|
||||||
|
func (p *Plugin) startServer(addr string) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/agent-card", p.handleAgentCard)
|
||||||
|
mux.HandleFunc("/task", p.handleIncomingTask)
|
||||||
|
mux.HandleFunc("/a2a", p.handleIncomingA2A)
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 120 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
addrStr := listener.Addr().String()
|
||||||
|
|
||||||
|
p.srvMu.Lock()
|
||||||
|
if p.server != nil {
|
||||||
|
p.server.Close()
|
||||||
|
}
|
||||||
|
p.server = srv
|
||||||
|
p.serverAddr = addrStr
|
||||||
|
p.srvMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("[%s] A2A server on %s", p.name, addrStr)
|
||||||
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("[%s] serve: %v", p.name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
card := map[string]interface{}{
|
||||||
|
"name": p.name,
|
||||||
|
"description": "HomeAgent A2A Agent - 支持多工具调用与记忆管理",
|
||||||
|
"url": r.Host,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capabilities": []map[string]string{
|
||||||
|
{"id": "a2a_query", "name": "查询", "description": "接收并处理文本查询"},
|
||||||
|
{"id": "a2a_stream", "name": "流式响应", "description": "支持 SSE 流式回复"},
|
||||||
|
},
|
||||||
|
"skills": []map[string]string{
|
||||||
|
{"id": "chat", "name": "对话", "description": "通用对话与问题回答"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
p.handleAgentCard(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Message *struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"message,omitempty"`
|
||||||
|
} `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "tasks.send":
|
||||||
|
// Extract query text
|
||||||
|
queryText := req.Params.Query
|
||||||
|
if queryText == "" && req.Params.Message != nil {
|
||||||
|
for _, part := range req.Params.Message.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
queryText += part.Text + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryText = strings.TrimSpace(queryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
||||||
|
if queryText != "" {
|
||||||
|
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond with task accepted
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||||
|
"status": "submitted",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
||||||
|
case "tasks.get":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown method", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleIncomingTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p.handleIncomingA2A(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A2A Protocol Types ----
|
||||||
|
|
||||||
|
type A2AAgentCard struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Capabilities []A2ACapability `json:"capabilities,omitempty"`
|
||||||
|
Skills []A2ASkill `json:"skills,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ACapability struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ASkill struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema string `json:"input_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2ARequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params A2AParams `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AParams struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Result *A2AResult `json:"result,omitempty"`
|
||||||
|
Error *A2AError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AResult struct {
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Parts []A2APart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2APart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type A2AError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Outbound Handlers ----
|
||||||
|
|
||||||
|
func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, error) {
|
||||||
|
agentURL, _ := args["agent_url"].(string)
|
||||||
|
agentURL = strings.TrimRight(agentURL, "/")
|
||||||
|
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||||
|
agentURL = "http://" + agentURL
|
||||||
|
}
|
||||||
|
|
||||||
|
cardURL := agentURL
|
||||||
|
if !strings.HasSuffix(cardURL, "/agent-card") {
|
||||||
|
cardURL = agentURL + "/agent-card"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(cardURL)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("连接失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var card A2AAgentCard
|
||||||
|
if err := json.Unmarshal(body, &card); err != nil {
|
||||||
|
var fallback map[string]interface{}
|
||||||
|
if err2 := json.Unmarshal(body, &fallback); err2 == nil {
|
||||||
|
return map[string]interface{}{"agent_info": fallback, "format": "非标准格式"}, nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("解析失败: %v", err), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": card.Name, "description": card.Description,
|
||||||
|
"version": card.Version, "url": card.URL,
|
||||||
|
"capabilities": card.Capabilities, "skills": card.Skills,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||||
|
agentURL, _ := args["agent_url"].(string)
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
timeoutSec := 60
|
||||||
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
|
timeoutSec = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentURL = strings.TrimRight(agentURL, "/")
|
||||||
|
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
|
||||||
|
agentURL = "http://" + agentURL
|
||||||
|
}
|
||||||
|
|
||||||
|
taskURL := agentURL
|
||||||
|
if strings.HasSuffix(agentURL, "/agent-card") {
|
||||||
|
taskURL = strings.TrimSuffix(agentURL, "/agent-card")
|
||||||
|
}
|
||||||
|
taskURL = strings.TrimRight(taskURL, "/") + "/task"
|
||||||
|
|
||||||
|
reqBody := A2ARequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||||
|
Method: "tasks.send",
|
||||||
|
Params: A2AParams{
|
||||||
|
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyData, _ := json.Marshal(reqBody)
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||||
|
resp, err := client.Post(taskURL, "application/json", bytes.NewReader(bodyData))
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var a2aResp A2AResponse
|
||||||
|
if err := json.Unmarshal(body, &a2aResp); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err), "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if a2aResp.Error != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("Agent错误 [%d]: %s", a2aResp.Error.Code, a2aResp.Error.Message)}, nil
|
||||||
|
}
|
||||||
|
if a2aResp.Result == nil {
|
||||||
|
return map[string]interface{}{"error": "空结果", "raw_body": string(body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyText string
|
||||||
|
if a2aResp.Result.Message != nil {
|
||||||
|
for _, part := range a2aResp.Result.Message.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
replyText += part.Text + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replyText = strings.TrimSpace(replyText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||||
|
"response": replyText,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Management Handlers ----
|
||||||
|
|
||||||
|
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
|
||||||
|
listen, _ := args["listen"].(string)
|
||||||
|
listen = strings.TrimSpace(listen)
|
||||||
|
|
||||||
|
if err := p.sdk.Settings().Set("listen", listen); err != nil {
|
||||||
|
return fmt.Sprintf("保存配置失败: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if listen == "" || listen == "off" || listen == "disabled" {
|
||||||
|
p.stopServer()
|
||||||
|
return "A2A HTTP 服务已禁用(listen 设为空)", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.startServer(listen); err != nil {
|
||||||
|
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
|
||||||
|
p.stopServer()
|
||||||
|
|
||||||
|
addr, _ := p.sdk.Settings().Get("listen")
|
||||||
|
addrStr, _ := addr.(string)
|
||||||
|
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
|
||||||
|
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.startServer(addrStr); err != nil {
|
||||||
|
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.srvMu.Lock()
|
||||||
|
listening := p.serverAddr
|
||||||
|
p.srvMu.Unlock()
|
||||||
|
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
|
addr, _ := p.sdk.Settings().Get("listen")
|
||||||
|
addrStr, _ := addr.(string)
|
||||||
|
|
||||||
|
p.srvMu.Lock()
|
||||||
|
serverRunning := p.server != nil
|
||||||
|
listening := p.serverAddr
|
||||||
|
p.srvMu.Unlock()
|
||||||
|
if !serverRunning {
|
||||||
|
listening = "未运行"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s",
|
||||||
|
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
@ -777,6 +777,7 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
}
|
}
|
||||||
case <-time.After(30 * time.Second):
|
case <-time.After(30 * time.Second):
|
||||||
cmd.Process.Kill()
|
cmd.Process.Kill()
|
||||||
|
<-done // 回收子进程避免僵尸
|
||||||
return errResult("chromium dump-dom timeout (30s)"), nil
|
return errResult("chromium dump-dom timeout (30s)"), nil
|
||||||
}
|
}
|
||||||
html = out.String()
|
html = out.String()
|
||||||
|
|||||||
20
third_party/homeagent-sdk/example/calendar/plg.json
vendored
Normal file
20
third_party/homeagent-sdk/example/calendar/plg.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "calendar",
|
||||||
|
"name_zh": "日历",
|
||||||
|
"name_en": "Calendar",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "日历事件管理,支持提醒和重复事件",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"calendar",
|
||||||
|
"event",
|
||||||
|
"reminder",
|
||||||
|
"schedule"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
1188
third_party/homeagent-sdk/example/calendar/plugin.go
vendored
Normal file
1188
third_party/homeagent-sdk/example/calendar/plugin.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
19
third_party/homeagent-sdk/example/memo/plg.json
vendored
Normal file
19
third_party/homeagent-sdk/example/memo/plg.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "memo",
|
||||||
|
"name_zh": "备忘录",
|
||||||
|
"name_en": "Memo",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"memo",
|
||||||
|
"todo",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
511
third_party/homeagent-sdk/example/memo/plugin.go
vendored
Normal file
511
third_party/homeagent-sdk/example/memo/plugin.go
vendored
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Todo 待办条目:会被主动提醒
|
||||||
|
type Todo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memo 备忘录条目:纯记事,不主动提醒
|
||||||
|
type Memo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
todos []Todo
|
||||||
|
nextTID int64
|
||||||
|
memos []Memo
|
||||||
|
nextMID int64
|
||||||
|
todoPath string
|
||||||
|
memoPath string
|
||||||
|
stopCh chan struct{}
|
||||||
|
tp string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
p.tp = p.name + "_"
|
||||||
|
|
||||||
|
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||||
|
if err != nil || dataDirVal == "" {
|
||||||
|
dataDirVal = "."
|
||||||
|
}
|
||||||
|
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
|
||||||
|
}
|
||||||
|
p.todoPath = filepath.Join(dir, "todos.json")
|
||||||
|
p.memoPath = filepath.Join(dir, "memos.json")
|
||||||
|
p.loadTodos()
|
||||||
|
p.loadMemos()
|
||||||
|
|
||||||
|
// 卸载(删除)时清理数据文件;重载不触发
|
||||||
|
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||||
|
|
||||||
|
// ── 待办(会被主动提醒)──
|
||||||
|
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||||||
|
Name: p.tp + "todo_add",
|
||||||
|
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||||||
|
},
|
||||||
|
"required": []string{"content"},
|
||||||
|
},
|
||||||
|
}, p.handleTodoAdd)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||||||
|
Name: p.tp + "todo_complete",
|
||||||
|
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, p.handleTodoComplete)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||||||
|
Name: p.tp + "todo_list",
|
||||||
|
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleTodoList)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
|
||||||
|
Name: p.tp + "todo_delete",
|
||||||
|
Description: "删除指定ID的待办事项(包括已完成的)。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, p.handleTodoDelete)
|
||||||
|
|
||||||
|
// ── 备忘(纯记事,不提醒)──
|
||||||
|
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
|
||||||
|
Name: p.tp + "memo_create",
|
||||||
|
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
|
||||||
|
},
|
||||||
|
"required": []string{"content"},
|
||||||
|
},
|
||||||
|
}, p.handleMemoCreate)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
|
||||||
|
Name: p.tp + "memo_list",
|
||||||
|
Description: "列出所有备忘录,包含ID、内容和创建时间。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleMemoList)
|
||||||
|
|
||||||
|
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
|
||||||
|
Name: p.tp + "memo_delete",
|
||||||
|
Description: "删除指定ID的备忘录。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, p.handleMemoDelete)
|
||||||
|
|
||||||
|
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
|
||||||
|
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||||||
|
go p.periodicCheck()
|
||||||
|
|
||||||
|
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
close(p.stopCh)
|
||||||
|
p.saveTodos()
|
||||||
|
p.saveMemos()
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadTodos() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
data, err := os.ReadFile(p.todoPath)
|
||||||
|
if err != nil {
|
||||||
|
p.todos = []Todo{}
|
||||||
|
p.nextTID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var store struct {
|
||||||
|
Todos []Todo `json:"todos"`
|
||||||
|
NextID int64 `json:"next_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &store) != nil {
|
||||||
|
p.todos = []Todo{}
|
||||||
|
p.nextTID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.todos = store.Todos
|
||||||
|
p.nextTID = store.NextID
|
||||||
|
if p.todos == nil {
|
||||||
|
p.todos = []Todo{}
|
||||||
|
}
|
||||||
|
if p.nextTID < 1 {
|
||||||
|
p.nextTID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadMemos() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
data, err := os.ReadFile(p.memoPath)
|
||||||
|
if err != nil {
|
||||||
|
p.memos = []Memo{}
|
||||||
|
p.nextMID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var store struct {
|
||||||
|
Memos []Memo `json:"memos"`
|
||||||
|
NextID int64 `json:"next_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &store) != nil {
|
||||||
|
p.memos = []Memo{}
|
||||||
|
p.nextMID = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.memos = store.Memos
|
||||||
|
p.nextMID = store.NextID
|
||||||
|
if p.memos == nil {
|
||||||
|
p.memos = []Memo{}
|
||||||
|
}
|
||||||
|
if p.nextMID < 1 {
|
||||||
|
p.nextMID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) saveTodos() {
|
||||||
|
p.mu.RLock()
|
||||||
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"todos": p.todos,
|
||||||
|
"next_id": p.nextTID,
|
||||||
|
}, "", " ")
|
||||||
|
p.mu.RUnlock()
|
||||||
|
atomicWriteJSON(p.todoPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) saveMemos() {
|
||||||
|
p.mu.RLock()
|
||||||
|
data, _ := json.MarshalIndent(map[string]interface{}{
|
||||||
|
"memos": p.memos,
|
||||||
|
"next_id": p.nextMID,
|
||||||
|
}, "", " ")
|
||||||
|
p.mu.RUnlock()
|
||||||
|
atomicWriteJSON(p.memoPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 待办:未完成计数与提醒 ──
|
||||||
|
|
||||||
|
func (p *Plugin) pendingTodoCount() int {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
n := 0
|
||||||
|
for _, t := range p.todos {
|
||||||
|
if !t.Done {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) pendingTodos() []Todo {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
var out []Todo
|
||||||
|
for _, t := range p.todos {
|
||||||
|
if !t.Done {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||||||
|
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||||||
|
n := p.pendingTodoCount()
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx.Lock()
|
||||||
|
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||||||
|
"role": "system",
|
||||||
|
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||||||
|
})
|
||||||
|
ctx.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||||||
|
func (p *Plugin) periodicCheck() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n := p.pendingTodoCount()
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.sdk != nil {
|
||||||
|
p.sdk.InjectInterruptText(p.name, p.name,
|
||||||
|
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 待办工具 ──
|
||||||
|
|
||||||
|
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("content is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
todo := Todo{
|
||||||
|
ID: p.nextTID,
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
Done: false,
|
||||||
|
}
|
||||||
|
p.nextTID++
|
||||||
|
p.todos = append(p.todos, todo)
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.saveTodos()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
|
||||||
|
"id": todo.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, ok := args["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return errorResult("id is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for i := range p.todos {
|
||||||
|
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
|
||||||
|
p.todos[i].Done = true
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
|
||||||
|
}
|
||||||
|
p.saveTodos()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
todos := p.pendingTodos()
|
||||||
|
if len(todos) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "暂无未完成的待办",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, t := range todos {
|
||||||
|
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": sb.String(),
|
||||||
|
"count": len(todos),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, ok := args["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return errorResult("id is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for i := range p.todos {
|
||||||
|
if p.todos[i].ID == int64(id) {
|
||||||
|
p.todos = append(p.todos[:i], p.todos[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
|
||||||
|
}
|
||||||
|
p.saveTodos()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 备忘工具 ──
|
||||||
|
|
||||||
|
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("content is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
memo := Memo{
|
||||||
|
ID: p.nextMID,
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
p.nextMID++
|
||||||
|
p.memos = append(p.memos, memo)
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.saveMemos()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||||||
|
"id": memo.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, ok := args["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return errorResult("id is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for i := range p.memos {
|
||||||
|
if p.memos[i].ID == int64(id) {
|
||||||
|
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
|
||||||
|
}
|
||||||
|
p.saveMemos()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
memos := append([]Memo{}, p.memos...)
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(memos) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "暂无备忘录",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, m := range memos {
|
||||||
|
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": sb.String(),
|
||||||
|
"count": len(memos),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"isError": true,
|
||||||
|
"content": msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupData 卸载时清理数据文件(待办 + 备忘)
|
||||||
|
func (p *Plugin) cleanupData() {
|
||||||
|
if p.todoPath != "" {
|
||||||
|
os.Remove(p.todoPath)
|
||||||
|
}
|
||||||
|
if p.memoPath != "" {
|
||||||
|
os.Remove(p.memoPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
18
third_party/homeagent-sdk/example/qq/plg.json
vendored
Normal file
18
third_party/homeagent-sdk/example/qq/plg.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "qq",
|
||||||
|
"name_zh": "QQ消息",
|
||||||
|
"name_en": "qq",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"qq",
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": false,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
10
third_party/homeagent-sdk/example/qq/plugin.go
vendored
10
third_party/homeagent-sdk/example/qq/plugin.go
vendored
@ -819,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
if evt.GroupID == rule.GroupID {
|
if evt.GroupID == rule.GroupID {
|
||||||
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
||||||
go func(r ForwardRule, msg string) {
|
go func(r ForwardRule, msg string) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
||||||
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
||||||
}
|
}
|
||||||
@ -989,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
|
|||||||
|
|
||||||
// 异步标记已读
|
// 异步标记已读
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
|
||||||
if d.MessageType == "group" && d.GroupID > 0 {
|
if d.MessageType == "group" && d.GroupID > 0 {
|
||||||
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
||||||
} else if d.UserID > 0 {
|
} else if d.UserID > 0 {
|
||||||
@ -1659,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
dlURL := parsed.Data.URL
|
dlURL := parsed.Data.URL
|
||||||
httpResp, err := http.Get(dlURL)
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
httpResp, err := client.Get(dlURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download: %w", err)
|
return nil, fmt.Errorf("download: %w", err)
|
||||||
}
|
}
|
||||||
@ -1714,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
|||||||
task := p.addDownloadTask(fileID, filename)
|
task := p.addDownloadTask(fileID, filename)
|
||||||
|
|
||||||
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[qq] download task %s panic: %v", fid, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
savePath := ""
|
savePath := ""
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if furl != "" {
|
if furl != "" {
|
||||||
|
|||||||
20
third_party/homeagent-sdk/example/rss/plg.json
vendored
Normal file
20
third_party/homeagent-sdk/example/rss/plg.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "rss",
|
||||||
|
"name_zh": "RSS订阅",
|
||||||
|
"name_en": "RSS",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"rss",
|
||||||
|
"feed",
|
||||||
|
"subscription",
|
||||||
|
"monitor"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
497
third_party/homeagent-sdk/example/rss/plugin.go
vendored
Normal file
497
third_party/homeagent-sdk/example/rss/plugin.go
vendored
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const injectDedupWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
type FeedSub struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
AddedAt string `json:"added_at"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
client *http.Client
|
||||||
|
fp *gofeed.Parser
|
||||||
|
dataDir string
|
||||||
|
mu sync.RWMutex
|
||||||
|
feeds []FeedSub
|
||||||
|
seenGUIDs map[string]bool
|
||||||
|
injected map[string]time.Time
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
|
pollTicker *time.Ticker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return &Plugin{name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
|
||||||
|
v, err := s.Get(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch any(fallback).(type) {
|
||||||
|
case string:
|
||||||
|
if sv, ok := v.(string); ok {
|
||||||
|
return any(sv).(T)
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return any(int64(val)).(T)
|
||||||
|
case string:
|
||||||
|
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||||
|
return any(n).(T)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return any(val).(T)
|
||||||
|
case string:
|
||||||
|
if n, err := strconv.ParseFloat(val, 64); err == nil {
|
||||||
|
return any(n).(T)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArg(args map[string]interface{}, key string) string {
|
||||||
|
if v, ok := args[key]; ok && v != nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArgInt(args map[string]interface{}, key string, fallback int) int {
|
||||||
|
if v, ok := args[key]; ok && v != nil {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.SetAutoRestart(true)
|
||||||
|
p.sdk = s
|
||||||
|
p.client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
p.fp = gofeed.NewParser()
|
||||||
|
p.stopCh = make(chan struct{})
|
||||||
|
p.seenGUIDs = make(map[string]bool)
|
||||||
|
p.injected = make(map[string]time.Time)
|
||||||
|
p.feeds = []FeedSub{}
|
||||||
|
|
||||||
|
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||||||
|
if err != nil || dataDirVal == "" {
|
||||||
|
dataDirVal = "."
|
||||||
|
}
|
||||||
|
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
|
||||||
|
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
|
||||||
|
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
|
||||||
|
}
|
||||||
|
p.loadData()
|
||||||
|
|
||||||
|
// 卸载(删除)时清理订阅数据目录;重载不触发
|
||||||
|
s.RegisterOnRemoveHandler(p.cleanupData)
|
||||||
|
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "poll_interval", Default: "30", Type: "string",
|
||||||
|
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
|
||||||
|
Category: "rss",
|
||||||
|
})
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
s.RegisterTool(tp+"subscribe", sdk.ToolDef{
|
||||||
|
Name: tp + "subscribe", Description: "Subscribe to an RSS/Atom feed URL",
|
||||||
|
NoMemory: true,
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "Feed URL"},
|
||||||
|
"interval": map[string]interface{}{"type": "integer", "description": "Poll interval in minutes (default: 30, minimum: 5)"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleSubscribe)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"unsubscribe", sdk.ToolDef{
|
||||||
|
Name: tp + "unsubscribe", Description: "Unsubscribe from a feed",
|
||||||
|
NoMemory: true,
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{"type": "string", "description": "Feed URL to unsubscribe"},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}, p.handleUnsubscribe)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"list", sdk.ToolDef{
|
||||||
|
Name: tp + "list", Description: "List all subscribed feeds",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleList)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"check_now", sdk.ToolDef{
|
||||||
|
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
|
||||||
|
NoMemory: true,
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleCheckNow)
|
||||||
|
|
||||||
|
pollMin := int(getSetting(s.Settings(), "poll_interval", int64(30)))
|
||||||
|
if pollMin < 5 {
|
||||||
|
pollMin = 5
|
||||||
|
}
|
||||||
|
p.pollTicker = time.NewTicker(time.Duration(pollMin) * time.Minute)
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go p.pollLoop()
|
||||||
|
|
||||||
|
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.stopOnce.Do(func() { close(p.stopCh) })
|
||||||
|
p.pollTicker.Stop()
|
||||||
|
p.wg.Wait()
|
||||||
|
p.saveData()
|
||||||
|
fmt.Printf("[%s] stopped\n", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) pollLoop() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
|
||||||
|
p.checkAllFeeds()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.pollTicker.C:
|
||||||
|
p.checkAllFeeds()
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkAllFeeds() {
|
||||||
|
p.mu.RLock()
|
||||||
|
feeds := make([]FeedSub, len(p.feeds))
|
||||||
|
copy(feeds, p.feeds)
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, feed := range feeds {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
p.checkFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkFeed(sub FeedSub) {
|
||||||
|
parsed, err := p.fp.ParseURL(sub.URL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := parsed.Title
|
||||||
|
if title == "" {
|
||||||
|
title = sub.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
var newArticles []*gofeed.Item
|
||||||
|
for _, item := range parsed.Items {
|
||||||
|
guid := item.GUID
|
||||||
|
if guid == "" {
|
||||||
|
guid = item.Link
|
||||||
|
}
|
||||||
|
if guid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guid = sub.URL + "|" + guid
|
||||||
|
p.mu.RLock()
|
||||||
|
seen := p.seenGUIDs[guid]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if !seen {
|
||||||
|
newArticles = append(newArticles, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newArticles) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
toInject := make([]*gofeed.Item, 0, len(newArticles))
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, item := range newArticles {
|
||||||
|
guid := item.GUID
|
||||||
|
if guid == "" {
|
||||||
|
guid = item.Link
|
||||||
|
}
|
||||||
|
if guid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := sub.URL + "|" + guid
|
||||||
|
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.injected[key] = now
|
||||||
|
p.seenGUIDs[key] = true
|
||||||
|
toInject = append(toInject, item)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if len(toInject) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
|
||||||
|
for _, item := range toInject {
|
||||||
|
pubDate := ""
|
||||||
|
if item.PublishedParsed != nil {
|
||||||
|
pubDate = item.PublishedParsed.Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" • %s", item.Title)
|
||||||
|
if pubDate != "" {
|
||||||
|
line += fmt.Sprintf(" [%s]", pubDate)
|
||||||
|
}
|
||||||
|
if item.Link != "" {
|
||||||
|
line += "\n " + item.Link
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
||||||
|
p.saveData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, error) {
|
||||||
|
url := readArg(args, "url")
|
||||||
|
if url == "" {
|
||||||
|
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
for _, f := range p.feeds {
|
||||||
|
if f.URL == url {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return map[string]interface{}{"isError": true, "content": "Already subscribed to: " + url}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
interval := readArgInt(args, "interval", 30)
|
||||||
|
if interval < 5 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := p.fp.ParseURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"isError": true, "content": "Failed to parse feed: " + err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
feedTitle := parsed.Title
|
||||||
|
if feedTitle == "" {
|
||||||
|
feedTitle = url
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := FeedSub{
|
||||||
|
URL: url,
|
||||||
|
Title: feedTitle,
|
||||||
|
AddedAt: time.Now().Format("2006-01-02 15:04"),
|
||||||
|
Interval: interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
guidCount := 0
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, item := range parsed.Items {
|
||||||
|
guid := item.GUID
|
||||||
|
if guid == "" {
|
||||||
|
guid = item.Link
|
||||||
|
}
|
||||||
|
if guid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.seenGUIDs[url+"|"+guid] = true
|
||||||
|
guidCount++
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.feeds = append(p.feeds, sub)
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.saveData()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Subscribed to: %s\nTitle: %s\nArticles found: %d\nPoll interval: %d min", url, feedTitle, guidCount, interval),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleUnsubscribe(args map[string]interface{}) (interface{}, error) {
|
||||||
|
url := readArg(args, "url")
|
||||||
|
if url == "" {
|
||||||
|
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
found := false
|
||||||
|
for i, f := range p.feeds {
|
||||||
|
if f.URL == url {
|
||||||
|
p.feeds = append(p.feeds[:i], p.feeds[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return map[string]interface{}{"isError": true, "content": "Not subscribed to: " + url}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for guid := range p.seenGUIDs {
|
||||||
|
if strings.HasPrefix(guid, url+"|") {
|
||||||
|
delete(p.seenGUIDs, guid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.saveData()
|
||||||
|
|
||||||
|
return map[string]interface{}{"content": "Unsubscribed: " + url}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(p.feeds) == 0 {
|
||||||
|
return map[string]interface{}{"content": "No subscriptions. Use rss_subscribe to add one."}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(p.feeds, func(i, j int) bool {
|
||||||
|
return p.feeds[i].Title < p.feeds[j].Title
|
||||||
|
})
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("📡 Subscriptions (%d):", len(p.feeds)))
|
||||||
|
for _, f := range p.feeds {
|
||||||
|
lines = append(lines, fmt.Sprintf(" • %s\n %s (every %dm, added %s)", f.Title, f.URL, f.Interval, f.AddedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
p.checkAllFeeds()
|
||||||
|
}()
|
||||||
|
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) dataFile() string {
|
||||||
|
return filepath.Join(p.dataDir, "feeds.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadData() {
|
||||||
|
b, err := os.ReadFile(p.dataFile())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Feeds []FeedSub `json:"feeds"`
|
||||||
|
SeenGUIDs map[string]bool `json:"seen"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &data) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.Feeds != nil {
|
||||||
|
p.feeds = data.Feeds
|
||||||
|
}
|
||||||
|
if data.SeenGUIDs != nil {
|
||||||
|
p.seenGUIDs = data.SeenGUIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) saveData() {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
data := struct {
|
||||||
|
Feeds []FeedSub `json:"feeds"`
|
||||||
|
SeenGUIDs map[string]bool `json:"seen"`
|
||||||
|
}{
|
||||||
|
Feeds: p.feeds,
|
||||||
|
SeenGUIDs: p.seenGUIDs,
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
atomicWriteJSON(p.dataFile(), b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||||
|
func (p *Plugin) cleanupData() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.dataDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range []string{"feeds.json"} {
|
||||||
|
path := filepath.Join(p.dataDir, f)
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user