From 75447aa7f877c2c798ce7d5e400a820d6aafb8bb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 26 Aug 2026 16:46:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E5=85=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88qq/a2a/memo/calend?= =?UTF-8?q?ar/rss/browser=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .../homeagent-sdk/example/a2a/plg.json | 19 + .../homeagent-sdk/example/a2a/plugin.go | 488 +++++++ .../homeagent-sdk/example/browser/plg.json | 2 +- .../homeagent-sdk/example/browser/plugin.go | 1 + .../homeagent-sdk/example/calendar/plg.json | 20 + .../homeagent-sdk/example/calendar/plugin.go | 1188 +++++++++++++++++ .../homeagent-sdk/example/memo/plg.json | 19 + .../homeagent-sdk/example/memo/plugin.go | 511 +++++++ third_party/homeagent-sdk/example/qq/plg.json | 18 + .../homeagent-sdk/example/qq/plugin.go | 10 +- .../homeagent-sdk/example/rss/plg.json | 20 + .../homeagent-sdk/example/rss/plugin.go | 497 +++++++ 12 files changed, 2791 insertions(+), 2 deletions(-) create mode 100644 third_party/homeagent-sdk/example/a2a/plg.json create mode 100644 third_party/homeagent-sdk/example/a2a/plugin.go create mode 100644 third_party/homeagent-sdk/example/calendar/plg.json create mode 100644 third_party/homeagent-sdk/example/calendar/plugin.go create mode 100644 third_party/homeagent-sdk/example/memo/plg.json create mode 100644 third_party/homeagent-sdk/example/memo/plugin.go create mode 100644 third_party/homeagent-sdk/example/qq/plg.json create mode 100644 third_party/homeagent-sdk/example/rss/plg.json create mode 100644 third_party/homeagent-sdk/example/rss/plugin.go diff --git a/third_party/homeagent-sdk/example/a2a/plg.json b/third_party/homeagent-sdk/example/a2a/plg.json new file mode 100644 index 0000000..e1938ea --- /dev/null +++ b/third_party/homeagent-sdk/example/a2a/plg.json @@ -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": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/a2a/plugin.go b/third_party/homeagent-sdk/example/a2a/plugin.go new file mode 100644 index 0000000..8732971 --- /dev/null +++ b/third_party/homeagent-sdk/example/a2a/plugin.go @@ -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 +} diff --git a/third_party/homeagent-sdk/example/browser/plg.json b/third_party/homeagent-sdk/example/browser/plg.json index 9da4a74..9e26446 100644 --- a/third_party/homeagent-sdk/example/browser/plg.json +++ b/third_party/homeagent-sdk/example/browser/plg.json @@ -2,7 +2,7 @@ "name": "browser", "name_zh": "浏览器", "name_en": "Browser", - "version": "2.2.1", + "version": "2.3.0", "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "author": "HomeAgent", "entry": "plugin.so", diff --git a/third_party/homeagent-sdk/example/browser/plugin.go b/third_party/homeagent-sdk/example/browser/plugin.go index 09b41ff..36a8844 100644 --- a/third_party/homeagent-sdk/example/browser/plugin.go +++ b/third_party/homeagent-sdk/example/browser/plugin.go @@ -777,6 +777,7 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) } case <-time.After(30 * time.Second): cmd.Process.Kill() + <-done // 回收子进程避免僵尸 return errResult("chromium dump-dom timeout (30s)"), nil } html = out.String() diff --git a/third_party/homeagent-sdk/example/calendar/plg.json b/third_party/homeagent-sdk/example/calendar/plg.json new file mode 100644 index 0000000..026cea4 --- /dev/null +++ b/third_party/homeagent-sdk/example/calendar/plg.json @@ -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": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/calendar/plugin.go b/third_party/homeagent-sdk/example/calendar/plugin.go new file mode 100644 index 0000000..c7581c9 --- /dev/null +++ b/third_party/homeagent-sdk/example/calendar/plugin.go @@ -0,0 +1,1188 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +const ( + RepeatNone = "none" + RepeatDaily = "daily" + RepeatWeekday = "weekday" + RepeatWeekly = "weekly" + RepeatBiweekly = "biweekly" + RepeatMonthly = "monthly" + RepeatYearly = "yearly" + RepeatLunarYearly = "lunar_yearly" +) + +type CalendarEvent struct { + ID string `json:"id"` + Title string `json:"title"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + AllDay bool `json:"all_day,omitempty"` + Location string `json:"location,omitempty"` + Note string `json:"note,omitempty"` + Reminds []int `json:"reminds,omitempty"` + RemindAt []int64 `json:"remind_at,omitempty"` + Repeat string `json:"repeat,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Lunar bool `json:"lunar,omitempty"` + LunarMonth int `json:"lunar_month,omitempty"` + LunarDay int `json:"lunar_day,omitempty"` +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + dataDir string + mu sync.RWMutex + events []CalendarEvent + nextEventID int + stopCh chan struct{} + wg sync.WaitGroup + remindTicker *time.Ticker +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name, stopCh: make(chan struct{})}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" { + switch any(fallback).(type) { + case string: + return any(sv).(T) + case int64: + if n, err := strconv.ParseInt(sv, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(sv, 64); err == nil { + return any(n).(T) + } + } + } + } + v2, err2 := s.GetCore("plugin." + "calendar" + "." + key) + if err2 == nil && v2 != nil { + if sv, ok := v2.(string); ok && sv != "" { + switch any(fallback).(type) { + case string: + return any(sv).(T) + case int64: + if n, err := strconv.ParseInt(sv, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(sv, 64); err == nil { + return any(n).(T) + } + } + } + } + return fallback +} + +func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { + v, ok := args[key] + if !ok || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if s, ok := v.(string); ok { + return any(s).(T) + } + case int64: + switch n := v.(type) { + case float64: + return any(int64(n)).(T) + case int64: + return any(n).(T) + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return any(i).(T) + } + } + case float64: + switch n := v.(type) { + case float64: + return any(n).(T) + case int64: + return any(float64(n)).(T) + case string: + if f, err := strconv.ParseFloat(n, 64); err == nil { + return any(f).(T) + } + } + } + return fallback +} + +func readArgBool(args map[string]interface{}, key string) bool { + if v, ok := args[key]; ok && v != nil { + if b, ok := v.(bool); ok { + return b + } + if s, ok := v.(string); ok { + return s == "1" || strings.EqualFold(s, "true") + } + } + return false +} + +// --- Time Helpers --- + +var shortWeekday = map[time.Weekday]string{ + time.Monday: "一", time.Tuesday: "二", time.Wednesday: "三", + time.Thursday: "四", time.Friday: "五", time.Saturday: "六", time.Sunday: "日", +} + +func parseEventTime(s string) (time.Time, bool, bool) { + t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local) + if err == nil { + return t, false, true + } + t, err = time.ParseInLocation("2006-01-02", s, time.Local) + if err == nil { + return t, true, true + } + return time.Time{}, false, false +} + +// --- Lunar Calendar Engine --- + +var lunarInfo = []int{ + 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, + 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, + 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, + 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, + 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, + 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, + 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, + 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, + 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, + 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, + 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, + 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, + 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, + 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, + 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, + 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0, + 0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, + 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, + 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, + 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252, + 0x0d520, +} + +func daysInLunarYear(year int) int { + if year < 1900 || year > 2100 { + return 365 + } + y := lunarInfo[year-1900] + sum := 0 + for i := 0x8000; i > 0x8; i >>= 1 { + if y&i > 0 { + sum += 30 + } else { + sum += 29 + } + } + return sum + leapDays(year) +} + +func leapMonth(year int) int { + if year < 1900 || year > 2100 { + return 0 + } + return lunarInfo[year-1900] & 0xf +} + +func leapDays(year int) int { + if year < 1900 || year > 2100 { + return 0 + } + if leapMonth(year) == 0 { + return 0 + } + if lunarInfo[year-1900]&0x10000 > 0 { + return 30 + } + return 29 +} + +func monthDays(year, month int) int { + if year < 1900 || year > 2100 || month < 1 || month > 12 { + return 30 + } + if lunarInfo[year-1900]&(0x10000>>month) > 0 { + return 30 + } + return 29 +} + +var baseSolar = func() time.Time { t, _ := time.ParseInLocation("2006-01-02", "1900-01-31", time.Local); return t }() + +func lunarToSolar(year, month, day int) (time.Time, bool) { + if year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 30 { + return time.Time{}, false + } + offset := 0 + for y := 1900; y < year; y++ { + offset += daysInLunarYear(y) + } + lm := leapMonth(year) + _ = lm + for m := 1; m < month; m++ { + offset += monthDays(year, m) + } + offset += day - 1 + solar := baseSolar.AddDate(0, 0, offset) + return solar, true +} + +func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bool) { + afterYear := after.Year() + for y := afterYear; y <= afterYear+2; y++ { + t, ok := lunarToSolar(y, targetMonth, targetDay) + if !ok { + continue + } + if t.After(after) { + return t, true + } + } + return time.Time{}, false +} + +// --- Plugin Lifecycle --- + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") + if err != nil || dataDirVal == "" { + dataDirVal = "." + } + p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar") + if err := os.MkdirAll(p.dataDir, 0755); err != nil { + fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err) + } + p.loadEvents() + + // 持久化交由 stop handler:内核会在调用 Stop() 之前执行, + // 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。 + s.RegisterStopHandler(p.saveEvents) + // 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。 + s.RegisterOnRemoveHandler(p.cleanupData) + + tp := p.name + "_" + + s.RegisterTool(tp+"event_add", sdk.ToolDef{ + Name: tp + "event_add", Description: "Add a calendar event. Time: YYYY-MM-DD HH:MM or YYYY-MM-DD for all-day.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "title": map[string]interface{}{"type": "string", "description": "Event title"}, + "start_time": map[string]interface{}{"type": "string", "description": "Start time (YYYY-MM-DD HH:MM or YYYY-MM-DD)"}, + "end_time": map[string]interface{}{"type": "string", "description": "End time (optional)"}, + "location": map[string]interface{}{"type": "string", "description": "Location (optional)"}, + "note": map[string]interface{}{"type": "string", "description": "Notes (optional)"}, + "remind_before": map[string]interface{}{"type": "string", "description": "Reminder minutes before event. Multiple: comma-separated, e.g. '15,60,1440' for 15min + 1hr + 1day before. 0 or empty = no reminder."}, + "repeat": map[string]interface{}{"type": "string", "description": "Repeat: none, daily, weekday, weekly, biweekly, monthly, yearly, lunar_yearly"}, + "lunar": map[string]interface{}{"type": "boolean", "description": "Whether the date is lunar calendar. If true, repeat=lunar_yearly by default. Also set lunar_month and lunar_day."}, + "lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month (1-12), required when lunar=true"}, + "lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day (1-30), required when lunar=true"}, + }, + "required": []string{"title", "start_time"}, + }, + }, p.handleEventAdd) + + s.RegisterTool(tp+"event_list", sdk.ToolDef{ + Name: tp + "event_list", Description: "List upcoming events. Shows date, time, repeat pattern.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "days": map[string]interface{}{"type": "integer", "description": "Days ahead (default 7, max 365)"}, + }, + }, + }, p.handleEventList) + + s.RegisterTool(tp+"event_delete", sdk.ToolDef{ + Name: tp + "event_delete", Description: "Delete an event by ID. Deletes this and all future recurrences.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "Event ID"}, + }, + "required": []string{"id"}, + }, + }, p.handleEventDelete) + + s.RegisterTool(tp+"event_update", sdk.ToolDef{ + Name: tp + "event_update", Description: "Update an event. Only provided fields change. Resets reminder state.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "Event ID"}, + "title": map[string]interface{}{"type": "string", "description": "New title"}, + "start_time": map[string]interface{}{"type": "string", "description": "New start time"}, + "end_time": map[string]interface{}{"type": "string", "description": "New end time"}, + "location": map[string]interface{}{"type": "string", "description": "New location"}, + "note": map[string]interface{}{"type": "string", "description": "New notes"}, + "remind_before": map[string]interface{}{"type": "string", "description": "New reminder minutes (comma-separated)"}, + "repeat": map[string]interface{}{"type": "string", "description": "New repeat type"}, + "lunar": map[string]interface{}{"type": "boolean", "description": "Whether lunar calendar"}, + "lunar_month": map[string]interface{}{"type": "integer", "description": "Lunar month 1-12"}, + "lunar_day": map[string]interface{}{"type": "integer", "description": "Lunar day 1-30"}, + }, + "required": []string{"id"}, + }, + }, p.handleEventUpdate) + + s.RegisterTool(tp+"today", sdk.ToolDef{ + Name: tp + "today", Description: "Show today's events with countdown.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleToday) + + s.RegisterTool(tp+"week", sdk.ToolDef{ + Name: tp + "week", Description: "Show this week's events grouped by day.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleWeek) + + s.RegisterTool(tp+"month", sdk.ToolDef{ + Name: tp + "month", Description: "Show a month calendar grid with event dots.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "year": map[string]interface{}{"type": "integer", "description": "Year (default: current)"}, + "month": map[string]interface{}{"type": "integer", "description": "Month 1-12 (default: current)"}, + }, + }, + }, p.handleMonth) + + s.RegisterTool(tp+"search", sdk.ToolDef{ + Name: tp + "search", Description: "Search events by keyword in title, location, or notes.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "Search keyword"}, + }, + "required": []string{"keyword"}, + }, + }, p.handleSearch) + + p.remindTicker = time.NewTicker(30 * time.Second) + p.wg.Add(1) + go p.remindLoop() + + fmt.Printf("[%s] started (%d events)\n", p.name, len(p.events)) + return nil +} + +func (p *Plugin) Stop() error { + p.remindTicker.Stop() + close(p.stopCh) + p.wg.Wait() + fmt.Printf("[%s] stopped\n", p.name) + return nil +} + +// --- Reminder Loop --- + +func (p *Plugin) remindLoop() { + defer p.wg.Done() + for { + select { + case <-p.remindTicker.C: + p.checkReminders() + case <-p.stopCh: + return + } + } +} + +func (p *Plugin) checkReminders() { + now := time.Now() + + p.mu.Lock() + + changed := false + var injectMsgs []string + + for i := range p.events { + e := &p.events[i] + evtTime, allDay, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if allDay || evtTime.Before(now) { + continue + } + + for ri, remindMin := range e.Reminds { + if remindMin <= 0 { + continue + } + if ri < len(e.RemindAt) && e.RemindAt[ri] > 0 { + continue + } + remindAt := evtTime.Add(-time.Duration(remindMin) * time.Minute) + if !now.After(remindAt) && !now.Equal(remindAt) { + continue + } + if len(e.RemindAt) <= ri { + e.RemindAt = append(e.RemindAt, make([]int64, ri+1-len(e.RemindAt))...) + } + e.RemindAt[ri] = remindAt.Unix() + changed = true + timeUntil := evtTime.Sub(now).Round(time.Minute) + msg := fmt.Sprintf("⏰ 提醒: %s (%s)", e.Title, e.StartTime) + if timeUntil > 0 { + msg += fmt.Sprintf(" (还有%s)", timeUntil) + } + if len(e.Reminds) > 1 { + msg += fmt.Sprintf(" [第%d次提醒]", ri+1) + } + if e.Location != "" { + msg += fmt.Sprintf("\n📍 %s", e.Location) + } + if e.Note != "" { + msg += fmt.Sprintf("\n📝 %s", e.Note) + } + injectMsgs = append(injectMsgs, msg) + } + } + + newEvents := []CalendarEvent{} + for i := range p.events { + e := &p.events[i] + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if !now.After(evtTime) { + continue + } + if e.Repeat == "" || e.Repeat == RepeatNone { + continue + } + next := p.nextOccurrence(*e, evtTime) + if next != nil { + pid := e.ID + if e.ParentID != "" { + pid = e.ParentID + } + next.ParentID = pid + dup := false + for _, ev := range p.events { + if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime { + dup = true + break + } + } + if !dup { + newEvents = append(newEvents, *next) + changed = true + } + } + } + if len(newEvents) > 0 { + p.events = append(p.events, newEvents...) + } + + p.cleanupPastEvents() + if changed { + p.saveEventsLocked() + } + p.mu.Unlock() + + for _, msg := range injectMsgs { + p.sdk.InjectInterruptText("calendar", "calendar", msg) + } +} + +func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent { + var next time.Time + switch e.Repeat { + case RepeatDaily: + next = evtTime.AddDate(0, 0, 1) + case RepeatWeekday: + next = evtTime.AddDate(0, 0, 1) + for next.Weekday() == time.Saturday || next.Weekday() == time.Sunday { + next = next.AddDate(0, 0, 1) + } + case RepeatWeekly: + next = evtTime.AddDate(0, 0, 7) + case RepeatBiweekly: + next = evtTime.AddDate(0, 0, 14) + case RepeatMonthly: + next = evtTime.AddDate(0, 1, 0) + case RepeatYearly: + next = evtTime.AddDate(1, 0, 0) + case RepeatLunarYearly: + if e.LunarMonth > 0 && e.LunarDay > 0 { + t, ok := nextLunarYearly(e.LunarMonth, e.LunarDay, evtTime) + if ok { + next = t + } else { + return nil + } + } else { + return nil + } + default: + return nil + } + + timeStr := next.Format("2006-01-02 15:04") + if e.AllDay { + timeStr = next.Format("2006-01-02") + } + + reminds := make([]int, len(e.Reminds)) + copy(reminds, e.Reminds) + + return &CalendarEvent{ + ID: fmt.Sprintf("evt_%d_%d", next.Unix(), p.nextEventID), + Title: e.Title, + StartTime: timeStr, + EndTime: e.EndTime, + AllDay: e.AllDay, + Location: e.Location, + Note: e.Note, + Reminds: reminds, + Repeat: e.Repeat, + ParentID: e.ParentID, + } +} + +func (p *Plugin) cleanupPastEvents() { + now := time.Now() + keep := []CalendarEvent{} + for _, e := range p.events { + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if !now.After(evtTime) { + keep = append(keep, e) + continue + } + _ = e // 过时重复事件不再保留:next 已由 nextOccurrence 追加 + } + p.events = keep +} + +// --- Persistence --- + +func (p *Plugin) eventsFile() string { + return filepath.Join(p.dataDir, "events.json") +} + +// cleanupData 删除插件时清理本地持久化数据文件。 +func (p *Plugin) cleanupData() { + p.mu.Lock() + defer p.mu.Unlock() + if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) { + fmt.Printf("[calendar] onRemove cleanup: %v\n", err) + } else { + fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile()) + } +} + +func (p *Plugin) loadEvents() { + p.mu.Lock() + defer p.mu.Unlock() + b, err := os.ReadFile(p.eventsFile()) + if err != nil { + p.events = nil + p.nextEventID = 1 + return + } + var data struct { + Events []CalendarEvent `json:"events"` + NextEventID int `json:"next_id"` + } + if json.Unmarshal(b, &data) != nil { + p.events = nil + p.nextEventID = 1 + return + } + p.events = data.Events + p.nextEventID = data.NextEventID + if p.nextEventID < 1 { + p.nextEventID = 1 + } + if p.events == nil { + p.events = []CalendarEvent{} + } +} + +func (p *Plugin) saveEvents() { + p.mu.RLock() + defer p.mu.RUnlock() + p.saveEventsLocked() +} + +func (p *Plugin) saveEventsLocked() { + data := struct { + Events []CalendarEvent `json:"events"` + NextEventID int `json:"next_id"` + }{ + Events: p.events, + NextEventID: p.nextEventID, + } + b, _ := json.MarshalIndent(data, "", " ") + atomicWriteJSON(p.eventsFile(), b) +} + +// --- Helper: parse remind_before --- + +func parseReminds(s string) []int { + s = strings.TrimSpace(s) + if s == "" || s == "0" { + return nil + } + parts := strings.Split(s, ",") + vals := make([]int, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + v, err := strconv.Atoi(p) + if err != nil || v <= 0 { + continue + } + vals = append(vals, v) + } + sort.Ints(vals) + return vals +} + +// --- Helper: format event duration --- + +func formatTimeUntil(t time.Time) string { + now := time.Now() + if t.Before(now) { + return "已开始" + } + d := t.Sub(now) + if d < time.Hour { + m := int(d.Minutes()) + return fmt.Sprintf("还有%d分钟", m) + } + if d < 24*time.Hour { + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if m > 0 { + return fmt.Sprintf("还有%d小时%d分", h, m) + } + return fmt.Sprintf("还有%d小时", h) + } + d2 := int(d.Hours() / 24) + return fmt.Sprintf("还有%d天", d2) +} + +// --- Tool: event_add --- + +func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error) { + title := readArg(args, "title", "") + startTime := readArg(args, "start_time", "") + if title == "" || startTime == "" { + return map[string]interface{}{"isError": true, "content": "title and start_time are required"}, nil + } + + parsedStart, allDay, ok := parseEventTime(startTime) + if !ok { + return map[string]interface{}{"isError": true, "content": "Invalid start_time. Use YYYY-MM-DD HH:MM or YYYY-MM-DD."}, nil + } + + endTime := readArg(args, "end_time", "") + if endTime != "" { + if _, _, ok := parseEventTime(endTime); !ok { + return map[string]interface{}{"isError": true, "content": "Invalid end_time."}, nil + } + } + + location := readArg(args, "location", "") + note := readArg(args, "note", "") + remindStr := readArg(args, "remind_before", "") + reminds := parseReminds(remindStr) + lunar := readArgBool(args, "lunar") + lunarMonth := int(readArg(args, "lunar_month", int64(0))) + lunarDay := int(readArg(args, "lunar_day", int64(0))) + + repeat := readArg(args, "repeat", RepeatNone) + if lunar && repeat == RepeatNone { + repeat = RepeatLunarYearly + } + switch repeat { + case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly: + default: + repeat = RepeatNone + } + + if lunar && (lunarMonth < 1 || lunarMonth > 12 || lunarDay < 1 || lunarDay > 30) { + return map[string]interface{}{"isError": true, "content": "lunar_month (1-12) and lunar_day (1-30) required when lunar=true"}, nil + } + + event := CalendarEvent{ + ID: fmt.Sprintf("evt_%d_%d", parsedStart.Unix(), p.nextEventID), + Title: title, + StartTime: startTime, + EndTime: endTime, + AllDay: allDay, + Location: location, + Note: note, + Reminds: reminds, + Repeat: repeat, + Lunar: lunar, + LunarMonth: lunarMonth, + LunarDay: lunarDay, + } + + p.mu.Lock() + p.events = append(p.events, event) + p.nextEventID++ + p.mu.Unlock() + p.saveEvents() + + detail := fmt.Sprintf("Event added: %s (ID: %s)", title, event.ID) + if len(reminds) > 0 { + parts := make([]string, len(reminds)) + for i, r := range reminds { + parts[i] = fmt.Sprintf("%dmin", r) + } + detail += fmt.Sprintf(" | 提醒: %s", strings.Join(parts, ", ")) + } + if repeat != RepeatNone { + detail += " | 重复: " + repeat + } + return map[string]interface{}{"content": detail}, nil +} + +// --- Tool: event_list --- + +func (p *Plugin) handleEventList(args map[string]interface{}) (interface{}, error) { + days := int(readArg(args, "days", int64(7))) + if days < 1 { + days = 1 + } + if days > 365 { + days = 365 + } + + now := time.Now() + cutoff := now.AddDate(0, 0, days) + + p.mu.RLock() + upcoming := make([]CalendarEvent, 0) + for _, e := range p.events { + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if evtTime.Before(cutoff) && evtTime.After(now.Add(-24*time.Hour)) { + upcoming = append(upcoming, e) + } + } + p.mu.RUnlock() + + sort.Slice(upcoming, func(i, j int) bool { + return upcoming[i].StartTime < upcoming[j].StartTime + }) + + if len(upcoming) == 0 { + return map[string]interface{}{"content": fmt.Sprintf("No events in the next %d days.", days)}, nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("📋 Events (%d):", len(upcoming))) + for _, e := range upcoming { + timeStr := e.StartTime + if e.EndTime != "" { + timeStr += " → " + e.EndTime + } + extra := "" + if e.Location != "" { + extra += " 📍" + e.Location + } + if len(e.Reminds) > 0 { + parts := make([]string, len(e.Reminds)) + for i, r := range e.Reminds { + parts[i] = fmt.Sprintf("%d′", r) + } + extra += " 🔔" + strings.Join(parts, ",") + } + if e.Lunar { + extra += fmt.Sprintf(" 🌙%d-%d", e.LunarMonth, e.LunarDay) + } + if e.Repeat != "" && e.Repeat != RepeatNone { + extra += " 🔄" + e.Repeat + } + if e.Note != "" { + extra += " 📝" + e.Note + } + lines = append(lines, fmt.Sprintf(" [%s] %s%s", timeStr, e.Title, extra)) + } + + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil +} + +// --- Tool: event_delete --- + +func (p *Plugin) handleEventDelete(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + found := false + remaining := []CalendarEvent{} + for _, e := range p.events { + if e.ID == id { + found = true + continue + } + pid := e.ParentID + if pid == "" { + pid = e.ID + } + if pid == id { + continue + } + remaining = append(remaining, e) + } + if !found { + return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil + } + p.events = remaining + p.saveEventsLocked() + return map[string]interface{}{"content": "Deleted event and all recurrences: " + id}, nil +} + +// --- Tool: event_update --- + +func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, error) { + id := readArg(args, "id", "") + if id == "" { + return map[string]interface{}{"isError": true, "content": "Event ID is required"}, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + for i := range p.events { + if p.events[i].ID != id { + continue + } + e := &p.events[i] + + if v := readArg(args, "title", ""); v != "" { + e.Title = v + } + if v := readArg(args, "start_time", ""); v != "" { + if _, allDay, ok := parseEventTime(v); ok { + e.StartTime = v + e.AllDay = allDay + } + } + if v := readArg(args, "end_time", ""); v != "" { + if _, _, ok := parseEventTime(v); ok { + e.EndTime = v + } + } + if v := readArg(args, "location", ""); v != "" { + e.Location = v + } + if v := readArg(args, "note", ""); v != "" { + e.Note = v + } + if v := readArg(args, "remind_before", ""); v != "" { + e.Reminds = parseReminds(v) + } + if v := readArg(args, "repeat", ""); v != "" { + switch v { + case RepeatNone, RepeatDaily, RepeatWeekday, RepeatWeekly, RepeatBiweekly, RepeatMonthly, RepeatYearly, RepeatLunarYearly: + e.Repeat = v + } + } + if v, ok := args["lunar"]; ok && v != nil { + if b, ok := v.(bool); ok { + e.Lunar = b + } else if s, ok := v.(string); ok { + e.Lunar = s == "1" || strings.EqualFold(s, "true") + } + } + if v := readArg(args, "lunar_month", int64(0)); v > 0 { + e.LunarMonth = int(v) + } + if v := readArg(args, "lunar_day", int64(0)); v > 0 { + e.LunarDay = int(v) + } + e.RemindAt = nil + + p.saveEventsLocked() + return map[string]interface{}{"content": "Event updated: " + e.Title}, nil + } + + return map[string]interface{}{"isError": true, "content": "Event not found: " + id}, nil +} + +// --- Tool: today --- + +func (p *Plugin) handleToday(args map[string]interface{}) (interface{}, error) { + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayEnd := todayStart.AddDate(0, 0, 1) + + p.mu.RLock() + events := make([]CalendarEvent, 0) + for _, e := range p.events { + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if evtTime.After(todayStart.Add(-time.Hour)) && evtTime.Before(todayEnd) { + events = append(events, e) + } + } + p.mu.RUnlock() + + sort.Slice(events, func(i, j int) bool { + return events[i].StartTime < events[j].StartTime + }) + + dateStr := now.Format("2006-01-02") + weekday := shortWeekday[now.Weekday()] + lines := []string{fmt.Sprintf("📅 %s 周%s — 今天", dateStr, weekday)} + + if len(events) == 0 { + lines = append(lines, " 今天没有事件") + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil + } + + for _, e := range events { + evtTime, _, _ := parseEventTime(e.StartTime) + timeStr := e.StartTime + if now.Format("2006-01-02") == evtTime.Format("2006-01-02") { + timeStr = evtTime.Format("15:04") + } + countdown := formatTimeUntil(evtTime) + detail := fmt.Sprintf(" %s — %s (%s)", timeStr, e.Title, countdown) + if e.AllDay { + detail = fmt.Sprintf(" 🌙 %s (全天)", e.Title) + } + if e.Location != "" { + detail += " 📍" + e.Location + } + lines = append(lines, detail) + } + + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil +} + +// --- Tool: week --- + +func (p *Plugin) handleWeek(args map[string]interface{}) (interface{}, error) { + now := time.Now() + weekStart := now.AddDate(0, 0, -int(now.Weekday()-time.Monday)) + if now.Weekday() == time.Sunday { + weekStart = now.AddDate(0, 0, -6) + } + weekEnd := weekStart.AddDate(0, 0, 7) + + p.mu.RLock() + dayEvents := make(map[string][]CalendarEvent) + for _, e := range p.events { + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if evtTime.After(weekStart.Add(-time.Hour)) && evtTime.Before(weekEnd) { + dayKey := evtTime.Format("2006-01-02") + dayEvents[dayKey] = append(dayEvents[dayKey], e) + } + } + p.mu.RUnlock() + + for k := range dayEvents { + sort.Slice(dayEvents[k], func(i, j int) bool { + return dayEvents[k][i].StartTime < dayEvents[k][j].StartTime + }) + } + + lines := []string{fmt.Sprintf("📅 %s ~ %s", weekStart.Format("01-02"), weekEnd.AddDate(0, 0, -1).Format("01-02"))} + eventCount := 0 + for i := 0; i < 7; i++ { + d := weekStart.AddDate(0, 0, i) + dayKey := d.Format("2006-01-02") + wd := shortWeekday[d.Weekday()] + prefix := " " + if d.Format("2006-01-02") == now.Format("2006-01-02") { + prefix = "▶" + } + line := fmt.Sprintf("%s %s %s", prefix, d.Format("01-02"), wd) + if evts, ok := dayEvents[dayKey]; ok && len(evts) > 0 { + titles := make([]string, len(evts)) + for i, e := range evts { + timeStr := e.StartTime + if !e.AllDay { + timeStr = parseTimeShort(e.StartTime) + } else { + timeStr = "全天" + } + titles[i] = fmt.Sprintf("%s %s", timeStr, e.Title) + eventCount++ + } + line += " " + strings.Join(titles, ", ") + } + lines = append(lines, line) + } + if eventCount == 0 { + lines = append(lines, " 本周没有事件") + } + + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil +} + +func parseTimeShort(s string) string { + t, _, ok := parseEventTime(s) + if !ok { + return s + } + return t.Format("15:04") +} + +// --- Tool: month --- + +func (p *Plugin) handleMonth(args map[string]interface{}) (interface{}, error) { + now := time.Now() + year := int(readArg(args, "year", int64(now.Year()))) + month := int(readArg(args, "month", int64(now.Month()))) + if month < 1 || month > 12 { + month = int(now.Month()) + } + + firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location()) + lastDay := firstDay.AddDate(0, 1, -1) + daysInMonth := lastDay.Day() + startWeekday := int(firstDay.Weekday()) + if startWeekday == 0 { + startWeekday = 7 + } + + p.mu.RLock() + daySet := make(map[int]bool) + for _, e := range p.events { + evtTime, _, ok := parseEventTime(e.StartTime) + if !ok { + continue + } + if evtTime.Year() == year && evtTime.Month() == time.Month(month) { + daySet[evtTime.Day()] = true + } + } + p.mu.RUnlock() + + monthName := firstDay.Format("January") + lines := []string{fmt.Sprintf("📅 %d年%d月 (%s)", year, month, monthName)} + lines = append(lines, " 一 二 三 四 五 六 日") + lines = append(lines, "") + + row := " " + for i := 1; i < startWeekday; i++ { + row += " " + } + for d := 1; d <= daysInMonth; d++ { + mark := " " + if daySet[d] { + mark = "•" + } + row += fmt.Sprintf(" %2d%s", d, mark) + wd := startWeekday - 1 + d + if wd%7 == 0 || d == daysInMonth { + lines = append(lines, row) + row = " " + } + } + + count := 0 + for d := range daySet { + count++ + _ = d + } + lines = append(lines, fmt.Sprintf("\n本月 %d 天有事件", count)) + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil +} + +// --- Tool: search --- + +func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { + keyword := strings.ToLower(readArg(args, "keyword", "")) + if keyword == "" { + return map[string]interface{}{"isError": true, "content": "keyword is required"}, nil + } + + p.mu.RLock() + results := make([]CalendarEvent, 0) + for _, e := range p.events { + if strings.Contains(strings.ToLower(e.Title), keyword) || + strings.Contains(strings.ToLower(e.Location), keyword) || + strings.Contains(strings.ToLower(e.Note), keyword) { + results = append(results, e) + } + } + p.mu.RUnlock() + + sort.Slice(results, func(i, j int) bool { + return results[i].StartTime < results[j].StartTime + }) + + if len(results) == 0 { + return map[string]interface{}{"content": fmt.Sprintf("No events match: %s", keyword)}, nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("🔍 Found %d events for \"%s\":", len(results), keyword)) + for _, e := range results { + lines = append(lines, fmt.Sprintf(" [%s] %s (ID: %s)", e.StartTime, e.Title, e.ID)) + } + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil +} + +// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。 +func atomicWriteJSON(path string, data []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/third_party/homeagent-sdk/example/memo/plg.json b/third_party/homeagent-sdk/example/memo/plg.json new file mode 100644 index 0000000..c3e1125 --- /dev/null +++ b/third_party/homeagent-sdk/example/memo/plg.json @@ -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": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/memo/plugin.go b/third_party/homeagent-sdk/example/memo/plugin.go new file mode 100644 index 0000000..ecfc6ed --- /dev/null +++ b/third_party/homeagent-sdk/example/memo/plugin.go @@ -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) +} diff --git a/third_party/homeagent-sdk/example/qq/plg.json b/third_party/homeagent-sdk/example/qq/plg.json new file mode 100644 index 0000000..ecf9e80 --- /dev/null +++ b/third_party/homeagent-sdk/example/qq/plg.json @@ -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": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/qq/plugin.go b/third_party/homeagent-sdk/example/qq/plugin.go index ff4f02b..8226e41 100644 --- a/third_party/homeagent-sdk/example/qq/plugin.go +++ b/third_party/homeagent-sdk/example/qq/plugin.go @@ -819,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { if evt.GroupID == rule.GroupID { mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) go func(r ForwardRule, msg string) { + defer func() { _ = recover() }() if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) } @@ -989,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err // 异步标记已读 go func() { + defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程 if d.MessageType == "group" && d.GroupID > 0 { p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) } else if d.UserID > 0 { @@ -1659,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, return resp, nil } dlURL := parsed.Data.URL - httpResp, err := http.Get(dlURL) + client := &http.Client{Timeout: 120 * time.Second} + httpResp, err := client.Get(dlURL) if err != nil { 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) go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) { + defer func() { + if r := recover(); r != nil { + log.Printf("[qq] download task %s panic: %v", fid, r) + } + }() savePath := "" errMsg := "" if furl != "" { diff --git a/third_party/homeagent-sdk/example/rss/plg.json b/third_party/homeagent-sdk/example/rss/plg.json new file mode 100644 index 0000000..7c092c5 --- /dev/null +++ b/third_party/homeagent-sdk/example/rss/plg.json @@ -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": [] +} \ No newline at end of file diff --git a/third_party/homeagent-sdk/example/rss/plugin.go b/third_party/homeagent-sdk/example/rss/plugin.go new file mode 100644 index 0000000..5b996f4 --- /dev/null +++ b/third_party/homeagent-sdk/example/rss/plugin.go @@ -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) +}