commit 1762f0c34b568a2b782e6133019efbf302127b98 Author: root Date: Sat Jul 18 20:47:17 2026 +0800 docs: 修正全部文档使其与源码实现一致 - Plugin.Start(sdk *PluginSDK) 接口签名改为指针 - 方法表重写: 移除 CallLLM/QueryKnowledge/SetMemory 等不存在方法 - IOInjector 参数顺序修正为 (source, channel, text) - 删除虚构 SDKConfig, 替换为实际 New() 构造函数签名 - .hmap 内容描述一致化 (plugin.so + plugin.dll + main.lua) - 添加 meta/ 包元数据文件 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a82b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +*.so +*.dll +*.hmap +plugin.json + +# Build output directories +build/ +dist/ + +# Binaries +*.exe + +# Test artifacts +testdist/ + +# Logs +*.logz_bridge_gen.go\nz_entry.c\nbuild/\ndist/ +z_bridge_gen.go +z_entry.c +build/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6776bd0 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# HomeAgent SDK + +HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。 + +## SDK API 接口 + +### Plugin 接口 + +插件需实现 `Plugin` 接口: + +```go +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error +} +``` + +### PluginSDK 方法 + +通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法: + +| 分类 | 方法 | 说明 | +|------|------|------| +| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) | +| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 | +| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 | +| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 | +| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) | +| 文本记忆 | `TextMemory()` | 访问文本记忆 API(时序事件) | +| 文档记忆 | `DocMemory()` | 访问文档记忆 API(向量存储) | +| 社交图谱 | `Social()` | 访问社交图谱 API(外部插件只读) | +| 知识库 | `Knowledge()` | 访问知识库 API | +| LLM | `LLM()` | 访问 LLM 提供商管理 API | +| 设置 | `Settings()` | 访问设置 API | +| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) | +| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 | +| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 | + +### 阶段钩子 + +```go +// 全局监听所有插件的阶段事件 +sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }) + +// 仅监听自己注册的工具的 before_toolcall / after_toolcall +sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools) +``` + +### 输出通道 + +```go +sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler) +``` + +能力标志位: + +| 标志 | 值 | 说明 | +|------|----|------| +| `CapText` | 1 | 纯文本输出 | +| `CapFile` | 2 | 文件输出 | +| `CapImage` | 4 | 图片输出 | +| `CapAudio` | 8 | 音频输出 | +| `CapStructured` | 16 | 结构化数据输出 | + +### IOInjector 通道路由 + +| 方法 | 说明 | +|------|------| +| `InjectText(source, channel, text)` | 注入文本,记入内存,路由到指定通道 | +| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 | +| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 | + +`source` 标识来源,`channel` 指定目标输出通道。 + +### Triple 扩展字段 + +Triple 数据结构新增字段: + +- `Confidence` — 置信度(0.0~1.0) +- `SubjectType` — 主体类型 +- `ObjectType` — 客体类型 + +### New 构造函数 + +`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK: + +```go +func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK +``` + +插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。 + +## plugindev 工具链 + +`plugindev` 提供插件开发全流程支持: + +| 命令 | 说明 | +|------|------| +| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) | +| `plugindev build` | 构建插件,输出 .hmap 包 | +| `plugindev clean` | 清理构建产物 | +| `plugindev debug` | 本地调试模式运行插件 | + +支持 **Go** 和 **Lua** 两种插件语言。 + +### plg.json 清单格式 + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "lang": "go", + "entry": "main.go", + "description": "插件描述", + "channels": ["my-channel"], + "dependencies": {} +} +``` + +### .hmap 包格式 + +`.hmap` 为 ZIP 归档,包含: + +- `plugin.json` — 插件元数据 +- `plugin.so` — Go 编译产物(Linux) +- `plugin.dll` — Go 编译产物(Windows) +- `main.lua` — Lua 插件入口(Lua 插件时) + +## 插件生命周期 + +### 启动与停止 + +- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例 +- `Stop() error` — 插件停止,释放资源 + +### 自动重启 + +```go +sdk.SetAutoRestart(true) +// 查询状态 +enabled := sdk.AutoRestart() +``` + +插件崩溃时平台自动拉起,保障服务可用性。 + +## 受限 SDK vs 完整 SDK + +外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集: + +| 受限 API | 允许操作 | +|----------|----------| +| `SocialAPI` | 只读:`GetPerson`、`GetTrait`、`GetRelations`、`GetNetwork`、`ListPersons` | +| `EventSubscriber` | 仅订阅:`Subscribe`(无 `Publish`) | + +内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。 + +## 示例插件 + +| 插件 | 说明 | +|------|------| +| a2a | Agent-to-Agent 协议通信 | +| bili | Bilibili 数据获取 | +| editdoc | 文档编辑 | +| files | 文件管理 | +| memo | 备忘录/记忆 | +| ocr | 光学字符识别 | +| qq | QQ 消息集成 | +| sanitizer | 内容清洗/安全过滤 | +| web | 网页浏览与交互 | +| webfetch | 网页内容抓取 | + +## 构建与安装 + +### 构建 + +```bash +plugindev build +``` + +输出 `.hmap` 包到项目目录。 + +### 安装 + +通过 pluginmgr HTTP API 安装: + +```bash +curl -X POST http://:/api/plugins/install \ + -F "package=@my-plugin.hmap" +``` + +或手动将 `.hmap` 放入插件目录后重启平台。 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..b5905f0 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,192 @@ +# HomeAgent SDK + +Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform. + +## SDK API Surface + +### Plugin Interface + +Plugins implement the `Plugin` interface: + +```go +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error +} +``` + +### PluginSDK Methods + +The SDK instance injected via `Start(sdk *PluginSDK)` provides: + +| Category | Method | Description | +|----------|--------|-------------| +| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) | +| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask | +| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation | +| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access | +| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) | +| Text Memory | `TextMemory()` | Access text memory API (chronological events) | +| Doc Memory | `DocMemory()` | Access document memory API (vector store) | +| Social Graph | `Social()` | Access social graph API (read-only for external plugins) | +| Knowledge | `Knowledge()` | Access knowledge base API | +| LLM | `LLM()` | Access LLM provider manager API | +| Settings | `Settings()` | Access settings API | +| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) | +| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline | +| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash | + +### Stage Hooks + +```go +// Listen to all stage events globally +sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }) + +// Listen only to this plugin's own tool calls (before_toolcall / after_toolcall only) +sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools) +``` + +### Output Channels + +```go +sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler) +``` + +Capability flags: + +| Flag | Value | Description | +|------|-------|-------------| +| `CapText` | 1 | Plain text output | +| `CapFile` | 2 | File output | +| `CapImage` | 4 | Image output | +| `CapAudio` | 8 | Audio output | +| `CapStructured` | 16 | Structured data output | + +### IOInjector Channel Routing + +| Method | Description | +|--------|-------------| +| `InjectText(source, channel, text)` | Inject text, record to memory, route to specified channel | +| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel | +| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel | + +`source` identifies the origin, `channel` specifies the target output channel. + +### Triple Extended Fields + +The Triple data structure includes additional fields: + +- `Confidence` — confidence score (0.0–1.0) +- `SubjectType` — subject type +- `ObjectType` — object type + +### New Constructor + +`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually: + +```go +func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK +``` + +Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function. + +## plugindev Toolchain + +`plugindev` provides full development workflow support: + +| Command | Description | +|---------|-------------| +| `plugindev init` | Initialize plugin project (generates plg.json, entry template) | +| `plugindev build` | Build plugin, output .hmap package | +| `plugindev clean` | Clean build artifacts | +| `plugindev debug` | Run plugin in local debug mode | + +Supports both **Go** and **Lua** plugin languages. + +### plg.json Manifest Format + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "lang": "go", + "entry": "main.go", + "description": "Plugin description", + "channels": ["my-channel"], + "dependencies": {} +} +``` + +### .hmap Package Format + +`.hmap` is a ZIP archive containing: + +- `plugin.json` — plugin metadata +- `plugin.so` — Go compiled artifact (Linux) +- `plugin.dll` — Go compiled artifact (Windows) +- `main.lua` — Lua plugin entry (for Lua plugins) + +## Plugin Lifecycle + +### Start & Stop + +- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance +- `Stop() error` — Plugin shutdown, release resources + +### Auto-Restart + +```go +sdk.SetAutoRestart(true) +// Query state +enabled := sdk.AutoRestart() +``` + +The platform automatically restarts the plugin on crash, ensuring service availability. + +## Restricted SDK vs Full SDK + +External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset: + +| Restricted API | Allowed Operations | +|----------------|-------------------| +| `SocialAPI` | Read-only: `GetPerson`, `GetTrait`, `GetRelations`, `GetNetwork`, `ListPersons` | +| `EventSubscriber` | Subscribe-only: `Subscribe` (no `Publish`) | + +Internal plugins (platform built-in) have full SDK access including SocialAPI write operations and EventPublisher. + +## Example Plugins + +| Plugin | Description | +|--------|-------------| +| a2a | Agent-to-Agent protocol communication | +| bili | Bilibili data fetching | +| editdoc | Document editing | +| files | File management | +| memo | Memo/notes | +| ocr | Optical character recognition | +| qq | QQ messaging integration | +| sanitizer | Content sanitization/safety filtering | +| web | Web browsing and interaction | +| webfetch | Web content fetching | + +## Building & Installing + +### Build + +```bash +plugindev build +``` + +Outputs a `.hmap` package to the project directory. + +### Install + +Via pluginmgr HTTP API: + +```bash +curl -X POST http://:/api/plugins/install \ + -F "package=@my-plugin.hmap" +``` + +Or manually place the `.hmap` in the plugin directory and restart the platform. diff --git a/example/a2a/go.mod b/example/a2a/go.mod new file mode 100644 index 0000000..5402408 --- /dev/null +++ b/example/a2a/go.mod @@ -0,0 +1,7 @@ +module a2a + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/a2a/plg.json b/example/a2a/plg.json new file mode 100644 index 0000000..ed94b6b --- /dev/null +++ b/example/a2a/plg.json @@ -0,0 +1,11 @@ +{ + "name": "a2a", + "name_zh": "A2A 代理通信", + "name_en": "A2A Agent Communication", + "version": "1.0.0", + "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["a2a", "agent", "interop"], + "targets": "linux/amd64" +} diff --git a/example/a2a/plugin.go b/example/a2a/plugin.go new file mode 100644 index 0000000..04352bf --- /dev/null +++ b/example/a2a/plugin.go @@ -0,0 +1,370 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + server *http.Server +} + +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: "plugin." + p.name + ".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"}, + }, + }, 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) + + // Inbound HTTP server + if addr, _ := s.Settings().Get("plugin." + p.name + ".listen"); addr != nil { + if addrStr, ok := addr.(string); ok && addrStr != "" { + p.startServer(addrStr) + } + } + + log.Printf("[%s] started", p.name) + return nil +} + +func (p *Plugin) Stop() error { + if p.server != nil { + p.server.Close() + } + return nil +} + +// ---- Inbound HTTP Server ---- + +func (p *Plugin) startServer(addr string) { + 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 { + log.Printf("[%s] listen %s: %v", p.name, addr, err) + return + } + + p.server = &http.Server{Handler: mux} + go func() { + log.Printf("[%s] A2A server on %s", p.name, listener.Addr()) + if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("[%s] serve: %v", p.name, err) + } + }() +} + +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 +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/bili/go.mod b/example/bili/go.mod new file mode 100644 index 0000000..1051dc9 --- /dev/null +++ b/example/bili/go.mod @@ -0,0 +1,7 @@ +module bili + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/bili/plg.json b/example/bili/plg.json new file mode 100644 index 0000000..a537a61 --- /dev/null +++ b/example/bili/plg.json @@ -0,0 +1,11 @@ +{ + "name": "bili", + "name_zh": "B站视频下载", + "name_en": "Bilibili Video Downloader", + "version": "1.1.0", + "description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["bili", "video", "download"], + "targets": "linux/amd64" +} diff --git a/example/bili/plugin.go b/example/bili/plugin.go new file mode 100644 index 0000000..cc3a4d7 --- /dev/null +++ b/example/bili/plugin.go @@ -0,0 +1,234 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +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: "plugin." + p.name + ".output_dir", Default: "/tmp/bili_videos", + Type: "string", DisplayName: "下载目录", + Description: "B站视频下载后的保存目录", + Category: p.name, + }) + + s.RegisterTool(tp+"video", sdk.ToolDef{ + Name: tp + "video", + Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"}, + "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"}, + "format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"}, + }, + "required": []string{"url"}, + }, + }, p.handleBiliVideo) + return nil +} + +func (p *Plugin) Stop() error { return nil } + +type ytdlpFormat struct { + FormatID string `json:"format_id"` + FormatNote string `json:"format_note"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` + TBR float64 `json:"tbr"` + Filesize int64 `json:"filesize"` + FilesizeApprox int64 `json:"filesize_approx"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + FPS float64 `json:"fps"` +} + +type ytdlpInfo struct { + Title string `json:"title"` + Duration float64 `json:"duration"` + WebpageURL string `json:"webpage_url"` + Filename string `json:"_filename"` + Formats []ytdlpFormat `json:"formats"` +} + +func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return nil, fmt.Errorf("url is required") + } + infoOnly, _ := args["info_only"].(bool) + format, _ := args["format"].(string) + + outputDir := "/tmp/bili_videos" + if p.sdk != nil { + if v, _ := p.sdk.Settings().Get("plugin." + p.name + ".output_dir"); v != nil { + if s, ok := v.(string); ok && s != "" { + outputDir = s + } + } + } + os.MkdirAll(outputDir, 0755) + + var out bytes.Buffer + ytdlpArgs := []string{"--no-warnings", "--dump-json", url} + cmd := exec.Command("yt-dlp", ytdlpArgs...) + cmd.Stdout = &out + cmd.Stderr = &out + cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) + } + + var info ytdlpInfo + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return nil, fmt.Errorf("parse yt-dlp output: %w", err) + } + + if infoOnly { + var filtered []ytdlpFormat + for _, f := range info.Formats { + if f.VCodec != "none" || f.ACodec != "none" { + filtered = append(filtered, f) + } + } + info.Formats = filtered + + lines := []string{fmt.Sprintf("标题: %s", info.Title)} + if info.Duration > 0 { + lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration)) + } + + type fmtLine struct { + ID string + Note string + Res string + Ext string + Size string + } + var seen []string + var display []fmtLine + for _, f := range info.Formats { + if f.FormatNote == "" { + continue + } + key := f.FormatNote + f.Ext + if contains(seen, key) { + continue + } + seen = append(seen, key) + res := "" + if f.Width > 0 && f.Height > 0 { + res = fmt.Sprintf("%dx%d", f.Width, f.Height) + } + sz := "" + fs := f.Filesize + if fs == 0 { + fs = f.FilesizeApprox + } + if fs > 0 { + sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576) + } + display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz}) + } + if len(display) > 0 { + lines = append(lines, "清晰度列表:") + for _, d := range display { + r := d.Res + if r != "" { + r = " " + r + } + lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size)) + } + } + + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil + } + + dlArgs := []string{ + "--no-warnings", + "--socket-timeout", "30", + "--retries", "3", + "--fragment-retries", "3", + "-o", filepath.Join(outputDir, "%(title)s.%(ext)s"), + "--no-overwrites", + } + if format != "" { + dlArgs = append(dlArgs, "-f", format) + } + dlArgs = append(dlArgs, url) + cmd2 := exec.Command("yt-dlp", dlArgs...) + cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") + var dlOut bytes.Buffer + cmd2.Stdout = &dlOut + cmd2.Stderr = &dlOut + if err := cmd2.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) + } + + entries, _ := os.ReadDir(outputDir) + var newest string + var newestTime int64 + for _, e := range entries { + if e.IsDir() { + continue + } + fi, _ := e.Info() + if fi == nil { + continue + } + t := fi.ModTime().Unix() + if t > newestTime { + newestTime = t + newest = e.Name() + } + } + if newest == "" { + return map[string]interface{}{ + "content": "下载完成,但未找到视频文件", + }, nil + } + dlPath := filepath.Join(outputDir, newest) + fi, _ := os.Stat(dlPath) + var fileSize int64 + if fi != nil { + fileSize = fi.Size() + } + return map[string]interface{}{ + "content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath), + "file": dlPath, + "filename": newest, + }, nil +} + +func contains(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/bili/plugin.h b/example/bili/plugin.h new file mode 100644 index 0000000..618dde6 --- /dev/null +++ b/example/bili/plugin.h @@ -0,0 +1,101 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package bili */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#ifndef GO_CGO_EXPORT_PROLOGUE_H +#define GO_CGO_EXPORT_PROLOGUE_H + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef struct { const char *p; ptrdiff_t n; } _GoString_; +extern size_t _GoStringLen(_GoString_ s); +extern const char *_GoStringPtr(_GoString_ s); +#endif + +#endif + +/* Start of preamble from import "C" comments. */ + + +#line 3 "z_bridge_gen.go" + +#include +int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error); + +#line 1 "cgo-generated-wrapper" + + +/* End of preamble from import "C" comments. */ + + +/* Start of boilerplate cgo prologue. */ +#line 1 "cgo-gcc-export-header-prolog" + +#ifndef GO_CGO_PROLOGUE_H +#define GO_CGO_PROLOGUE_H + +typedef signed char GoInt8; +typedef unsigned char GoUint8; +typedef short GoInt16; +typedef unsigned short GoUint16; +typedef int GoInt32; +typedef unsigned int GoUint32; +typedef long long GoInt64; +typedef unsigned long long GoUint64; +typedef GoInt64 GoInt; +typedef GoUint64 GoUint; +typedef size_t GoUintptr; +typedef float GoFloat32; +typedef double GoFloat64; +#ifdef _MSC_VER +#if !defined(__cplusplus) || _MSVC_LANG <= 201402L +#include +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +#include +typedef std::complex GoComplex64; +typedef std::complex GoComplex128; +#endif +#else +typedef float _Complex GoComplex64; +typedef double _Complex GoComplex128; +#endif + +/* + static assertion to make sure the file is being used on architecture + at least with matching size of GoInt. +*/ +typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef _GoString_ GoString; +#endif +typedef void *GoMap; +typedef void *GoChan; +typedef struct { void *t; void *v; } GoInterface; +typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; + +#endif + +/* End of boilerplate cgo prologue. */ + +#ifdef __cplusplus +extern "C" { +#endif + +extern int go_init_plugin(char* name, char* configJSON, char** errorOut); +extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut); +extern int go_stop_plugin(char** errorOut); +extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut); +extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut); +extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut); +extern void go_free_string(char* ptr); + +#ifdef __cplusplus +} +#endif diff --git a/example/editdoc/go.mod b/example/editdoc/go.mod new file mode 100644 index 0000000..e0f291c --- /dev/null +++ b/example/editdoc/go.mod @@ -0,0 +1,7 @@ +module editdoc + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/editdoc/plg.json b/example/editdoc/plg.json new file mode 100644 index 0000000..1465c83 --- /dev/null +++ b/example/editdoc/plg.json @@ -0,0 +1,11 @@ +{ + "name": "editdoc", + "name_zh": "文档编辑", + "name_en": "Document Editor", + "version": "1.0.0", + "description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["editdoc", "office", "document"], + "targets": "linux/amd64" +} diff --git a/example/editdoc/plugin.go b/example/editdoc/plugin.go new file mode 100644 index 0000000..b7d9ca4 --- /dev/null +++ b/example/editdoc/plugin.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + s.RegisterTool("edit_document", sdk.ToolDef{ + Name: "edit_document", + Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "file": map[string]interface{}{"type": "string", "description": "文档文件路径"}, + "operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"}, + "target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"}, + "replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"}, + "sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"}, + "row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"}, + "col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"}, + "value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"}, + }, + "required": []string{"file", "operation"}, + }, + }, p.handleEditDocument) + return nil +} + +func (p *Plugin) Stop() error { return nil } + +func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) { + file, _ := args["file"].(string) + if file == "" { + return nil, fmt.Errorf("file is required") + } + operation, _ := args["operation"].(string) + if operation == "" { + return nil, fmt.Errorf("operation is required") + } + if _, err := os.Stat(file); os.IsNotExist(err) { + return map[string]interface{}{ + "content": fmt.Sprintf("文件不存在: %s", file), + }, nil + } + + pyArgs := map[string]interface{}{} + if v, ok := args["target"]; ok { + pyArgs["target"] = v + } + if v, ok := args["replacement"]; ok { + pyArgs["replacement"] = v + } + if v, ok := args["sheet"]; ok { + pyArgs["sheet"] = v + } + if v, ok := args["row"]; ok { + pyArgs["row"] = v + } + if v, ok := args["col"]; ok { + pyArgs["col"] = v + } + if v, ok := args["value"]; ok { + pyArgs["value"] = v + } + pyArgsJSON, _ := json.Marshal(pyArgs) + + scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py" + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath) + } + + venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3" + pythonBin := "python3" + if _, err := os.Stat(venvPython); err == nil { + pythonBin = venvPython + } + + var out bytes.Buffer + cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON)) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("edit document: %w", err) + } + + var result struct { + Ok bool `json:"ok"` + Error string `json:"error,omitempty"` + Count int `json:"count,omitempty"` + } + if err := json.Unmarshal(out.Bytes(), &result); err != nil { + return map[string]interface{}{ + "content": fmt.Sprintf("编辑完成,输出: %s", out.String()), + "file": file, + }, nil + } + if !result.Ok { + return map[string]interface{}{ + "content": fmt.Sprintf("编辑失败: %s", result.Error), + }, nil + } + msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file) + if result.Count > 0 { + msg += fmt.Sprintf("\n共处理 %d 处", result.Count) + } + return map[string]interface{}{ + "content": msg, + "file": file, + }, nil +} + + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/files/plg.json b/example/files/plg.json new file mode 100644 index 0000000..cad766d --- /dev/null +++ b/example/files/plg.json @@ -0,0 +1,11 @@ +{ + "name": "files", + "name_zh": "文件系统", + "name_en": "File System", + "version": "1.0.0", + "description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["files", "filesystem"], + "targets": "linux/amd64" +} diff --git a/example/files/plugin.go b/example/files/plugin.go new file mode 100644 index 0000000..a63e9ad --- /dev/null +++ b/example/files/plugin.go @@ -0,0 +1,483 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + filesDir string +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.files.dir", + Default: "/", + Type: "string", + DisplayName: "文件系统根目录", + Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)", + Category: "files", + }) + + dir := getSetting[string](s.Settings(), "dir", "/") + if strings.HasPrefix(dir, "~/") { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, dir[2:]) + } + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolve files.dir: %w", err) + } + p.filesDir = abs + os.MkdirAll(p.filesDir, 0755) + + tp := p.name + "_" + + s.RegisterTool(tp+"read", sdk.ToolDef{ + Name: tp + "read", + Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"}, + "offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"}, + "limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"}, + }, + "required": []string{"path"}, + }, + }, p.handleRead) + + s.RegisterTool(tp+"write", sdk.ToolDef{ + Name: tp + "write", + Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "File path"}, + "content": map[string]interface{}{"type": "string", "description": "Content to write"}, + "mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"}, + "line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"}, + }, + "required": []string{"path", "content"}, + }, + }, p.handleWrite) + + s.RegisterTool(tp+"edit", sdk.ToolDef{ + Name: tp + "edit", + Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"}, + "edits": map[string]interface{}{ + "type": "array", + "description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"}, + "new": map[string]interface{}{"type": "string", "description": "Replacement text"}, + }, + "required": []string{"old", "new"}, + }, + }, + }, + "required": []string{"path", "edits"}, + }, + }, p.handleEdit) + + s.RegisterTool(tp+"ls", sdk.ToolDef{ + Name: tp + "ls", + Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"}, + "limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"}, + }, + }, + }, p.handleLs) + + log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir) + return nil +} + +func (p *Plugin) Stop() error { + log.Printf("[%s] stopped", p.name) + return nil +} + +// resolvePath resolves user-provided path to an absolute path within filesDir. +func (p *Plugin) resolvePath(userPath string) (string, error) { + if userPath == "" { + userPath = "." + } + if !filepath.IsAbs(userPath) { + userPath = filepath.Join(p.filesDir, userPath) + } + abs, err := filepath.Abs(userPath) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + base := filepath.Clean(p.filesDir) + if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { + return "", fmt.Errorf("path outside sandbox: %s", userPath) + } + return abs, nil +} + +// handleRead implements the read tool. +func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return errorResult("path is required"), nil + } + + absPath, err := p.resolvePath(path) + if err != nil { + return errorResult(err.Error()), nil + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return errorResult("file not found: " + path), nil + } + return errorResult("stat error: " + err.Error()), nil + } + if info.IsDir() { + return errorResult("is a directory, use ls instead: " + path), nil + } + + data, err := os.ReadFile(absPath) + if err != nil { + return errorResult("read error: " + err.Error()), nil + } + + text := string(data) + lines := strings.Split(text, "\n") + totalLines := len(lines) + + offset := 0 + if v, ok := args["offset"].(float64); ok && v > 0 { + offset = int(v) - 1 + } + if offset >= totalLines { + return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil + } + + limit := totalLines - offset + if v, ok := args["limit"].(float64); ok && v > 0 { + if int(v) < limit { + limit = int(v) + } + } + + end := offset + limit + if end > totalLines { + end = totalLines + } + + selected := lines[offset:end] + output := strings.Join(selected, "\n") + + truncated := false + if limit < totalLines-offset { + truncated = true + } + + var sb strings.Builder + sb.WriteString(output) + if truncated { + nextOffset := end + 1 + sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset)) + } else if offset > 0 || end < totalLines { + sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines)) + } + + return map[string]interface{}{ + "content": sb.String(), + }, nil +} + +// handleWrite implements the write tool. +func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return errorResult("path is required"), nil + } + content, _ := args["content"].(string) + mode, _ := args["mode"].(string) + if mode == "" { + mode = "overwrite" + } + + line := 0 + if v, ok := args["line"].(float64); ok && v > 0 { + line = int(v) + } + + absPath, err := p.resolvePath(path) + if err != nil { + return errorResult(err.Error()), nil + } + + switch mode { + case "create": + if _, err := os.Stat(absPath); err == nil { + return errorResult("file already exists: " + path), nil + } + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return errorResult("mkdir error: " + err.Error()), nil + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return errorResult("write error: " + err.Error()), nil + } + return map[string]interface{}{ + "content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)), + }, nil + + case "append": + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return errorResult("mkdir error: " + err.Error()), nil + } + f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return errorResult("open error: " + err.Error()), nil + } + defer f.Close() + if _, err := f.WriteString(content); err != nil { + return errorResult("append error: " + err.Error()), nil + } + return map[string]interface{}{ + "content": fmt.Sprintf("Appended %d bytes to %s", len(content), path), + }, nil + + case "insert": + if line < 1 { + return errorResult("line must be >= 1 for insert mode"), nil + } + data, err := os.ReadFile(absPath) + if err != nil { + if os.IsNotExist(err) { + return errorResult("file not found: " + path), nil + } + return errorResult("read error: " + err.Error()), nil + } + lines := strings.Split(string(data), "\n") + if line > len(lines)+1 { + return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil + } + idx := line - 1 + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:idx]...) + newLines = append(newLines, content) + newLines = append(newLines, lines[idx:]...) + result := strings.Join(newLines, "\n") + if err := os.WriteFile(absPath, []byte(result), 0644); err != nil { + return errorResult("write error: " + err.Error()), nil + } + return map[string]interface{}{ + "content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path), + }, nil + + default: // overwrite + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return errorResult("mkdir error: " + err.Error()), nil + } + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return errorResult("write error: " + err.Error()), nil + } + return map[string]interface{}{ + "content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path), + }, nil + } +} + +// handleEdit implements the edit tool. +func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return errorResult("path is required"), nil + } + + absPath, err := p.resolvePath(path) + if err != nil { + return errorResult(err.Error()), nil + } + + rawEdits, ok := args["edits"].([]interface{}) + if !ok || len(rawEdits) == 0 { + return errorResult("edits must be a non-empty array"), nil + } + + data, err := os.ReadFile(absPath) + if err != nil { + if os.IsNotExist(err) { + return errorResult("file not found: " + path), nil + } + return errorResult("read error: " + err.Error()), nil + } + + original := string(data) + content := original + applied := 0 + var errors []string + + for i, raw := range rawEdits { + edit, ok := raw.(map[string]interface{}) + if !ok { + errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i)) + continue + } + oldText, _ := edit["old"].(string) + newText, _ := edit["new"].(string) + if oldText == "" { + errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i)) + continue + } + + count := strings.Count(content, oldText) + if count == 0 { + errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path)) + continue + } + if count > 1 { + errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText)) + continue + } + + content = strings.Replace(content, oldText, newText, 1) + applied++ + } + + if applied == 0 { + msg := "no edits applied" + if len(errors) > 0 { + msg += ": " + strings.Join(errors, "; ") + } + return errorResult(msg), nil + } + + if err := os.WriteFile(absPath, []byte(content), 0644); err != nil { + return errorResult("write error: " + err.Error()), nil + } + + msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path) + if len(errors) > 0 { + msg += "\nWarnings:\n" + strings.Join(errors, "\n") + } + + return map[string]interface{}{ + "content": msg, + }, nil +} + +// handleLs implements the ls tool. +func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + path = "." + } + + absPath, err := p.resolvePath(path) + if err != nil { + return errorResult(err.Error()), nil + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return errorResult("path not found: " + path), nil + } + return errorResult("stat error: " + err.Error()), nil + } + if !info.IsDir() { + return errorResult("not a directory: " + path), nil + } + + entries, err := os.ReadDir(absPath) + if err != nil { + return errorResult("readdir error: " + err.Error()), nil + } + + limit := 500 + if v, ok := args["limit"].(float64); ok && v > 0 { + limit = int(v) + } + + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + + var lines []string + entryLimitReached := false + for i, entry := range entries { + if i >= limit { + entryLimitReached = true + break + } + name := entry.Name() + if entry.IsDir() { + name += "/" + } + lines = append(lines, name) + } + + if len(lines) == 0 { + return map[string]interface{}{ + "content": "(empty directory)", + }, nil + } + + output := strings.Join(lines, "\n") + if entryLimitReached { + output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit) + } + + return map[string]interface{}{ + "content": output, + }, nil +} + +// errorResult returns a standardized error result. +func errorResult(msg string) map[string]interface{} { + return map[string]interface{}{ + "isError": true, + "content": msg, + } +} + +// getSetting reads a setting with generic type assertion. +func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { + v, err := s.Get(key) + if err != nil || v == nil { + return def + } + val, ok := v.(T) + if !ok { + return def + } + return val +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/memo/go.mod b/example/memo/go.mod new file mode 100644 index 0000000..abb5c12 --- /dev/null +++ b/example/memo/go.mod @@ -0,0 +1,7 @@ +module memo + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/memo/plg.json b/example/memo/plg.json new file mode 100644 index 0000000..dd5766e --- /dev/null +++ b/example/memo/plg.json @@ -0,0 +1,11 @@ +{ + "name": "memo", + "name_zh": "备忘录", + "name_en": "Memo/Notes", + "version": "1.0.0", + "description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["memo", "todo", "notes"], + "targets": "linux/amd64" +} diff --git a/example/memo/plugin.go b/example/memo/plugin.go new file mode 100644 index 0000000..c1dd91a --- /dev/null +++ b/example/memo/plugin.go @@ -0,0 +1,274 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Memo struct { + ID int64 `json:"id"` + Content string `json:"content"` + CreatedAt int64 `json:"created_at"` + Done bool `json:"done"` +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + memos []Memo + nextID int64 + filePath string + stopCh chan struct{} + tp string +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + p.tp = p.name + "_" + p.stopCh = make(chan struct{}) + + dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") + if err != nil || dataDirVal == "" { + dataDirVal = "." + } + p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json") + p.load() + + s.RegisterTool(p.tp+"create", sdk.ToolDef{ + Name: p.tp + "create", + Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "content": map[string]interface{}{"type": "string", "description": "备忘内容"}, + }, + "required": []string{"content"}, + }, + }, p.handleCreate) + + s.RegisterTool(p.tp+"complete", sdk.ToolDef{ + Name: p.tp + "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.handleComplete) + + s.RegisterTool(p.tp+"list", sdk.ToolDef{ + Name: p.tp + "list", + Description: "列出所有未完成的备忘条目,包含ID、内容和创建时间。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleList) + + s.RegisterStage(sdk.StagePreAction, p.stagePreAction) + + go p.periodicCheck() + + log.Printf("[%s] started, path=%s", p.name, p.filePath) + return nil +} + +func (p *Plugin) Stop() error { + close(p.stopCh) + p.save() + log.Printf("[%s] stopped", p.name) + return nil +} + +func (p *Plugin) load() { + p.mu.Lock() + defer p.mu.Unlock() + data, err := os.ReadFile(p.filePath) + if err != nil { + p.memos = nil + p.nextID = 1 + return + } + var store struct { + Memos []Memo `json:"memos"` + NextID int64 `json:"next_id"` + } + if json.Unmarshal(data, &store) != nil { + p.memos = nil + p.nextID = 1 + return + } + p.memos = store.Memos + p.nextID = store.NextID + if p.memos == nil { + p.memos = []Memo{} + } + if p.nextID < 1 { + p.nextID = 1 + } +} + +func (p *Plugin) save() { + data, _ := json.MarshalIndent(map[string]interface{}{ + "memos": p.memos, + "next_id": p.nextID, + }, "", " ") + os.WriteFile(p.filePath, data, 0644) +} + +func (p *Plugin) pendingCount() int { + p.mu.RLock() + defer p.mu.RUnlock() + n := 0 + for _, m := range p.memos { + if !m.Done { + n++ + } + } + return n +} + +func (p *Plugin) pendingMemos() []Memo { + p.mu.RLock() + defer p.mu.RUnlock() + var out []Memo + for _, m := range p.memos { + if !m.Done { + out = append(out, m) + } + } + return out +} + +func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error { + n := p.pendingCount() + if n == 0 { + return nil + } + ctx.Lock() + ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ + "role": "system", + "content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp), + }) + ctx.Unlock() + return nil +} + +func (p *Plugin) periodicCheck() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + n := p.pendingCount() + if n == 0 { + continue + } + if p.sdk != nil { + p.sdk.InjectInterruptText(p.name, p.name, + fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n)) + } + } + } +} + +func (p *Plugin) handleCreate(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.nextID, + Content: content, + CreatedAt: time.Now().Unix(), + Done: false, + } + p.nextID++ + p.memos = append(p.memos, memo) + p.mu.Unlock() + p.save() + + return map[string]interface{}{ + "content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID), + "id": memo.ID, + }, nil +} + +func (p *Plugin) handleComplete(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[i].Done { + p.memos[i].Done = true + found = true + break + } + } + p.mu.Unlock() + + if !found { + return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil + } + p.save() + + return map[string]interface{}{ + "content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)), + }, nil +} + +func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) { + memos := p.pendingMemos() + if len(memos) == 0 { + return map[string]interface{}{ + "content": "暂无未完成的备忘", + }, nil + } + + var sb strings.Builder + for i, m := range memos { + t := 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, t)) + } + + 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 NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/ocr/go.mod b/example/ocr/go.mod new file mode 100644 index 0000000..5cb1257 --- /dev/null +++ b/example/ocr/go.mod @@ -0,0 +1,7 @@ +module ocr + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/ocr/plg.json b/example/ocr/plg.json new file mode 100644 index 0000000..c907591 --- /dev/null +++ b/example/ocr/plg.json @@ -0,0 +1,11 @@ +{ + "name": "ocr", + "name_zh": "OCR 文字识别", + "name_en": "OCR Text Recognition", + "version": "1.0.0", + "description": "图片文字识别工具,基于 Tesseract OCR 引擎。支持从 URL 或 base64 图片中提取文字内容。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["ocr", "image", "text"], + "targets": "linux/amd64" +} diff --git a/example/ocr/plugin.go b/example/ocr/plugin.go new file mode 100644 index 0000000..458d892 --- /dev/null +++ b/example/ocr/plugin.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +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.RegisterTool(tp+"ocr_image", sdk.ToolDef{ + Name: tp + "ocr_image", + Description: "对图片进行OCR文字识别,支持中文和英文。可传入图片URL或base64编码。返回识别出的文本内容。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "image_url": map[string]interface{}{"type": "string", "description": "图片的HTTP/HTTPS URL,与 image_data 二选一"}, + "image_data": map[string]interface{}{"type": "string", "description": "图片的base64编码数据(不含 data:image/... 前缀),与 image_url 二选一"}, + "language": map[string]interface{}{"type": "string", "description": "识别语言,默认 chi_sim+eng(中文简体+英文),可选 chi_sim / eng / chi_sim+eng"}, + }, + }, + }, p.handleOcrImage) + + log.Printf("[%s] plugin started", p.name) + return nil +} + +func (p *Plugin) Stop() error { + return nil +} + +func (p *Plugin) handleOcrImage(args map[string]interface{}) (interface{}, error) { + imageURL, _ := args["image_url"].(string) + imageData, _ := args["image_data"].(string) + language, _ := args["language"].(string) + + if imageURL == "" && imageData == "" { + return map[string]interface{}{"error": "请提供 image_url 或 image_data"}, nil + } + + tmpDir, err := os.MkdirTemp("", "ocr-*") + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("创建临时目录失败: %v", err)}, nil + } + defer os.RemoveAll(tmpDir) + + inputPath := filepath.Join(tmpDir, "input.png") + + if imageData != "" { + data := strings.TrimSpace(imageData) + if idx := strings.Index(data, "base64,"); idx >= 0 { + data = data[idx+7:] + } + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("base64解码失败: %v", err)}, nil + } + if err := os.WriteFile(inputPath, decoded, 0644); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil + } + } else { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(imageURL) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("下载图片失败: %v", err)}, nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return map[string]interface{}{"error": fmt.Sprintf("下载图片返回状态码 %d", resp.StatusCode)}, nil + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("读取图片数据失败: %v", err)}, nil + } + if err := os.WriteFile(inputPath, data, 0644); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("写入临时文件失败: %v", err)}, nil + } + } + + if language == "" { + language = "chi_sim+eng" + } + + outputPath := filepath.Join(tmpDir, "output") + + argsList := []string{inputPath, outputPath, "-l", language, "--psm", "3"} + cmd := exec.Command("tesseract", argsList...) + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return map[string]interface{}{"error": fmt.Sprintf("OCR识别失败: %v (stderr: %s)", err, stderr.String())}, nil + } + + resultFile := outputPath + ".txt" + text, err := os.ReadFile(resultFile) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("读取OCR结果失败: %v", err)}, nil + } + + recognized := strings.TrimSpace(string(text)) + if recognized == "" { + return map[string]interface{}{"text": "", "message": "未识别出文字内容"}, nil + } + + return map[string]interface{}{ + "text": recognized, + "length": len(recognized), + "language": language, + }, nil +} + + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/qq/go.mod b/example/qq/go.mod new file mode 100644 index 0000000..3185ccf --- /dev/null +++ b/example/qq/go.mod @@ -0,0 +1,7 @@ +module qq + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/qq/plg.json b/example/qq/plg.json new file mode 100644 index 0000000..e62ba9e --- /dev/null +++ b/example/qq/plg.json @@ -0,0 +1,11 @@ +{ + "name": "qq", + "name_zh": "QQ消息", + "name_en": "qq", + "version": "1.0.0", + "description": "QQ 消息收发插件,通过 NapCat 协议桥接", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["qq", "messaging"], + "targets": "linux/amd64" +} diff --git a/example/qq/plugin.go b/example/qq/plugin.go new file mode 100644 index 0000000..4fdf949 --- /dev/null +++ b/example/qq/plugin.go @@ -0,0 +1,1977 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type SavedMessage struct { + LocalID int64 `json:"local_id"` + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + GroupID int64 `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + MessageType string `json:"message_type"` + Text string `json:"text"` + RawText string `json:"raw_text,omitempty"` + ReplyToID int64 `json:"reply_to_id,omitempty"` + ReplyToText string `json:"reply_to_text,omitempty"` + HasImage bool `json:"has_image,omitempty"` + HasFile bool `json:"has_file,omitempty"` + FilePath string `json:"file_path,omitempty"` + Time int64 `json:"time"` +} + +const maxMessages = 2000 + +type ForwardRule struct { + GroupID int64 `json:"group_id"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + Template string `json:"template"` +} + +func rconSend(host string, port int, password, cmd string) error { + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return fmt.Errorf("rcon dial: %w", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + buf := make([]byte, 4096) + // Login + pkt := rconPacket(1, 3, password) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon login write: %w", err) + } + if _, err := io.ReadFull(conn, buf[:12]); err != nil { + return fmt.Errorf("rcon login read: %w", err) + } + // Command + pkt = rconPacket(2, 2, cmd) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon cmd write: %w", err) + } + n, err := io.ReadFull(conn, buf[:12]) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("rcon cmd read: %w (n=%d)", err, n) + } + return nil +} + +func rconPacket(id, typ int32, body string) []byte { + b := []byte(body) + b = append(b, 0) // null terminator + b = append(b, 0) // padding + length := 4 + 4 + len(b) + pkt := make([]byte, 4+len(b)) + binary.LittleEndian.PutUint32(pkt, uint32(length)) + binary.LittleEndian.PutUint32(pkt[4:], uint32(id)) + binary.LittleEndian.PutUint32(pkt[8:], uint32(typ)) + copy(pkt[12:], b) + return pkt +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + messages []*SavedMessage + nextID int64 + listenAddr string + napcatURL string + remoteDir string + filesDir string + adminID int64 + botID int64 + botNickname string + dmPolicy string + groupPolicy string + httpClient *http.Client + allowFrom map[int64]struct{} + groupAllowFrom map[int64]struct{} + srv *http.Server + groupNameCache map[int64]string + agentfsDir string +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录(CQ file/image 自动下载到此目录)", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"}) + + settings := s.Settings() + + p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") + p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") + p.adminID = getSetting[int64](settings, "admin", 0) + p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) + p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) + p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) + p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) + p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") + p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/") + p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/") + os.MkdirAll(p.remoteDir, 0755) + + p.httpClient = &http.Client{Timeout: 5 * time.Second} + + // 从 NapCat 获取 Bot 身份(阻塞等待,最多 5s) + p.fetchBotInfo() + if p.botID == 0 { + log.Printf("[qq] warning: 获取 Bot 身份失败,群 @ 检查将拒绝所有未提及消息") + } + + tp := p.name + "_" + + botInfo := "" + if p.botNickname != "" { + botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname) + if p.botID > 0 { + botInfo += fmt.Sprintf(",QQ号是%d", p.botID) + } + botInfo += "。" + } + + // ---- 注册输出通道 ---- + s.RegisterOutputChannel("qq", sdk.CapText|sdk.CapFile|sdk.CapImage|sdk.CapAudio, + `发送QQ群聊/私聊消息,支持文字和语音。content JSON 格式: +{ + "content": "消息正文(必填)", + "group_id": 123456, // 群号(与 user_id 二选一) + "user_id": 123456, // QQ号(与 group_id 二选一) + "as_voice": false // 可选,true 则将文本转为语音发送(使用 edge-tts) +}`, + p.handleChannelOutput) + + // ---- 消息 ---- + p.regTool(s, tp+"get_message", botInfo+"获取QQ消息正文和详细信息。可通过local_id(中断消息中的id号)或message_id(NapCat消息ID,reply_to中的id)查找。返回消息的完整信息包括发送者、引用回复(reply_to)、图片/文件标记等。若消息有引用回复,建议再用qq_get_history拉取最近消息确认上下文。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "local_id": map[string]interface{}{"type": "integer", "description": "本地消息ID(来自中断消息中的id号),与message_id二选一"}, + "message_id": map[string]interface{}{"type": "integer", "description": "NapCat消息ID(来自reply_to.message_id),与local_id二选一"}, + }, + }, p.handleGetMessage) + + p.regTool(s, tp+"send_file", "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "目标群号(与user_id二选一)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号(与group_id二选一)"}, + "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, + "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, + "as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送(true)还是作为文件(false,默认)"}, + }, + }, p.handleSendFile) + + p.regTool(s, tp+"get_history", "获取QQ群聊/私聊最近历史消息。当收到引用回复消息或需要了解对话上下文时应优先调用此工具查看前后文。返回值每条格式为 [时间] 发送者: 消息内容", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, + "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, + }, "required": []string{}, + }, p.handleGetHistory) + + // ---- 查询 ---- + p.regTool(s, tp+"get_groups", "获取QQ群列表,可按关键词搜索群名", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, p.handleGetGroups) + + p.regTool(s, tp+"get_friends", "获取QQ好友列表,可按昵称/备注关键词搜索", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, p.handleGetFriends) + + p.regTool(s, tp+"resolve_name", "将QQ号或群号解析为可读的用户昵称或群名称", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, + }, + }, p.handleResolveName) + + p.regTool(s, tp+"resolve_nickname", "按昵称/备注/群名片搜索QQ用户,返回匹配的QQ号和详细信息。支持搜索好友列表或指定群成员。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(昵称/备注/群名片)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "所在群号(可选),不传则搜索好友列表"}, + }, "required": []string{"keyword"}, + }, p.handleResolveNickname) + + p.regTool(s, tp+"get_group_member_info", "获取QQ群成员详细信息", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号"}, + }, "required": []string{"group_id", "user_id"}, + }, p.handleGetGroupMemberInfo) + + // ---- 群管理 ---- + p.regTool(s, tp+"group_manage", "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "操作命令"}, + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"}, + "message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"}, + "name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"}, + "card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"}, + "title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"}, + "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"}, + "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"}, + "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"}, + "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"}, + "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, + }, + }, p.handleGroupManage) + + p.regTool(s, tp+"friend_action", "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, + "flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"}, + "remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"}, + "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, + }, + }, p.handleFriendAction) + + // ---- 文件 ---- + p.regTool(s, tp+"get_group_files", "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, + "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, + "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, + }, + }, p.handleGetGroupFiles) + + p.regTool(s, tp+"upload_group_file", "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, + "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, + "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, + }, "required": []string{"group_id", "file"}, + }, p.handleUploadGroupFile) + + // ---- 文档/视频/网页工具 ---- + p.regTool(s, tp+"read_document", "读取文档内容文本。支持 PDF、DOCX、DOC、XLSX、XLS、PPTX、PPT、TXT、CSV、MD 格式。使用 libreoffice + pandoc 转换提取文本,返回前 20000 字符。适合处理用户发来的文档文件。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "文档文件路径(已保存到本地的文件路径)"}, + }, "required": []string{"path"}, + }, p.handleReadDocument) + + p.regTool(s, tp+"video_download", "下载视频到本地。支持 B站、YouTube 等主流视频网站(通过 yt-dlp)。先调用 info_only 查看视频信息,再下载。下载后文件保存在 agentfs 目录。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "视频分享链接"}, + "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、时长、清晰度列表),不下"}, + }, "required": []string{"url"}, + }, p.handleVideoDownload) + + // ---- 附加 ---- + p.regTool(s, tp+"send_like", "给QQ好友点赞/戳一戳", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, + "times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20,默认1"}, + }, "required": []string{"user_id"}, + }, p.handleSendLike) + + s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools) + + // ---- HTTP server for NapCat webhook ---- + mux := http.NewServeMux() + mux.HandleFunc("/", p.handleWebhook) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"ok"}`)) + }) + p.srv = &http.Server{Addr: p.listenAddr, Handler: mux} + go func() { + log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL) + if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[qq] http: %v", err) + } + }() + + log.Printf("[qq] plugin started: %s (%d tools)", p.name, 15) + return nil +} + +func (p *Plugin) Stop() error { + if p.srv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + p.srv.Shutdown(ctx) + } + return nil +} + +func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler) { + s.RegisterTool(name, sdk.ToolDef{Name: name, Description: desc, Parameters: params}, handler) +} + +// ======== Bot Identity ======== + +func (p *Plugin) fetchBotInfo() { + resp, err := p.rawNapcat("get_login_info", nil) + if err != nil { + log.Printf("[qq] fetch login info: %v", err) + return + } + var info struct { + Status string `json:"status"` + Data *struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(resp), &info); err != nil { + log.Printf("[qq] parse login info: %v", err) + return + } + if info.Data != nil { + p.botID = info.Data.UserID + p.botNickname = info.Data.Nickname + log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID) + } +} + +// rawNapcat sends a request to NapCat and returns raw JSON string. +func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) { + data, _ := json.Marshal(params) + url := fmt.Sprintf("%s/%s", p.napcatURL, action) + resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("napcat %s: %w", action, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil +} + +// getSetting reads a setting from the SDK; returns fallback if unset or wrong type. +func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err != nil || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if str, ok := v.(string); ok { + return any(str).(T) + } + case int64: + switch val := v.(type) { + case float64: + return any(int64(val)).(T) + case string: + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return any(n).(T) + } + } + case float64: + switch val := v.(type) { + case float64: + return any(val).(T) + case string: + if n, err := strconv.ParseFloat(val, 64); err == nil { + return any(n).(T) + } + } + } + return fallback +} + +func normalizePolicy(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "allowlist": + return "allowlist" + case "disabled": + return "disabled" + default: + return "open" + } +} + +func parseIDSet(raw string) map[int64]struct{} { + out := make(map[int64]struct{}) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if n, err := strconv.ParseInt(part, 10, 64); err == nil { + out[n] = struct{}{} + } + } + return out +} + +// isAtBot checks if the message contains an @-mention of the bot. +func (p *Plugin) isAtBot(msg interface{}) bool { + segments, ok := msg.([]interface{}) + if !ok { + return false + } + botIDStr := strconv.FormatInt(p.botID, 10) + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + if s["type"] == "at" { + if data, ok := s["data"].(map[string]interface{}); ok { + if qq, ok := data["qq"]; ok { + switch v := qq.(type) { + case string: + if v == botIDStr || v == "all" { + return true + } + case float64: + if int64(v) == p.botID { + return true + } + } + } + } + } + } + return false +} + +// ======== Webhook ======== + +func (p *Plugin) isDMAllowed(userID int64) bool { + switch p.dmPolicy { + case "disabled": + return false + case "allowlist": + _, ok := p.allowFrom[userID] + return ok + default: + return true + } +} + +func (p *Plugin) isGroupAllowed(groupID int64) bool { + switch p.groupPolicy { + case "disabled": + return false + case "allowlist": + _, ok := p.groupAllowFrom[groupID] + return ok + default: + return true + } +} + +func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error { + ctx.Lock() + defer ctx.Unlock() + if len(ctx.ToolCalls) == 0 { + return nil + } + tc := &ctx.ToolCalls[0] + if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" { + if file, ok := tc.Arguments["file"].(string); ok { + tc.Arguments["file"] = p.sensitiveFilter(file) + } + } + if tc.Name == p.name+"_group_manage" { + cmd, _ := tc.Arguments["command"].(string) + if requiresConfirmGroupCommand(cmd) { + if ok, _ := tc.Arguments["confirm"].(bool); !ok { + msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) + ctx.Response = &msg + return nil + } + } + } + if tc.Name == p.name+"_friend_action" { + cmd, _ := tc.Arguments["command"].(string) + if requiresConfirmFriendCommand(cmd) { + if ok, _ := tc.Arguments["confirm"].(bool); !ok { + msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) + ctx.Response = &msg + return nil + } + } + } + return nil +} + +func requiresConfirmGroupCommand(cmd string) bool { + switch cmd { + case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create": + return true + default: + return false + } +} + +func requiresConfirmFriendCommand(cmd string) bool { + switch cmd { + case "delete", "block", "approve-friend", "reject-friend": + return true + default: + return false + } +} + +func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + body, _ := io.ReadAll(r.Body) + var evt struct { + PostType string `json:"post_type"` + MessageType string `json:"message_type,omitempty"` + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id,omitempty"` + GroupID int64 `json:"group_id,omitempty"` + RawMessage string `json:"raw_message,omitempty"` + Message interface{} `json:"message,omitempty"` + Time int64 `json:"time"` + Sender *struct { + Nickname string `json:"nickname"` + Card string `json:"card,omitempty"` + } `json:"sender,omitempty"` + } + if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" { + w.WriteHeader(http.StatusOK) + return + } + + rawCQ := evt.RawMessage + text := rawCQ + if text == "" { + if s, ok := evt.Message.(string); ok { + text = s + } + } + if text == "" { + w.WriteHeader(http.StatusOK) + return + } + + // 提取引用回复和附件信息 + var replyToID int64 + var hasImage, hasFile bool + var filePath string + + // 如果有结构化消息段,解析并下载文件/图片,生成可读文本 + if segments, ok := evt.Message.([]interface{}); ok && len(segments) > 0 { + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + typ, _ := s["type"].(string) + data, _ := s["data"].(map[string]interface{}) + if data == nil { + continue + } + switch typ { + case "reply": + if idStr, ok := data["id"].(string); ok { + replyToID, _ = strconv.ParseInt(idStr, 10, 64) + } else if id, ok := data["id"].(float64); ok { + replyToID = int64(id) + } + case "image": + hasImage = true + case "file": + hasFile = true + if name, ok := data["name"].(string); ok && name != "" { + filePath = name + } + } + } + parsedText := p.processMessageSegments(segments) + if parsedText != "" { + text = parsedText + } + } + + // 查找被引用的消息正文 + replyToText := "" + if replyToID > 0 { + for _, sm := range p.messages { + if sm.MessageID == replyToID { + replyToText = sm.Text + break + } + } + } + + if evt.MessageType == "private" { + if !p.isDMAllowed(evt.UserID) { + w.WriteHeader(http.StatusOK) + return + } + } + if evt.MessageType == "group" { + if !p.isGroupAllowed(evt.GroupID) { + w.WriteHeader(http.StatusOK) + return + } + // 群消息必须 @ 机器人才响应 + if p.botID == 0 { + log.Printf("[qq] bot ID unknown, rejecting group message from %d", evt.GroupID) + w.WriteHeader(http.StatusOK) + return + } + if !p.isAtBot(evt.Message) { + w.WriteHeader(http.StatusOK) + return + } + } + + nickname := "" + if evt.Sender != nil { + nickname = evt.Sender.Nickname + if evt.Sender.Card != "" { + nickname = evt.Sender.Card + } + } + + p.mu.Lock() + localID := p.nextID + p.nextID++ + + msg := &SavedMessage{ + LocalID: localID, MessageID: evt.MessageID, UserID: evt.UserID, Nickname: nickname, + GroupID: evt.GroupID, MessageType: evt.MessageType, Text: text, RawText: rawCQ, + ReplyToID: replyToID, ReplyToText: replyToText, + HasImage: hasImage, HasFile: hasFile, FilePath: filePath, + Time: evt.Time, + } + groupName := "" + if evt.MessageType == "group" { + if n, ok := p.groupNameCache[evt.GroupID]; ok { + groupName = n + } else { + groupName = "群聊" + go func(gid int64) { + resp, err := p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) + if err != nil { + return + } + raw, _ := resp.(string) + var gi struct { + Data *struct { + GroupName string `json:"group_name"` + } `json:"data"` + } + if json.Unmarshal([]byte(raw), &gi) == nil && gi.Data != nil && gi.Data.GroupName != "" { + p.mu.Lock() + p.groupNameCache[gid] = gi.Data.GroupName + p.mu.Unlock() + } + }(evt.GroupID) + } + msg.GroupName = groupName + } + p.messages = append(p.messages, msg) + if len(p.messages) > maxMessages { + p.messages = p.messages[1:] + } + + tp := p.name + "_" + outputTool := "output_send__" + p.name + var interrupt string + if evt.MessageType == "group" { + interrupt = fmt.Sprintf("来自%s的(%s)群聊消息,通过id%d使用%sget_message工具获取消息正文。如果消息包含引用回复,使用%sget_history(group_id=%d)拉取最近消息以确认引用上下文。获取后使用%s工具回复该群聊(用%s_help查看JSON格式要求)", nickname, groupName, localID, tp, tp, evt.GroupID, outputTool, outputTool) + } else { + interrupt = fmt.Sprintf("来自%s的私聊消息,通过id%d使用%sget_message工具获取消息正文。如果消息包含引用回复,使用%sget_history(user_id=%d)拉取最近消息以确认引用上下文。获取后使用%s工具回复对方(用%s_help查看JSON格式要求)", nickname, localID, tp, tp, evt.UserID, outputTool, outputTool) + } + if p.adminID > 0 && evt.UserID == p.adminID { + interrupt = "【重要!老大消息】" + interrupt + } + p.mu.Unlock() + + if evt.MessageType == "group" && p.sdk != nil { + rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]") + var rules []ForwardRule + if json.Unmarshal([]byte(rulesRaw), &rules) == nil { + for _, rule := range rules { + if evt.GroupID == rule.GroupID { + mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) + go func(r ForwardRule, msg string) { + if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { + log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) + } + }(rule, mcMsg) + } + } + } + } + + if p.sdk != nil { + p.sdk.InjectInterruptText(p.name, p.name, interrupt) + } + w.WriteHeader(http.StatusOK) +} + +// ======== Tool Handlers ======== + +func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { + id, err := convInt64(args["local_id"]) + msgID, msgIDErr := convInt64(args["message_id"]) + + if err != nil && msgIDErr != nil { + return map[string]interface{}{ + "content": "需要提供 local_id 或 message_id 参数", + "not_found": true, + }, nil + } + + p.mu.RLock() + defer p.mu.RUnlock() + + var found *SavedMessage + for _, m := range p.messages { + if err == nil && m.LocalID == id { + found = m + break + } + if msgIDErr == nil && m.MessageID == msgID { + found = m + break + } + } + + if found == nil { + key := id + if err != nil { + key = msgID + } + return map[string]interface{}{ + "content": fmt.Sprintf("消息 %d 未找到。消息可能已被处理过期,或插件重启后本地缓存已清空。请使用 qq_get_history 从 NapCat 拉取历史消息。", key), + "local_id": id, + "message_id": msgID, + "not_found": true, + }, nil + } + + from := found.Nickname + if found.GroupName != "" && found.GroupName != fmt.Sprintf("%d", found.GroupID) { + from = fmt.Sprintf("%s(%s)", found.Nickname, found.GroupName) + } + loc := "私聊" + if found.MessageType == "group" { + loc = "群聊" + } + result := map[string]interface{}{ + "content": found.Text, + "local_id": found.LocalID, + "message_id": found.MessageID, + "from": from, + "type": loc, + "user_id": found.UserID, + "group_id": found.GroupID, + "nickname": found.Nickname, + "message_type": found.MessageType, + "time": time.Unix(found.Time, 0).Format("15:04:05"), + } + if found.RawText != "" { + result["raw_text"] = found.RawText + } + if found.ReplyToID > 0 { + replyInfo := map[string]interface{}{ + "message_id": found.ReplyToID, + } + if found.ReplyToText != "" { + replyInfo["content"] = found.ReplyToText + } + // 在本机缓存中查找被引用的消息 local_id + for _, sm := range p.messages { + if sm.MessageID == found.ReplyToID { + replyInfo["local_id"] = sm.LocalID + break + } + } + result["reply_to"] = replyInfo + } + if found.HasImage { + result["has_image"] = true + } + if found.HasFile { + result["has_file"] = true + } + if found.FilePath != "" { + result["file_name"] = found.FilePath + } + + return result, nil +} + +// fixJSON 尝试修复 LLM 生成的常见 JSON 格式错误: +// 未转义的双引号出现在字符串值内(如 "content":"他说"你好"") +func fixJSON(raw string) string { + var test interface{} + if json.Unmarshal([]byte(raw), &test) == nil { + return raw + } + + contentPrefix := `"content":"` + idx := strings.Index(raw, contentPrefix) + if idx < 0 { + return raw + } + start := idx + len(contentPrefix) + + suffixPatterns := []string{`","user_id`, `","group_id`, `","as_voice`, `"}`} + bestEnd := -1 + for _, suffix := range suffixPatterns { + if j := strings.Index(raw[start:], suffix); j >= 0 { + end := start + j + if bestEnd < 0 || end < bestEnd { + bestEnd = end + } + } + } + if bestEnd < 0 { + return raw + } + + contentVal := raw[start:bestEnd] + var b strings.Builder + b.Grow(len(contentVal) + 4) + for i := 0; i < len(contentVal); i++ { + if contentVal[i] == '\\' && i+1 < len(contentVal) { + b.WriteByte(contentVal[i]) + i++ + b.WriteByte(contentVal[i]) + continue + } + if contentVal[i] == '"' { + b.WriteByte('\\') + } + b.WriteByte(contentVal[i]) + } + escaped := b.String() + return raw[:start] + escaped + raw[bestEnd:] +} + +// handleChannelOutput — output_send(channel="qq") 的处理器 +// content 参数为 JSON 字符串,格式: +// {"content":"消息正文","group_id":123} +// {"content":"消息正文","user_id":456,"as_voice":true} +func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) { + content, _ := args["content"].(string) + log.Printf("[qq] handleChannelOutput content=%q type=%T len=%d", content, args["content"], len(content)) + if content == "" { + return nil, fmt.Errorf("content 参数是必需的 JSON 字符串。请用 output_send__qq_help 查看格式说明") + } + + var msg struct { + Content string `json:"content"` + UserID int64 `json:"user_id,omitempty"` + GroupID int64 `json:"group_id,omitempty"` + AsVoice bool `json:"as_voice,omitempty"` + } + if err := json.Unmarshal([]byte(content), &msg); err != nil || msg.Content == "" { + fixed := fixJSON(content) + if err2 := json.Unmarshal([]byte(fixed), &msg); err2 != nil || msg.Content == "" { + help := p.buildOutputHelp() + return nil, fmt.Errorf("content 参数 JSON 格式错误(%v)。检查 content 字段值内的双引号是否已用 \\ 转义,以及整个 JSON 是否合法。\n\n正确的格式示例:\n%s", err, help) + } + log.Printf("[qq] JSON auto-fixed by escaping quotes: %q -> %q", content, fixed) + } + + text := p.sensitiveFilter(msg.Content) + + // 语音模式:edge-tts 转语音后通过 NapCat 发送 + if msg.AsVoice { + audioFile, err := p.ttsToFile(msg.Content) + if err != nil { + return nil, fmt.Errorf("语音生成失败: %w", err) + } + os.MkdirAll(p.remoteDir, 0755) + dest := filepath.Join(p.remoteDir, filepath.Base(audioFile)) + data, err := os.ReadFile(audioFile) + if err != nil { + return nil, fmt.Errorf("读取音频文件失败: %w", err) + } + if err := os.WriteFile(dest, data, 0644); err != nil { + return nil, fmt.Errorf("写入共享目录失败: %w", err) + } + os.Remove(audioFile) + uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest)) + cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri) + if msg.GroupID != 0 { + return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": cqMsg}) + } + if msg.UserID != 0 { + return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": cqMsg}) + } + return nil, fmt.Errorf("JSON 中需要 group_id 或 user_id") + } + + // 文字模式 + if msg.GroupID != 0 { + return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": text}) + } + if msg.UserID != 0 { + return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": text}) + } + return nil, fmt.Errorf("content JSON 中需要 group_id 或 user_id 字段。请用 output_send__qq_help 查看格式说明") +} + +func (p *Plugin) buildOutputHelp() string { + return `群聊回复:{"content":"你的消息正文","group_id":123456789} +私聊回复:{"content":"你的消息正文","user_id":123456789} +语音发送:{"content":"你的消息正文","group_id":123456789,"as_voice":true} + +注意:content 字段值内部如果再出现双引号,必须用反斜杠转义!!! +错误的例子(双引号未转义):{"content":"他说"你好"","user_id":123} +正确的例子(双引号已转义):{"content":"他说\\"你好\\"","user_id":123} +另外常见错误:如果正文包含网址或代码,也可能出现未转义的双引号,同样需要转义。` +} + +// ttsToFile 用 edge-tts 将文本转为音频文件,返回临时文件路径 +func (p *Plugin) ttsToFile(text string) (string, error) { + // 清理文本中的特殊字符 + clean := strings.Map(func(r rune) rune { + if r == '"' || r == '\n' || r == '\r' { + return ' ' + } + return r + }, text) + clean = strings.TrimSpace(clean) + if clean == "" { + clean = " " + } + + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("qq_tts_%d.mp3", time.Now().UnixNano())) + cmd := exec.Command("edge-tts", + "--voice", "zh-CN-XiaoxiaoNeural", + "--text", clean, + "--write-media", tmpFile, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("edge-tts: %w\nstderr: %s", err, stderr.String()) + } + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + return "", fmt.Errorf("edge-tts 未生成输出文件") + } + return tmpFile, nil +} + +func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { + gid, gerr := convInt64(args["group_id"]) + uid, uerr := convInt64(args["user_id"]) + if gerr != nil && uerr != nil { + return nil, fmt.Errorf("need group_id or user_id") + } + filePath, _ := args["file"].(string) + if filePath == "" { + return nil, fmt.Errorf("need file path") + } + name, _ := args["name"].(string) + if name == "" { + name = filepath.Base(filePath) + } + name = p.sensitiveFilter(name) + asImage, _ := args["as_image"].(bool) + + // copy to remote dir for NapCat container access + dest := filepath.Join(p.remoteDir, name) + srcData, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + if err := os.WriteFile(dest, srcData, 0644); err != nil { + return nil, fmt.Errorf("write remote: %w", err) + } + + uri := fmt.Sprintf("file:///app/files/%s", name) + var cqMsg string + if asImage { + cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri) + } else { + cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name) + } + + params := map[string]interface{}{"message": cqMsg} + if gerr == nil { + params["group_id"] = gid + return p.napcat("send_group_msg", params) + } + params["user_id"] = uid + return p.napcat("send_private_msg", params) +} + +func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { + gid, gerr := convInt64(args["group_id"]) + uid, uerr := convInt64(args["user_id"]) + count := 10 + if c, err := convInt64(args["count"]); err == nil && c > 0 { + count = int(c) + } + + var endpoint string + var params map[string]interface{} + if gerr == nil { + endpoint = "get_group_msg_history" + params = map[string]interface{}{"group_id": gid, "count": count} + } else if uerr == nil { + endpoint = "get_friend_msg_history" + params = map[string]interface{}{"user_id": uid, "count": count} + } else { + return nil, fmt.Errorf("need group_id or user_id") + } + + rawResp, err := p.napcat(endpoint, params) + if err != nil { + return nil, err + } + rawStr, _ := rawResp.(string) + if rawStr == "" { + return map[string]interface{}{"messages": []interface{}{}, "note": "未获取到历史消息"}, nil + } + + // 解析 NapCat 响应,提取消息列表 + var resp struct { + Data *struct { + Messages []interface{} `json:"messages"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(rawStr), &resp); err != nil || resp.Data == nil { + return map[string]interface{}{"raw_response": rawStr, "note": "解析 NapCat 响应失败"}, nil + } + + // 格式化消息为可读文本 + var lines []string + for _, m := range resp.Data.Messages { + msg, ok := m.(map[string]interface{}) + if !ok { + continue + } + sender := "" + if s, ok := msg["sender"].(map[string]interface{}); ok { + if nick, _ := s["nickname"].(string); nick != "" { + sender = nick + } + if card, _ := s["card"].(string); card != "" { + sender = card + } + } + msgText, _ := msg["raw_message"].(string) + if msgText == "" { + msgText, _ = msg["message"].(string) + } + if msgText == "" { + continue + } + ts := "" + if t, ok := msg["time"].(float64); ok { + ts = time.Unix(int64(t), 0).Format("15:04") + } + line := msgText + if sender != "" { + line = sender + ": " + msgText + } + if ts != "" { + line = "[" + ts + "] " + line + } + lines = append(lines, line) + } + + if len(lines) == 0 { + return map[string]interface{}{ + "messages": []interface{}{}, + "note": "未找到历史消息,可能群内暂无消息记录", + }, nil + } + + return map[string]interface{}{ + "messages": lines, + "count": len(lines), + }, nil +} + +func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) { + return p.napcat("get_group_list", map[string]interface{}{}) +} + +func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) { + return p.napcat("get_friend_list", map[string]interface{}{}) +} + +func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) { + if uid, err := convInt64(args["user_id"]); err == nil { + return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true}) + } + if gid, err := convInt64(args["group_id"]); err == nil { + return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true}) + } + return nil, fmt.Errorf("need user_id or group_id") +} + +func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}, error) { + keyword, _ := args["keyword"].(string) + if keyword == "" { + return nil, fmt.Errorf("keyword is required") + } + keyword = strings.ToLower(keyword) + + gid, groupErr := convInt64(args["group_id"]) + if groupErr == nil { + v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) + if err != nil { + return nil, err + } + raw, _ := v.(string) + return filterMemberList(raw, keyword) + } + + v, err := p.napcat("get_friend_list", map[string]interface{}{}) + if err != nil { + return nil, err + } + raw, _ := v.(string) + return filterFriendList(raw, keyword) +} + +func filterFriendList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Remark string `json:"remark"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, f := range resp.Data { + if strings.Contains(strings.ToLower(f.Nickname), keyword) || + strings.Contains(strings.ToLower(f.Remark), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": f.UserID, + "nickname": f.Nickname, + "remark": f.Remark, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/备注包含 %q 的好友", keyword), nil + } + return matches, nil +} + +func filterMemberList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, m := range resp.Data { + if strings.Contains(strings.ToLower(m.Nickname), keyword) || + strings.Contains(strings.ToLower(m.Card), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": m.UserID, + "nickname": m.Nickname, + "card": m.Card, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/名片包含 %q 的群成员", keyword), nil + } + return matches, nil +} + +func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) +} + +func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) { + cmd, _ := args["command"].(string) + if cmd == "" { + return nil, fmt.Errorf("need command") + } + if requiresConfirmGroupCommand(cmd) { + if ok, _ := args["confirm"].(bool); !ok { + return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil + } + } + + switch cmd { + case "group-list": + return p.napcat("get_group_list", map[string]interface{}{}) + case "group-info", "member-list", "member-info", "at-all-remain", "msg-history": + gid, _ := convInt64(args["group_id"]) + if cmd == "msg-history" { + count := 10 + if c, err := convInt64(args["count"]); err == nil && c > 0 { + count = int(c) + } + return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count}) + } + if cmd == "member-info" { + uid, _ := convInt64(args["user_id"]) + return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) + } + if cmd == "at-all-remain" { + return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid}) + } + if cmd == "group-info" { + return p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) + } + return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) + + case "list-files": + gid, _ := convInt64(args["group_id"]) + folderID, _ := args["folder_id"].(string) + if folderID != "" { + return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) + } + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "pending-requests": + return p.napcat("get_group_system_msg", map[string]interface{}{}) + + case "leave": + gid, _ := convInt64(args["group_id"]) + return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid}) + + case "kick": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + reject, _ := args["reject_add"].(bool) + return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject}) + + case "ban": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + minutes := 10 + if m, err := convInt64(args["minutes"]); err == nil { + minutes = int(m) + } + return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60}) + + case "unban": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0}) + + case "rename": + gid, _ := convInt64(args["group_id"]) + name, _ := args["name"].(string) + return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name}) + + case "mute-all": + gid, _ := convInt64(args["group_id"]) + enable, _ := args["enable"].(bool) + return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable}) + + case "set-card": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + card, _ := args["card"].(string) + return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card}) + + case "set-admin": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + enable, _ := args["enable"].(bool) + return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable}) + + case "set-title": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + title, _ := args["title"].(string) + return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title}) + + case "recall": + mid, _ := convInt64(args["message_id"]) + return p.napcat("delete_msg", map[string]interface{}{"message_id": mid}) + + case "pin-msg": + mid, _ := convInt64(args["message_id"]) + return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid}) + + case "folder-create": + gid, _ := convInt64(args["group_id"]) + name, _ := args["name"].(string) + return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name}) + + default: + return nil, fmt.Errorf("unknown group_manage command: %s", cmd) + } +} + +func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) { + cmd, _ := args["command"].(string) + if requiresConfirmFriendCommand(cmd) { + if ok, _ := args["confirm"].(bool); !ok { + return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil + } + } + switch cmd { + case "list-friends": + return p.napcat("get_friend_list", map[string]interface{}{}) + case "delete": + uid, _ := convInt64(args["user_id"]) + return p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) + case "block": + uid, _ := convInt64(args["user_id"]) + // delete friend + p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) + // kick from groups + if gid, err := convInt64(args["group_id"]); err == nil { + p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) + } else { + grps, _ := p.napcat("get_group_list", map[string]interface{}{}) + if list, ok := grps.([]interface{}); ok { + for _, g := range list { + if m, ok := g.(map[string]interface{}); ok { + if gid, ok := m["group_id"].(float64); ok { + p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true}) + } + } + } + } + } + return `{"status":"ok","message":"blocked"}`, nil + case "approve-friend": + flag, _ := args["flag"].(string) + remark, _ := args["remark"].(string) + return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark}) + case "reject-friend": + flag, _ := args["flag"].(string) + return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false}) + default: + return nil, fmt.Errorf("unknown friend_action command: %s", cmd) + } +} + +func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + cmd, _ := args["command"].(string) + + switch cmd { + case "list": + folderID, _ := args["folder_id"].(string) + if folderID != "" { + return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) + } + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "search": + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "download": + fileID, _ := args["file_id"].(string) + filename, _ := args["filename"].(string) + if filename == "" { + filename = fmt.Sprintf("group_file_%s", fileID) + } + // get download URL + resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) + if err != nil { + return nil, err + } + respStr, ok := resp.(string) + if !ok { + return resp, nil + } + // parse URL from response + var parsed struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { + return resp, nil + } + dlURL := parsed.Data.URL + httpResp, err := http.Get(dlURL) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer httpResp.Body.Close() + content, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("read download: %w", err) + } + os.MkdirAll(p.filesDir, 0755) + savePath := filepath.Join(p.filesDir, filename) + if err := os.WriteFile(savePath, content, 0644); err != nil { + return nil, fmt.Errorf("save: %w", err) + } + return map[string]interface{}{ + "status": "ok", "path": savePath, "filename": filename, "size": len(content), + }, nil + + default: + return nil, fmt.Errorf("unknown get_group_files command: %s", cmd) + } +} + +func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + filePath, _ := args["file"].(string) + name, _ := args["name"].(string) + if name == "" { + name = filepath.Base(filePath) + } + name = p.sensitiveFilter(name) + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + b64 := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(data)) + + resp, err := p.napcat("send_group_msg", map[string]interface{}{ + "group_id": gid, + "message": []map[string]interface{}{ + {"type": "file", "data": map[string]interface{}{"file": b64, "name": name}}, + }, + }) + if err != nil { + return nil, err + } + return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil +} + +func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) { + uid, _ := convInt64(args["user_id"]) + times := 1 + if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 { + times = int(t) + } + return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times}) +} + +// ======== CQ Code / Message Segment Processing ======== + +func (p *Plugin) processMessageSegments(segments []interface{}) string { + if len(segments) == 0 { + return "" + } + os.MkdirAll(p.filesDir, 0755) + botIDStr := strconv.FormatInt(p.botID, 10) + var parts []string + type dlItem struct{ fileID, name string } + var dlQueue []dlItem + + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + typ, _ := s["type"].(string) + data, _ := s["data"].(map[string]interface{}) + if data == nil { + continue + } + + switch typ { + case "text": + if t, _ := data["text"].(string); t != "" { + parts = append(parts, t) + } + case "at": + qq, _ := data["qq"].(string) + if qq == "all" { + parts = append(parts, "@所有人") + } else if qq == botIDStr { + continue + } else { + parts = append(parts, "@"+qq) + } + case "face", "sface": + if id, _ := data["id"].(string); id != "" { + parts = append(parts, "[表情]") + } + case "file": + fid, _ := data["file"].(string) + name, _ := data["name"].(string) + size, _ := data["size"].(string) + sizeDesc := "" + if s, err := strconv.ParseInt(size, 10, 64); err == nil && s > 0 { + sizeDesc = fmt.Sprintf(" (%.1f MB)", float64(s)/1048576) + } + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, name}) + } + if name != "" { + parts = append(parts, fmt.Sprintf("[文件:%s%s]", name, sizeDesc)) + } else { + parts = append(parts, "[文件]") + } + case "image": + fid, _ := data["file"].(string) + summary, _ := data["summary"].(string) + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, "image_" + fid + ".jpg"}) + } + label := "图片" + if summary != "" { + label = summary + } + parts = append(parts, fmt.Sprintf("[%s]", label)) + case "video": + fid, _ := data["file"].(string) + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, "video_" + fid + ".mp4"}) + } + parts = append(parts, "[视频]") + case "reply": + if id, ok := data["id"].(float64); ok { + parts = append(parts, fmt.Sprintf("[回复消息id=%.0f]", id)) + } + case "music": + if title, _ := data["title"].(string); title != "" { + parts = append(parts, fmt.Sprintf("[音乐:%s]", title)) + } else { + parts = append(parts, "[音乐]") + } + case "share": + title, _ := data["title"].(string) + urlStr, _ := data["url"].(string) + if title != "" && urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s %s]", title, urlStr)) + } else if urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s]", urlStr)) + } + default: + if typ != "" { + parts = append(parts, "["+typ+"]") + } + } + } + + // 异步下载文件(不影响消息处理) + if len(dlQueue) > 0 { + go func(items []dlItem) { + for _, item := range items { + p.downloadFile(item.fileID, item.name) + } + }(dlQueue) + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} + +func (p *Plugin) downloadFile(fileID, filename string) string { + if fileID == "" || p.filesDir == "" { + return "" + } + os.MkdirAll(p.filesDir, 0755) + + // 处理 base64:// 前缀的内嵌文件 + if strings.HasPrefix(fileID, "base64://") { + data, err := base64.StdEncoding.DecodeString(fileID[9:]) + if err != nil { + return "" + } + if filename == "" { + filename = "file.bin" + } + filename = sanitizeFilename(filename) + localPath := filepath.Join(p.filesDir, filename) + os.WriteFile(localPath, data, 0644) + return localPath + } + + // 处理 file:// 路径 + if strings.HasPrefix(fileID, "file://") { + localFile := strings.TrimPrefix(fileID, "file://") + if _, err := os.Stat(localFile); err == nil { + return localFile + } + } + + // 通过 NapCat get_file API 获取文件信息 + raw, err := p.napcat("get_file", map[string]interface{}{"file_id": fileID}) + if err != nil { + log.Printf("[qq] get_file %s: %v", fileID, err) + return "" + } + rawStr, _ := raw.(string) + var resp struct { + Data *struct { + File string `json:"file"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + Base64 string `json:"base64"` + URL string `json:"url"` + } `json:"data"` + } + if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil { + log.Printf("[qq] parse get_file %s: bad response", fileID) + return "" + } + info := resp.Data + + if filename == "" { + filename = info.FileName + } + if filename == "" { + filename = "file_" + fileID + } + filename = sanitizeFilename(filename) + localPath := filepath.Join(p.filesDir, filename) + + // 优先 base64 + if info.Base64 != "" { + data, err := base64.StdEncoding.DecodeString(info.Base64) + if err == nil { + os.WriteFile(localPath, data, 0644) + return localPath + } + } + + // 其次 URL 下载 + if info.URL != "" { + dlResp, err := http.Get(info.URL) + if err == nil { + defer dlResp.Body.Close() + data, err := io.ReadAll(dlResp.Body) + if err == nil { + os.WriteFile(localPath, data, 0644) + return localPath + } + } + } + + // 尝试直接读取 file 路径 + if info.File != "" { + src, err := os.ReadFile(info.File) + if err == nil { + os.WriteFile(localPath, src, 0644) + return localPath + } + } + + return "" +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '_' + } + return r + }, name) + return name +} + +// ======== Tool Handlers: Document / Video / Web ======== + +func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return nil, fmt.Errorf("path is required") + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return map[string]interface{}{ + "content": fmt.Sprintf("文件不存在: %s", path), + }, nil + } + + ext := strings.ToLower(filepath.Ext(path)) + textContent := "" + + switch ext { + case ".txt", ".md", ".csv": + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + textContent = string(data) + case ".docx", ".doc", ".epub", ".html", ".htm": + textContent = p.readWithPandoc(path) + default: + // Try pandoc first, fallback to libreoffice + textContent = p.readWithPandoc(path) + if textContent == "" { + textContent = p.readWithLibreoffice(path) + } + if textContent == "" { + // last resort: read as plain text + data, err := os.ReadFile(path) + if err == nil { + textContent = string(data) + } + } + } + + if textContent == "" { + return map[string]interface{}{ + "content": fmt.Sprintf("无法提取文件内容: %s(不支持的文件格式或文件损坏)", path), + }, nil + } + + // 截断到 20000 字符 + origLen := len(textContent) + truncated := origLen > 20000 + if truncated { + textContent = textContent[:20000] + } + + result := textContent + if truncated { + result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) + } + return map[string]interface{}{ + "content": result, + "file": path, + "truncated": truncated, + }, nil +} + +func (p *Plugin) readWithPandoc(path string) string { + var out bytes.Buffer + cmd := exec.Command("pandoc", path, "-t", "plain", "--wrap=none") + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + return strings.TrimSpace(out.String()) +} + +func (p *Plugin) readWithLibreoffice(path string) string { + tmpDir, err := os.MkdirTemp("", "lo-doc-*") + if err != nil { + return "" + } + defer os.RemoveAll(tmpDir) + + cmd := exec.Command("libreoffice", "--headless", "--convert-to", "txt:Text", "--outdir", tmpDir, path) + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + + // 找生成的 txt 文件 + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".txt") { + data, err := os.ReadFile(filepath.Join(tmpDir, e.Name())) + if err == nil { + return strings.TrimSpace(string(data)) + } + } + } + return "" +} + +func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return nil, fmt.Errorf("url is required") + } + infoOnly, _ := args["info_only"].(bool) + + outputDir := filepath.Join(p.agentfsDir, "videos") + os.MkdirAll(outputDir, 0755) + + if infoOnly { + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "--dump-json", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp info: %w", err) + } + var info struct { + Title string `json:"title"` + Duration int `json:"duration"` + Webpage string `json:"webpage_url"` + Formats []struct { + FormatID string `json:"format_id"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` + Filesize int64 `json:"filesize"` + Format string `json:"format"` + } `json:"formats"` + } + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return string(out.String()), nil + } + dur := "" + if info.Duration > 0 { + dur = fmt.Sprintf("%d分%d秒", info.Duration/60, info.Duration%60) + } + lines := []string{fmt.Sprintf("🎬 %s", info.Title)} + if dur != "" { + lines = append(lines, fmt.Sprintf(" 时长: %s", dur)) + } + lines = append(lines, fmt.Sprintf(" 链接: %s", info.Webpage)) + lines = append(lines, "") + for _, f := range info.Formats { + fs := "" + if f.Filesize > 0 { + fs = fmt.Sprintf(" (%.1f MB)", float64(f.Filesize)/1048576) + } + res := "" + if f.Width > 0 && f.Height > 0 { + res = fmt.Sprintf(" %dx%d", f.Width, f.Height) + } + lines = append(lines, fmt.Sprintf(" [%s] %s%s%s", f.FormatID, f.Format, res, fs)) + } + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil + } + + // 下载 + outputTmpl := filepath.Join(outputDir, "%(title)s.%(ext)s") + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "-o", outputTmpl, "--no-playlist", "--print", "after_move:filepath", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp download: %w", err) + } + + // 解析 yt-dlp 输出的文件路径 + dlPath := strings.TrimSpace(out.String()) + if dlPath == "" { + return map[string]interface{}{ + "content": "下载完成,但无法获取文件路径", + }, nil + } + dlFilename := filepath.Base(dlPath) + var fileSize int64 = 0 + if fi, err := os.Stat(dlPath); err == nil { + fileSize = fi.Size() + } + return map[string]interface{}{ + "content": fmt.Sprintf("✅ 下载完成: %s\n 大小: %.1f MB\n 路径: %s", dlFilename, float64(fileSize)/1048576, dlPath), + "file": dlPath, + "filename": dlFilename, + }, nil +} + +// ======== NapCat HTTP Client ======== + +func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { + data, _ := json.Marshal(params) + url := fmt.Sprintf("%s/%s", p.napcatURL, action) + + resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("napcat %s: %w", action, err) + } + defer resp.Body.Close() + + var raw json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("napcat decode %s: %w", action, err) + } + return string(raw), nil +} + +// ======== Helpers ======== + + +var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) +var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) +var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) + +func (p *Plugin) sensitiveFilter(text string) string { + if p.remoteDir != "" { + text = strings.ReplaceAll(text, p.remoteDir, "[remote]") + } + if p.filesDir != "" { + text = strings.ReplaceAll(text, p.filesDir, "[files]") + } + + text = reAPIKey.ReplaceAllString(text, "$1=***") + text = reSKKey.ReplaceAllString(text, "sk-***") + text = reInternalIP.ReplaceAllString(text, "[IP]") + return text +} + +func convInt64(v interface{}) (int64, error) { + switch n := v.(type) { + case int64: + return n, nil + case float64: + return int64(n), nil + case int: + return int64(n), nil + case json.Number: + return n.Int64() + case string: + return strconv.ParseInt(n, 10, 64) + } + return 0, fmt.Errorf("cannot convert %T to int64", v) +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{ + name: name, + nextID: 1, + messages: make([]*SavedMessage, 0, maxMessages), + groupNameCache: make(map[int64]string), + allowFrom: make(map[int64]struct{}), + groupAllowFrom: make(map[int64]struct{}), + dmPolicy: "open", + groupPolicy: "open", + }, nil +} + + + + + + diff --git a/example/qq/plugin.go.bak b/example/qq/plugin.go.bak new file mode 100644 index 0000000..ea6ee1e --- /dev/null +++ b/example/qq/plugin.go.bak @@ -0,0 +1,1728 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type SavedMessage struct { + LocalID int64 `json:"local_id"` + MessageID int64 `json:"message_id"` + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + GroupID int64 `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + MessageType string `json:"message_type"` + Text string `json:"text"` + Time int64 `json:"time"` +} + +const maxMessages = 2000 + +type ForwardRule struct { + GroupID int64 `json:"group_id"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + Template string `json:"template"` +} + +func rconSend(host string, port int, password, cmd string) error { + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return fmt.Errorf("rcon dial: %w", err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + buf := make([]byte, 4096) + // Login + pkt := rconPacket(1, 3, password) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon login write: %w", err) + } + if _, err := io.ReadFull(conn, buf[:12]); err != nil { + return fmt.Errorf("rcon login read: %w", err) + } + // Command + pkt = rconPacket(2, 2, cmd) + if _, err := conn.Write(pkt); err != nil { + return fmt.Errorf("rcon cmd write: %w", err) + } + n, err := io.ReadFull(conn, buf[:12]) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("rcon cmd read: %w (n=%d)", err, n) + } + return nil +} + +func rconPacket(id, typ int32, body string) []byte { + b := []byte(body) + b = append(b, 0) // null terminator + b = append(b, 0) // padding + length := 4 + 4 + len(b) + pkt := make([]byte, 4+len(b)) + binary.LittleEndian.PutUint32(pkt, uint32(length)) + binary.LittleEndian.PutUint32(pkt[4:], uint32(id)) + binary.LittleEndian.PutUint32(pkt[8:], uint32(typ)) + copy(pkt[12:], b) + return pkt +} + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + messages []*SavedMessage + nextID int64 + listenAddr string + napcatURL string + remoteDir string + filesDir string + adminID int64 + botID int64 + botNickname string + dmPolicy string + groupPolicy string + httpClient *http.Client + allowFrom map[int64]struct{} + groupAllowFrom map[int64]struct{} + srv *http.Server + groupNameCache map[int64]string + agentfsDir string +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号,收到其消息时标记【重要!老大消息】", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.group_allow_from", Default: "", Type: "string", DisplayName: "群聊白名单", Description: "允许接入的群号列表,逗号分隔", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录(CQ file/image 自动下载到此目录)", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录,主机路径。发文件时文件会复制到此目录,NapCat内部映射为/app/files/", Category: "qq"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "plugin.qq.agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录,read_document/video_download 等工具的默认工作目录", Category: "qq"}) + + settings := s.Settings() + + p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") + p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") + p.adminID = getSetting[int64](settings, "admin", 0) + p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) + p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) + p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) + p.groupAllowFrom = parseIDSet(getSetting[string](settings, "group_allow_from", "")) + p.filesDir = strings.TrimRight(getSetting[string](settings, "files_dir", "/home/newqqagent/agentfs/merged/qq_files"), "/") + p.agentfsDir = strings.TrimRight(getSetting[string](settings, "agentfs_dir", "/home/newqqagent/agentfs/merged"), "/") + p.remoteDir = strings.TrimRight(getSetting[string](settings, "remote_dir", "/home/program/qq-workspace/remote"), "/") + os.MkdirAll(p.remoteDir, 0755) + + p.httpClient = &http.Client{Timeout: 5 * time.Second} + + // 从 NapCat 自动获取 Bot 身份(异步,不阻塞启动) + go p.fetchBotInfo() + + tp := p.name + "_" + + botInfo := "" + if p.botNickname != "" { + botInfo = fmt.Sprintf("你的QQ昵称是%s", p.botNickname) + if p.botID > 0 { + botInfo += fmt.Sprintf(",QQ号是%d", p.botID) + } + botInfo += "。" + } + + // ---- 注册输出通道 ---- + s.RegisterOutputChannel("qq", sdk.CapText|sdk.CapFile|sdk.CapImage|sdk.CapAudio, + `发送QQ群聊/私聊消息,支持文字和语音。content JSON 格式: +{ + "content": "消息正文(必填)", + "group_id": 123456, // 群号(与 user_id 二选一) + "user_id": 123456, // QQ号(与 group_id 二选一) + "as_voice": false // 可选,true 则将文本转为语音发送(使用 edge-tts) +}`, + p.handleChannelOutput) + + // ---- 消息 ---- + p.regTool(s, tp+"get_message", botInfo+"获取通过中断通知的QQ消息正文。local_id来自中断文字中的id号。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "local_id": map[string]interface{}{"type": "integer", "description": "本地消息ID"}, + }, "required": []string{"local_id"}, + }, p.handleGetMessage) + + p.regTool(s, tp+"send_file", "发送文件/图片到QQ(私聊或群聊)。文件先复制到remote目录供NapCat容器访问。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "目标群号(与user_id二选一)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号(与group_id二选一)"}, + "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, + "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, + "as_image": map[string]interface{}{"type": "boolean", "description": "作为图片发送(true)还是作为文件(false,默认)"}, + }, + }, p.handleSendFile) + + p.regTool(s, tp+"get_history", "获取QQ群聊/私聊历史消息,用于回顾之前的对话上下文", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号私聊历史(与group_id二选一)"}, + "count": map[string]interface{}{"type": "integer", "description": "拉取条数,默认10"}, + }, "required": []string{}, + }, p.handleGetHistory) + + // ---- 查询 ---- + p.regTool(s, tp+"get_groups", "获取QQ群列表,可按关键词搜索群名", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, p.handleGetGroups) + + p.regTool(s, tp+"get_friends", "获取QQ好友列表,可按昵称/备注关键词搜索", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(可选)"}, + }, + }, p.handleGetFriends) + + p.regTool(s, tp+"resolve_name", "将QQ号或群号解析为可读的用户昵称或群名称", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(与group_id二选一)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "群号(与user_id二选一)"}, + }, + }, p.handleResolveName) + + p.regTool(s, tp+"resolve_nickname", "按昵称/备注/群名片搜索QQ用户,返回匹配的QQ号和详细信息。支持搜索好友列表或指定群成员。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(昵称/备注/群名片)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "所在群号(可选),不传则搜索好友列表"}, + }, "required": []string{"keyword"}, + }, p.handleResolveNickname) + + p.regTool(s, tp+"get_group_member_info", "获取QQ群成员详细信息", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号"}, + }, "required": []string{"group_id", "user_id"}, + }, p.handleGetGroupMemberInfo) + + // ---- 群管理 ---- + p.regTool(s, tp+"group_manage", "QQ群综合管理。通过command参数执行各种操作:leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意:leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "操作命令"}, + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "user_id": map[string]interface{}{"type": "integer", "description": "QQ号(踢人/禁言/设名片等需要)"}, + "message_id": map[string]interface{}{"type": "integer", "description": "消息ID(撤回/精华)"}, + "name": map[string]interface{}{"type": "string", "description": "群名称(rename)或文件夹名(folder-create)"}, + "card": map[string]interface{}{"type": "string", "description": "群名片(set-card)"}, + "title": map[string]interface{}{"type": "string", "description": "群头衔(set-title)"}, + "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用(set-admin/mute-all)"}, + "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数(ban),0=解禁"}, + "count": map[string]interface{}{"type": "integer", "description": "消息条数(msg-history),默认10"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list-files)"}, + "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群(kick)"}, + "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, + }, + }, p.handleGroupManage) + + p.regTool(s, tp+"friend_action", "QQ好友管理:delete删除好友, block拉黑(删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意:涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, + "flag": map[string]interface{}{"type": "string", "description": "好友请求flag(approve-friend/reject-friend需要)"}, + "remark": map[string]interface{}{"type": "string", "description": "好友备注(approve-friend可选)"}, + "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出(block配合)"}, + "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, + }, + }, p.handleFriendAction) + + // ---- 文件 ---- + p.regTool(s, tp+"get_group_files", "查询群文件列表、搜索文件、下载文件到本地。操作: list列出, search搜索, download下载", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "群号"}, + "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, + "folder_id": map[string]interface{}{"type": "string", "description": "文件夹ID(list指定文件夹)"}, + "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词(search)"}, + "file_id": map[string]interface{}{"type": "string", "description": "文件ID(download)"}, + "filename": map[string]interface{}{"type": "string", "description": "保存文件名(download可选)"}, + }, + }, p.handleGetGroupFiles) + + p.regTool(s, tp+"upload_group_file", "上传文件到QQ群(通过base64编码发送,同时出现在群消息和群文件柜)", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "group_id": map[string]interface{}{"type": "integer", "description": "目标群号"}, + "file": map[string]interface{}{"type": "string", "description": "本地文件路径"}, + "name": map[string]interface{}{"type": "string", "description": "文件名(可选,默认取原文件名)"}, + }, "required": []string{"group_id", "file"}, + }, p.handleUploadGroupFile) + + // ---- 文档/视频/网页工具 ---- + p.regTool(s, tp+"read_document", "读取文档内容文本。支持 PDF、DOCX、DOC、XLSX、XLS、PPTX、PPT、TXT、CSV、MD 格式。使用 libreoffice + pandoc 转换提取文本,返回前 20000 字符。适合处理用户发来的文档文件。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "path": map[string]interface{}{"type": "string", "description": "文档文件路径(已保存到本地的文件路径)"}, + }, "required": []string{"path"}, + }, p.handleReadDocument) + + p.regTool(s, tp+"video_download", "下载视频到本地。支持 B站、YouTube 等主流视频网站(通过 yt-dlp)。先调用 info_only 查看视频信息,再下载。下载后文件保存在 agentfs 目录。", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "视频分享链接"}, + "info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、时长、清晰度列表),不下"}, + }, "required": []string{"url"}, + }, p.handleVideoDownload) + + // ---- 附加 ---- + p.regTool(s, tp+"send_like", "给QQ好友点赞/戳一戳", map[string]interface{}{ + "type": "object", "properties": map[string]interface{}{ + "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, + "times": map[string]interface{}{"type": "integer", "description": "点赞次数1-20,默认1"}, + }, "required": []string{"user_id"}, + }, p.handleSendLike) + + s.RegisterStage(sdk.StageBeforeToolcall, p.beforeOwnToolcall, sdk.StageScopeOwnTools) + + // ---- HTTP server for NapCat webhook ---- + mux := http.NewServeMux() + mux.HandleFunc("/", p.handleWebhook) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"ok"}`)) + }) + p.srv = &http.Server{Addr: p.listenAddr, Handler: mux} + go func() { + log.Printf("[qq] webhook %s napcat=%s", p.listenAddr, p.napcatURL) + if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[qq] http: %v", err) + } + }() + + log.Printf("[qq] plugin started: %s (%d tools)", p.name, 15) + return nil +} + +func (p *Plugin) Stop() error { + if p.srv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + p.srv.Shutdown(ctx) + } + return nil +} + +func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler) { + s.RegisterTool(name, sdk.ToolDef{Name: name, Description: desc, Parameters: params}, handler) +} + +// ======== Bot Identity ======== + +func (p *Plugin) fetchBotInfo() { + resp, err := p.rawNapcat("get_login_info", nil) + if err != nil { + log.Printf("[qq] fetch login info: %v", err) + return + } + var info struct { + Status string `json:"status"` + Data *struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(resp), &info); err != nil { + log.Printf("[qq] parse login info: %v", err) + return + } + if info.Data != nil { + p.botID = info.Data.UserID + p.botNickname = info.Data.Nickname + log.Printf("[qq] bot identity: %s (%d)", p.botNickname, p.botID) + } +} + +// rawNapcat sends a request to NapCat and returns raw JSON string. +func (p *Plugin) rawNapcat(action string, params map[string]interface{}) (string, error) { + data, _ := json.Marshal(params) + url := fmt.Sprintf("%s/%s", p.napcatURL, action) + resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("napcat %s: %w", action, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil +} + +// getSetting reads a setting from the SDK; returns fallback if unset or wrong type. +func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err != nil || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if str, ok := v.(string); ok { + return any(str).(T) + } + case int64: + switch val := v.(type) { + case float64: + return any(int64(val)).(T) + case string: + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return any(n).(T) + } + } + case float64: + switch val := v.(type) { + case float64: + return any(val).(T) + case string: + if n, err := strconv.ParseFloat(val, 64); err == nil { + return any(n).(T) + } + } + } + return fallback +} + +func normalizePolicy(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "allowlist": + return "allowlist" + case "disabled": + return "disabled" + default: + return "open" + } +} + +func parseIDSet(raw string) map[int64]struct{} { + out := make(map[int64]struct{}) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if n, err := strconv.ParseInt(part, 10, 64); err == nil { + out[n] = struct{}{} + } + } + return out +} + +// isAtBot checks if the message contains an @-mention of the bot. +func (p *Plugin) isAtBot(msg interface{}) bool { + segments, ok := msg.([]interface{}) + if !ok { + return false + } + botIDStr := strconv.FormatInt(p.botID, 10) + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + if s["type"] == "at" { + if data, ok := s["data"].(map[string]interface{}); ok { + if qq, ok := data["qq"]; ok { + switch v := qq.(type) { + case string: + if v == botIDStr || v == "all" { + return true + } + case float64: + if int64(v) == p.botID { + return true + } + } + } + } + } + } + return false +} + +// ======== Webhook ======== + +func (p *Plugin) isDMAllowed(userID int64) bool { + switch p.dmPolicy { + case "disabled": + return false + case "allowlist": + _, ok := p.allowFrom[userID] + return ok + default: + return true + } +} + +func (p *Plugin) isGroupAllowed(groupID int64) bool { + switch p.groupPolicy { + case "disabled": + return false + case "allowlist": + _, ok := p.groupAllowFrom[groupID] + return ok + default: + return true + } +} + +func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error { + ctx.Lock() + defer ctx.Unlock() + if len(ctx.ToolCalls) == 0 { + return nil + } + tc := &ctx.ToolCalls[0] + if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" { + if file, ok := tc.Arguments["file"].(string); ok { + tc.Arguments["file"] = p.sensitiveFilter(file) + } + } + if tc.Name == p.name+"_group_manage" { + cmd, _ := tc.Arguments["command"].(string) + if requiresConfirmGroupCommand(cmd) { + if ok, _ := tc.Arguments["confirm"].(bool); !ok { + msg := fmt.Sprintf("QQ群管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) + ctx.Response = &msg + return nil + } + } + } + if tc.Name == p.name+"_friend_action" { + cmd, _ := tc.Arguments["command"].(string) + if requiresConfirmFriendCommand(cmd) { + if ok, _ := tc.Arguments["confirm"].(bool); !ok { + msg := fmt.Sprintf("QQ好友管理命令 %s 属于高风险操作,必须显式传入 confirm=true 后才能执行", cmd) + ctx.Response = &msg + return nil + } + } + } + return nil +} + +func requiresConfirmGroupCommand(cmd string) bool { + switch cmd { + case "leave", "kick", "ban", "unban", "rename", "mute-all", "set-card", "set-admin", "set-title", "recall", "pin-msg", "folder-create": + return true + default: + return false + } +} + +func requiresConfirmFriendCommand(cmd string) bool { + switch cmd { + case "delete", "block", "approve-friend", "reject-friend": + return true + default: + return false + } +} + +func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + body, _ := io.ReadAll(r.Body) + var evt struct { + PostType string `json:"post_type"` + MessageType string `json:"message_type,omitempty"` + UserID int64 `json:"user_id,omitempty"` + GroupID int64 `json:"group_id,omitempty"` + RawMessage string `json:"raw_message,omitempty"` + Message interface{} `json:"message,omitempty"` + Time int64 `json:"time"` + Sender *struct { + Nickname string `json:"nickname"` + Card string `json:"card,omitempty"` + } `json:"sender,omitempty"` + } + if json.Unmarshal(body, &evt) != nil || evt.PostType != "message" { + w.WriteHeader(http.StatusOK) + return + } + + text := evt.RawMessage + if text == "" { + if s, ok := evt.Message.(string); ok { + text = s + } + } + if text == "" { + w.WriteHeader(http.StatusOK) + return + } + + // 如果有结构化消息段,解析并下载文件/图片,生成可读文本 + if segments, ok := evt.Message.([]interface{}); ok && len(segments) > 0 { + parsedText := p.processMessageSegments(segments) + if parsedText != "" { + text = parsedText + } + } + + if evt.MessageType == "private" { + if !p.isDMAllowed(evt.UserID) { + w.WriteHeader(http.StatusOK) + return + } + } + if evt.MessageType == "group" { + if !p.isGroupAllowed(evt.GroupID) { + w.WriteHeader(http.StatusOK) + return + } + // 群消息必须 @ 机器人才响应 + if p.botID > 0 && !p.isAtBot(evt.Message) { + w.WriteHeader(http.StatusOK) + return + } + } + + nickname := "" + if evt.Sender != nil { + nickname = evt.Sender.Nickname + if evt.Sender.Card != "" { + nickname = evt.Sender.Card + } + } + + p.mu.Lock() + localID := p.nextID + p.nextID++ + + msg := &SavedMessage{ + LocalID: localID, UserID: evt.UserID, Nickname: nickname, + GroupID: evt.GroupID, MessageType: evt.MessageType, Text: text, Time: evt.Time, + } + groupName := "" + if evt.MessageType == "group" { + if n, ok := p.groupNameCache[evt.GroupID]; ok { + groupName = n + } else { + // 尝试从 NapCat 获取群名称 + resp, err := p.napcat("get_group_info", map[string]interface{}{"group_id": evt.GroupID}) + if err == nil { + raw, _ := resp.(string) + var gi struct { + Data *struct { + GroupName string `json:"group_name"` + } `json:"data"` + } + if json.Unmarshal([]byte(raw), &gi) == nil && gi.Data != nil && gi.Data.GroupName != "" { + groupName = gi.Data.GroupName + p.groupNameCache[evt.GroupID] = groupName + } + } + if groupName == "" { + groupName = "群聊" + } + } + msg.GroupName = groupName + } + p.messages = append(p.messages, msg) + if len(p.messages) > maxMessages { + p.messages = p.messages[1:] + } + + tp := p.name + "_" + var interrupt string + if evt.MessageType == "group" { + interrupt = fmt.Sprintf("来自%s的(%s)群聊消息,通过id%d使用%sget_message工具获取消息正文。获取内容后使用 output_send(channel=\"qq\") 回复该群聊,content 设为 JSON 字符串:{\"content\":\"你的回复\",\"group_id\":%d}", nickname, groupName, localID, tp, evt.GroupID) + } else { + interrupt = fmt.Sprintf("来自%s的私聊消息,通过id%d使用%sget_message工具获取消息正文。获取内容后使用 output_send(channel=\"qq\") 回复对方,content 设为 JSON 字符串:{\"content\":\"你的回复\",\"user_id\":%d}", nickname, localID, tp, evt.UserID) + } + if p.adminID > 0 && evt.UserID == p.adminID { + interrupt = "【重要!老大消息】" + interrupt + } + p.mu.Unlock() + + if evt.MessageType == "group" && p.sdk != nil { + rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]") + var rules []ForwardRule + if json.Unmarshal([]byte(rulesRaw), &rules) == nil { + for _, rule := range rules { + if evt.GroupID == rule.GroupID { + mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) + go func(r ForwardRule, msg string) { + if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { + log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) + } + }(rule, mcMsg) + } + } + } + } + + if p.sdk != nil { + p.sdk.InjectInterruptText(p.name, p.name, interrupt) + } + w.WriteHeader(http.StatusOK) +} + +// ======== Tool Handlers ======== + +func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, error) { + id, err := convInt64(args["local_id"]) + if err != nil { + return map[string]interface{}{ + "content": fmt.Sprintf("无效的 local_id 参数,请传入整数类型的消息ID"), + "error": err.Error(), + }, nil + } + p.mu.RLock() + defer p.mu.RUnlock() + for _, m := range p.messages { + if m.LocalID == id { + from := m.Nickname + if m.GroupName != "" && m.GroupName != fmt.Sprintf("%d", m.GroupID) { + from = fmt.Sprintf("%s(%s)", m.Nickname, m.GroupName) + } + loc := "私聊" + if m.MessageType == "group" { + loc = "群聊" + } + return map[string]interface{}{ + "content": m.Text, + "from": from, + "type": loc, + "time": time.Unix(m.Time, 0).Format("15:04:05"), + "local_id": m.LocalID, + "user_id": m.UserID, + "group_id": m.GroupID, + }, nil + } + } + return map[string]interface{}{ + "content": fmt.Sprintf("消息 %d 未找到。消息可能已被处理过期,或插件重启后本地缓存已清空。", id), + "local_id": id, + "not_found": true, + }, nil +} + +// handleChannelOutput — output_send(channel="qq") 的处理器 +// content 参数为 JSON 字符串,格式: +// {"content":"消息正文","group_id":123} +// {"content":"消息正文","user_id":456,"as_voice":true} +func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, error) { + content, _ := args["content"].(string) + log.Printf("[qq] handleChannelOutput content=%q type=%T len=%d", content, args["content"], len(content)) + if content == "" { + return nil, fmt.Errorf("content is required") + } + + var msg struct { + Content string `json:"content"` + UserID int64 `json:"user_id,omitempty"` + GroupID int64 `json:"group_id,omitempty"` + AsVoice bool `json:"as_voice,omitempty"` + } + if err := json.Unmarshal([]byte(content), &msg); err != nil || msg.Content == "" { + return nil, fmt.Errorf("content 必须是 JSON 字符串,包含 content 字段和 group_id/user_id 之一: %s", content) + } + + text := p.sensitiveFilter(msg.Content) + + // 语音模式:edge-tts 转语音后通过 NapCat 发送 + if msg.AsVoice { + audioFile, err := p.ttsToFile(msg.Content) + if err != nil { + return nil, fmt.Errorf("语音生成失败: %w", err) + } + os.MkdirAll(p.remoteDir, 0755) + dest := filepath.Join(p.remoteDir, filepath.Base(audioFile)) + data, err := os.ReadFile(audioFile) + if err != nil { + return nil, fmt.Errorf("读取音频文件失败: %w", err) + } + if err := os.WriteFile(dest, data, 0644); err != nil { + return nil, fmt.Errorf("写入共享目录失败: %w", err) + } + os.Remove(audioFile) + uri := fmt.Sprintf("file:///app/files/%s", filepath.Base(dest)) + cqMsg := fmt.Sprintf("[CQ:record,file=%s]", uri) + if msg.GroupID != 0 { + return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": cqMsg}) + } + if msg.UserID != 0 { + return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": cqMsg}) + } + return nil, fmt.Errorf("JSON 中需要 group_id 或 user_id") + } + + // 文字模式 + if msg.GroupID != 0 { + return p.napcat("send_group_msg", map[string]interface{}{"group_id": msg.GroupID, "message": text}) + } + if msg.UserID != 0 { + return p.napcat("send_private_msg", map[string]interface{}{"user_id": msg.UserID, "message": text}) + } + return nil, fmt.Errorf("JSON 中需要 group_id 或 user_id") +} + +// ttsToFile 用 edge-tts 将文本转为音频文件,返回临时文件路径 +func (p *Plugin) ttsToFile(text string) (string, error) { + // 清理文本中的特殊字符 + clean := strings.Map(func(r rune) rune { + if r == '"' || r == '\n' || r == '\r' { + return ' ' + } + return r + }, text) + clean = strings.TrimSpace(clean) + if clean == "" { + clean = " " + } + + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("qq_tts_%d.mp3", time.Now().UnixNano())) + cmd := exec.Command("edge-tts", + "--voice", "zh-CN-XiaoxiaoNeural", + "--text", clean, + "--write-media", tmpFile, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("edge-tts: %w\nstderr: %s", err, stderr.String()) + } + if _, err := os.Stat(tmpFile); os.IsNotExist(err) { + return "", fmt.Errorf("edge-tts 未生成输出文件") + } + return tmpFile, nil +} + +func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error) { + gid, gerr := convInt64(args["group_id"]) + uid, uerr := convInt64(args["user_id"]) + if gerr != nil && uerr != nil { + return nil, fmt.Errorf("need group_id or user_id") + } + filePath, _ := args["file"].(string) + if filePath == "" { + return nil, fmt.Errorf("need file path") + } + name, _ := args["name"].(string) + if name == "" { + name = filepath.Base(filePath) + } + name = p.sensitiveFilter(name) + asImage, _ := args["as_image"].(bool) + + // copy to remote dir for NapCat container access + dest := filepath.Join(p.remoteDir, name) + srcData, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + if err := os.WriteFile(dest, srcData, 0644); err != nil { + return nil, fmt.Errorf("write remote: %w", err) + } + + uri := fmt.Sprintf("file:///app/files/%s", name) + var cqMsg string + if asImage { + cqMsg = fmt.Sprintf("[CQ:image,file=%s]", uri) + } else { + cqMsg = fmt.Sprintf("[CQ:file,file=%s,title=%s]", uri, name) + } + + params := map[string]interface{}{"message": cqMsg} + if gerr == nil { + params["group_id"] = gid + return p.napcat("send_group_msg", params) + } + params["user_id"] = uid + return p.napcat("send_private_msg", params) +} + +func (p *Plugin) handleGetHistory(args map[string]interface{}) (interface{}, error) { + gid, gerr := convInt64(args["group_id"]) + uid, uerr := convInt64(args["user_id"]) + count := 10 + if c, err := convInt64(args["count"]); err == nil && c > 0 { + count = int(c) + } + + var endpoint string + var params map[string]interface{} + if gerr == nil { + endpoint = "get_group_msg_history" + params = map[string]interface{}{"group_id": gid, "count": count} + } else if uerr == nil { + endpoint = "get_friend_msg_history" + params = map[string]interface{}{"user_id": uid, "count": count} + } else { + return nil, fmt.Errorf("need group_id or user_id") + } + + data, err := p.napcat(endpoint, params) + if err != nil { + return nil, err + } + return data, nil +} + +func (p *Plugin) handleGetGroups(args map[string]interface{}) (interface{}, error) { + return p.napcat("get_group_list", map[string]interface{}{}) +} + +func (p *Plugin) handleGetFriends(args map[string]interface{}) (interface{}, error) { + return p.napcat("get_friend_list", map[string]interface{}{}) +} + +func (p *Plugin) handleResolveName(args map[string]interface{}) (interface{}, error) { + if uid, err := convInt64(args["user_id"]); err == nil { + return p.napcat("get_stranger_info", map[string]interface{}{"user_id": uid, "no_cache": true}) + } + if gid, err := convInt64(args["group_id"]); err == nil { + return p.napcat("get_group_info", map[string]interface{}{"group_id": gid, "no_cache": true}) + } + return nil, fmt.Errorf("need user_id or group_id") +} + +func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}, error) { + keyword, _ := args["keyword"].(string) + if keyword == "" { + return nil, fmt.Errorf("keyword is required") + } + keyword = strings.ToLower(keyword) + + gid, groupErr := convInt64(args["group_id"]) + if groupErr == nil { + v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) + if err != nil { + return nil, err + } + raw, _ := v.(string) + return filterMemberList(raw, keyword) + } + + v, err := p.napcat("get_friend_list", map[string]interface{}{}) + if err != nil { + return nil, err + } + raw, _ := v.(string) + return filterFriendList(raw, keyword) +} + +func filterFriendList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Remark string `json:"remark"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, f := range resp.Data { + if strings.Contains(strings.ToLower(f.Nickname), keyword) || + strings.Contains(strings.ToLower(f.Remark), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": f.UserID, + "nickname": f.Nickname, + "remark": f.Remark, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/备注包含 %q 的好友", keyword), nil + } + return matches, nil +} + +func filterMemberList(raw, keyword string) (interface{}, error) { + var resp struct { + Data []struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return raw, nil + } + var matches []map[string]interface{} + for _, m := range resp.Data { + if strings.Contains(strings.ToLower(m.Nickname), keyword) || + strings.Contains(strings.ToLower(m.Card), keyword) { + matches = append(matches, map[string]interface{}{ + "user_id": m.UserID, + "nickname": m.Nickname, + "card": m.Card, + }) + } + } + if len(matches) == 0 { + return fmt.Sprintf("未找到昵称/名片包含 %q 的群成员", keyword), nil + } + return matches, nil +} + +func (p *Plugin) handleGetGroupMemberInfo(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) +} + +func (p *Plugin) handleGroupManage(args map[string]interface{}) (interface{}, error) { + cmd, _ := args["command"].(string) + if cmd == "" { + return nil, fmt.Errorf("need command") + } + if requiresConfirmGroupCommand(cmd) { + if ok, _ := args["confirm"].(bool); !ok { + return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil + } + } + + switch cmd { + case "group-list": + return p.napcat("get_group_list", map[string]interface{}{}) + case "group-info", "member-list", "member-info", "at-all-remain", "msg-history": + gid, _ := convInt64(args["group_id"]) + if cmd == "msg-history" { + count := 10 + if c, err := convInt64(args["count"]); err == nil && c > 0 { + count = int(c) + } + return p.napcat("get_group_msg_history", map[string]interface{}{"group_id": gid, "count": count}) + } + if cmd == "member-info" { + uid, _ := convInt64(args["user_id"]) + return p.napcat("get_group_member_info", map[string]interface{}{"group_id": gid, "user_id": uid}) + } + if cmd == "at-all-remain" { + return p.napcat("get_group_at_all_remain", map[string]interface{}{"group_id": gid}) + } + if cmd == "group-info" { + return p.napcat("get_group_info", map[string]interface{}{"group_id": gid}) + } + return p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) + + case "list-files": + gid, _ := convInt64(args["group_id"]) + folderID, _ := args["folder_id"].(string) + if folderID != "" { + return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) + } + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "pending-requests": + return p.napcat("get_group_system_msg", map[string]interface{}{}) + + case "leave": + gid, _ := convInt64(args["group_id"]) + return p.napcat("set_group_leave", map[string]interface{}{"group_id": gid}) + + case "kick": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + reject, _ := args["reject_add"].(bool) + return p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": reject}) + + case "ban": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + minutes := 10 + if m, err := convInt64(args["minutes"]); err == nil { + minutes = int(m) + } + return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": minutes * 60}) + + case "unban": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + return p.napcat("set_group_ban", map[string]interface{}{"group_id": gid, "user_id": uid, "duration": 0}) + + case "rename": + gid, _ := convInt64(args["group_id"]) + name, _ := args["name"].(string) + return p.napcat("set_group_name", map[string]interface{}{"group_id": gid, "group_name": name}) + + case "mute-all": + gid, _ := convInt64(args["group_id"]) + enable, _ := args["enable"].(bool) + return p.napcat("set_group_whole_ban", map[string]interface{}{"group_id": gid, "enable": enable}) + + case "set-card": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + card, _ := args["card"].(string) + return p.napcat("set_group_card", map[string]interface{}{"group_id": gid, "user_id": uid, "card": card}) + + case "set-admin": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + enable, _ := args["enable"].(bool) + return p.napcat("set_group_admin", map[string]interface{}{"group_id": gid, "user_id": uid, "enable": enable}) + + case "set-title": + gid, _ := convInt64(args["group_id"]) + uid, _ := convInt64(args["user_id"]) + title, _ := args["title"].(string) + return p.napcat("set_group_special_title", map[string]interface{}{"group_id": gid, "user_id": uid, "special_title": title}) + + case "recall": + mid, _ := convInt64(args["message_id"]) + return p.napcat("delete_msg", map[string]interface{}{"message_id": mid}) + + case "pin-msg": + mid, _ := convInt64(args["message_id"]) + return p.napcat("set_essence_msg", map[string]interface{}{"message_id": mid}) + + case "folder-create": + gid, _ := convInt64(args["group_id"]) + name, _ := args["name"].(string) + return p.napcat("create_group_file_folder", map[string]interface{}{"group_id": gid, "name": name}) + + default: + return nil, fmt.Errorf("unknown group_manage command: %s", cmd) + } +} + +func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, error) { + cmd, _ := args["command"].(string) + if requiresConfirmFriendCommand(cmd) { + if ok, _ := args["confirm"].(bool); !ok { + return map[string]interface{}{"isError": true, "content": fmt.Sprintf("高风险操作 %s 需要 confirm=true", cmd)}, nil + } + } + switch cmd { + case "list-friends": + return p.napcat("get_friend_list", map[string]interface{}{}) + case "delete": + uid, _ := convInt64(args["user_id"]) + return p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) + case "block": + uid, _ := convInt64(args["user_id"]) + // delete friend + p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) + // kick from groups + if gid, err := convInt64(args["group_id"]); err == nil { + p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) + } else { + grps, _ := p.napcat("get_group_list", map[string]interface{}{}) + if list, ok := grps.([]interface{}); ok { + for _, g := range list { + if m, ok := g.(map[string]interface{}); ok { + if gid, ok := m["group_id"].(float64); ok { + p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true}) + } + } + } + } + } + return `{"status":"ok","message":"blocked"}`, nil + case "approve-friend": + flag, _ := args["flag"].(string) + remark, _ := args["remark"].(string) + return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": true, "remark": remark}) + case "reject-friend": + flag, _ := args["flag"].(string) + return p.napcat("set_friend_add_request", map[string]interface{}{"flag": flag, "approve": false}) + default: + return nil, fmt.Errorf("unknown friend_action command: %s", cmd) + } +} + +func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + cmd, _ := args["command"].(string) + + switch cmd { + case "list": + folderID, _ := args["folder_id"].(string) + if folderID != "" { + return p.napcat("get_group_files_by_folder", map[string]interface{}{"group_id": gid, "folder_id": folderID}) + } + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "search": + return p.napcat("get_group_root_files", map[string]interface{}{"group_id": gid}) + + case "download": + fileID, _ := args["file_id"].(string) + filename, _ := args["filename"].(string) + if filename == "" { + filename = fmt.Sprintf("group_file_%s", fileID) + } + // get download URL + resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) + if err != nil { + return nil, err + } + respStr, ok := resp.(string) + if !ok { + return resp, nil + } + // parse URL from response + var parsed struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(respStr), &parsed); err != nil || parsed.Data.URL == "" { + return resp, nil + } + dlURL := parsed.Data.URL + httpResp, err := http.Get(dlURL) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer httpResp.Body.Close() + content, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("read download: %w", err) + } + os.MkdirAll(p.filesDir, 0755) + savePath := filepath.Join(p.filesDir, filename) + if err := os.WriteFile(savePath, content, 0644); err != nil { + return nil, fmt.Errorf("save: %w", err) + } + return map[string]interface{}{ + "status": "ok", "path": savePath, "filename": filename, "size": len(content), + }, nil + + default: + return nil, fmt.Errorf("unknown get_group_files command: %s", cmd) + } +} + +func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}, error) { + gid, _ := convInt64(args["group_id"]) + filePath, _ := args["file"].(string) + name, _ := args["name"].(string) + if name == "" { + name = filepath.Base(filePath) + } + name = p.sensitiveFilter(name) + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + b64 := fmt.Sprintf("base64://%s", base64.StdEncoding.EncodeToString(data)) + + resp, err := p.napcat("send_group_msg", map[string]interface{}{ + "group_id": gid, + "message": []map[string]interface{}{ + {"type": "file", "data": map[string]interface{}{"file": b64, "name": name}}, + }, + }) + if err != nil { + return nil, err + } + return map[string]interface{}{"status": "ok", "file": name, "napcat": resp}, nil +} + +func (p *Plugin) handleSendLike(args map[string]interface{}) (interface{}, error) { + uid, _ := convInt64(args["user_id"]) + times := 1 + if t, err := convInt64(args["times"]); err == nil && t > 0 && t <= 20 { + times = int(t) + } + return p.napcat("send_like", map[string]interface{}{"user_id": uid, "times": times}) +} + +// ======== CQ Code / Message Segment Processing ======== + +func (p *Plugin) processMessageSegments(segments []interface{}) string { + if len(segments) == 0 { + return "" + } + os.MkdirAll(p.filesDir, 0755) + botIDStr := strconv.FormatInt(p.botID, 10) + var parts []string + type dlItem struct{ fileID, name string } + var dlQueue []dlItem + + for _, seg := range segments { + s, ok := seg.(map[string]interface{}) + if !ok { + continue + } + typ, _ := s["type"].(string) + data, _ := s["data"].(map[string]interface{}) + if data == nil { + continue + } + + switch typ { + case "text": + if t, _ := data["text"].(string); t != "" { + parts = append(parts, t) + } + case "at": + qq, _ := data["qq"].(string) + if qq == "all" { + parts = append(parts, "@所有人") + } else if qq == botIDStr { + continue + } else { + parts = append(parts, "@"+qq) + } + case "face", "sface": + if id, _ := data["id"].(string); id != "" { + parts = append(parts, "[表情]") + } + case "file": + fid, _ := data["file"].(string) + name, _ := data["name"].(string) + size, _ := data["size"].(string) + sizeDesc := "" + if s, err := strconv.ParseInt(size, 10, 64); err == nil && s > 0 { + sizeDesc = fmt.Sprintf(" (%.1f MB)", float64(s)/1048576) + } + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, name}) + } + if name != "" { + parts = append(parts, fmt.Sprintf("[文件:%s%s]", name, sizeDesc)) + } else { + parts = append(parts, "[文件]") + } + case "image": + fid, _ := data["file"].(string) + summary, _ := data["summary"].(string) + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, "image_" + fid + ".jpg"}) + } + label := "图片" + if summary != "" { + label = summary + } + parts = append(parts, fmt.Sprintf("[%s]", label)) + case "video": + fid, _ := data["file"].(string) + if fid != "" { + dlQueue = append(dlQueue, dlItem{fid, "video_" + fid + ".mp4"}) + } + parts = append(parts, "[视频]") + case "reply": + if id, ok := data["id"].(float64); ok { + parts = append(parts, fmt.Sprintf("[回复消息id=%.0f]", id)) + } + case "music": + if title, _ := data["title"].(string); title != "" { + parts = append(parts, fmt.Sprintf("[音乐:%s]", title)) + } else { + parts = append(parts, "[音乐]") + } + case "share": + title, _ := data["title"].(string) + urlStr, _ := data["url"].(string) + if title != "" && urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s %s]", title, urlStr)) + } else if urlStr != "" { + parts = append(parts, fmt.Sprintf("[分享:%s]", urlStr)) + } + default: + if typ != "" { + parts = append(parts, "["+typ+"]") + } + } + } + + // 异步下载文件(不影响消息处理) + if len(dlQueue) > 0 { + go func(items []dlItem) { + for _, item := range items { + p.downloadFile(item.fileID, item.name) + } + }(dlQueue) + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} + +func (p *Plugin) downloadFile(fileID, filename string) string { + if fileID == "" || p.filesDir == "" { + return "" + } + os.MkdirAll(p.filesDir, 0755) + + // 处理 base64:// 前缀的内嵌文件 + if strings.HasPrefix(fileID, "base64://") { + data, err := base64.StdEncoding.DecodeString(fileID[9:]) + if err != nil { + return "" + } + if filename == "" { + filename = "file.bin" + } + filename = sanitizeFilename(filename) + localPath := filepath.Join(p.filesDir, filename) + os.WriteFile(localPath, data, 0644) + return localPath + } + + // 处理 file:// 路径 + if strings.HasPrefix(fileID, "file://") { + localFile := strings.TrimPrefix(fileID, "file://") + if _, err := os.Stat(localFile); err == nil { + return localFile + } + } + + // 通过 NapCat get_file API 获取文件信息 + raw, err := p.napcat("get_file", map[string]interface{}{"file_id": fileID}) + if err != nil { + log.Printf("[qq] get_file %s: %v", fileID, err) + return "" + } + rawStr, _ := raw.(string) + var resp struct { + Data *struct { + File string `json:"file"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + Base64 string `json:"base64"` + URL string `json:"url"` + } `json:"data"` + } + if json.Unmarshal([]byte(rawStr), &resp) != nil || resp.Data == nil { + log.Printf("[qq] parse get_file %s: bad response", fileID) + return "" + } + info := resp.Data + + if filename == "" { + filename = info.FileName + } + if filename == "" { + filename = "file_" + fileID + } + filename = sanitizeFilename(filename) + localPath := filepath.Join(p.filesDir, filename) + + // 优先 base64 + if info.Base64 != "" { + data, err := base64.StdEncoding.DecodeString(info.Base64) + if err == nil { + os.WriteFile(localPath, data, 0644) + return localPath + } + } + + // 其次 URL 下载 + if info.URL != "" { + dlResp, err := http.Get(info.URL) + if err == nil { + defer dlResp.Body.Close() + data, err := io.ReadAll(dlResp.Body) + if err == nil { + os.WriteFile(localPath, data, 0644) + return localPath + } + } + } + + // 尝试直接读取 file 路径 + if info.File != "" { + src, err := os.ReadFile(info.File) + if err == nil { + os.WriteFile(localPath, src, 0644) + return localPath + } + } + + return "" +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '_' + } + return r + }, name) + return name +} + +// ======== Tool Handlers: Document / Video / Web ======== + +func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, error) { + path, _ := args["path"].(string) + if path == "" { + return nil, fmt.Errorf("path is required") + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return map[string]interface{}{ + "content": fmt.Sprintf("文件不存在: %s", path), + }, nil + } + + ext := strings.ToLower(filepath.Ext(path)) + textContent := "" + + switch ext { + case ".txt", ".md", ".csv": + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + textContent = string(data) + case ".docx", ".doc", ".epub", ".html", ".htm": + textContent = p.readWithPandoc(path) + default: + // Try pandoc first, fallback to libreoffice + textContent = p.readWithPandoc(path) + if textContent == "" { + textContent = p.readWithLibreoffice(path) + } + if textContent == "" { + // last resort: read as plain text + data, err := os.ReadFile(path) + if err == nil { + textContent = string(data) + } + } + } + + if textContent == "" { + return map[string]interface{}{ + "content": fmt.Sprintf("无法提取文件内容: %s(不支持的文件格式或文件损坏)", path), + }, nil + } + + // 截断到 20000 字符 + origLen := len(textContent) + truncated := origLen > 20000 + if truncated { + textContent = textContent[:20000] + } + + result := textContent + if truncated { + result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) + } + return map[string]interface{}{ + "content": result, + "file": path, + "truncated": truncated, + }, nil +} + +func (p *Plugin) readWithPandoc(path string) string { + var out bytes.Buffer + cmd := exec.Command("pandoc", path, "-t", "plain", "--wrap=none") + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + return strings.TrimSpace(out.String()) +} + +func (p *Plugin) readWithLibreoffice(path string) string { + tmpDir, err := os.MkdirTemp("", "lo-doc-*") + if err != nil { + return "" + } + defer os.RemoveAll(tmpDir) + + cmd := exec.Command("libreoffice", "--headless", "--convert-to", "txt:Text", "--outdir", tmpDir, path) + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + + // 找生成的 txt 文件 + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".txt") { + data, err := os.ReadFile(filepath.Join(tmpDir, e.Name())) + if err == nil { + return strings.TrimSpace(string(data)) + } + } + } + return "" +} + +func (p *Plugin) handleVideoDownload(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return nil, fmt.Errorf("url is required") + } + infoOnly, _ := args["info_only"].(bool) + + outputDir := filepath.Join(p.agentfsDir, "videos") + os.MkdirAll(outputDir, 0755) + + if infoOnly { + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "--dump-json", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp info: %w", err) + } + var info struct { + Title string `json:"title"` + Duration int `json:"duration"` + Webpage string `json:"webpage_url"` + Formats []struct { + FormatID string `json:"format_id"` + Ext string `json:"ext"` + Width int `json:"width"` + Height int `json:"height"` + Filesize int64 `json:"filesize"` + Format string `json:"format"` + } `json:"formats"` + } + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return string(out.String()), nil + } + dur := "" + if info.Duration > 0 { + dur = fmt.Sprintf("%d分%d秒", info.Duration/60, info.Duration%60) + } + lines := []string{fmt.Sprintf("🎬 %s", info.Title)} + if dur != "" { + lines = append(lines, fmt.Sprintf(" 时长: %s", dur)) + } + lines = append(lines, fmt.Sprintf(" 链接: %s", info.Webpage)) + lines = append(lines, "") + for _, f := range info.Formats { + fs := "" + if f.Filesize > 0 { + fs = fmt.Sprintf(" (%.1f MB)", float64(f.Filesize)/1048576) + } + res := "" + if f.Width > 0 && f.Height > 0 { + res = fmt.Sprintf(" %dx%d", f.Width, f.Height) + } + lines = append(lines, fmt.Sprintf(" [%s] %s%s%s", f.FormatID, f.Format, res, fs)) + } + return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil + } + + // 下载 + outputTmpl := filepath.Join(outputDir, "%(title)s.%(ext)s") + var out bytes.Buffer + cmd := exec.Command("yt-dlp", "-o", outputTmpl, "--no-playlist", "--print", "after_move:filepath", url) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("yt-dlp download: %w", err) + } + + // 解析 yt-dlp 输出的文件路径 + dlPath := strings.TrimSpace(out.String()) + if dlPath == "" { + return map[string]interface{}{ + "content": "下载完成,但无法获取文件路径", + }, nil + } + dlFilename := filepath.Base(dlPath) + var fileSize int64 = 0 + if fi, err := os.Stat(dlPath); err == nil { + fileSize = fi.Size() + } + return map[string]interface{}{ + "content": fmt.Sprintf("✅ 下载完成: %s\n 大小: %.1f MB\n 路径: %s", dlFilename, float64(fileSize)/1048576, dlPath), + "file": dlPath, + "filename": dlFilename, + }, nil +} + +// ======== NapCat HTTP Client ======== + +func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) { + data, _ := json.Marshal(params) + url := fmt.Sprintf("%s/%s", p.napcatURL, action) + + resp, err := http.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("napcat %s: %w", action, err) + } + defer resp.Body.Close() + + var raw json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("napcat decode %s: %w", action, err) + } + return string(raw), nil +} + +// ======== Helpers ======== + + +var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) +var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) +var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) + +func (p *Plugin) sensitiveFilter(text string) string { + if p.remoteDir != "" { + text = strings.ReplaceAll(text, p.remoteDir, "[remote]") + } + if p.filesDir != "" { + text = strings.ReplaceAll(text, p.filesDir, "[files]") + } + + text = reAPIKey.ReplaceAllString(text, "$1=***") + text = reSKKey.ReplaceAllString(text, "sk-***") + text = reInternalIP.ReplaceAllString(text, "[IP]") + return text +} + +func convInt64(v interface{}) (int64, error) { + switch n := v.(type) { + case int64: + return n, nil + case float64: + return int64(n), nil + case int: + return int64(n), nil + case json.Number: + return n.Int64() + case string: + return strconv.ParseInt(n, 10, 64) + } + return 0, fmt.Errorf("cannot convert %T to int64", v) +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{ + name: name, + nextID: 1, + messages: make([]*SavedMessage, 0, maxMessages), + groupNameCache: make(map[int64]string), + allowFrom: make(map[int64]struct{}), + groupAllowFrom: make(map[int64]struct{}), + dmPolicy: "open", + groupPolicy: "open", + }, nil +} diff --git a/example/qq/plugin.h b/example/qq/plugin.h new file mode 100644 index 0000000..6da5dae --- /dev/null +++ b/example/qq/plugin.h @@ -0,0 +1,101 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package qq */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#ifndef GO_CGO_EXPORT_PROLOGUE_H +#define GO_CGO_EXPORT_PROLOGUE_H + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef struct { const char *p; ptrdiff_t n; } _GoString_; +extern size_t _GoStringLen(_GoString_ s); +extern const char *_GoStringPtr(_GoString_ s); +#endif + +#endif + +/* Start of preamble from import "C" comments. */ + + +#line 3 "z_bridge_gen.go" + +#include +int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error); + +#line 1 "cgo-generated-wrapper" + + +/* End of preamble from import "C" comments. */ + + +/* Start of boilerplate cgo prologue. */ +#line 1 "cgo-gcc-export-header-prolog" + +#ifndef GO_CGO_PROLOGUE_H +#define GO_CGO_PROLOGUE_H + +typedef signed char GoInt8; +typedef unsigned char GoUint8; +typedef short GoInt16; +typedef unsigned short GoUint16; +typedef int GoInt32; +typedef unsigned int GoUint32; +typedef long long GoInt64; +typedef unsigned long long GoUint64; +typedef GoInt64 GoInt; +typedef GoUint64 GoUint; +typedef size_t GoUintptr; +typedef float GoFloat32; +typedef double GoFloat64; +#ifdef _MSC_VER +#if !defined(__cplusplus) || _MSVC_LANG <= 201402L +#include +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +#include +typedef std::complex GoComplex64; +typedef std::complex GoComplex128; +#endif +#else +typedef float _Complex GoComplex64; +typedef double _Complex GoComplex128; +#endif + +/* + static assertion to make sure the file is being used on architecture + at least with matching size of GoInt. +*/ +typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef _GoString_ GoString; +#endif +typedef void *GoMap; +typedef void *GoChan; +typedef struct { void *t; void *v; } GoInterface; +typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; + +#endif + +/* End of boilerplate cgo prologue. */ + +#ifdef __cplusplus +extern "C" { +#endif + +extern int go_init_plugin(char* name, char* configJSON, char** errorOut); +extern int go_start_plugin(void* coreAPIptr, int coreVersion, char** errorOut); +extern int go_stop_plugin(char** errorOut); +extern int go_invoke_tool(char* name, char* argsJSON, char** resultOut, char** errorOut); +extern int go_invoke_stage(char* stage, char* ctxJSON, char** errorOut); +extern int go_invoke_output(char* channel, char* msgType, char* payloadJSON, char** errorOut); +extern void go_free_string(char* ptr); + +#ifdef __cplusplus +} +#endif diff --git a/example/sanitizer/go.mod b/example/sanitizer/go.mod new file mode 100644 index 0000000..2d29add --- /dev/null +++ b/example/sanitizer/go.mod @@ -0,0 +1,7 @@ +module sanitizer + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../.. diff --git a/example/sanitizer/plg.json b/example/sanitizer/plg.json new file mode 100644 index 0000000..5a59cf4 --- /dev/null +++ b/example/sanitizer/plg.json @@ -0,0 +1,11 @@ +{ + "name": "sanitizer", + "name_zh": "输出清洗", + "name_en": "sanitizer", + "version": "0.1.0", + "description": "清洗 LLM 输出中的工具调用残留(思维泄漏)", + "author": "HomeAgent SDK", + "entry": "plugin.so", + "tags": ["sanitizer"], + "targets": "linux/amd64" +} diff --git a/example/sanitizer/plugin.go b/example/sanitizer/plugin.go new file mode 100644 index 0000000..17c0330 --- /dev/null +++ b/example/sanitizer/plugin.go @@ -0,0 +1,102 @@ +// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。 +// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。 +// +// 编译: +// +// go build -buildmode=plugin -o sanitizer.so . +// +// 安装到 HomeAgent 插件目录(如 plugins/sanitizer/plugin.so), +// HomeAgent 自动通过 tryLoadSO 加载。 +package main + +import ( + "log" + "regexp" + "strings" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +var ( + toolCallTagRE = regexp.MustCompile(`(?s)]*>.*?`) + invokeTagRE = regexp.MustCompile(`(?s)]*>.*?`) + toolTagRE = regexp.MustCompile(`(?s)]*>.*?`) + functionTagRE = regexp.MustCompile(`(?s)]*>.*?`) + toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```") + invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```") + toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```") + chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`) + multiNewlineRE = regexp.MustCompile(`\n{3,}`) + toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`) +) + +type Plugin struct{} + +func (p *Plugin) Name() string { return "sanitizer" } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error { + ctx.Lock() + before := len(ctx.LLMText) + ctx.LLMText = cleanToolCallLeakage(ctx.LLMText) + after := len(ctx.LLMText) + ctx.Unlock() + if before != after { + log.Printf("[sanitizer] cleaned %d bytes (before=%d after=%d)", before-after, before, after) + } + return nil + }) + log.Printf("[sanitizer] stage PostAction registered") + return nil +} + +func (p *Plugin) Stop() error { return nil } + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{}, nil +} + +func cleanToolCallLeakage(content string) string { + if content == "" { + return content + } + + before := len(content) + + content = toolCodeBlockRE.ReplaceAllString(content, "") + content = invokeCodeBlockRE.ReplaceAllString(content, "") + content = toolCodeBlockRE2.ReplaceAllString(content, "") + + content = toolCallTagRE.ReplaceAllString(content, "") + content = invokeTagRE.ReplaceAllString(content, "") + content = toolTagRE.ReplaceAllString(content, "") + content = functionTagRE.ReplaceAllString(content, "") + + content = chineseMarkerRE.ReplaceAllString(content, "") + + lines := strings.Split(content, "\n") + var cleaned []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + cleaned = append(cleaned, line) + continue + } + if toolNameRE.MatchString(trimmed) { + if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") { + continue + } + } + cleaned = append(cleaned, line) + } + content = strings.Join(cleaned, "\n") + + content = multiNewlineRE.ReplaceAllString(content, "\n\n") + content = strings.TrimSpace(content) + + if len(content) != before { + log.Printf("[sanitizer] cleanToolCallLeakage: %d bytes removed", before-len(content)) + } + return content +} diff --git a/example/sanitizer/sanitizer_test.go b/example/sanitizer/sanitizer_test.go new file mode 100644 index 0000000..e74c935 --- /dev/null +++ b/example/sanitizer/sanitizer_test.go @@ -0,0 +1,31 @@ +package main + +import "testing" + +func TestCleanToolCallLeakage(t *testing.T) { + tests := []struct { + name, input, want string + }{ + {"empty", "", ""}, + {"clean", "你好", "你好"}, + {"tool_call", "axb", "ab"}, + {"invoke", "axb", "ab"}, + {"function", "axb", "ab"}, + {"xml_block", "a\n```xml\nx\n```\nb", "a\n\nb"}, + {"json_block", "a\n```json\nx\n```\nb", "a\n\nb"}, + {"bare_code", "```python\nprint(1)\n```", "```python\nprint(1)\n```"}, + {"chinese_marker", "a【tool_call】x【/tool_call】b", "ab"}, + {"tool_line", "cmd_run(\"ls\")\nok", "ok"}, + {"prose_kept", "cmd_run 是一个工具", "cmd_run 是一个工具"}, + {"multiline", "a\n\nx\n\nb", "a\n\nb"}, + {"whitespace", "a\n\n\n\nb", "a\n\nb"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanToolCallLeakage(tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/example/web/go.mod b/example/web/go.mod new file mode 100644 index 0000000..998112f --- /dev/null +++ b/example/web/go.mod @@ -0,0 +1,7 @@ +module web + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../../. diff --git a/example/web/plg.json b/example/web/plg.json new file mode 100644 index 0000000..58731c3 --- /dev/null +++ b/example/web/plg.json @@ -0,0 +1,11 @@ +{ + "name": "web", + "name_zh": "网络搜索", + "name_en": "web", + "version": "1.0.0", + "description": "网络搜索与抓取工具(web_search/web_fetch)", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["web", "search", "fetch"], + "targets": "linux/amd64" +} diff --git a/example/web/plugin.go b/example/web/plugin.go new file mode 100644 index 0000000..cdc9401 --- /dev/null +++ b/example/web/plugin.go @@ -0,0 +1,568 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + "unicode" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + timeout int + proxy string + client *http.Client +} + +func newHTTPClient(timeout int, proxyURL string) *http.Client { + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: time.Duration(timeout) * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: time.Duration(timeout) * time.Second, + ResponseHeaderTimeout: time.Duration(timeout) * time.Second, + } + if proxyURL != "" { + u, err := url.Parse(proxyURL) + if err == nil { + transport.Proxy = http.ProxyURL(u) + } + } + return &http.Client{ + Timeout: time.Duration(timeout) * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.web.timeout", + Default: "30", + Type: "int", + DisplayName: "HTTP 超时(秒)", + Description: "Web fetch 和搜索的 HTTP 请求超时时间", + Category: "web", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.web.proxy", + Default: "", + Type: "string", + DisplayName: "HTTP 代理", + Description: "HTTP 代理地址,如 http://:。为空则不使用代理", + Category: "web", + }) + + t := getSetting[float64](s.Settings(), "timeout", 30) + p.timeout = int(t) + if p.timeout < 5 { + p.timeout = 5 + } + if p.timeout > 120 { + p.timeout = 120 + } + + p.proxy = getSetting[string](s.Settings(), "proxy", "") + p.client = newHTTPClient(p.timeout, p.proxy) + + tp := p.name + "_" + + s.RegisterTool(tp+"search", sdk.ToolDef{ + Name: tp + "search", + Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "Search query"}, + "count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"}, + }, + "required": []string{"query"}, + }, + }, p.handleSearch) + + s.RegisterTool(tp+"fetch", sdk.ToolDef{ + Name: tp + "fetch", + Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"}, + "max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"}, + }, + "required": []string{"url"}, + }, + }, p.handleFetch) + + proxyMsg := "" + if p.proxy != "" { + proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy) + } + log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg) + return nil +} + +func (p *Plugin) Stop() error { + p.client.CloseIdleConnections() + log.Printf("[%s] stopped", p.name) + return nil +} + +// ── SSRF 保护 ────────────────────────────────────────────── + +var privateCIDRs []*net.IPNet + +func init() { + cidrs := []string{ + "127.0.0.0/8", // loopback + "10.0.0.0/8", // private + "172.16.0.0/12", // private + "192.168.0.0/16", // private + "100.64.0.0/10", // carrier-grade NAT + "169.254.0.0/16", // link-local + "::1/128", // IPv6 loopback + "fc00::/7", // IPv6 unique local + "fe80::/10", // IPv6 link-local + } + for _, c := range cidrs { + _, n, err := net.ParseCIDR(c) + if err == nil { + privateCIDRs = append(privateCIDRs, n) + } + } +} + +func isPrivateIP(ip net.IP) bool { + for _, n := range privateCIDRs { + if n.Contains(ip) { + return true + } + } + return false +} + +func (p *Plugin) ssrfCheck(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme) + } + + host := u.Hostname() + ips, err := net.LookupHost(host) + if err != nil { + return fmt.Errorf("DNS lookup failed for %s: %w", host, err) + } + + for _, ip := range ips { + parsed := net.ParseIP(ip) + if parsed == nil { + continue + } + if isPrivateIP(parsed) { + return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip) + } + } + return nil +} + +// ── DuckDuckGo 搜索 ──────────────────────────────────────── + +type ddgResult struct { + Title string + URL string + Snippet string +} + +func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) { + form := url.Values{"q": {query}} + req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + return parseDDGResults(string(body), count), nil +} + +func parseDDGResults(html string, count int) []ddgResult { + var results []ddgResult + + // Find all result blocks:
...
+ bodyMarker := `result__body"` + for i := 0; i < len(html); i++ { + idx := strings.Index(html[i:], bodyMarker) + if idx < 0 { + break + } + i += idx + + // Find closing + closeIdx := findClosingTag(html, i, "") + if closeIdx < 0 { + break + } + block := html[i : closeIdx+6] + + r := parseSingleDDGResult(block) + if r.URL != "" { + results = append(results, r) + if len(results) >= count { + break + } + } + + i = closeIdx + 6 + } + + return results +} + +func findClosingTag(s string, start int, tag string) int { + depth := 1 + pos := start + for pos < len(s) { + nextOpen := strings.Index(s[pos:], `= 0 && nextOpen < nextClose { + depth++ + pos += nextOpen + 4 + } else { + depth-- + if depth == 0 { + return pos + nextClose + } + pos += nextClose + len(tag) + } + } + return -1 +} + +func parseSingleDDGResult(block string) ddgResult { + var r ddgResult + + // Extract URL and title from: TITLE + urlMarker := `class="result__a" href="` + uIdx := strings.Index(block, urlMarker) + if uIdx >= 0 { + start := uIdx + len(urlMarker) + end := strings.Index(block[start:], `"`) + if end >= 0 { + r.URL = block[start : start+end] + } + + aStart := strings.Index(block[start+end:], `>`) + if aStart >= 0 { + titleStart := start + end + aStart + 1 + aEnd := strings.Index(block[titleStart:], ``) + if aEnd >= 0 { + r.Title = stripTags(block[titleStart : titleStart+aEnd]) + } + } + } + + // Extract snippet: ... + snippetMarkers := []string{ + `= 0 { + aStart := strings.Index(block[sIdx:], `>`) + if aStart >= 0 { + snipStart := sIdx + aStart + 1 + snipEnd := strings.Index(block[snipStart:], ``) + if snipEnd < 0 { + snipEnd = strings.Index(block[snipStart:], ``) + } + if snipEnd >= 0 { + r.Snippet = stripTags(block[snipStart : snipStart+snipEnd]) + } + } + break + } + } + + return r +} + +// ── Web Fetch ────────────────────────────────────────────── + +func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) { + rawURL, _ := args["url"].(string) + if rawURL == "" { + return errorResult("url is required"), nil + } + + maxChars := 20000 + if v, ok := args["max_chars"].(float64); ok && v > 0 { + maxChars = int(v) + } + if maxChars > 500000 { + maxChars = 500000 + } + + if err := p.ssrfCheck(rawURL); err != nil { + return errorResult(err.Error()), nil + } + + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return errorResult("invalid URL: " + err.Error()), nil + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := p.client.Do(req) + if err != nil { + return errorResult("fetch failed: " + err.Error()), nil + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000)) + if err != nil { + return errorResult("read error: " + err.Error()), nil + } + + rawText := string(body) + + // Extract readable content based on content type + ct := resp.Header.Get("Content-Type") + var extracted string + if strings.Contains(ct, "text/html") { + extracted = htmlToText(rawText) + } else if strings.Contains(ct, "application/json") { + // Pretty-print JSON + var v interface{} + if json.Unmarshal(body, &v) == nil { + if pretty, err := json.MarshalIndent(v, "", " "); err == nil { + extracted = string(pretty) + } else { + extracted = rawText + } + } else { + extracted = rawText + } + } else { + extracted = rawText + } + + // Clean up and truncate + extracted = strings.TrimSpace(extracted) + if len(extracted) > maxChars { + extracted = extracted[:maxChars] + "\n\n[Content truncated]" + } + + if extracted == "" { + extracted = "(empty content)" + } + + return map[string]interface{}{ + "content": extracted, + "details": map[string]interface{}{ + "url": rawURL, + "status": resp.StatusCode, + "content_type": ct, + }, + }, nil +} + +// ── HTML → 文本 ────────────────────────────────────────────── + +func htmlToText(html string) string { + // Remove scripts + for { + start := strings.Index(strings.ToLower(html), "") + if end < 0 { + break + } + html = html[:start] + html[start+end+9:] + } + + // Remove styles + for { + start := strings.Index(strings.ToLower(html), "") + if end < 0 { + break + } + html = html[:start] + html[start+end+8:] + } + + // Replace block-level tags with newlines + for _, tag := range []string{"

", "", "", "", "", "", "", "", "", "", "", ""} { + html = strings.ReplaceAll(html, tag, "\n") + } + + // Remove remaining tags + html = stripTags(html) + + // Decode common entities + html = strings.ReplaceAll(html, "&", "&") + html = strings.ReplaceAll(html, "<", "<") + html = strings.ReplaceAll(html, ">", ">") + html = strings.ReplaceAll(html, """, "\"") + html = strings.ReplaceAll(html, "'", "'") + html = strings.ReplaceAll(html, " ", " ") + + // Collapse whitespace + lines := strings.Split(html, "\n") + var cleaned []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Collapse internal whitespace + in := []rune(line) + var out []rune + space := false + for _, r := range in { + if unicode.IsSpace(r) { + if !space { + out = append(out, ' ') + space = true + } + } else { + out = append(out, r) + space = false + } + } + cleaned = append(cleaned, string(out)) + } + + return strings.Join(cleaned, "\n") +} + +func stripTags(s string) string { + var out strings.Builder + inTag := false + for _, r := range s { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + out.WriteRune(r) + } + } + return out.String() +} + +// ── Search 处理 ────────────────────────────────────────────── + +func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) { + query, _ := args["query"].(string) + if query == "" { + return errorResult("query is required"), nil + } + + count := 5 + if v, ok := args["count"].(float64); ok && v > 0 { + count = int(v) + } + if count < 1 { + count = 1 + } + if count > 20 { + count = 20 + } + + results, err := p.ddgSearch(query, count) + if err != nil { + return errorResult("search failed: " + err.Error()), nil + } + + if len(results) == 0 { + return map[string]interface{}{ + "content": "No results found.", + }, nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query)) + for i, r := range results { + sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)) + } + + return map[string]interface{}{ + "content": strings.TrimSpace(sb.String()), + }, nil +} + +// ── 工具函数 ────────────────────────────────────────────── + +func errorResult(msg string) map[string]interface{} { + return map[string]interface{}{ + "isError": true, + "content": msg, + } +} + +func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { + v, err := s.Get(key) + if err != nil || v == nil { + return def + } + val, ok := v.(T) + if !ok { + return def + } + return val +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/example/webfetch/go.mod b/example/webfetch/go.mod new file mode 100644 index 0000000..f53138d --- /dev/null +++ b/example/webfetch/go.mod @@ -0,0 +1,7 @@ +module webfetch + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ../../. diff --git a/example/webfetch/plg.json b/example/webfetch/plg.json new file mode 100644 index 0000000..b00ea8a --- /dev/null +++ b/example/webfetch/plg.json @@ -0,0 +1,11 @@ +{ + "name": "webfetch", + "name_zh": "网页抓取", + "name_en": "webfetch", + "version": "1.0.0", + "description": "网页内容抓取工具,使用无头 Chromium 浏览器获取网页文字内容", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["web", "fetch"], + "targets": "linux/amd64" +} diff --git a/example/webfetch/plugin.go b/example/webfetch/plugin.go new file mode 100644 index 0000000..d6236fd --- /dev/null +++ b/example/webfetch/plugin.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + + s.RegisterTool("web_fetch", sdk.ToolDef{ + Name: "web_fetch", + Description: "获取网页文字内容。使用无头 Chromium 浏览器渲染页面后提取正文文字,返回标题和前 5000 字符。适用于需要查看网页内容的场景。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "要访问的网页 URL"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待秒数(用于 JS 渲染页面,默认 0)"}, + }, + "required": []string{"url"}, + }, + }, p.handleWebFetch) + + log.Printf("[%s] plugin started", p.name) + return nil +} + +func (p *Plugin) Stop() error { return nil } + +func convInt64(v interface{}) (int64, error) { + switch x := v.(type) { + case float64: + return int64(x), nil + case int64: + return x, nil + case json.Number: + return x.Int64() + case string: + return 0, fmt.Errorf("cannot convert string to int64") + default: + return 0, fmt.Errorf("cannot convert %T to int64", v) + } +} + +func (p *Plugin) handleWebFetch(args map[string]interface{}) (interface{}, error) { + url, _ := args["url"].(string) + if url == "" { + return nil, fmt.Errorf("url is required") + } + waitSec, _ := convInt64(args["wait"]) + + if waitSec > 0 { + time.Sleep(time.Duration(waitSec) * time.Second) + } + + var html string + chromiumPath := "/usr/local/bin/chromium" + if _, err := os.Stat(chromiumPath); err == nil { + var out bytes.Buffer + argsList := []string{"--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", url} + cmd := exec.Command(chromiumPath, argsList...) + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("chromium: %w", err) + } + html = out.String() + } else { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("http get: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + html = string(body) + } + + title := "" + if m := regexp.MustCompile(`([^<]+)`).FindStringSubmatch(html); len(m) > 1 { + title = m[1] + } + + var textOut bytes.Buffer + pyCmd := exec.Command("python3", "-c", ` +import sys, re, html +raw = sys.stdin.read() +text = re.sub(r'<[^>]+>', ' ', raw) +text = re.sub(r'\s+', ' ', text).strip() +text = html.unescape(text) +sys.stdout.write(text) +`) + pyCmd.Stdin = strings.NewReader(html) + pyCmd.Stdout = &textOut + pyCmd.Stderr = nil + pyCmd.Run() + text := strings.TrimSpace(textOut.String()) + + origLen := len(text) + truncated := origLen > 5000 + if truncated { + text = text[:5000] + } + + result := "" + if title != "" { + result = fmt.Sprintf("标题: %s\nURL: %s\n\n", title, url) + } + result += text + if truncated { + result += fmt.Sprintf("\n\n...(内容过长,仅显示前 5000 字符,共 %d 字符)", origLen) + } + + return map[string]interface{}{ + "content": result, + "title": title, + }, nil +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..665374d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitcode.com/JianFeeeee/homeagent-sdk + +go 1.25.0 diff --git a/meta/meta.go b/meta/meta.go new file mode 100644 index 0000000..883a2bd --- /dev/null +++ b/meta/meta.go @@ -0,0 +1,23 @@ +// Package meta 收集 HomeAgent SDK 的全部元数据。 +// 版本号应与核心 meta.Version 保持一致。 +package meta + +var ( + // Version 是 HomeAgent SDK 版本号。 + // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 + Version = "0.7.1" + + // Commit 是构建时的 Git commit hash。 + Commit = "unknown" + + // BuildTime 是构建时间。 + BuildTime = "unknown" + + // SDKName 是 SDK 名称。 + SDKName = "HomeAgent SDK" +) + +// FullVersion 返回完整的版本字符串。 +func FullVersion() string { + return SDKName + " v" + Version + " (" + Commit + ")" +} diff --git a/sdk/knowledge.go b/sdk/knowledge.go new file mode 100644 index 0000000..4c9d5d7 --- /dev/null +++ b/sdk/knowledge.go @@ -0,0 +1,14 @@ +package sdk + +// KnowledgeAPI provides access to the knowledge store. +type KnowledgeAPI interface { + Search(query string, topK int) ([]*Knowledge, error) + Add(name, content string) error + List() ([]string, error) +} + +// Knowledge represents a knowledge entry. +type Knowledge struct { + Name string `json:"name"` + Content string `json:"content"` +} diff --git a/sdk/llm.go b/sdk/llm.go new file mode 100644 index 0000000..b9da86a --- /dev/null +++ b/sdk/llm.go @@ -0,0 +1,8 @@ +package sdk + +// LLMAPI provides access to the LLM provider manager. +type LLMAPI interface { + ListSources() []string + SetSource(name string) error + CurrentSource() string +} diff --git a/sdk/memory.go b/sdk/memory.go new file mode 100644 index 0000000..215b313 --- /dev/null +++ b/sdk/memory.go @@ -0,0 +1,87 @@ +package sdk + +// MemoryAPI provides access to the graph memory (entity-relation store). +type MemoryAPI interface { + Recall(query []string, depth int) ([]Entity, []Relation, error) + Commit(triples []Triple) error + Introspect() (map[string]interface{}, error) + MergeEntities(source, target string) (int, error) + Purge(criteria map[string]string, mode string) (int, error) +} + +// Entity represents a named entity in the knowledge graph. +type Entity struct { + Name string `json:"name"` + Type string `json:"type"` + MentionCount int `json:"mention_count"` +} + +// Relation represents a relationship between two entities. +type Relation struct { + SourceName string `json:"source_name"` + TargetName string `json:"target_name"` + RelationType string `json:"relation_type"` + Confidence float64 `json:"confidence,omitempty"` +} + +// Triple represents a subject-relation-object triple for the knowledge graph. +type Triple struct { + Subject string `json:"subject"` + Relation string `json:"relation"` + Object string `json:"object"` + Confidence float64 `json:"confidence,omitempty"` + SubjectType string `json:"subject_type,omitempty"` + ObjectType string `json:"object_type,omitempty"` +} + +// TextMemoryAPI provides access to chronological text event storage. +type TextMemoryAPI interface { + Append(evt TextEvent) error +} + +// TextEvent represents a single text memory event. +type TextEvent struct { + Role string `json:"role"` + Content string `json:"content"` + Timestamp int64 `json:"timestamp"` + Channel string `json:"channel,omitempty"` +} + +// DocMemoryAPI provides access to the document vector store. +type DocMemoryAPI interface { + Query(text string, topK int) []*Doc + Insert(doc *Doc) error + Remove(id string) + Stats() map[string]interface{} +} + +// Doc represents a document in the document store. +type Doc struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Score float64 `json:"score,omitempty"` +} + +// SocialAPI provides read-only access to the social graph (person profiles and relationships). +// External plugins can query person traits and social networks but cannot modify them. +type SocialAPI interface { + GetPerson(name string) (*PersonProfile, error) + GetTrait(name, trait string) (string, bool) + GetRelations(name string) ([]SocialRelation, error) + GetNetwork(name string, depth int) ([]*PersonProfile, error) + ListPersons() ([]string, error) +} + +// PersonProfile represents a person's complete profile (traits + social relations). +type PersonProfile struct { + Name string `json:"name"` + Traits map[string]string `json:"traits,omitempty"` + Relations []SocialRelation `json:"relations,omitempty"` +} + +// SocialRelation represents a social relationship between two persons. +type SocialRelation struct { + Person string `json:"person"` + Relation string `json:"relation"` +} diff --git a/sdk/plugin.go b/sdk/plugin.go new file mode 100644 index 0000000..c93afa0 --- /dev/null +++ b/sdk/plugin.go @@ -0,0 +1,341 @@ +package sdk + +import ( + "sync" + + "gitcode.com/JianFeeeee/homeagent-sdk/meta" +) + +// SDKVersion 是对外暴露的 SDK 版本号。 +var SDKVersion = meta.Version + +// Plugin is the interface every plugin must implement. +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error +} + +// ToolHandler is a function that handles a tool call. +type ToolHandler func(args map[string]interface{}) (interface{}, error) + +// StageHandler is a function that handles a pipeline stage event. +type StageHandler func(ctx *StageContext) error + +// Stage represents a point in the message processing pipeline. +type Stage string + +const ( + StageOnInput Stage = "on_input" + StagePreAction Stage = "pre_action" + StagePostAction Stage = "post_action" + StageBeforeToolcall Stage = "before_toolcall" + StageAfterToolcall Stage = "after_toolcall" + StageBeforeOutput Stage = "before_output" + StageAfterOutput Stage = "after_output" +) + +// StageContext provides context for stage handlers. +type StageContext struct { + mu sync.RWMutex + RawMessage string + UserID string + GroupID string + ContextMsgs []map[string]interface{} + LLMText string + ReasoningContent string + TokenUsage map[string]int + ToolCalls []ToolCall + ToolResults []ToolResult + FinalText string + Response *string + Phase Stage + Memory []MemItem + NoMemory bool + Extra map[string]interface{} + Errors []string // 阶段处理过程中的错误信息 +} + +func (c *StageContext) RLock() { c.mu.RLock() } +func (c *StageContext) RUnlock() { c.mu.RUnlock() } +func (c *StageContext) Lock() { c.mu.Lock() } +func (c *StageContext) Unlock() { c.mu.Unlock() } +func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil } + +// MemItem represents a memory item in stage context. +type MemItem struct { + Role string `json:"role"` + Content string `json:"content"` + Score float64 `json:"score"` +} + +// ToolCall represents a model's request to call a tool. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult represents the result of a tool call. +type ToolResult struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Success bool `json:"success"` + Result interface{} `json:"result"` +} + +// ToolDef describes a tool that the plugin exposes. +type ToolDef struct { + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// IOInjector provides methods for injecting input and interrupts into the agent pipeline. +// All methods accept (source, channel) where channel is the target output channel +// for routing the agent's response. +type IOInjector interface { + InjectInterruptText(source, channel, text string) + InjectText(source, channel, text string) + InjectTextNoMemory(source, channel, text string) +} + +// EventType identifies the kind of system event. +type EventType string + +const ( + EventRawInput EventType = "raw_input" + EventAgentOutput EventType = "agent_output" + EventAgentLLMChain EventType = "agent_llm_chain" + EventToolCall EventType = "tool_call" + EventReasoning EventType = "reasoning" + EventStage EventType = "stage" + EventSystem EventType = "system" +) + +// Event represents a system event published by the kernel. +type Event struct { + Type EventType `json:"type"` + Source string `json:"source"` + Payload map[string]interface{} `json:"payload"` + Timestamp int64 `json:"timestamp"` +} + +// EventHandler processes a system event. +type EventHandler func(evt *Event) + +// EventSubscriber allows plugins to subscribe to kernel events. +// This is a restricted interface: plugins can subscribe but the kernel +// controls which events are delivered. +type EventSubscriber interface { + Subscribe(eventType EventType, handler EventHandler) func() +} + +// StageScope controls which events a stage handler receives. +type StageScope int + +const ( + // StageScopeGlobal receives all stage events (default). + StageScopeGlobal StageScope = 0 + // StageScopeOwnTools only receives events for this plugin's own tool calls + // (before_toolcall / after_toolcall only). Other stages degrade to global. + StageScopeOwnTools StageScope = 1 +) + +// ToolRegistrar registers a tool dynamically. +type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error + +// StageRegistrar registers a stage handler. +type StageRegistrar func(stage Stage, handler StageHandler) + +// APIRegistrar registers a plugin API for external access. +type APIRegistrar func(name string) error + +// OutputChannelRegistrar registers an output channel that the output_send tool can use. +type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error + +// Output capability flags +const ( + CapText = 1 + CapFile = 2 + CapImage = 4 + CapAudio = 8 + CapStructured = 16 +) + +// PluginSDK is the main API surface provided to plugins at runtime. +// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection. +type PluginSDK struct { + name string + regTool ToolRegistrar + regStage StageRegistrar + regAPI APIRegistrar + regOutput OutputChannelRegistrar + io IOInjector + mem MemoryAPI + textMem TextMemoryAPI + docMem DocMemoryAPI + know KnowledgeAPI + llm LLMAPI + sett SettingsAPI + social SocialAPI + events EventSubscriber + + autoRestart bool +} + +// New creates a PluginSDK with the given dependencies. +func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK { + return &PluginSDK{ + name: name, + sett: sett, + regTool: regTool, + regStage: regStage, + regAPI: regAPI, + regOutput: regOutput, + autoRestart: true, + } +} + +// PluginName returns the name of the plugin. +func (s *PluginSDK) PluginName() string { return s.name } + +// Settings returns the settings API for reading/writing plugin configuration. +func (s *PluginSDK) Settings() SettingsAPI { return s.sett } + +// Memory returns the graph memory API (may be nil if not available). +func (s *PluginSDK) Memory() MemoryAPI { return s.mem } + +// TextMemory returns the text memory API (may be nil if not available). +func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem } + +// DocMemory returns the document memory API (may be nil if not available). +func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem } + +// Knowledge returns the knowledge store API (may be nil if not available). +func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know } + +// LLM returns the LLM provider API (may be nil if not available). +func (s *PluginSDK) LLM() LLMAPI { return s.llm } + +// Social returns the social graph API (may be nil if not available). +func (s *PluginSDK) Social() SocialAPI { return s.social } + +// Events returns the event subscriber for listening to kernel events (may be nil if not available). +func (s *PluginSDK) Events() EventSubscriber { return s.events } + +// RegisterTool registers a tool that the LLM can call. +func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error { + if def.Plugin == "" { + def.Plugin = s.name + } + if s.regTool != nil { + return s.regTool(name, def, handler) + } + return nil +} + +// RegisterStage registers a handler for a pipeline stage. +// scope: StageScopeGlobal (default) — receives all stage events. +// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools. +func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) { + if s.regStage == nil { + return + } + sc := StageScopeGlobal + if len(scope) > 0 { + sc = scope[0] + } + if sc == StageScopeGlobal { + s.regStage(stage, handler) + return + } + // OwnTools scope — only for before_toolcall / after_toolcall + if stage != StageBeforeToolcall && stage != StageAfterToolcall { + s.regStage(stage, handler) + return + } + s.regStage(stage, func(ctx *StageContext) error { + ctx.RLock() + match := false + switch stage { + case StageBeforeToolcall: + match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name + case StageAfterToolcall: + match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name + } + ctx.RUnlock() + if !match { + return nil + } + return handler(ctx) + }) +} + +// RegisterPluginAPI registers this plugin's API for access by other plugins. +func (s *PluginSDK) RegisterPluginAPI(name string) error { + if s.regAPI != nil { + return s.regAPI(name) + } + return nil +} + +// RegisterOutputChannel registers an output channel that the output_send tool can route to. +// name: channel name (e.g. "qq", "webui") +// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) +// desc: description of the channel and expected JSON format for content +// handler: receives the content (JSON string) and returns result/error +func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error { + if s.regOutput != nil { + return s.regOutput(name, caps, desc, handler) + } + return nil +} + +// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup). +func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r } + +// SetIOInjector sets the IO injector (called by the core at startup). +func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io } + +// SetMemoryAPI sets the memory API (called by the core at startup). +func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem } +func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm } +func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm } +func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn } +func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm } +func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social } +func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es } + +// ---- IO Convenience Methods ---- + +// InjectInterruptText injects a text interrupt that can preempt current LLM processing. +func (s *PluginSDK) InjectInterruptText(source, channel, text string) { + if s.io != nil { + s.io.InjectInterruptText(source, channel, text) + } +} + +// InjectText injects a text message into the agent pipeline. +func (s *PluginSDK) InjectText(source, channel, text string) { + if s.io != nil { + s.io.InjectText(source, channel, text) + } +} + +// InjectTextNoMemory injects a text message without generating memory. +func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) { + if s.io != nil { + s.io.InjectTextNoMemory(source, channel, text) + } +} + +// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。 +// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。 +func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled } + +// AutoRestart 返回插件是否允许自动重启。 +func (s *PluginSDK) AutoRestart() bool { return s.autoRestart } diff --git a/sdk/plugin_test.go b/sdk/plugin_test.go new file mode 100644 index 0000000..fda9049 --- /dev/null +++ b/sdk/plugin_test.go @@ -0,0 +1,179 @@ +package sdk + +import ( + "testing" +) + +func TestRegisterStageGlobalDefault(t *testing.T) { + called := false + regStage := func(stage Stage, handler StageHandler) { + called = true + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }) + + if !called { + t.Error("global scope: handler not registered") + } +} + +func TestRegisterStageGlobalExplicit(t *testing.T) { + called := false + regStage := func(stage Stage, handler StageHandler) { + called = true + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal) + + if !called { + t.Error("global scope: handler not registered") + } +} + +func TestRegisterStageOwnToolsMatch(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}} + ctx.ToolResults = nil + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called for other plugin's tool") + } +} + +func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) { + regStage := func(stage Stage, handler StageHandler) { + if stage != StagePreAction { + t.Errorf("expected StagePreAction, got %s", stage) + } + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) +} + +func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called when ToolCalls is empty") + } +} + +func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 1 { + t.Error("handler should be called for own plugin's tool result") + } +} + +func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + ctx := &StageContext{} + ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called for other plugin's tool result") + } +} + +func TestRegisterStageOwnToolsNilRegStage(t *testing.T) { + s := &PluginSDK{name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) +} diff --git a/sdk/settings.go b/sdk/settings.go new file mode 100644 index 0000000..61bfdc6 --- /dev/null +++ b/sdk/settings.go @@ -0,0 +1,58 @@ +package sdk + +type SettingsAPI interface { + // Get reads the plugin's own config value (config_ table). + Get(key string) (interface{}, error) + + // Set writes a config value to the plugin's own config table. + Set(key string, value interface{}) error + + // List returns all keys matching the given prefix. + List(prefix string) ([]string, error) + + // GetCore reads the core config table. + GetCore(key string) (interface{}, error) + + // SetCore writes to the core config table. + SetCore(key string, value interface{}) error + + // ListCore lists core config keys matching the prefix. + ListCore(prefix string) ([]string, error) + + // GetPlugin reads another plugin's config table. + GetPlugin(plugin, key string) (interface{}, error) + + // SetPlugin writes to another plugin's config table. + SetPlugin(plugin, key string, value interface{}) error + + // ListPlugin lists another plugin's config keys matching the prefix. + ListPlugin(plugin, prefix string) ([]string, error) + + // RegisterDef registers a config definition for UI display. + RegisterDef(def ConfigDef) + + // Defs returns config definitions matching the prefix. + Defs(prefix string) []*ConfigDef + + // Dump returns all config values. + Dump() map[string]interface{} + + // Plugins returns a list of all plugin config namespaces. + Plugins() []string +} + +// ConfigDef describes a configuration field for the WebUI. +type ConfigDef struct { + Key string `json:"key"` + Default interface{} `json:"default,omitempty"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Options []string `json:"options,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` + Step float64 `json:"step,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` +} diff --git a/tools/plugindev/cmd_build.go b/tools/plugindev/cmd_build.go new file mode 100644 index 0000000..5b0f50b --- /dev/null +++ b/tools/plugindev/cmd_build.go @@ -0,0 +1,415 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +type BuildConfig struct { + OutDir string + Targets []string // "linux/amd64", "windows/amd64", "lua" +} + +func cmdBuild(args []string) { + cfg := BuildConfig{OutDir: "dist"} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--outdir": + if i+1 < len(args) { + cfg.OutDir = args[i+1] + i++ + } + case "--target": + if i+1 < len(args) { + cfg.Targets = append(cfg.Targets, args[i+1]) + i++ + } + } + } + + // read plg.json + plg, err := readPlgJSON("plg.json") + if err != nil { + fmt.Printf("error: read plg.json: %v\n", err) + os.Exit(1) + } + + // determine targets + targets := cfg.Targets + if len(targets) == 0 { + targets = parseTargets(plg.Targets) + } + if len(targets) == 0 { + targets = []string{"native"} + } + + // build for each target + for _, t := range targets { + buildTarget(plg, t, cfg.OutDir) + } +} + +func readPlgJSON(path string) (*PlgConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var plg PlgConfig + if err := json.Unmarshal(data, &plg); err != nil { + return nil, err + } + return &plg, nil +} + +func parseTargets(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "native" { + return nil + } + var t []string + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s != "" { + t = append(t, s) + } + } + return t +} + +func writePluginJSON(plg *PlgConfig, entry string) { + m := map[string]interface{}{ + "name": plg.Name, + "name_zh": plg.NameZh, + "name_en": plg.NameEn, + "version": plg.Version, + "description": plg.Description, + "author": plg.Author, + "entry": entry, + } + if len(plg.Tags) > 0 { + m["tags"] = plg.Tags + } + data, _ := json.MarshalIndent(m, "", " ") + os.WriteFile("plugin.json", data, 0644) +} + +type buildConfig struct { + goos string + goarch string + entryFile string // "plugin.so" or "plugin.dll" +} + +func resolveBuild(target string) (*buildConfig, string) { + if target == "lua" || target == "" { + return nil, "lua" + } + + goos, goarch, _ := strings.Cut(target, "/") + if goos == "" { + goos = runtime.GOOS + if goarch == "" { + goarch = runtime.GOARCH + } + } + + switch goos { + case "linux", "darwin", "freebsd", "windows": + ext := ".so" + if goos == "windows" { + ext = ".dll" + } + return &buildConfig{ + goos: goos, + goarch: goarch, + entryFile: "plugin" + ext, + }, "" + default: + return nil, fmt.Sprintf("unsupported OS %q", goos) + } +} + +func buildTarget(plg *PlgConfig, target, outDir string) { + os.MkdirAll(outDir, 0755) + + // Lua: no compilation, package source directly + if target == "lua" { + writePluginJSON(plg, "main.lua") + pkgFiles := []string{"plugin.json", "main.lua"} + for _, f := range []string{"README.md", "LICENSE"} { + if _, err := os.Stat(f); err == nil { + pkgFiles = append(pkgFiles, f) + } + } + // Include thirdpart Lua files + if entries, err := os.ReadDir("thirdpart"); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") { + path := filepath.Join("thirdpart", e.Name()) + if _, err := os.Stat(path); err == nil { + pkgFiles = append(pkgFiles, path) + } + } + } + } + hmapName := fmt.Sprintf("%s_lua.hmap", toSnake(plg.NameEn)) + createHmap(filepath.Join(outDir, hmapName), pkgFiles) + fmt.Printf(" packaged %s\n", hmapName) + return + } + + // Resolve build config + cfg, errMsg := resolveBuild(target) + if cfg == nil { + fmt.Printf(" error: %s\n", errMsg) + return + } + + buildDir := "build" + os.MkdirAll(buildDir, 0755) + outPath := filepath.Join(buildDir, cfg.entryFile) + + // Auto-generate C ABI bridge (all platforms use c-shared) + bridgeCleanup := generateBridge(cfg.goos) + _ = bridgeCleanup // DISABLED cleanup for debug + + // Auto-link thirdpart/ contents + thirdpartCleanup := linkThirdpart(target) + defer thirdpartCleanup() + + // Write plugin.json with the correct entry for this target + writePluginJSON(plg, cfg.entryFile) + + cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", outPath) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=1") + + // Auto-detect MinGW gcc on Windows + if cfg.goos == "windows" { + cc := detectWindowsCC() + if cc != "" { + cmd.Env = append(cmd.Env, "CC="+cc) + } + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // DEBUG: list files before building + entries, _ := os.ReadDir(".") + for _, e := range entries { + fmt.Printf(" [DEBUG] file: %s\n", e.Name()) + } + + fmt.Printf(" compiling %s/%s (-buildmode=c-shared)...\n", cfg.goos, cfg.goarch) + if err := cmd.Run(); err != nil { + fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err) + return + } + + // package + pkgFiles := []string{"plugin.json", outPath} + for _, f := range []string{"README.md", "LICENSE"} { + if _, err := os.Stat(f); err == nil { + pkgFiles = append(pkgFiles, f) + } + } + hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch) + createHmap(filepath.Join(outDir, hmapName), pkgFiles) + fmt.Printf(" packaged %s\n", hmapName) +} + +func createHmap(hmapPath string, files []string) { + f, err := os.Create(hmapPath) + if err != nil { + fmt.Printf("error: create hmap %s: %v\n", hmapPath, err) + return + } + defer f.Close() + + w := zip.NewWriter(f) + defer w.Close() + + for _, path := range files { + if path == "" { + continue + } + info, err := os.Stat(path) + if err != nil { + continue + } + hdr, err := zip.FileInfoHeader(info) + if err != nil { + continue + } + hdr.Method = zip.Deflate + hdr.Name = filepath.Base(path) + writer, err := w.CreateHeader(hdr) + if err != nil { + continue + } + src, err := os.Open(path) + if err != nil { + continue + } + io.Copy(writer, src) + src.Close() + } +} + +func toSnake(s string) string { + return strings.ToLower(strings.ReplaceAll(s, " ", "_")) +} + +// detectWindowsCC looks for a MinGW-w64 gcc on Windows for c-shared builds. +func detectWindowsCC() string { + // Check CC from environment first + if cc := os.Getenv("CC"); cc != "" { + if _, err := exec.LookPath(cc); err == nil { + return cc + } + } + // Check common MinGW install paths + candidates := []string{ + "C:\\mingw64\\bin\\gcc.exe", + "C:\\MinGW\\bin\\gcc.exe", + "C:\\msys64\\mingw64\\bin\\gcc.exe", + "C:\\Users\\21989\\AppData\\Local\\Temp\\mingw64\\mingw64\\bin\\gcc.exe", + } + // Also search PATH for gcc + if path, err := exec.LookPath("gcc"); err == nil { + return path + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// stripIncludeGuard strips preprocessor guards and C++ comments from a C header, +// since these can confuse cgo's type resolution. +func stripIncludeGuard(header string) string { + lines := strings.Split(header, "\n") + var out []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "#ifndef HOMEAGENT_CABI_H" || trimmed == "#define HOMEAGENT_CABI_H" { + continue + } + if trimmed == "#endif" || strings.HasPrefix(trimmed, "#endif") { + continue + } + if trimmed == "#ifdef __cplusplus" || trimmed == "extern \"C\" {" || trimmed == "}" { + continue + } + // Strip C++-style comments (cgo parser may not handle them in /* */ blocks) + if idx := strings.Index(line, "//"); idx >= 0 { + line = line[:idx] + } + cleaned := strings.TrimSpace(line) + if cleaned == "" { + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +// generateBridge generates the C ABI bridge files for non-Lua builds. +// Returns a cleanup function to remove generated files. +func generateBridge(goos string) func() { + const bridgeFile = "z_bridge_gen.go" + const cEntryFile = "z_entry.c" + os.Remove(bridgeFile) + os.Remove(cEntryFile) + + var files []string + + if goos == "windows" { + if err := os.WriteFile(bridgeFile, []byte(tmplBridge), 0644); err != nil { + fmt.Printf(" error: write bridge: %v\n", err) + return func() {} + } + files = append(files, bridgeFile) + } else { + if err := os.WriteFile(bridgeFile, []byte(tmplLinuxBridge), 0644); err != nil { + fmt.Printf(" error: write bridge: %v\n", err) + return func() {} + } + files = append(files, bridgeFile) + // Write C entry point file + if err := os.WriteFile(cEntryFile, []byte(tmplPluginInitC), 0644); err != nil { + fmt.Printf(" error: write C entry: %v\n", err) + return func() {} + } + files = append(files, cEntryFile) + } + + return func() { + for _, f := range files { + os.Remove(f) + } + } +} + +// linkThirdpart scans thirdpart/ for source files and generates auto-import stubs. +// For Go plugins: if thirdpart/*.go exists, generate z_thirdpart.go with import. +// For Lua plugins: no action needed (thirdpart/*.lua is packaged separately in buildTarget). +// Returns cleanup function to remove generated files. +func linkThirdpart(target string) func() { + const thirdpartDir = "thirdpart" + const importFile = "z_thirdpart.go" + os.Remove(importFile) + + if info, err := os.Stat(thirdpartDir); err != nil || !info.IsDir() { + return func() {} + } + + entries, err := os.ReadDir(thirdpartDir) + if err != nil { + return func() {} + } + + // For Go builds: check for .go files + if target != "lua" { + hasGo := false + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") { + hasGo = true + break + } + } + if hasGo { + // Read go.mod to get the module path + gomodPath := "go.mod" + data, err := os.ReadFile(gomodPath) + if err != nil { + return func() {} + } + modulePath := "" + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "module ") { + modulePath = strings.TrimSpace(line[7:]) + break + } + } + if modulePath != "" { + importPath := modulePath + "/" + thirdpartDir + stub := "package main\nimport _ \"" + importPath + "\"\n" + os.WriteFile(importFile, []byte(stub), 0644) + } + } + } + + return func() { + os.Remove(importFile) + } +} diff --git a/tools/plugindev/cmd_clean.go b/tools/plugindev/cmd_clean.go new file mode 100644 index 0000000..d98a3ad --- /dev/null +++ b/tools/plugindev/cmd_clean.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "os" +) + +func cmdClean(args []string) { + outDir := "dist" + if len(args) > 0 && args[0] == "--outdir" && len(args) > 1 { + outDir = args[1] + } + + dirs := []string{"build", outDir} + for _, d := range dirs { + if _, err := os.Stat(d); os.IsNotExist(err) { + continue + } + if err := os.RemoveAll(d); err != nil { + fmt.Printf("error: remove %s: %v\n", d, err) + } else { + fmt.Printf("removed %s/\n", d) + } + } + + // also clean generated files + for _, f := range []string{"plugin.json", "z_bridge_gen.go"} { + if _, err := os.Stat(f); err == nil { + os.Remove(f) + fmt.Printf("removed %s\n", f) + } + } +} diff --git a/tools/plugindev/cmd_debug.go b/tools/plugindev/cmd_debug.go new file mode 100644 index 0000000..64cc89e --- /dev/null +++ b/tools/plugindev/cmd_debug.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// tmplLuaDebug is the temporary Lua debug script template +const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug +-- Generated by plugindev debug --lua +sdk = require("sdk") +local ok, plugin = pcall(dofile, "main.lua") +if not ok then + print("[debug] ERROR loading main.lua: " .. tostring(plugin)) + os.exit(1) +end +if type(plugin) == "table" then + print("[debug] Plugin: " .. tostring(plugin.name or "unnamed")) + if plugin.start then + print("[debug] Calling plugin.start(sdk) ...") + local ok, err = pcall(plugin.start, sdk) + if ok then + print("[debug] plugin.start() OK") + else + print("[debug] plugin:start() ERROR: " .. tostring(err)) + end + end +end +print("") +print("=== Interactive REPL ===") +print("sdk, plugin globals are available") +local function repl() + while true do + io.write("> ") + io.flush() + local line = io.read() + if line == nil or line == "exit" or line == "quit" then break end + if line == "help" then + print(" help - this help") + print(" exit/quit - exit debug") + print(" sdk - SDK API table") + print(" plugin - loaded plugin table") + else + local fn, err = (loadstring or load)(line) + if fn then + local ok, result = pcall(fn) + if ok and result ~= nil then print(tostring(result)) end + if not ok then print("Error: " .. tostring(result)) end + else + print("Error: " .. tostring(err)) + end + end + end +end +repl() +` + +func cmdDebug(args []string) { + dir := "." + if len(args) > 0 && args[0] != "" { + dir = args[0] + } + + luaPath := filepath.Join(dir, "main.lua") + goPath := filepath.Join(dir, "main.go") + sdkPath := filepath.Join(dir, "sdk.lua") + + if _, err := os.Stat(luaPath); err == nil { + debugLua(dir, sdkPath, luaPath) + } else if _, err := os.Stat(goPath); err == nil { + debugGo(dir, goPath) + } else { + fmt.Println("error: no main.lua or main.go found in", dir) + os.Exit(1) + } +} + +func debugLua(dir, sdkPath, luaPath string) { + // check lua interpreter + luaBin, err := exec.LookPath("lua") + if err != nil { + fmt.Println("error: lua interpreter not found in PATH") + fmt.Println(" install Lua 5.1+ or use plugindev build to compile your plugin") + os.Exit(1) + } + + fmt.Printf("[debug] Lua interpreter: %s\n", luaBin) + fmt.Printf("[debug] Plugin dir: %s\n", dir) + + // check if sdk.lua exists + if _, err := os.Stat(sdkPath); os.IsNotExist(err) { + fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available") + } + + // write temporary debug script + debugScript := filepath.Join(dir, "_debug.lua") + if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil { + fmt.Printf("error: write debug script: %v\n", err) + os.Exit(1) + } + defer os.Remove(debugScript) + + cmd := exec.Command(luaBin, filepath.Base(debugScript)) + cmd.Dir = dir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Println("[debug] Starting Lua debug session...") + fmt.Println() + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + fmt.Printf("[debug] error: %v\n", err) + os.Exit(1) + } +} + +func debugGo(dir, goPath string) { + fmt.Println("Go debug mode: use standard Go tooling") + fmt.Println() + fmt.Println(" go test -v ./... # run tests") + fmt.Println(" go build -o plugin.so -buildmode=plugin . # build plugin") + fmt.Println(" plugindev build # package as .hmap") + fmt.Println() + fmt.Println("For interactive Go debugging, use your IDE or dlv:") + fmt.Println(" dlv debug # Delve debugger") +} + +var _ = strings.TrimSpace diff --git a/tools/plugindev/cmd_init.go b/tools/plugindev/cmd_init.go new file mode 100644 index 0000000..08ef37f --- /dev/null +++ b/tools/plugindev/cmd_init.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "text/template" +) + +// sdkRoot is the HomeAgent SDK root directory, computed at init time from source location. +var sdkRoot string + +const cabiVersion = 1 + +func init() { + _, filename, _, ok := runtime.Caller(0) + if !ok { + return + } + // filename: /tools/plugindev/cmd_init.go + sdkRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename))) +} + +type PlgConfig struct { + Name string `json:"name"` + NameZh string `json:"name_zh"` + NameEn string `json:"name_en"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Entry string `json:"entry"` + Tags []string `json:"tags"` + Targets string `json:"targets"` +} + +type TemplateData struct { + Plg PlgConfig + IsLua bool + + // Go module info (for go.mod) + ModulePath string + GoVersion string + SDKModule string + SDKVersion string + SDKReplace string + + // C ABI + CABIVersion int + CABIHeader string +} + +func cmdInit(args []string) { + if len(args) < 1 { + fmt.Println("Usage: plugindev init [--lua]") + os.Exit(1) + } + + name := args[0] + isLua := false + for _, a := range args[1:] { + switch a { + case "--lua": + isLua = true + } + } + + dir := name + if _, err := os.Stat(dir); !os.IsNotExist(err) { + fmt.Printf("error: directory %q already exists\n", dir) + os.Exit(1) + } + + entry := "plugin.so" + var targets string + if isLua { + entry = "main.lua" + targets = "lua" + } else { + targets = "linux/amd64,windows/amd64" + } + + nameEn := strings.ReplaceAll(name, "-", " ") + nameEn = strings.Title(nameEn) + + data := TemplateData{ + Plg: PlgConfig{ + Name: name, + NameZh: "中文名", + NameEn: nameEn, + Version: "0.1.0", + Description: name + " plugin", + Author: "HomeAgent", + Entry: entry, + Tags: []string{name}, + Targets: targets, + }, + IsLua: isLua, + CABIVersion: cabiVersion, + CABIHeader: tmplCABIHeader, + } + + // Detect SDK info for Go plugin go.mod + if !isLua { + sdkMod, goVer, sdkPath := detectSDKInfo() + sdkReplace := sdkPath + // Make replace path absolute and use forward slashes + if abs, err := filepath.Abs(sdkPath); err == nil { + sdkReplace = strings.ReplaceAll(abs, "\\", "/") + } + data.ModulePath = name + data.GoVersion = goVer + data.SDKModule = sdkMod + data.SDKVersion = "v0.0.0" + data.SDKReplace = sdkReplace + } + + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Printf("error: create dir: %v\n", err) + os.Exit(1) + } + + // write plg.json + writeTemplate(filepath.Join(dir, "plg.json"), tmplPlgJSON, data) + + // Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only + if isLua { + writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data) + writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data) + } else { + writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data) + } + + // write README.md + writeTemplate(filepath.Join(dir, "README.md"), tmplReadme, data) + + // write go.mod for Go plugins + if !isLua { + writeTemplate(filepath.Join(dir, "go.mod"), tmplGoMod, data) + } + + // create thirdpart directory for external library sources + os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755) + + fmt.Printf("Created plugin project %q (%s)\n", dir, entry) + if isLua { + fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir) + } + fmt.Printf(" cd %s && plugindev build\n", dir) +} + +// detectSDKInfo reads the HomeAgent SDK's go.mod to get module path and go version. +func detectSDKInfo() (modulePath, goVersion, sdkPath string) { + if sdkRoot == "" { + fmt.Printf("error: cannot detect SDK root (built outside SDK tree?)\n") + os.Exit(1) + } + gomodPath := filepath.Join(sdkRoot, "go.mod") + data, err := os.ReadFile(gomodPath) + if err != nil { + fmt.Printf("error: cannot read SDK go.mod at %s: %v\n", gomodPath, err) + os.Exit(1) + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + modulePath = strings.TrimSpace(line[7:]) + } + if strings.HasPrefix(line, "go ") { + goVersion = strings.TrimSpace(line[3:]) + } + } + if modulePath == "" { + fmt.Printf("error: no module directive in %s\n", gomodPath) + os.Exit(1) + } + if goVersion == "" { + goVersion = "1.21" + } + return modulePath, goVersion, sdkRoot +} + +func writeTemplate(path, content string, data TemplateData) { + tmpl, err := template.New("").Parse(content) + if err != nil { + fmt.Printf("error: parse template: %v\n", err) + os.Exit(1) + } + f, err := os.Create(path) + if err != nil { + fmt.Printf("error: create %s: %v\n", path, err) + os.Exit(1) + } + defer f.Close() + if err := tmpl.Execute(f, data); err != nil { + fmt.Printf("error: execute template: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/plugindev/go.mod b/tools/plugindev/go.mod new file mode 100644 index 0000000..b5fe1f4 --- /dev/null +++ b/tools/plugindev/go.mod @@ -0,0 +1,3 @@ +module github.com/JianFeeeee/homeagent-sdk/tools/plugindev + +go 1.21 diff --git a/tools/plugindev/main.go b/tools/plugindev/main.go new file mode 100644 index 0000000..184035c --- /dev/null +++ b/tools/plugindev/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + help() + return + } + switch os.Args[1] { + case "init": + cmdInit(os.Args[2:]) + case "build": + cmdBuild(os.Args[2:]) + case "clean": + cmdClean(os.Args[2:]) + case "debug": + cmdDebug(os.Args[2:]) + default: + help() + } +} + +func help() { + fmt.Println(`HomeAgent Plugin Dev Tool + +Usage: + plugindev init Scaffold a new plugin project + plugindev build [flags] Compile and package plugin + plugindev clean Clean build/dist artifacts + plugindev debug [dir] Interpret and debug plugin source + +Flags: + --outdir Output directory (default: dist) + --target Target OS/arch (e.g. linux/amd64), repeatable + --lua Create Lua plugin (for init) +`) +} diff --git a/tools/plugindev/template_c.go b/tools/plugindev/template_c.go new file mode 100644 index 0000000..e4a5bbe --- /dev/null +++ b/tools/plugindev/template_c.go @@ -0,0 +1,3 @@ +package main + +// tmplPluginInitC is in templates.go (moved to keep all C ABI together) diff --git a/tools/plugindev/templates.go b/tools/plugindev/templates.go new file mode 100644 index 0000000..0308ab1 --- /dev/null +++ b/tools/plugindev/templates.go @@ -0,0 +1,727 @@ +package main + +// tmplPlgJSON is the plg.json template +const tmplPlgJSON = `{ + "name": "{{.Plg.Name}}", + "name_zh": "{{.Plg.NameZh}}", + "name_en": "{{.Plg.NameEn}}", + "version": "{{.Plg.Version}}", + "description": "{{.Plg.Description}}", + "author": "{{.Plg.Author}}", + "entry": "{{.Plg.Entry}}", + "tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}], + "targets": "{{.Plg.Targets}}" +} +` + +const tmplGoMod = `module {{.ModulePath}} + +go {{.GoVersion}} + +require {{.SDKModule}} {{.SDKVersion}} + +replace {{.SDKModule}} => {{.SDKReplace}} +` + +const tmplPluginGo = `package main + +import ( + "fmt" + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string", + DisplayName: "示例配置", Description: "An example configuration key", + Category: "{{.Plg.Name}}", + }) + tp := p.name + "_" + s.RegisterTool(tp+"hello", sdk.ToolDef{ + Name: tp + "hello", Description: "A hello world tool", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + }, p.handleHello) + fmt.Printf("[%s] started\n", p.name) + return nil +} + +func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil } + +func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) { + return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} +` + +const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock) +sdk = {} +function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end +function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end +function sdk.register_stage(stage, handler) print("[lua-plugin] register_stage: " .. tostring(stage)) end +function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end +function sdk.get_setting(key) return nil end +function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end +function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end +function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end +function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end +sdk.json = {} +function sdk.json.encode(val) + if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"' + elseif type(val) == "number" or type(val) == "boolean" then return tostring(val) + elseif type(val) == "table" then local parts, i = {}, 1 + for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end + return "{" .. table.concat(parts, ",") .. "}" end + return "null" +end +function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end +sdk.http = {} +function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end +function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end +return sdk +` + +const tmplMainLua = `-- {{.Plg.Name}} plugin +local plugin = { name = "{{.Plg.Name}}" } +function plugin.start(sdk) + sdk.log("info", "{{.Plg.Name}} starting...") + sdk.register_tool("{{.Plg.Name}}_hello", { + description = "A hello world tool", + parameters = { type = "object", properties = {} } + }, function(args) return { content = "Hello from {{.Plg.Name}} plugin!" } end) + sdk.log("info", "{{.Plg.Name}} started") +end +function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end +return plugin +` + +// tmplBridge — Windows DLL C ABI bridge (unchanged) +const tmplBridge = `//go:build windows && cgo + +package main + +/* +#include +*/ +import "C" +import ( + "encoding/json" + "sync" + "unsafe" + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +var ( + mu sync.Mutex + handleMap = map[unsafe.Pointer]*bridgeState{} +) + +type bridgeState struct { + plugin sdk.Plugin + toolDefs map[string]sdk.ToolDef + handlers map[string]sdk.ToolHandler + stages map[string]sdk.StageHandler + settings map[string]interface{} +} + +func newHandle(plg sdk.Plugin) unsafe.Pointer { + mu.Lock(); defer mu.Unlock() + h := C.malloc(C.size_t(1)) + handleMap[h] = &bridgeState{ + plugin: plg, toolDefs: make(map[string]sdk.ToolDef), + handlers: make(map[string]sdk.ToolHandler), stages: make(map[string]sdk.StageHandler), + settings: make(map[string]interface{}), + } + return h +} +func getState(h unsafe.Pointer) *bridgeState { mu.Lock(); defer mu.Unlock(); return handleMap[h] } +func delState(h unsafe.Pointer) { mu.Lock(); defer mu.Unlock(); delete(handleMap, h); C.free(h) } + +//export NewPlugin +func NewPlugin(name *C.char, configJSON *C.char) unsafe.Pointer { + goName := C.GoString(name) + var config map[string]interface{} + if configJSON != nil { + var wrapper map[string]interface{} + if err := json.Unmarshal([]byte(C.GoString(configJSON)), &wrapper); err == nil { + if c, ok := wrapper["config"].(map[string]interface{}); ok { config = c } + } + } + plg, err := NewPlugin(goName, config) + if err != nil { return nil } + return newHandle(plg) +} + +//export StartPlugin +func StartPlugin(handle unsafe.Pointer) C.int { + bs := getState(handle) + if bs == nil { return 1 } + mockSett := &bridgeSettings{data: bs.settings} + mockSDK := sdk.New(bs.plugin.Name(), mockSett, + func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + bs.toolDefs[name] = def; bs.handlers[name] = handler; return nil + }, + func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler }, + func(name string) error { return nil }, + ) + if err := bs.plugin.Start(mockSDK); err != nil { return 1 } + return 0 +} + +//export StopPlugin +func StopPlugin(handle unsafe.Pointer) C.int { + bs := getState(handle) + if bs == nil { return 1 } + if err := bs.plugin.Stop(); err != nil { return 1 } + return 0 +} + +//export DestroyPlugin +func DestroyPlugin(handle unsafe.Pointer) { + if bs := getState(handle); bs != nil { delState(handle) } +} + +//export GetToolDefsJSON +func GetToolDefsJSON(handle unsafe.Pointer) *C.char { + bs := getState(handle) + if bs == nil { return nil } + defs := make([]sdk.ToolDef, 0, len(bs.toolDefs)) + for _, def := range bs.toolDefs { defs = append(defs, def) } + b, _ := json.Marshal(defs) + return C.CString(string(b)) +} + +//export InvokeToolJSON +func InvokeToolJSON(handle unsafe.Pointer, toolName *C.char, argsJSON *C.char) *C.char { + bs := getState(handle) + if bs == nil || toolName == nil { return nil } + goName := C.GoString(toolName) + handler, ok := bs.handlers[goName] + if !ok { r, _ := json.Marshal(map[string]interface{}{"error": "tool not found: " + goName}); return C.CString(string(r)) } + var args map[string]interface{} + if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) } + r, err := handler(args) + if err != nil { r, _ = json.Marshal(map[string]interface{}{"error": err.Error()}); return C.CString(string(r)) } + b, _ := json.Marshal(r) + return C.CString(string(b)) +} + +//export GetStagesJSON +func GetStagesJSON(handle unsafe.Pointer) *C.char { + bs := getState(handle) + if bs == nil { return nil } + type se struct { Stage string ` + "`" + `json:"stage"` + "`" + ` } + var entries []se + for s := range bs.stages { entries = append(entries, se{s}) } + b, _ := json.Marshal(entries) + return C.CString(string(b)) +} + +//export InvokeStage +func InvokeStage(handle unsafe.Pointer, stage *C.char, contextJSON *C.char) C.int { + bs := getState(handle) + if bs == nil || stage == nil { return 1 } + goStage := C.GoString(stage) + handler, ok := bs.stages[goStage] + if !ok { return 1 } + var ctx map[string]interface{} + if contextJSON != nil { json.Unmarshal([]byte(C.GoString(contextJSON)), &ctx) } + sc := &sdk.StageContext{} + if ctx != nil { + if v, ok := ctx["raw_message"].(string); ok { sc.RawMessage = v } + if v, ok := ctx["user_id"].(string); ok { sc.UserID = v } + if v, ok := ctx["phase"].(string); ok { sc.Phase = sdk.Stage(v) } + } + if err := handler(sc); err != nil { return 1 } + return 0 +} + +//export FreeCString +func FreeCString(s *C.char) { C.free(unsafe.Pointer(s)) } + +type bridgeSettings struct{ data map[string]interface{} } +func (s *bridgeSettings) Get(key string) (interface{}, error) { v, ok := s.data[key]; if !ok { return nil, nil }; return v, nil } +func (s *bridgeSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil } +func (s *bridgeSettings) List(prefix string) ([]string, error) { + var keys []string + for k := range s.data { if len(k) >= len(prefix) && k[:len(prefix)] == prefix { keys = append(keys, k) } } + return keys, nil +} +func (s *bridgeSettings) GetCore(key string) (interface{}, error) { return nil, nil } +func (s *bridgeSettings) SetCore(key string, value interface{}) error { return nil } +func (s *bridgeSettings) ListCore(prefix string) ([]string, error) { return nil, nil } +func (s *bridgeSettings) GetPlugin(plugin, key string) (interface{}, error) { return nil, nil } +func (s *bridgeSettings) SetPlugin(plugin, key string, value interface{}) error { return nil } +func (s *bridgeSettings) ListPlugin(plugin, prefix string) ([]string, error) { return nil, nil } +func (s *bridgeSettings) RegisterDef(def sdk.ConfigDef) {} +func (s *bridgeSettings) Defs(prefix string) []*sdk.ConfigDef { return nil } +func (s *bridgeSettings) Dump() map[string]interface{} { return s.data } +func (s *bridgeSettings) Plugins() []string { return nil } + +func main() {} +` + +// tmplCABIHeader — shared C ABI type definitions for both core and plugin +const tmplCABIHeader = ` +#ifndef HOMEAGENT_CABI_H +#define HOMEAGENT_CABI_H +#define HOMEAGENT_ABI_VERSION 1 +#ifdef __cplusplus +extern "C" { +#endif + +// PluginAPI — implemented by the plugin, called by the core +typedef struct { + int version; int version_min; + int (*init_plugin)(char*, char*, char**); + int (*start_plugin)(void*, int, char**); + int (*stop_plugin)(char**); + int (*invoke_tool)(char*, char*, char**, char**); + int (*invoke_stage)(char*, char*, char**); + int (*invoke_output)(char*, char*, char*, char**); + void (*free_string)(char*); +} PluginAPI; + +// CoreAPI — implemented by the core, passed to plugin via start_plugin +// Uses single dispatch function to avoid function pointer ABI issues +typedef struct { + int version; int version_min; + int (*dispatch)(int method_id, void* ctx, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error); + void* ctx; +} CoreAPI; + +// Dispatch method IDs (plugin→core SDK calls) +enum { + CORE_REGISTER_TOOL = 1, + CORE_REGISTER_STAGE = 2, + CORE_REGISTER_OUTPUT_CH = 3, + CORE_REGISTER_PLUGIN_API = 4, + CORE_INJECT_TEXT = 5, + CORE_INJECT_INTERRUPT_TEXT = 6, + CORE_INJECT_TEXT_NO_MEMORY = 7, + CORE_SET_AUTO_RESTART = 8, + CORE_MEMORY_RECALL = 9, + CORE_MEMORY_COMMIT = 10, + CORE_MEMORY_INTROSPECT = 11, + CORE_MEMORY_MERGE = 12, + CORE_MEMORY_PURGE = 13, + CORE_DOC_QUERY = 14, + CORE_KNOWLEDGE_SEARCH = 15, + CORE_SETTINGS_GET = 16, + CORE_SETTINGS_SET = 17, + CORE_SETTINGS_REGISTER_DEF = 18, + CORE_LLM_LIST_SOURCES = 19, + CORE_LLM_SET_SOURCE = 20, + CORE_SOCIAL_GET_PERSON = 21, + CORE_SOCIAL_GET_NETWORK = 22, + CORE_SUBSCRIBE = 23, + CORE_UNSUBSCRIBE = 24, + CORE_FREE_STRING = 25, + CORE_SETTINGS_GET_CORE = 26, + CORE_SETTINGS_SET_CORE = 27, + CORE_SETTINGS_LIST_CORE = 28, + CORE_SETTINGS_GET_PLUGIN = 29, + CORE_SETTINGS_SET_PLUGIN = 30, + CORE_SETTINGS_LIST_PLUGIN = 31, + CORE_DOC_INSERT = 32, + CORE_DOC_REMOVE = 33, + CORE_DOC_STATS = 34, + CORE_KNOWLEDGE_ADD = 35, + CORE_KNOWLEDGE_LIST = 36, + CORE_LLM_CURRENT_SOURCE = 37, + CORE_SOCIAL_GET_TRAIT = 38, + CORE_SOCIAL_GET_RELATIONS = 39, + CORE_SOCIAL_LIST_PERSONS = 40, + CORE_TEXT_MEMORY_APPEND = 41, + CORE_SETTINGS_LIST = 42, + CORE_SETTINGS_DEFS = 43, + CORE_SETTINGS_DUMP = 44, + CORE_SETTINGS_PLUGINS = 45, +}; + +#ifdef __cplusplus +} +#endif +#endif +` + +// tmplLinuxBridge — auto-generated Go bridge for Linux c-shared builds. +// Called by plugin's Start() with a PluginSDK that wraps CoreAPI dispatch. +// PluginSDK calls go through C ABI → CoreAPI dispatch → core's Go PluginSDK. +const tmplLinuxBridge = `package main + +/* +#include +int ha_dispatch(int method_id, void* core_api, char* s1, char* s2, char* s3, int i1, int i2, char** result, char** error); +*/ +import "C" +import ( + "encoding/json" + "fmt" + "sync" + "unsafe" + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// ---- global state ---- + +var ( + mu sync.Mutex + currentPlg sdk.Plugin + coreAPI unsafe.Pointer + + handlerMu sync.RWMutex + toolHandlers = map[string]sdk.ToolHandler{} + stageHandlers = map[string]sdk.StageHandler{} + outputHandlers = map[string]sdk.ToolHandler{} +) + +// ---- CoreAPI dispatch helpers ---- + +func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error { + var c1, c2, c3 *C.char + if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } + if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } + if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } + var cErr *C.char + if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil { + return fmt.Errorf("%s", C.GoString(cErr)) + } + return nil +} + +func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) { + var c1, c2, c3 *C.char + if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } + if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } + if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } + var strResult, cErr *C.char + if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil { + return "", fmt.Errorf("%s", C.GoString(cErr)) + } + if strResult != nil { + result := C.GoString(strResult) + C.ha_dispatch(C.int(25), coreAPI, strResult, nil, nil, 0, 0, nil, nil) + return result, nil + } + return "", nil +} + +// ---- buildPluginSDK: PluginSDK backed by CoreAPI dispatch ---- +// - ALL SDK methods route through C ABI → CoreAPI → core's PluginSDK +// - Handlers for tools/stages/output are stored locally AND registered via dispatch + +func buildPluginSDK(name string) *sdk.PluginSDK { + sett := &dispatchSettings{} + base := sdk.New(name, sett, + func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error { + handlerMu.Lock() + toolHandlers[toolName] = handler + handlerMu.Unlock() + b, _ := json.Marshal(def) + return callVoid(1, toolName, string(b), "", 0, 0) + }, + func(stage sdk.Stage, handler sdk.StageHandler) { + handlerMu.Lock() + stageHandlers[string(stage)] = handler + handlerMu.Unlock() + callVoid(2, string(stage), "", "", 0, 0) + }, + func(name string) error { return callVoid(4, name, "", "", 0, 0) }, + func(name string, caps int, desc string, handler sdk.ToolHandler) error { + handlerMu.Lock() + outputHandlers[name] = handler + handlerMu.Unlock() + return callVoid(3, name, desc, "", caps, 0) + }, + ) + base.SetIOInjector(dispatchIO{}) + base.SetMemoryAPI(dispatchMemory{}) + base.SetDocMemoryAPI(dispatchDocMemory{}) + base.SetKnowledgeAPI(dispatchKnowledge{}) + base.SetLLMAPI(dispatchLLM{}) + base.SetSocialAPI(dispatchSocial{}) + base.SetTextMemoryAPI(dispatchTextMemory{}) + return base +} + +// ---- dispatch IO (inline definitions) ---- + +type dispatchIO struct{} +func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) } +func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) } +func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) } + +type dispatchMemory struct{} +func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) { + b, _ := json.Marshal(q); r, e := callString(9, string(b), "", "", d, 0) + if e != nil || r == "" { return nil, nil, e } + var v struct{ Entities []sdk.Entity; Relations []sdk.Relation } + if e = json.Unmarshal([]byte(r), &v); e != nil { return nil, nil, e } + if v.Entities == nil { v.Entities = []sdk.Entity{} } + if v.Relations == nil { v.Relations = []sdk.Relation{} } + return v.Entities, v.Relations, nil +} +func (dispatchMemory) Commit(t []sdk.Triple) error { b, _ := json.Marshal(t); return callVoid(10, string(b), "", "", 0, 0) } +func (dispatchMemory) Introspect() (map[string]interface{}, error) { r, e := callString(11, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var m map[string]interface{}; return m, json.Unmarshal([]byte(r), &m) } +func (dispatchMemory) MergeEntities(s, t string) (int, error) { return 1, callVoid(12, s, t, "", 0, 0) } +func (dispatchMemory) Purge(c map[string]string, m string) (int, error) { b, _ := json.Marshal(c); i := 0; if m == "hard" { i = 1 }; return 1, callVoid(13, string(b), "", "", i, 0) } + +type dispatchDocMemory struct{} +func (dispatchDocMemory) Query(t string, k int) []*sdk.Doc { r, e := callString(14, t, "", "", k, 0); if e != nil || r == "" { return nil }; var d []*sdk.Doc; json.Unmarshal([]byte(r), &d); return d } +func (dispatchDocMemory) Insert(doc *sdk.Doc) error { b, _ := json.Marshal(doc); return callVoid(32, string(b), "", "", 0, 0) } +func (dispatchDocMemory) Remove(id string) { callVoid(33, id, "", "", 0, 0) } +func (dispatchDocMemory) Stats() map[string]interface{} { r, e := callString(34, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m } + +type dispatchKnowledge struct{} +func (dispatchKnowledge) Search(q string, k int) ([]*sdk.Knowledge, error) { r, e := callString(15, q, "", "", k, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.Knowledge; return v, json.Unmarshal([]byte(r), &v) } +func (dispatchKnowledge) Add(n, c string) error { return callVoid(35, n, c, "", 0, 0) } +func (dispatchKnowledge) List() ([]string, error) { r, e := callString(36, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) } + +type dispatchLLM struct{} +func (dispatchLLM) ListSources() []string { r, e := callString(19, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v } +func (dispatchLLM) SetSource(n string) error { return callVoid(20, n, "", "", 0, 0) } +func (dispatchLLM) CurrentSource() string { r, e := callString(37, "", "", "", 0, 0); if e != nil || r == "" { return "" }; return r } + +type dispatchSocial struct{} +func (dispatchSocial) GetPerson(n string) (*sdk.PersonProfile, error) { r, e := callString(21, n, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v sdk.PersonProfile; return &v, json.Unmarshal([]byte(r), &v) } +func (dispatchSocial) GetTrait(n, t string) (string, bool) { r, e := callString(38, n, t, "", 0, 0); if e != nil || r == "" { return "", false }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); v, _ := m["value"].(string); ok, _ := m["found"].(bool); return v, ok } +func (dispatchSocial) GetRelations(name string) ([]sdk.SocialRelation, error) { r, e := callString(39, name, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []sdk.SocialRelation; return v, json.Unmarshal([]byte(r), &v) } +func (dispatchSocial) GetNetwork(n string, d int) ([]*sdk.PersonProfile, error) { r, e := callString(22, n, "", "", d, 0); if e != nil || r == "" { return nil, e }; var v []*sdk.PersonProfile; return v, json.Unmarshal([]byte(r), &v) } +func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, "", "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) } + +type dispatchTextMemory struct{} +func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) } + +// ---- dispatchSettings (inline) ---- + +type dispatchSettings struct{} +func (d *dispatchSettings) Get(key string) (interface{}, error) { + r, e := callString(16, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) Set(key string, value interface{}) error { + b, _ := json.Marshal(value); return callVoid(17, key, string(b), "", 0, 0) +} +func (d *dispatchSettings) RegisterDef(def sdk.ConfigDef) { b, _ := json.Marshal(def); callVoid(18, string(b), "", "", 0, 0) } +func (d *dispatchSettings) List(prefix string) ([]string, error) { + r, e := callString(42, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) GetCore(key string) (interface{}, error) { + r, e := callString(26, key, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) SetCore(key string, value interface{}) error { + b, _ := json.Marshal(value); return callVoid(27, key, string(b), "", 0, 0) +} +func (d *dispatchSettings) ListCore(prefix string) ([]string, error) { + r, e := callString(28, prefix, "", "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) GetPlugin(plugin, key string) (interface{}, error) { + r, e := callString(29, plugin, key, "", 0, 0); if e != nil || r == "" { return nil, e }; var v interface{}; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) SetPlugin(plugin, key string, value interface{}) error { + b, _ := json.Marshal(value); return callVoid(30, plugin, key, string(b), 0, 0) +} +func (d *dispatchSettings) ListPlugin(plugin, prefix string) ([]string, error) { + r, e := callString(31, plugin, prefix, "", 0, 0); if e != nil || r == "" { return nil, e }; var v []string; return v, json.Unmarshal([]byte(r), &v) +} +func (d *dispatchSettings) Defs(prefix string) []*sdk.ConfigDef { + r, e := callString(43, prefix, "", "", 0, 0); if e != nil || r == "" { return nil }; var v []*sdk.ConfigDef; json.Unmarshal([]byte(r), &v); return v +} +func (d *dispatchSettings) Dump() map[string]interface{} { + r, e := callString(44, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var m map[string]interface{}; json.Unmarshal([]byte(r), &m); return m +} +func (d *dispatchSettings) Plugins() []string { + r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v +} + +// ---- Go callbacks (called from z_entry.c via C) ---- + +//export go_init_plugin +func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int { + plg, err := NewPlugin(C.GoString(name), nil) + if err != nil || plg == nil { + if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPlugin returned nil") } + return 1 + } + mu.Lock(); currentPlg = plg; mu.Unlock() + _ = configJSON + return 0 +} + +//export go_start_plugin +func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int { + mu.Lock() + plg := currentPlg + coreAPI = coreAPIptr + mu.Unlock() + _ = coreVersion + if plg == nil { *errorOut = C.CString("not initialized"); return 1 } + sdk := buildPluginSDK(plg.Name()) + if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 } + return 0 +} + +//export go_stop_plugin +func go_stop_plugin(errorOut **C.char) C.int { + mu.Lock() + plg := currentPlg + currentPlg = nil + coreAPI = nil + mu.Unlock() + if plg != nil { + if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 } + } + return 0 +} + +//export go_invoke_tool +func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut **C.char) C.int { + goName := C.GoString(name) + handlerMu.RLock() + h, ok := toolHandlers[goName] + handlerMu.RUnlock() + if !ok { *errorOut = C.CString("tool not found"); return 1 } + var args map[string]interface{} + if argsJSON != nil { json.Unmarshal([]byte(C.GoString(argsJSON)), &args) } + r, err := h(args) + if err != nil { *errorOut = C.CString(err.Error()); return 1 } + b, _ := json.Marshal(r) + *resultOut = C.CString(string(b)) + return 0 +} + +//export go_invoke_stage +func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int { + goStage := C.GoString(stage) + handlerMu.RLock() + h, ok := stageHandlers[goStage] + handlerMu.RUnlock() + if !ok { return 0 } + sc := &sdk.StageContext{} + if ctxJSON != nil { + var m map[string]interface{} + if err := json.Unmarshal([]byte(C.GoString(ctxJSON)), &m); err == nil { + if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v } + if v, _ := m["user_id"].(string); v != "" { sc.UserID = v } + if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v } + if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) } + if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v } + if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v } + if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true } + if v, _ := m["response"].(string); v != "" { sc.Response = &v } + if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 { + b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls) + } + if v, _ := m["tool_results"].([]interface{}); len(v) > 0 { + b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults) + } + } + } + if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 } + return 0 +} + +//export go_invoke_output +func go_invoke_output(channel *C.char, msgType *C.char, payloadJSON *C.char, errorOut **C.char) C.int { + goChan := C.GoString(channel) + handlerMu.RLock() + h, ok := outputHandlers[goChan] + handlerMu.RUnlock() + if !ok { return 0 } + // payloadJSON contains the full args JSON from output_send (e.g. {"content":"...","user_id":123}) + var args map[string]interface{} + if payloadJSON != nil { + json.Unmarshal([]byte(C.GoString(payloadJSON)), &args) + } + if _, err := h(args); err != nil { *errorOut = C.CString(err.Error()); return 1 } + return 0 +} + +//export go_free_string +func go_free_string(ptr *C.char) { C.free(unsafe.Pointer(ptr)) } + +func main() {} +` + +// tmplPluginInitC — C entry point for the plugin .so file. +// Contains PluginAPI, CoreAPI (single dispatch), and ha_dispatch bridge. +const tmplPluginInitC = `#include +#include + +#define HOMEAGENT_ABI_VERSION 1 + +typedef struct { + int version; int version_min; + int (*init_plugin)(char*, char*, char**); + int (*start_plugin)(void*, int, char**); + int (*stop_plugin)(char**); + int (*invoke_tool)(char*, char*, char**, char**); + int (*invoke_stage)(char*, char*, char**); + int (*invoke_output)(char*, char*, char*, char**); + void (*free_string)(char*); +} PluginAPI; + +typedef struct { + int version; int version_min; + int (*dispatch)(int, void*, char*, char*, char*, int, int, char**, char**); + void* ctx; +} CoreAPI; + +extern int go_init_plugin(char*, char*, char**); +extern int go_start_plugin(void*, int, char**); +extern int go_stop_plugin(char**); +extern int go_invoke_tool(char*, char*, char**, char**); +extern int go_invoke_stage(char*, char*, char**); +extern int go_invoke_output(char*, char*, char*, char**); +extern void go_free_string(char*); + +int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e); } +int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); } +int c_stop_plugin(char** e) { return go_stop_plugin(e); } +int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); } +int c_invoke_stage(char* s, char* c, char** e) { return go_invoke_stage(s, c, e); } +int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); } +void c_free_string(char* p) { go_free_string(p); } + +// ha_dispatch — called by Go bridge, passes through to CoreAPI dispatch +int ha_dispatch(int id, void* api, char* s1, char* s2, char* s3, int i1, int i2, char** r, char** e) { + CoreAPI* a = (CoreAPI*)api; + if (!a || !a->dispatch) return 1; + return a->dispatch(id, a->ctx, s1, s2, s3, i1, i2, r, e); +} + +PluginAPI* plugin_init(void) { + static PluginAPI api; + memset(&api, 0, sizeof(api)); + api.version = HOMEAGENT_ABI_VERSION; api.version_min = HOMEAGENT_ABI_VERSION; + api.init_plugin = c_init_plugin; api.start_plugin = c_start_plugin; api.stop_plugin = c_stop_plugin; + api.invoke_tool = c_invoke_tool; api.invoke_stage = c_invoke_stage; api.invoke_output = c_invoke_output; + api.free_string = c_free_string; + return &api; +} +` + +const tmplReadme = `# {{.Plg.Name}} + +{{.Plg.Description}} + +## Build + +` + "```bash" + ` +plugindev build +` + "```" + ` + +## Install + +Upload the .hmap file through the Plugin Manager API. +` diff --git a/tools/plugindev/templates.go.new_header b/tools/plugindev/templates.go.new_header new file mode 100644 index 0000000..a22823b --- /dev/null +++ b/tools/plugindev/templates.go.new_header @@ -0,0 +1 @@ +I need to rewrite the preamble section. Let me use a python script to make this change. diff --git a/tools/plugindev/templates/README.md.tmpl b/tools/plugindev/templates/README.md.tmpl new file mode 100644 index 0000000..45cfe8f --- /dev/null +++ b/tools/plugindev/templates/README.md.tmpl @@ -0,0 +1,19 @@ +# {{.Plg.Name}} + +{{.Plg.Description}} + +## Build + +```bash +plugindev build +``` + +## Install + +Upload the `.hmap` file through the Plugin Manager API: + +```bash +curl -X POST http://localhost:8080/api/v1/plugins \ + -H "Content-Type: application/octet-stream" \ + --data-binary @dist/_linux_amd64.hmap +``` diff --git a/tools/plugindev/templates/main.go.tmpl b/tools/plugindev/templates/main.go.tmpl new file mode 100644 index 0000000..935963b --- /dev/null +++ b/tools/plugindev/templates/main.go.tmpl @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "fmt" + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.{{.Plg.Name}}.example", + Default: "hello", + Type: "string", + DisplayName: "示例配置", + Description: "An example configuration key", + Category: "{{.Plg.Name}}", + }) + + tp := p.name + "_" + s.RegisterTool(tp+"hello", sdk.ToolDef{ + Name: tp + "hello", + Description: "A hello world tool", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleHello) + + fmt.Printf("[%s] started\n", p.name) + return nil +} + +func (p *Plugin) Stop() error { + fmt.Printf("[%s] stopped\n", p.name) + return nil +} + +func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) { + return map[string]interface{}{ + "content": "Hello from {{.Plg.Name}} plugin!", + }, nil +} diff --git a/tools/plugindev/templates/main.lua.tmpl b/tools/plugindev/templates/main.lua.tmpl new file mode 100644 index 0000000..d696eff --- /dev/null +++ b/tools/plugindev/templates/main.lua.tmpl @@ -0,0 +1,45 @@ +local plugin = { + name = "{{.Plg.Name}}", + tools = {}, + stages = {}, + settings = {} +} + +function plugin:start(sdk) + sdk.log("{{.Plg.Name}} plugin starting...") + + -- Register a configuration setting + -- sdk.settings.register({ + -- key = "{{.Plg.Name}}.example", + -- default = "hello", + -- type = "string", + -- display_name = "Example Config", + -- description = "An example configuration key" + -- }) + + -- Register a tool + local ok = sdk:register_tool("{{.Plg.Name}}_hello", { + name = "{{.Plg.Name}}_hello", + description = "A hello world tool", + parameters = { + type = "object", + properties = {} + } + }, function(args) + return { content = "Hello from {{.Plg.Name}} plugin!" } + end) + + if not ok then + sdk.log("error: failed to register tool") + return false + end + + sdk.log("{{.Plg.Name}} plugin started") + return true +end + +function plugin:stop() + return true +end + +return plugin diff --git a/tools/plugindev/templates/plg.json.tmpl b/tools/plugindev/templates/plg.json.tmpl new file mode 100644 index 0000000..9047080 --- /dev/null +++ b/tools/plugindev/templates/plg.json.tmpl @@ -0,0 +1,11 @@ +{ + "name": "{{.Plg.Name}}", + "name_zh": "{{.Plg.NameZh}}", + "name_en": "{{.Plg.NameEn}}", + "version": "{{.Plg.Version}}", + "description": "{{.Plg.Description}}", + "author": "{{.Plg.Author}}", + "entry": "{{.Plg.Entry}}", + "tags": {{.Plg.Tags}}, + "targets": "{{.Plg.Targets}}" +} diff --git a/tools/plugindev/testplugin/README.md b/tools/plugindev/testplugin/README.md new file mode 100644 index 0000000..b97bc29 --- /dev/null +++ b/tools/plugindev/testplugin/README.md @@ -0,0 +1,13 @@ +# testplugin + +testplugin plugin + +## Build + +```bash +plugindev build +``` + +## Install + +Upload the .hmap file through the Plugin Manager API. diff --git a/tools/plugindev/testplugin/go.mod b/tools/plugindev/testplugin/go.mod new file mode 100644 index 0000000..4145436 --- /dev/null +++ b/tools/plugindev/testplugin/go.mod @@ -0,0 +1,7 @@ +module testplugin + +go 1.21 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk diff --git a/tools/plugindev/testplugin/main.go b/tools/plugindev/testplugin/main.go new file mode 100644 index 0000000..acbee2e --- /dev/null +++ b/tools/plugindev/testplugin/main.go @@ -0,0 +1,11 @@ +//go:build !windows || !cgo + +package main + +import ( + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return NewPluginFactory(name, config) +} diff --git a/tools/plugindev/testplugin/plg.json b/tools/plugindev/testplugin/plg.json new file mode 100644 index 0000000..6f1e716 --- /dev/null +++ b/tools/plugindev/testplugin/plg.json @@ -0,0 +1,11 @@ +{ + "name": "testplugin", + "name_zh": "中文名", + "name_en": "Testplugin", + "version": "0.1.0", + "description": "testplugin plugin", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["testplugin"], + "targets": "linux/amd64,windows/amd64" +} diff --git a/tools/plugindev/testplugin/plugin.go b/tools/plugindev/testplugin/plugin.go new file mode 100644 index 0000000..ea36a54 --- /dev/null +++ b/tools/plugindev/testplugin/plugin.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + + "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.testplugin.example", + Default: "hello", + Type: "string", + DisplayName: "示例配置", + Description: "An example configuration key", + Category: "testplugin", + }) + + tp := p.name + "_" + s.RegisterTool(tp+"hello", sdk.ToolDef{ + Name: tp + "hello", + Description: "A hello world tool", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleHello) + + fmt.Printf("[%s] started\n", p.name) + return nil +} + +func (p *Plugin) Stop() error { + fmt.Printf("[%s] stopped\n", p.name) + return nil +} + +func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) { + return map[string]interface{}{ + "content": "Hello from testplugin plugin!", + }, nil +} + +// NewPluginFactory creates a Plugin instance. Called by both Linux entry (main.go) +// and Windows bridge (z_bridge_gen.go) to avoid naming conflict with C export. +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +}