chore(vendor): 主仓不再跟踪 SDK 示例代码(决策 A)

## 背景

`.gitignore` 第 26-45 行早已写明「外部插件与工具链维护在独立 SDK 仓
(决策 sdk_repo_only),本仓经 go.mod 的 replace 引用」,并忽略了
example/、tools/、docs/、site_build/、package/、scripts/。

但有 29 个文件**早于该规则**被跟踪,靠「已跟踪文件不受 .gitignore
影响」留着,注释还特意写了「这是有意的,不要『修』」。

本轮修 example/bili 时踩到了:那 87 行改动先落在主仓、再手动同步到
SDK 仓 —— 同一份代码两个仓各改一遍,正是这个遗留结构的成本。

## 核实:主仓到底需要什么

- 主仓 Go 代码只 import 两个包:`homeagent-sdk/sdk`(插件入口)、
  `homeagent-sdk/meta`(版本号)
- `remotedevice/` 是 C 库,主仓 C 侧明确「不链接任何外部库」,
  只有注释里提到它
- `tools/hmapdev/yaegi` 的 import 出现在 **SDK 仓自己的文件之间**
  (yaegi/interp.go → yaegi/mocksdk),不是主仓依赖;
  且 tools/ 本就在 .gitignore 里,从未被跟踪

所以 29 个文件全部可以删。实际仓库负担也只有 42 个文件 / 0.6MB
(工作树里那 971MB 绝大部分是未跟踪的构建产物,不是仓库体积)。

## 改动

- 删 21 个 example/ 文件(10 个示例的 plg.json + plugin.go + qq/plugin_test.go)
- 删 8 个 remotedevice/ C 文件
- 工作树里一并清掉(不受跟踪的构建产物顺带回收)

构建与全量测试均通过。

## 判据:3 条 + 变异(internal/meta/vendored_sdk_test.go)

- TestVendoredSDKHasNoExampleOrRemotedevice:防止示例代码被重新提交进来
- TestVendoredSDKKeepsRequiredPackages:**反向**保护,防止为省事把
  sdk/ 与 meta/ 也删掉(上一条只防"多了",删过头要靠这条)
- TestGoModStillReplacesSDKToVendoredPath:决策 A 依赖 replace 指向 vendored 路径

★ 判据自己踩了两个坑,都靠实跑抓出来:
1. `git ls-files` 的路径参数**相对当前目录**解析,而测试跑在
   internal/meta/ 下 ⇒ 就地执行返回空,表现为「必需包全都不在」的假红。
   改用 `git -C <仓库根>`。
2. 把 tools/hmapdev/yaegi 当成主仓依赖写进必需清单 ⇒ 又一次假红。
   根因是把 SDK 内部的引用误当成主仓依赖(它本就在 .gitignore 里)。

变异验证:塞一个 example 文件进版本控制 → 判红。
This commit is contained in:
JianFeeeee
2026-09-26 17:18:41 +08:00
parent ceeef0b4e1
commit bd1d5fff2b
30 changed files with 126 additions and 14144 deletions

View File

@ -0,0 +1,126 @@
package meta_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ===== 主仓不再 vendoring SDK 的示例代码 =====
//
// 决策(对应 .gitignore 里的 sdk_repo_only):外部插件与工具链只在 SDK 仓
// 维护,主仓经 go.mod 的 replace 引用,**只保留编译所需的那几个包**。
//
// 曾经的状况:主仓跟踪了 29 个 SDK 示例文件(example/*/plg.json、plugin.go、
// qq/plugin_test.go,加 remotedevice/ 的 8 个 C 文件)。它们不参与主仓构建
// (唯一 import 的 SDK 包是 sdk/ 与 meta/),却要在两个仓里同步同一份代码。
// 本轮修 example/bili 时的重复劳动就是这么来的。
//
// 本测试钉住这个决策:有人再把示例代码提交进主仓时立刻变红。
// ★ 必须用 git ls-files 判断「**版本控制里有没有**」而不是看目录是否存在 ——
// 工作树里可能因为 vendoring 或本地试验而临时存在,那不算回归。
// vendoredSDKRoot 是 vendored SDK 在主仓里的位置。
const vendoredSDKRoot = "third_party/homeagent-sdk"
// repoRoot 返回主仓根目录(本测试包在 internal/meta/,往上是两层)。
func repoRoot(t *testing.T) string {
t.Helper()
abs, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("解析仓库根: %v", err)
}
return abs
}
// gitTracked 列出 git 版本控制里该路径下的文件(相对仓库根)。
//
// ★ 必须用 `git -C <仓库根>` 而不是就地执行:git ls-files 的路径参数
//
// 是**相对当前目录**解析的,而这个测试运行在 internal/meta/ 下,
// 就地执行会返回空 —— 表现为"必需包全都不在"的假红(我第一版就这样)。
func gitTracked(t *testing.T, pathPrefix string) []string {
t.Helper()
out, err := exec.Command("git", "-C", repoRoot(t), "ls-files", pathPrefix).Output()
if err != nil {
// 不是 git 仓库 / git 不可用:跳过而不是误报。
if _, statErr := os.Stat(filepath.Join(repoRoot(t), ".git")); statErr != nil {
t.Skip("非 git 仓库,跳过结构守卫")
}
t.Fatalf("git ls-files %s: %v", pathPrefix, err)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line = strings.TrimSpace(line); line != "" {
files = append(files, line)
}
}
return files
}
// example/ 与 remotedevice/ 不该再被主仓跟踪。
func TestVendoredSDKHasNoExampleOrRemotedevice(t *testing.T) {
for _, sub := range []string{"example", "remotedevice"} {
files := gitTracked(t, vendoredSDKRoot+"/"+sub)
if len(files) > 0 {
t.Errorf("主仓仍在跟踪 %s/ 下的 %d 个文件(决策:示例代码只在 SDK 仓维护):\n %s\n"+
" 这些文件不参与主仓构建(唯一 import 的 SDK 包是 sdk/ 与 meta/),\n"+
" 却要在两个仓同步同一份代码 —— 本轮修 example/bili 时已因此重复劳动。\n"+
" 修复:git rm -r --cached %s/%s",
sub, len(files), strings.Join(files, "\n "), vendoredSDKRoot, sub)
}
}
}
// 反向保护:编译真正需要的包必须在,否则删过头会编译失败。
//
// 这条不能省:上一条只防"多了",若有人为了省事把 sdk/ 或 meta/ 也删了,
// 上一条照样绿,而 go build ./... 会在 CI 里才炸。
func TestVendoredSDKKeepsRequiredPackages(t *testing.T) {
required := []string{
"sdk", // 公开契约:所有插件的入口
"sdk/plugin.go", // 具体文件:确保不是空目录
"meta", // 版本号:主仓与 SDK 版本一致性检查
"meta/meta.go",
// ★ 不列 tools/hmapdev/yaegi:.gitignore 第 33 行忽略了
// third_party/homeagent-sdk/tools/,它从来没被跟踪过。
// 而 grep 到的 yaegi 引用是 **SDK 仓自己的文件**在互相 import
// (tools/hmapdev/yaegi/interp.go → .../mocksdk),不是主仓依赖。
// 我第一版把它当主仓依赖写进来,判据直接假红。
}
tracked := map[string]bool{}
for _, f := range gitTracked(t, vendoredSDKRoot) {
tracked[f] = true
}
for _, req := range required {
// 支持两种形态:目录(下面有文件)或直接是文件
found := tracked[vendoredSDKRoot+"/"+req]
if !found {
// 目录形态:检查它下面是否有被跟踪的文件
for f := range tracked {
if strings.HasPrefix(f, vendoredSDKRoot+"/"+req+"/") {
found = true
break
}
}
}
if !found {
t.Errorf("必需包 %s 不在主仓 vendored 范围内(删过头了):%s/%s",
req, vendoredSDKRoot, req)
}
}
}
// go.mod 的 replace 必须仍指向 vendored 路径 —— 决策 A 依赖它。
func TestGoModStillReplacesSDKToVendoredPath(t *testing.T) {
data, err := os.ReadFile(filepath.Join(repoRoot(t), "go.mod"))
if err != nil {
t.Fatalf("读 go.mod: %v", err)
}
content := string(data)
if !strings.Contains(content, vendoredSDKRoot) {
t.Errorf("go.mod 里没有 replace 到 %s:\n%s", vendoredSDKRoot, content)
}
}

View File

@ -1,19 +0,0 @@
{
"name": "a2a",
"name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication",
"version": "1.3.1",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"a2a",
"agent",
"interop"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,665 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server
serverAddr string
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
sessMu sync.Mutex
sessions map[string]*a2aSession
}
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
type a2aSession struct {
ID string
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
LastUsed time.Time
}
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
const maxSessionTurns = 10
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
const sessionGCPeriod = 30 * time.Minute
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*a2aSession)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
// 且 output_list_channels 可见(agent 能主动向 a2a 会话推送消息)。
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
}); err != nil {
log.Printf("[%s] register output channel: %v", p.name, err)
}
// 会话 GC:后台周期回收长期不用的会话
go p.sessionGCLoop()
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12000",
Type: "string", DisplayName: "监听地址",
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
})
// Outbound: query + discover
s.RegisterTool(tp+"a2a_query", sdk.ToolDef{
Name: tp + "a2a_query", Description: "向另一个 A2A Agent 发送查询并获取回复",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
},
"required": []string{"agent_url", "query"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleA2AQuery)
s.RegisterTool(tp+"a2a_discover", sdk.ToolDef{
Name: tp + "a2a_discover", Description: "获取另一个 A2A Agent 的能力描述(Agent Card)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
},
"required": []string{"agent_url"},
},
}, p.handleA2ADiscover)
// Management tools
s.RegisterTool(tp+"a2a_configure", sdk.ToolDef{
Name: tp + "a2a_configure", Description: "修改 A2A 插件配置并自动重启服务。支持动态更改监听地址等参数。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12000,设为空字符串禁用 HTTP 服务)"},
},
},
}, p.handleConfigure)
s.RegisterTool(tp+"a2a_restart", sdk.ToolDef{
Name: tp + "a2a_restart", Description: "重启 A2A HTTP 服务端。当连接异常或配置变更后需要重新加载时使用。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleRestart)
s.RegisterTool(tp+"a2a_status", sdk.ToolDef{
Name: tp + "a2a_status", Description: "查看 A2A 插件的运行状态,包括监听地址和当前配置。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleStatus)
// Inbound HTTP server
if addr, _ := s.Settings().Get("listen"); addr != nil {
if addrStr, ok := addr.(string); ok && addrStr != "" {
if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start A2A server: %v", p.name, err)
}
}
}
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
p.stopServer()
return nil
}
// sessionGCLoop 周期清理超时会话。
func (p *Plugin) sessionGCLoop() {
ticker := time.NewTicker(sessionGCPeriod)
defer ticker.Stop()
for range ticker.C {
p.sessMu.Lock()
for id, sess := range p.sessions {
if time.Since(sess.LastUsed) > 2*time.Hour {
delete(p.sessions, id)
}
}
p.sessMu.Unlock()
}
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
}
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
p.sessMu.Lock()
sess := p.sessions[sessionID]
var hist []string
var lastUsed time.Time
if sess != nil {
hist = append([]string{}, sess.History...)
lastUsed = sess.LastUsed
}
p.sessMu.Unlock()
if sess == nil {
return nil
}
_ = lastUsed
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": text,
})
}
return msgs
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil {
p.server.Close()
p.server = nil
p.serverAddr = ""
}
}
// ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/agent-card", p.handleAgentCard)
mux.HandleFunc("/task", p.handleIncomingTask)
mux.HandleFunc("/a2a", p.handleIncomingA2A)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %v", addr, err)
}
srv := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverAddr = addrStr
p.srvMu.Unlock()
go func() {
log.Printf("[%s] A2A server on %s", p.name, addrStr)
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err)
}
}()
return nil
}
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
card := map[string]interface{}{
"name": p.name,
"description": "HomeAgent A2A Agent - 支持多工具调用与记忆管理",
"url": r.Host,
"version": "1.0.0",
"capabilities": []map[string]string{
{"id": "a2a_query", "name": "查询", "description": "接收并处理文本查询"},
{"id": "a2a_stream", "name": "流式响应", "description": "支持 SSE 流式回复"},
},
"skills": []map[string]string{
{"id": "chat", "name": "对话", "description": "通用对话与问题回答"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(card)
}
func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
p.handleAgentCard(w, r)
return
}
body, _ := io.ReadAll(r.Body)
var req struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params struct {
Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Message *struct {
Role string `json:"role"`
Parts []struct {
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
} `json:"parts"`
} `json:"message,omitempty"`
} `json:"params,omitempty"`
}
json.Unmarshal(body, &req)
switch req.Method {
case "tasks.send":
// Extract query text
queryText := req.Params.Query
if queryText == "" && req.Params.Message != nil {
for _, part := range req.Params.Message.Parts {
if part.Text != "" {
queryText += part.Text + "\n"
}
}
queryText = strings.TrimSpace(queryText)
}
if queryText == "" {
http.Error(w, "query/message.text required", http.StatusBadRequest)
return
}
// 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
sessionID := strings.TrimSpace(req.Params.SessionID)
injectText := queryText
p.sessMu.Lock()
if sessionID != "" {
sess := p.sessions[sessionID]
if sess == nil {
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
p.sessions[sessionID] = sess
}
sess.LastUsed = time.Now()
// 有历史则把上下文拼在前面(截尾防爆量)
if len(sess.History) > 0 {
ctxText := strings.Join(sess.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
}
} else {
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
}
p.sessMu.Unlock()
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
// 也不再回 202 让请求方永远等不到结果)。HTTP 超时由调用方控制。
reply := p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
// 回复写回会话历史(下一轮作为上下文)
p.sessMu.Lock()
if sess := p.sessions[sessionID]; sess != nil {
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
if len(sess.History) > maxSessionTurns*2 {
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
}
sess.LastUsed = time.Now()
}
p.sessMu.Unlock()
resp := map[string]interface{}{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]interface{}{
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
"status": "completed",
"session_id": sessionID,
"message": map[string]interface{}{
"role": "agent",
"parts": []map[string]string{{"type": "text", "text": reply}},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
case "tasks.get", "session.get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
sessionID := strings.TrimSpace(req.Params.SessionID)
if sessionID == "" {
sessionID = strings.TrimSpace(req.Params.Query)
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
msgs := p.sessionMessages(sessionID, limit)
if msgs == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sessionID,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sessionID,
"status": "completed",
"messages": msgs,
},
})
default:
http.Error(w, "unknown method", http.StatusBadRequest)
}
}
func (p *Plugin) handleIncomingTask(w http.ResponseWriter, r *http.Request) {
p.handleIncomingA2A(w, r)
}
// ---- A2A Protocol Types ----
type A2AAgentCard struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
URL string `json:"url"`
Version string `json:"version,omitempty"`
Capabilities []A2ACapability `json:"capabilities,omitempty"`
Skills []A2ASkill `json:"skills,omitempty"`
}
type A2ACapability struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type A2ASkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema string `json:"input_schema,omitempty"`
}
type A2ARequest struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Method string `json:"method"`
Params A2AParams `json:"params,omitempty"`
}
type A2AParams struct {
Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
TaskID string `json:"id,omitempty"`
}
type A2AResponse struct {
JSONRPC string `json:"jsonrpc"`
ID string `json:"id"`
Result *A2AResult `json:"result,omitempty"`
Error *A2AError `json:"error,omitempty"`
}
type A2AResult struct {
TaskID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
}
type A2AMessage struct {
Role string `json:"role"`
Parts []A2APart `json:"parts"`
}
type A2APart struct {
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"`
Type string `json:"type,omitempty"`
}
type A2AError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ---- Outbound Handlers ----
func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, error) {
agentURL, _ := args["agent_url"].(string)
agentURL = strings.TrimRight(agentURL, "/")
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
agentURL = "http://" + agentURL
}
cardURL := agentURL
if !strings.HasSuffix(cardURL, "/agent-card") {
cardURL = agentURL + "/agent-card"
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(cardURL)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("连接失败: %v", err)}, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
}
var card A2AAgentCard
if err := json.Unmarshal(body, &card); err != nil {
var fallback map[string]interface{}
if err2 := json.Unmarshal(body, &fallback); err2 == nil {
return map[string]interface{}{"agent_info": fallback, "format": "非标准格式"}, nil
}
return map[string]interface{}{"error": fmt.Sprintf("解析失败: %v", err), "raw_body": string(body)}, nil
}
return map[string]interface{}{
"name": card.Name, "description": card.Description,
"version": card.Version, "url": card.URL,
"capabilities": card.Capabilities, "skills": card.Skills,
}, nil
}
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
agentURL, _ := args["agent_url"].(string)
query, _ := args["query"].(string)
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 60
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
}
agentURL = strings.TrimRight(agentURL, "/")
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
agentURL = "http://" + agentURL
}
taskURL := agentURL
if strings.HasSuffix(agentURL, "/agent-card") {
taskURL = strings.TrimSuffix(agentURL, "/agent-card")
}
taskURL = strings.TrimRight(taskURL, "/") + "/task"
reqBody := A2ARequest{
JSONRPC: "2.0",
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
Method: "tasks.send",
Params: A2AParams{
SessionID: sessionID,
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
},
}
bodyData, _ := json.Marshal(reqBody)
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
resp, err := client.Post(taskURL, "application/json", bytes.NewReader(bodyData))
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": string(body)}, nil
}
var a2aResp A2AResponse
if err := json.Unmarshal(body, &a2aResp); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err), "raw_body": string(body)}, nil
}
if a2aResp.Error != nil {
return map[string]interface{}{"error": fmt.Sprintf("Agent错误 [%d]: %s", a2aResp.Error.Code, a2aResp.Error.Message)}, nil
}
if a2aResp.Result == nil {
return map[string]interface{}{"error": "空结果", "raw_body": string(body)}, nil
}
var replyText string
if a2aResp.Result.Message != nil {
for _, part := range a2aResp.Result.Message.Parts {
if part.Text != "" {
replyText += part.Text + "\n"
}
}
replyText = strings.TrimSpace(replyText)
}
result := map[string]interface{}{
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
"response": replyText,
}
if a2aResp.Result.SessionID != "" || sessionID != "" {
result["session_id"] = a2aResp.Result.SessionID
if result["session_id"] == "" {
result["session_id"] = sessionID
}
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
}
return result, nil
}
// ---- Management Handlers ----
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string)
listen = strings.TrimSpace(listen)
if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil
}
if listen == "" || listen == "off" || listen == "disabled" {
p.stopServer()
return "A2A HTTP 服务已禁用(listen 设为空)", nil
}
if err := p.startServer(listen); err != nil {
return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
}
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
}
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
p.stopServer()
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
return "A2A 服务未配置监听地址(listen 为空),无法启动", nil
}
if err := p.startServer(addrStr); err != nil {
return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
}
p.srvMu.Lock()
listening := p.serverAddr
p.srvMu.Unlock()
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
}
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil
listening := p.serverAddr
p.srvMu.Unlock()
if !serverRunning {
listening = "未运行"
}
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s",
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning]), nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,19 +0,0 @@
{
"name": "acp",
"name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol",
"version": "1.2.1",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"acp",
"agent",
"interop"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,645 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
// - 服务端:POST /api/session (JSON-RPC:session/new / session/update),
// 请求注入本 Agent,另提供 GET /api/session?id=xxx SSE 事件流。
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
type Plugin struct {
name string
sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server
serverID string
mu sync.RWMutex
sessions map[string]*sessionState
}
type sessionState struct {
ID string
Replying []map[string]interface{}
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
LastUsed time.Time
}
// maxSessionTurns 单会话保留的最大轮次对数。
const maxSessionTurns = 10
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
tp := p.name + "_"
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
// (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp)
s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12001",
Type: "string", DisplayName: "监听地址",
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
})
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
Name: tp + "acp_query", Description: "向远程 ACP Agent(如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001)发起一个会话请求并等待回复,返回其最终回答文本,兼容 SSE 型与同步 JSON 型 ACP 服务端",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
},
"required": []string{"server_url", "prompt"},
},
Cleaner: func(output string) string {
var r struct {
Reply string `json:"reply"`
}
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
return r.Reply
}
return output
},
}, p.handleAcpQuery)
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001,设为空禁用)"},
},
},
}, p.handleConfigure)
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleStatus)
addr, _ := s.Settings().Get("listen")
if addrStr, ok := addr.(string); ok && addrStr != "" {
if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start ACP server: %v", p.name, err)
}
}
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
p.stopServer()
return nil
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil {
p.server.Close()
p.server = nil
p.serverID = ""
}
}
// ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/session", p.handleSession)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %v", addr, err)
}
srv := &http.Server{Handler: mux}
addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverID = addrStr
p.srvMu.Unlock()
go func() {
log.Printf("[%s] ACP server on %s", p.name, addrStr)
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err)
}
}()
return nil
}
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
p.handleSessionPost(w, r)
case "GET":
p.handleSessionSSE(w, r)
default:
http.Error(w, "", http.StatusMethodNotAllowed)
}
}
// handleSessionPost 处理 JSON-RPC:session/new 与 session/update
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Method string `json:"method"`
Params struct {
Request *struct {
Text string `json:"text"`
} `json:"request,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Final bool `json:"final,omitempty"`
} `json:"params,omitempty"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
return
}
switch req.Method {
case "session/new":
text := ""
if req.Params.Request != nil {
text = strings.TrimSpace(req.Params.Request.Text)
}
if text == "" {
http.Error(w, "request.text required", http.StatusBadRequest)
return
}
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
sid := strings.TrimSpace(req.Params.SessionID)
p.mu.Lock()
if sid != "" {
if _, exists := p.sessions[sid]; !exists {
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
} else {
sid = fmt.Sprintf("session_%d", time.Now().UnixNano())
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
st := p.sessions[sid]
p.mu.Unlock()
// 延续上下文
injectText := text
p.mu.Lock()
if len(st.History) > 0 {
ctxText := strings.Join(st.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text
}
p.mu.Unlock()
// 同步注入等待回复:不抢占打断,完整闭环返回文本。
reply := ""
if p.sdk != nil {
reply = p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name))
}
// 写回历史 + 填充 Replying 供 SSE 消费
p.mu.Lock()
st.History = append(st.History, "用户: "+text, "助手: "+reply)
if len(st.History) > maxSessionTurns*2 {
st.History = st.History[len(st.History)-maxSessionTurns*2:]
}
st.LastUsed = time.Now()
if reply != "" {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": reply,
})
}
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session": map[string]interface{}{"id": sid},
"reply": reply,
},
})
case "session/get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序)
sid := req.Params.SessionID
p.mu.RLock()
st := p.sessions[sid]
var hist []string
if st != nil {
hist = append([]string{}, st.History...)
}
p.mu.RUnlock()
if st == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sid,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": text,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sid,
"status": "completed",
"messages": msgs,
},
})
case "session/update":
sid := req.Params.SessionID
p.mu.Lock()
st := p.sessions[sid]
p.mu.Unlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if req.Params.Final {
// 客户端结束会话:标记并保留历史(后续可再 session/new 续)
p.mu.Lock()
st.LastUsed = time.Now()
p.mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"final": true},
})
case "session/cancel":
p.mu.Lock()
delete(p.sessions, req.Params.SessionID)
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"canceled": true},
})
default:
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
}
}
// handleSessionSSE 提供 SSE 事件流订阅
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
sid := r.URL.Query().Get("id")
if sid == "" {
http.Error(w, "id query param required", http.StatusBadRequest)
return
}
p.mu.RLock()
st := p.sessions[sid]
p.mu.RUnlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
p.mu.RLock()
replies := append([]map[string]interface{}{}, st.Replying...)
p.mu.RUnlock()
for _, rep := range replies {
data, _ := json.Marshal(rep)
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
fl.Flush()
}
p.mu.Lock()
st.Replying = nil
p.mu.Unlock()
select {
case <-r.Context().Done():
return
case <-ticker.C:
}
}
}
// ---- Outbound:ACP 客户端 ----
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
if strings.Contains(ct, "text/event-stream") {
sc := bufio.NewScanner(bytes.NewReader(body))
var last string
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data != "" && data != "[DONE]" {
last = data
}
}
}
if last == "" {
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
}
body = []byte(last)
}
var raw json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
}
return &raw, nil
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
serverURL, _ := args["server_url"].(string)
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
if serverURL == "" {
return map[string]interface{}{"error": "server_url 不能为空"}, nil
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "http://" + serverURL
}
prompt, _ := args["prompt"].(string)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return map[string]interface{}{"error": "prompt 不能为空"}, nil
}
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 120
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
}
endpoint := serverURL + "/api/session"
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
params := map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
}
if sessionID != "" {
params["session_id"] = sessionID
}
newBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
"method": "session/new",
"params": params,
})
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
}
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
var rpcResp struct {
Result *struct {
Session *struct {
ID string `json:"id"`
} `json:"session,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Reply string `json:"reply,omitempty"`
} `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
}
if rpcResp.Error != nil {
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
}
if rpcResp.Result == nil {
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
}
// 兼容两种协议:
// A) 标准/SSE 型(opencode、本插件服务端):result.session.id,回复经 SSE 事件流
// B) 同步 JSON 型(pi bridge):result.sessionId + result.reply
if rpcResp.Result.Reply != "" {
return map[string]interface{}{
"session_id": rpcResp.Result.SessionID,
"status": "completed",
"reply": rpcResp.Result.Reply,
}, nil
}
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
}
sid := rpcResp.Result.Session.ID
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
return map[string]interface{}{
"session_id": sid,
"status": "completed",
"reply": replyText,
"note": "延续会话:下次调用传此 session_id 可保持上下文",
}, nil
}
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Sprintf("(SSE 读取失败: %v)", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bb, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
}
var sb strings.Builder
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for sc.Scan() {
if time.Now().After(deadline) {
break
}
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
break
}
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var evt struct {
SessionID string `json:"session_id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Message *struct {
Text string `json:"text"`
} `json:"message,omitempty"`
}
if json.Unmarshal([]byte(data), &evt) == nil {
text := evt.Text
if evt.Message != nil && evt.Message.Text != "" {
text = evt.Message.Text
}
if text != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(text)
}
}
}
}
if sb.Len() == 0 {
return "(未收到回复)"
}
return sb.String()
}
// ---- Management ----
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string)
listen = strings.TrimSpace(listen)
if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil
}
if listen == "" || listen == "off" || listen == "disabled" {
p.stopServer()
return "ACP HTTP 服务已禁用", nil
}
if err := p.startServer(listen); err != nil {
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
}
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
}
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil
listening := p.serverID
p.srvMu.Unlock()
p.mu.RLock()
n := len(p.sessions)
p.mu.RUnlock()
if !serverRunning {
listening = "未运行"
}
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,20 +0,0 @@
{
"name": "ai_image",
"name_zh": "AI绘图",
"name_en": "AI Image",
"version": "1.3.0",
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"ai",
"image",
"draw",
"generate"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,434 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
apiKey string
provider string
model string
size string
baseURL string
dataDir string // <data>/ai_images:生成本地图片存放目录
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
switch any(def).(type) {
case string:
if sv, ok := v.(string); ok {
return any(sv).(T)
}
case int64:
switch n := v.(type) {
case float64:
return any(int64(n)).(T)
case int64:
return any(n).(T)
case string:
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
return any(i).(T)
}
}
case float64:
switch n := v.(type) {
case float64:
return any(n).(T)
case int64:
return any(float64(n)).(T)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return any(f).(T)
}
}
}
return def
}
func getArg[T string | int64 | float64](args map[string]interface{}, key string, def T) T {
v, ok := args[key]
if !ok || v == nil {
return def
}
switch any(def).(type) {
case string:
if s, ok := v.(string); ok {
return any(s).(T)
}
case int64:
switch n := v.(type) {
case float64:
return any(int64(n)).(T)
case int64:
return any(n).(T)
case string:
if i, err := strconv.ParseInt(n, 10, 64); err == nil {
return any(i).(T)
}
}
case float64:
switch n := v.(type) {
case float64:
return any(n).(T)
case int64:
return any(float64(n)).(T)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return any(f).(T)
}
}
}
return def
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 120 * time.Second}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "api_key", Default: "", Type: "string",
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
Category: "ai_image", Secret: true,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "model", Default: "dall-e-3", Type: "string",
DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "size", Default: "1024x1024", Type: "string",
DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)",
Category: "ai_image",
})
p.apiKey = getSetting(s.Settings(), "api_key", "")
p.provider = getSetting(s.Settings(), "provider", "openai")
p.model = getSetting(s.Settings(), "model", "dall-e-3")
p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"},
"size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"},
"model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"},
"n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"},
},
"required": []string{"prompt"},
},
}, p.handleGenerate)
fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
type openAIReq struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
N int `json:"n"`
Size string `json:"size"`
ResponseFormat string `json:"response_format"`
}
type openAIResp struct {
Created int64 `json:"created"`
Data []struct {
RevisedPrompt string `json:"revised_prompt"`
URL string `json:"url"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) {
prompt := getArg(args, "prompt", "")
if prompt == "" {
return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil
}
key := getSetting(p.sdk.Settings(), "api_key", p.apiKey)
if key == "" {
return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil
}
provider := getSetting(p.sdk.Settings(), "provider", p.provider)
model := getArg(args, "model", getSetting(p.sdk.Settings(), "model", p.model))
size := getArg(args, "size", getSetting(p.sdk.Settings(), "size", p.size))
n := getArg(args, "n", int64(1))
if n < 1 {
n = 1
}
if n > 10 {
n = 10
}
switch provider {
case "openai":
return p.generateOpenAI(prompt, model, size, int(n), key)
case "stability":
return p.generateStability(prompt, model, size, int(n), key)
default:
return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil
}
}
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
// 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀;
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
endpoint := "https://api.openai.com/v1/images/generations"
if p.baseURL != "" {
base := strings.TrimSuffix(p.baseURL, "/v1")
endpoint = base + "/v1/images/generations"
}
body := openAIReq{
Model: model,
Prompt: prompt,
N: n,
Size: size,
ResponseFormat: "url",
}
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := p.client.Do(req)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result openAIResp
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
}
if result.Error != nil {
return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil
}
if len(result.Data) == 0 {
return map[string]interface{}{"isError": true, "content": "No images returned"}, nil
}
urls := make([]string, len(result.Data))
for i, d := range result.Data {
urls[i] = d.URL
}
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL):
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
localPaths := make([]string, len(urls))
var errs []string
for i, u := range urls {
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
if err != nil {
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
continue
}
localPaths[i] = path
}
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
for _, pth := range localPaths {
if pth != "" {
content += "\n" + pth
}
}
if len(errs) > 0 {
content += "\n\n" + strings.Join(errs, "\n")
}
content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
return map[string]interface{}{
"content": content,
"images": localPaths,
"prompt": prompt,
"model": model,
"local_paths": localPaths,
}, nil
}
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
dl := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
resp, err := dl.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
ext := ".png"
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
ext = ".jpg"
} else if strings.Contains(ct, "webp") {
ext = ".webp"
}
path := filepath.Join(p.dataDir, baseName+ext)
if err := os.WriteFile(path, data, 0644); err != nil {
return "", err
}
return path, nil
}
type stabilityReq struct {
TextPrompts []stabilityPrompt `json:"text_prompts"`
Width int `json:"width"`
Height int `json:"height"`
Samples int `json:"samples"`
}
type stabilityPrompt struct {
Text string `json:"text"`
Weight float64 `json:"weight,omitempty"`
}
type stabilityArtifact struct {
Base64 string `json:"base64"`
Seed int `json:"seed"`
}
type stabilityResp struct {
Artifacts []stabilityArtifact `json:"artifacts"`
Message string `json:"message,omitempty"`
}
func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey string) (interface{}, error) {
width, height := 1024, 1024
if parts := strings.Split(size, "x"); len(parts) == 2 {
if w, err := strconv.Atoi(parts[0]); err == nil {
width = w
}
if h, err := strconv.Atoi(parts[1]); err == nil {
height = h
}
}
body := stabilityReq{
TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}},
Width: width,
Height: height,
Samples: n,
}
apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image"
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil
}
respBody, _ := io.ReadAll(resp.Body)
var result stabilityResp
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil
}
if len(result.Artifacts) == 0 {
msg := result.Message
if msg == "" {
msg = "No images returned"
}
return map[string]interface{}{"isError": true, "content": msg}, nil
}
urls := make([]string, len(result.Artifacts))
for i, a := range result.Artifacts {
urls[i] = "data:image/png;base64," + a.Base64
}
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
"images": urls,
"prompt": prompt,
"model": model,
}, nil
}

View File

@ -1,19 +0,0 @@
{
"name": "bili",
"name_zh": "B站视频下载",
"name_en": "Bilibili Video Downloader",
"version": "1.2.0",
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"bili",
"video",
"download"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,355 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
proxy string
// runCancel 取消**正在跑**的 yt-dlp;runWG 等它真正退出。
//
// 为何需要:下载是分钟级操作,而 Stop() 必须能把它掐掉。
// 只 cancel 不 wait 的话内核会在插件死后立刻释放共享段,
// 而 yt-dlp 还在往插件的 stdout 写 —— 那正是内核 readLoop 挂死的成因。
runMu sync.Mutex
runCancel context.CancelFunc
runWG sync.WaitGroup
}
// trackRun 登记一次外部命令运行,返回完成时调用 untrack。
func (p *Plugin) trackRun() (context.Context, func()) {
ctx, cancel := context.WithCancel(context.Background())
p.runMu.Lock()
p.runCancel = cancel
p.runWG.Add(1)
p.runMu.Unlock()
return ctx, func() {
p.runWG.Done()
p.runMu.Lock()
p.runCancel = nil
p.runMu.Unlock()
}
}
// killRunGroup 掐掉正在跑的 yt-dlp 及其子进程(ffmpeg 等)。
func (p *Plugin) killRunGroup(pid int) {
if pid <= 0 {
return
}
// 负 pid = 整个进程组(yt-dlp 拉起的 ffmpeg 也在内)
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "output_dir", Default: "/tmp/bili_videos",
Type: "string", DisplayName: "下载目录",
Description: "B站视频下载后的保存目录",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890),留空则不设置",
Category: p.name,
})
if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok {
p.proxy = str
}
}
s.RegisterTool(tp+"video", sdk.ToolDef{
Name: tp + "video",
Description: "使用 yt-dlp 下载B站视频到本地。支持查看视频信息后再下载。下载后返回文件路径。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "B站视频分享链接"},
"info_only": map[string]interface{}{"type": "boolean", "description": "仅获取视频信息(标题、清晰度列表),不下载"},
"format": map[string]interface{}{"type": "string", "description": "视频格式ID(如 30112=高清1080P, 30080=高清1080P, 30064=高清720P, 30032=清晰480P, 30016=流畅360P),不指定则自动选最优"},
},
"required": []string{"url"},
},
Cleaner: func(output string) string {
var r struct{ Content string }
if json.Unmarshal([]byte(output), &r) == nil && r.Content != "" {
return r.Content
}
return output
},
}, p.handleBiliVideo)
return nil
}
// Stop 取消并等待正在跑的下载。
//
// 空实现的代价(实测):yt-dlp 是分钟级操作,Stop 时它还在跑,
// 而它**继承本插件的 stdout**。内核 Kill 掉本插件后,yt-dlp 变孤儿
// 且继续持有管道写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 整个关停挂死
// 直到 systemd 90 秒超时 SIGKILL(线上症状:只有 bili 报
// "SIGKILL 后 2s 仍未被收割",之后近 90 秒无日志)。
func (p *Plugin) Stop() error {
p.runMu.Lock()
cancel := p.runCancel
p.runMu.Unlock()
if cancel != nil {
cancel()
}
// 等它真的退出:不等的话内核会先释放共享段,孙进程仍在写 stdout。
p.runWG.Wait()
return nil
}
type ytdlpFormat struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Width int `json:"width"`
Height int `json:"height"`
TBR float64 `json:"tbr"`
Filesize int64 `json:"filesize"`
FilesizeApprox int64 `json:"filesize_approx"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
FPS float64 `json:"fps"`
}
type ytdlpInfo struct {
Title string `json:"title"`
Duration float64 `json:"duration"`
WebpageURL string `json:"webpage_url"`
Filename string `json:"_filename"`
Formats []ytdlpFormat `json:"formats"`
}
func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, error) {
url, _ := args["url"].(string)
if url == "" {
return nil, fmt.Errorf("url is required")
}
infoOnly, _ := args["info_only"].(bool)
format, _ := args["format"].(string)
outputDir := "/tmp/bili_videos"
if p.sdk != nil {
if v, _ := p.sdk.Settings().Get("output_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
outputDir = s
}
}
}
// 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
outputDir = filepath.Clean(outputDir)
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
if outputDir == forbidden {
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
}
}
os.MkdirAll(outputDir, 0755)
var out bytes.Buffer
ytdlpArgs := []string{"--no-warnings", "--dump-json", url}
ctx, done := p.trackRun()
defer done()
// CommandContext:Stop 里的 cancel 能直接掐掉它。
// Setpgid:让 yt-dlp 自成进程组,它再拉的 ffmpeg 也在组内,
// killRunGroup 能一次带走整棵子进程树。
cmd := exec.CommandContext(ctx, "yt-dlp", ytdlpArgs...)
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Env = proxyEnv(p.proxy)
setPgid(cmd)
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("yt-dlp info 已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
}
var info ytdlpInfo
if err := json.Unmarshal(out.Bytes(), &info); err != nil {
return nil, fmt.Errorf("parse yt-dlp output: %w", err)
}
if infoOnly {
var filtered []ytdlpFormat
for _, f := range info.Formats {
if f.VCodec != "none" || f.ACodec != "none" {
filtered = append(filtered, f)
}
}
info.Formats = filtered
lines := []string{fmt.Sprintf("标题: %s", info.Title)}
if info.Duration > 0 {
lines = append(lines, fmt.Sprintf("时长: %.0f 秒", info.Duration))
}
type fmtLine struct {
ID string
Note string
Res string
Ext string
Size string
}
var seen []string
var display []fmtLine
for _, f := range info.Formats {
if f.FormatNote == "" {
continue
}
key := f.FormatNote + f.Ext
if contains(seen, key) {
continue
}
seen = append(seen, key)
res := ""
if f.Width > 0 && f.Height > 0 {
res = fmt.Sprintf("%dx%d", f.Width, f.Height)
}
sz := ""
fs := f.Filesize
if fs == 0 {
fs = f.FilesizeApprox
}
if fs > 0 {
sz = fmt.Sprintf(" (%.1f MB)", float64(fs)/1048576)
}
display = append(display, fmtLine{ID: f.FormatID, Note: f.FormatNote, Res: res, Ext: f.Ext, Size: sz})
}
if len(display) > 0 {
lines = append(lines, "清晰度列表:")
for _, d := range display {
r := d.Res
if r != "" {
r = " " + r
}
lines = append(lines, fmt.Sprintf(" [%s] %s%s | %s%s", d.ID, d.Note, r, d.Ext, d.Size))
}
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
if err := os.MkdirAll(taskDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir task dir: %w", err)
}
dlArgs := []string{
"--no-warnings",
"--socket-timeout", "30",
"--retries", "3",
"--fragment-retries", "3",
"-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
"--no-overwrites",
}
if format != "" {
dlArgs = append(dlArgs, "-f", format)
}
dlArgs = append(dlArgs, url)
cmd2 := exec.CommandContext(ctx, "yt-dlp", dlArgs...)
cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer
cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut
setPgid(cmd2)
if err := cmd2.Run(); err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("下载已取消(插件停止)")
}
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
}
parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
for _, f := range parts {
os.Remove(f)
}
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
for _, f := range residuals {
os.Remove(f)
}
entries, _ := os.ReadDir(taskDir)
var mainFile string
var mainSize int64
for _, e := range entries {
if e.IsDir() {
continue
}
fi, _ := e.Info()
if fi == nil {
continue
}
if fi.Size() > mainSize {
mainSize = fi.Size()
mainFile = e.Name()
}
}
if mainFile == "" {
return map[string]interface{}{
"content": "下载完成,但未找到视频文件",
}, nil
}
dlPath := filepath.Join(taskDir, mainFile)
return map[string]interface{}{
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
"file": dlPath,
"filename": mainFile,
}, nil
}
// setPgid 让命令自成进程组:它自己拉的子进程(yt-dlp → ffmpeg)
// 都在同一组里,kill(-pgid) 能一次带走,避免孤儿持有 stdout 管道。
func setPgid(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func proxyEnv(proxy string) []string {
env := os.Environ()
if proxy != "" {
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
}
return env
}
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,21 +0,0 @@
{
"name": "browser",
"name_zh": "浏览器",
"name_en": "Browser",
"version": "2.4.1",
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"web",
"search",
"fetch",
"browser",
"cdp"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
{
"name": "calendar",
"name_zh": "日历",
"name_en": "Calendar",
"version": "1.1.0",
"description": "日历事件管理,支持提醒和重复事件",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"calendar",
"event",
"reminder",
"schedule"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
{
"name": "memo",
"name_zh": "备忘录",
"name_en": "Memo",
"version": "1.1.0",
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"memo",
"todo",
"notes"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,515 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Todo 待办条目:会被主动提醒
type Todo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
Done bool `json:"done"`
}
// Memo 备忘录条目:纯记事,不主动提醒
type Memo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
todos []Todo
nextTID int64
memos []Memo
nextMID int64
todoPath string
memoPath string
stopCh chan struct{}
tp string
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
}
p.todoPath = filepath.Join(dir, "todos.json")
p.memoPath = filepath.Join(dir, "memos.json")
p.loadTodos()
p.loadMemos()
// 卸载(删除)时清理数据文件;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
// ── 待办(会被主动提醒)──
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
Name: p.tp + "todo_add",
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
},
"required": []string{"content"},
},
}, p.handleTodoAdd)
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
Name: p.tp + "todo_complete",
Description: "将指定ID的待办标记为已完成(不再提醒)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleTodoComplete)
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
Name: p.tp + "todo_list",
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleTodoList)
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
Name: p.tp + "todo_delete",
Description: "删除指定ID的待办事项(包括已完成的)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleTodoDelete)
// ── 备忘(纯记事,不提醒)──
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
Name: p.tp + "memo_create",
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
},
"required": []string{"content"},
},
}, p.handleMemoCreate)
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
Name: p.tp + "memo_list",
Description: "列出所有备忘录,包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleMemoList)
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
Name: p.tp + "memo_delete",
Description: "删除指定ID的备忘录。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
},
"required": []string{"id"},
},
}, p.handleMemoDelete)
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
go p.periodicCheck()
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
return nil
}
func (p *Plugin) Stop() error {
close(p.stopCh)
p.saveTodos()
p.saveMemos()
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) loadTodos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.todoPath)
if err != nil {
p.todos = []Todo{}
p.nextTID = 1
return
}
var store struct {
Todos []Todo `json:"todos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.todos = []Todo{}
p.nextTID = 1
return
}
p.todos = store.Todos
p.nextTID = store.NextID
if p.todos == nil {
p.todos = []Todo{}
}
if p.nextTID < 1 {
p.nextTID = 1
}
}
func (p *Plugin) loadMemos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.memoPath)
if err != nil {
p.memos = []Memo{}
p.nextMID = 1
return
}
var store struct {
Memos []Memo `json:"memos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.memos = []Memo{}
p.nextMID = 1
return
}
p.memos = store.Memos
p.nextMID = store.NextID
if p.memos == nil {
p.memos = []Memo{}
}
if p.nextMID < 1 {
p.nextMID = 1
}
}
func (p *Plugin) saveTodos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"todos": p.todos,
"next_id": p.nextTID,
}, "", " ")
p.mu.RUnlock()
atomicWriteJSON(p.todoPath, data)
}
func (p *Plugin) saveMemos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextMID,
}, "", " ")
p.mu.RUnlock()
atomicWriteJSON(p.memoPath, data)
}
// ── 待办:未完成计数与提醒 ──
func (p *Plugin) pendingTodoCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
n := 0
for _, t := range p.todos {
if !t.Done {
n++
}
}
return n
}
func (p *Plugin) pendingTodos() []Todo {
p.mu.RLock()
defer p.mu.RUnlock()
var out []Todo
for _, t := range p.todos {
if !t.Done {
out = append(out, t)
}
}
return out
}
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
n := p.pendingTodoCount()
if n == 0 {
return nil
}
ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
})
ctx.Unlock()
return nil
}
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
func (p *Plugin) periodicCheck() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
n := p.pendingTodoCount()
if n == 0 {
continue
}
if p.sdk != nil {
// NoMemory:这是定时提醒,不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
}
}
}
}
// ── 待办工具 ──
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
todo := Todo{
ID: p.nextTID,
Content: content,
CreatedAt: time.Now().Unix(),
Done: false,
}
p.nextTID++
p.todos = append(p.todos, todo)
p.mu.Unlock()
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
"id": todo.ID,
}, nil
}
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.todos {
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
p.todos[i].Done = true
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
}, nil
}
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
todos := p.pendingTodos()
if len(todos) == 0 {
return map[string]interface{}{
"content": "暂无未完成的待办",
}, nil
}
var sb strings.Builder
for i, t := range todos {
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(todos),
}, nil
}
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.todos {
if p.todos[i].ID == int64(id) {
p.todos = append(p.todos[:i], p.todos[i+1:]...)
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
}, nil
}
// ── 备忘工具 ──
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
memo := Memo{
ID: p.nextMID,
Content: content,
CreatedAt: time.Now().Unix(),
}
p.nextMID++
p.memos = append(p.memos, memo)
p.mu.Unlock()
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
"id": memo.ID,
}, nil
}
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.memos {
if p.memos[i].ID == int64(id) {
p.memos = append(p.memos[:i], p.memos[i+1:]...)
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
}
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
}, nil
}
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
p.mu.RLock()
memos := append([]Memo{}, p.memos...)
p.mu.RUnlock()
if len(memos) == 0 {
return map[string]interface{}{
"content": "暂无备忘录",
}, nil
}
var sb strings.Builder
for i, m := range memos {
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(memos),
}, nil
}
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
}
// cleanupData 卸载时清理数据文件(待办 + 备忘)
func (p *Plugin) cleanupData() {
if p.todoPath != "" {
os.Remove(p.todoPath)
}
if p.memoPath != "" {
os.Remove(p.memoPath)
}
}
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -1,18 +0,0 @@
{
"name": "qq",
"name_zh": "QQ消息",
"name_en": "qq",
"version": "1.4.1",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"qq",
"messaging"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -1,433 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func newPermissionTestPlugin(t *testing.T) *Plugin {
t.Helper()
instance, err := NewPluginFactory("qq", nil)
if err != nil {
t.Fatal(err)
}
return instance.(*Plugin)
}
func toolCallContext(name string, args map[string]interface{}) *sdk.StageContext {
return &sdk.StageContext{ToolCalls: []sdk.ToolCall{{Name: name, Arguments: args}}}
}
func TestOwnerBypassesQQPermissionBoundary(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("owner call rejected: %s", *ctx.Response)
}
}
func TestPrivateResourceCannotBeAllowlisted(t *testing.T) {
p := newPermissionTestPlugin(t)
p.privateToolAllowlist = append(p.privateToolAllowlist, "calendar_*")
p.auth = qqAuthContext{active: true, userID: 10001}
ctx := toolCallContext("calendar_list", nil)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "私人资源工具") {
t.Fatalf("expected private-resource denial, got %#v", ctx.Response)
}
}
func TestNonOwnerQQHistoryIsScopedToCurrentGroup(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, messageID: 88, userID: 10001, groupID: 20002, isGroup: true}
ctx := toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20003)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "当前 QQ 会话") {
t.Fatalf("cross-group history not rejected: %#v", ctx.Response)
}
ctx = toolCallContext("qq_get_history", map[string]interface{}{"group_id": int64(20002)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("current-group history rejected: %s", *ctx.Response)
}
}
func TestUnmatchedQQInputIsDowngraded(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "来自未知事件(message_id=404)",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.active || p.auth.owner || p.auth.userID != 0 {
t.Fatalf("unmatched input reused prior privilege: %+v", p.auth)
}
}
func TestDuplicateQQOutputIsStopped(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxDuplicateSend = 1
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
args := map[string]interface{}{"payload": "same", "type": "text", "meta": `{"user_id":123}`}
ctx := toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("first send rejected: %s", *ctx.Response)
}
ctx = toolCallContext("output_send__qq", args)
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response == nil || !strings.Contains(*ctx.Response, "循环保险") {
t.Fatalf("duplicate send not stopped: %#v", ctx.Response)
}
}
func TestGroupAndUserRouteAddsLeadingMention(t *testing.T) {
var path string
var request map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"data":{"message_id":1}}`))
}))
defer server.Close()
p := newPermissionTestPlugin(t)
p.napcatURL = server.URL
p.httpClient = server.Client()
_, err := p.handleChannelOutput(map[string]interface{}{
"payload": "hello",
"type": "text",
"meta": `{"group_id":20002,"user_id":10001}`,
})
if err != nil {
t.Fatal(err)
}
if path != "/send_group_msg" {
t.Fatalf("path=%q, want /send_group_msg", path)
}
segments, ok := request["message"].([]interface{})
if !ok || len(segments) < 2 {
t.Fatalf("message is not a segment array: %#v", request["message"])
}
mention, _ := segments[0].(map[string]interface{})
data, _ := mention["data"].(map[string]interface{})
if mention["type"] != "at" || data["qq"] != "10001" {
t.Fatalf("leading mention=%#v", mention)
}
}
// 回归:循环保险曾按“总数”拦截,导致参数不同且必需的调用被误杀。
// 现在只拦参数完全相同的重复调用。
func TestDistinctQQOutputsAreNotTreatedAsDuplicates(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// maxDuplicateSend 默认 1:同一条消息重复才会被拦,不同消息必须全部放行。
for i := 0; i < 5; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": fmt.Sprintf("message-%d", i),
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("distinct message %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestDistinctNecessaryToolCallsAreNotBlocked(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
// 旧实现 maxQQToolCalls=32 会在第 33 个不同参数的必需调用处误拦。
for i := 0; i < 50; i++ {
ctx := toolCallContext("cmd_run", map[string]interface{}{"command": fmt.Sprintf("cmd-%d", i)})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("necessary tool call %d was blocked: %s", i, *ctx.Response)
}
}
}
func TestZeroLimitsMeanUnlimited(t *testing.T) {
p := newPermissionTestPlugin(t)
p.maxQQOutputCalls = 0
p.maxDuplicateSend = 0
p.maxQQToolCalls = 0
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
for i := 0; i < 30; i++ {
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "same-content",
"type": "text",
"meta": `{"user_id":123}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("0 should mean unlimited, blocked at %d: %s", i, *ctx.Response)
}
}
}
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner)时,
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
// (报「可信 QQ 会话身份不完整」)。
//
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true}
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "带齐 meta 的主动发送",
"type": "text",
"meta": `{"user_id":2198972886}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
}
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
if err := p.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
}
}
// ---- 消息合并(debounce)----
// collectInterrupts 用注入钩子收集中断文本(避免测试依赖真实 SDK)。
func collectInterrupts(p *Plugin) *[]string {
got := []string{}
p.injectHook = func(s, _ string) { got = append(got, s) }
return &got
}
func TestConsecutiveMessagesFromSameSenderAreBatched(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
for i := 0; i < 3; i++ {
p.enqueueInterrupt("private", 10001, 0, int64(100+i), "小明", "单条", false, false)
}
time.Sleep(120 * time.Millisecond)
if len(*got) != 1 {
t.Fatalf("同一发送者连发 3 条应合并成 1 次中断,实际 %d 次: %#v", len(*got), *got)
}
if !strings.Contains((*got)[0], "3 条消息") {
t.Fatalf("合并中断应说明一共几条,实际: %s", (*got)[0])
}
// 三个 message_id 都要带上,模型才能取全
for _, id := range []string{"100", "101", "102"} {
if !strings.Contains((*got)[0], id) {
t.Fatalf("合并中断漏了 message_id=%s: %s", id, (*got)[0])
}
}
}
func TestDifferentSendersAreNotBatchedTogether(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("private", 10001, 0, 1, "小明", "a", false, false)
p.enqueueInterrupt("private", 10002, 0, 2, "小红", "b", false, false)
time.Sleep(120 * time.Millisecond)
if len(*got) != 2 {
t.Fatalf("不同发送者不该合并,应有 2 次中断,实际 %d: %#v", len(*got), *got)
}
}
func TestBatchWindowZeroFallsBackToPerMessage(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 0
for i := 0; i < 3; i++ {
p.enqueueInterrupt("private", 10001, 0, int64(i), "小明", "原文", false, false)
}
if len(*got) != 3 {
t.Fatalf("关闭合并时应逐条投递(3 次),实际 %d: %#v", len(*got), *got)
}
}
func TestSingleMessageKeepsOriginalText(t *testing.T) {
p := newPermissionTestPlugin(t)
got := collectInterrupts(p)
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("group", 10001, 20002, 7, "小明", "单条原文", true, false)
time.Sleep(120 * time.Millisecond)
if len(*got) != 1 || (*got)[0] != "单条原文" {
t.Fatalf("单条消息应沿用原文(含所有者前缀),实际 %#v", *got)
}
}
// Bot 所有者/管理员的消息给 L2,普通人的给 L1 —— 否则所有者的话会被路人
// 的 L1 闲聊抢占/挤到队尾。
func TestOwnerMessagesGetHigherInterruptLevel(t *testing.T) {
p := newPermissionTestPlugin(t)
got := []string{}
p.injectHook = func(text, level string) { got = append(got, text+"|"+level) }
p.batchWindow = 20 * time.Millisecond
p.batchMax = time.Second
p.enqueueInterrupt("private", 1, 0, 1, "owner", "owner-msg", true, false)
p.enqueueInterrupt("private", 2, 0, 2, "someone", "other-msg", false, false)
time.Sleep(120 * time.Millisecond)
joined := strings.Join(got, ",")
if !strings.Contains(joined, "owner-msg|L2") {
t.Fatalf("所有者消息应为 L2,实际 %q", joined)
}
if !strings.Contains(joined, "other-msg|L1") {
t.Fatalf("普通人消息应为 L1,实际 %q", joined)
}
}
// 身份必须绑在帧上:中断抢占当前轮、中断轮收尾清空插件全局身份之后,
// 外层轮被恢复(resumeTask 复用同一帧、不重跑 onInput)时权限门不能整体失效。
func TestAuthSurvivesInterruptPreemptionOfAnotherTurn(t *testing.T) {
p := newPermissionTestPlugin(t)
// 中断轮(Bot 所有者)跑完:afterOutput 会清掉插件全局身份。
inner := &sdk.StageContext{Extra: map[string]interface{}{
qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886},
}}
if err := p.afterOutputAuthContext(inner); err != nil {
t.Fatal(err)
}
if p.auth.active {
t.Fatal("收尾后插件全局身份应为空(复现恢复前状态)")
}
// 外层轮(非所有者群成员)恢复后继续调工具:仍须按非所有者拦下私人资源工具。
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, userID: 10001, groupID: 20002, isGroup: true}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response == nil || !strings.Contains(*frame.Response, "私人资源工具") {
t.Fatalf("中断恢复后权限门失效(整体放行): %#v", frame.Response)
}
}
// 运行中到达的新消息会改写插件全局身份;正在跑的那一轮必须不受影响。
func TestMidTurnMessageDoesNotChangeRunningTurnAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
frame := &sdk.StageContext{
Extra: map[string]interface{}{qqAuthExtraKey: qqAuthContext{active: true, owner: true, userID: 2198972886}},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
// 路人的群消息在所有者轮运行中到达。
p.activateAuthContext(4242, 10001, 20002, true)
if p.auth.owner {
t.Fatal("到达事件应改写全局身份(复现场景)")
}
if err := p.beforeToolcall(frame); err != nil {
t.Fatal(err)
}
if frame.Response != nil {
t.Fatalf("在跑的所有者轮被到达消息篡改: %s", *frame.Response)
}
}
// 合并中断正文里的整批 message_id 都要消费掉,并在帧上绑定身份。
func TestBatchInterruptConsumesAllMessageIDs(t *testing.T) {
p := newPermissionTestPlugin(t)
p.authByMessageID = map[int64]qqAuthContext{
100: {active: true, owner: true, userID: 2198972886},
101: {active: true, owner: true, userID: 2198972886},
}
ctx := &sdk.StageContext{
RawMessage: "来自「老板」的私聊短时间内连续发来 2 条消息(message_id=100,101, user_id=2198972886)。",
Extra: map[string]interface{}{"input_source": "qq"},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if !p.auth.owner {
t.Fatalf("合并中断未恢复所有者身份: %+v", p.auth)
}
if len(p.authByMessageID) != 0 {
t.Fatalf("同批 message_id 未全部清理: %v", p.authByMessageID)
}
if auth, ok := authOnFrame(ctx); !ok || !auth.owner {
t.Fatalf("身份未绑定到帧上: %+v ok=%v", auth, ok)
}
}
// 非 QQ 来源(webui/timer 等)的帧上绑空身份:权限门对这些轮整体关闭。
func TestNonQQFrameBindsInactiveAuth(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true, owner: true, userID: 2198972886}
ctx := &sdk.StageContext{
RawMessage: "webui 里的提问",
Extra: map[string]interface{}{"input_source": "webui"},
ToolCalls: []sdk.ToolCall{{Name: "calendar_list"}},
}
if err := p.onInputAuthContext(ctx); err != nil {
t.Fatal(err)
}
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("非 QQ 轮不应被 QQ 权限门拦: %s", *ctx.Response)
}
}

View File

@ -1,21 +0,0 @@
{
"name": "recoverydiag",
"name_zh": "恢复诊断",
"name_en": "Recovery Diagnostics",
"version": "0.2.0",
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"diag",
"recovery",
"diagnostics",
"triage",
"failback"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
{
"name": "rss",
"name_zh": "RSS订阅",
"name_en": "RSS",
"version": "1.1.0",
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"rss",
"feed",
"subscription",
"monitor"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

View File

@ -1,501 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"github.com/mmcdole/gofeed"
)
const injectDedupWindow = 5 * time.Minute
type FeedSub struct {
URL string `json:"url"`
Title string `json:"title"`
AddedAt string `json:"added_at"`
Interval int `json:"interval"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
fp *gofeed.Parser
dataDir string
mu sync.RWMutex
feeds []FeedSub
seenGUIDs map[string]bool
injected map[string]time.Time
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
pollTicker *time.Ticker
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func getSetting[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return fallback
}
switch any(fallback).(type) {
case string:
if sv, ok := v.(string); ok {
return any(sv).(T)
}
case int64:
switch val := v.(type) {
case float64:
return any(int64(val)).(T)
case string:
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
return any(n).(T)
}
}
case float64:
switch val := v.(type) {
case float64:
return any(val).(T)
case string:
if n, err := strconv.ParseFloat(val, 64); err == nil {
return any(n).(T)
}
}
}
return fallback
}
func readArg(args map[string]interface{}, key string) string {
if v, ok := args[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func readArgInt(args map[string]interface{}, key string, fallback int) int {
if v, ok := args[key]; ok && v != nil {
switch n := v.(type) {
case float64:
return int(n)
case int64:
return int(n)
}
}
return fallback
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 30 * time.Second}
// 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true})
p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool)
p.injected = make(map[string]time.Time)
p.feeds = []FeedSub{}
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
}
p.loadData()
// 卸载(删除)时清理订阅数据目录;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "poll_interval", Default: "30", Type: "string",
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
Category: "rss",
})
tp := p.name + "_"
s.RegisterTool(tp+"subscribe", sdk.ToolDef{
Name: tp + "subscribe", Description: "Subscribe to an RSS/Atom feed URL",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "Feed URL"},
"interval": map[string]interface{}{"type": "integer", "description": "Poll interval in minutes (default: 30, minimum: 5)"},
},
"required": []string{"url"},
},
}, p.handleSubscribe)
s.RegisterTool(tp+"unsubscribe", sdk.ToolDef{
Name: tp + "unsubscribe", Description: "Unsubscribe from a feed",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "Feed URL to unsubscribe"},
},
"required": []string{"url"},
},
}, p.handleUnsubscribe)
s.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list", Description: "List all subscribed feeds",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
s.RegisterTool(tp+"check_now", sdk.ToolDef{
Name: tp + "check_now", Description: "Manually check all feeds for new articles now",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleCheckNow)
pollMin := int(getSetting(s.Settings(), "poll_interval", int64(30)))
if pollMin < 5 {
pollMin = 5
}
p.pollTicker = time.NewTicker(time.Duration(pollMin) * time.Minute)
p.wg.Add(1)
go p.pollLoop()
fmt.Printf("[%s] started (%d feeds, poll every %dm)\n", p.name, len(p.feeds), pollMin)
return nil
}
func (p *Plugin) Stop() error {
p.stopOnce.Do(func() { close(p.stopCh) })
p.pollTicker.Stop()
p.wg.Wait()
p.saveData()
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
func (p *Plugin) pollLoop() {
defer p.wg.Done()
p.checkAllFeeds()
for {
select {
case <-p.pollTicker.C:
p.checkAllFeeds()
case <-p.stopCh:
return
}
}
}
func (p *Plugin) checkAllFeeds() {
p.mu.RLock()
feeds := make([]FeedSub, len(p.feeds))
copy(feeds, p.feeds)
p.mu.RUnlock()
for _, feed := range feeds {
select {
case <-p.stopCh:
return
default:
}
p.checkFeed(feed)
}
}
func (p *Plugin) checkFeed(sub FeedSub) {
parsed, err := p.fp.ParseURL(sub.URL)
if err != nil {
return
}
title := parsed.Title
if title == "" {
title = sub.URL
}
var newArticles []*gofeed.Item
for _, item := range parsed.Items {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
guid = sub.URL + "|" + guid
p.mu.RLock()
seen := p.seenGUIDs[guid]
p.mu.RUnlock()
if !seen {
newArticles = append(newArticles, item)
}
}
if len(newArticles) == 0 {
return
}
now := time.Now()
toInject := make([]*gofeed.Item, 0, len(newArticles))
p.mu.Lock()
for _, item := range newArticles {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
key := sub.URL + "|" + guid
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
continue
}
p.injected[key] = now
p.seenGUIDs[key] = true
toInject = append(toInject, item)
}
p.mu.Unlock()
if len(toInject) == 0 {
return
}
var lines []string
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
for _, item := range toInject {
pubDate := ""
if item.PublishedParsed != nil {
pubDate = item.PublishedParsed.Format("01-02 15:04")
}
line := fmt.Sprintf(" • %s", item.Title)
if pubDate != "" {
line += fmt.Sprintf(" [%s]", pubDate)
}
if item.Link != "" {
line += "\n " + item.Link
}
lines = append(lines, line)
}
// 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算,
// 原文仍进上下文(模型当轮看得到)。
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
sdk.InjectOptions{NoMemory: true})
p.saveData()
}
func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, error) {
url := readArg(args, "url")
if url == "" {
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
}
p.mu.RLock()
for _, f := range p.feeds {
if f.URL == url {
p.mu.RUnlock()
return map[string]interface{}{"isError": true, "content": "Already subscribed to: " + url}, nil
}
}
p.mu.RUnlock()
interval := readArgInt(args, "interval", 30)
if interval < 5 {
interval = 5
}
parsed, err := p.fp.ParseURL(url)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Failed to parse feed: " + err.Error()}, nil
}
feedTitle := parsed.Title
if feedTitle == "" {
feedTitle = url
}
sub := FeedSub{
URL: url,
Title: feedTitle,
AddedAt: time.Now().Format("2006-01-02 15:04"),
Interval: interval,
}
guidCount := 0
p.mu.Lock()
for _, item := range parsed.Items {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
p.seenGUIDs[url+"|"+guid] = true
guidCount++
}
p.mu.Unlock()
p.mu.Lock()
p.feeds = append(p.feeds, sub)
p.mu.Unlock()
p.saveData()
return map[string]interface{}{
"content": fmt.Sprintf("Subscribed to: %s\nTitle: %s\nArticles found: %d\nPoll interval: %d min", url, feedTitle, guidCount, interval),
}, nil
}
func (p *Plugin) handleUnsubscribe(args map[string]interface{}) (interface{}, error) {
url := readArg(args, "url")
if url == "" {
return map[string]interface{}{"isError": true, "content": "URL is required"}, nil
}
p.mu.Lock()
found := false
for i, f := range p.feeds {
if f.URL == url {
p.feeds = append(p.feeds[:i], p.feeds[i+1:]...)
found = true
break
}
}
if !found {
p.mu.Unlock()
return map[string]interface{}{"isError": true, "content": "Not subscribed to: " + url}, nil
}
for guid := range p.seenGUIDs {
if strings.HasPrefix(guid, url+"|") {
delete(p.seenGUIDs, guid)
}
}
p.mu.Unlock()
p.saveData()
return map[string]interface{}{"content": "Unsubscribed: " + url}, nil
}
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if len(p.feeds) == 0 {
return map[string]interface{}{"content": "No subscriptions. Use rss_subscribe to add one."}, nil
}
sort.Slice(p.feeds, func(i, j int) bool {
return p.feeds[i].Title < p.feeds[j].Title
})
var lines []string
lines = append(lines, fmt.Sprintf("📡 Subscriptions (%d):", len(p.feeds)))
for _, f := range p.feeds {
lines = append(lines, fmt.Sprintf(" • %s\n %s (every %dm, added %s)", f.Title, f.URL, f.Interval, f.AddedAt))
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
select {
case <-p.stopCh:
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
default:
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
p.checkAllFeeds()
}()
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
}
func (p *Plugin) dataFile() string {
return filepath.Join(p.dataDir, "feeds.json")
}
func (p *Plugin) loadData() {
b, err := os.ReadFile(p.dataFile())
if err != nil {
return
}
var data struct {
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}
if json.Unmarshal(b, &data) != nil {
return
}
if data.Feeds != nil {
p.feeds = data.Feeds
}
if data.SeenGUIDs != nil {
p.seenGUIDs = data.SeenGUIDs
}
}
func (p *Plugin) saveData() {
p.mu.RLock()
defer p.mu.RUnlock()
data := struct {
Feeds []FeedSub `json:"feeds"`
SeenGUIDs map[string]bool `json:"seen"`
}{
Feeds: p.feeds,
SeenGUIDs: p.seenGUIDs,
}
b, _ := json.MarshalIndent(data, "", " ")
atomicWriteJSON(p.dataFile(), b)
}
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if p.dataDir == "" {
return
}
for _, f := range []string{"feeds.json"} {
path := filepath.Join(p.dataDir, f)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
}
}
}
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -1,116 +0,0 @@
cmake_minimum_required(VERSION 3.10)
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
# ============================================================
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
#
# 使用方式:
# add_subdirectory(path/to/ha_remotedevice)
# target_link_libraries(my_app ha_remotedevice)
# target_include_directories(my_app PRIVATE
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
# ============================================================
# 选项: 构建为静态库或动态库
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调)
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
# 选项: 日志级别
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
# 源文件
set(HA_REMOTEDEVICE_SRC
src/ha_remotedevice.c
src/ha_json.c
src/ha_ws.c
)
# 头文件
set(HA_REMOTEDEVICE_INCLUDE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
# 编译选项
if(HA_NO_ALLOC)
add_definitions(-DHA_NO_ALLOC)
endif()
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
# 创建库
if(BUILD_SHARED_LIBS)
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
if(WIN32)
# Windows 需要导出符号
set_target_properties(ha_remotedevice PROPERTIES
WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
else()
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
endif()
# 包含目录
target_include_directories(ha_remotedevice
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
)
# 不链接任何外部库
target_link_libraries(ha_remotedevice PRIVATE)
# 导出包含目录供外部项目使用
set(HA_REMOTEDEVICE_INCLUDE_DIR
${HA_REMOTEDEVICE_INCLUDE}
CACHE INTERNAL "ha_remotedevice include directories")
# 安装规则
install(TARGETS ha_remotedevice
EXPORT ha_remotedevice-targets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
install(DIRECTORY include/
DESTINATION include
)
install(EXPORT ha_remotedevice-targets
DESTINATION lib/cmake/ha_remotedevice
NAMESPACE ha_remotedevice::
)
# ============================================================
# 测试(可选)
# ============================================================
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
if(BUILD_TESTS)
find_package(Threads REQUIRED)
add_executable(ha_remotedevice_test
test/test_ha_remotedevice.c
)
target_link_libraries(ha_remotedevice_test
PRIVATE ha_remotedevice Threads::Threads
)
target_include_directories(ha_remotedevice_test
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
)
# 添加测试
add_test(NAME ha_remotedevice_test
COMMAND ha_remotedevice_test
)
endif()
# ============================================================
# 编译信息
# ============================================================
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
message(STATUS " Build type: $<CONFIG>")
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
message(STATUS " No alloc: ${HA_NO_ALLOC}")

View File

@ -1,216 +0,0 @@
#ifndef HA_REMOTEDEVICE_H
#define HA_REMOTEDEVICE_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ==================================================================
* ha_remotedevice — 远程设备接入 C SDK
*
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
* 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。
*
* 声明式设计:
* 设备在代码中声明自己是什么(kind)和能做什么(caps),
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
*
* 协议流程:
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
* ================================================================== */
/* ======================== 状态码 ======================== */
typedef enum {
HA_OK = 0,
HA_ERR_GENERIC = -1,
HA_ERR_NOMEM = -2,
HA_ERR_INVALID = -3,
HA_ERR_TIMEOUT = -4,
HA_ERR_DISCONNECTED = -5,
HA_ERR_PROTOCOL = -6,
HA_ERR_TRANSPORT = -7,
HA_ERR_NOT_FOUND = -8,
} ha_status_t;
/* ======================== 传输层抽象 ========================
*
* 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。
*
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
* recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败
* close(ctx) → 关闭连接
*/
typedef struct {
int (*connect)(void *ctx, const char *host, uint16_t port);
int (*send)(void *ctx, const uint8_t *data, int len);
int (*recv)(void *ctx, uint8_t *buf, int len);
void (*close)(void *ctx);
void *ctx;
} ha_transport_t;
/* ======================== 设备声明 ========================
*
* 声明式配置:设备在代码中声明自己的类型和能力。
* 这些信息通过 hello 消息发送给网关。
*
* device_id — 唯一标识,如 "esp32-cam-1"
* name — 设备显示名,如 "门口摄像头"
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
* info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
*/
typedef struct {
const char *device_id;
const char *name;
const char *kind;
const char **caps; /* NULL 结尾 */
const char *info_json; /* 可选,NULL 或 JSON 字符串 */
} ha_device_info_t;
/* ======================== 命令结果 ========================
*
* 命令处理函数通过填写此结构体返回数据。
* SDK 收到结果后自动发送回执(文本或二进制分块)。
*
* 使用方式:
* 1. 简单文本:设置 status=0, output="结果文本"
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
* 3. 错误:设置 status=1, error="错误信息"
*
* 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
* 所以 handler 可以返回栈上或静态字符串。
*/
typedef struct {
int status; /* 0=ok, 非0=error */
const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */
const char *error; /* 错误信息 */
int has_binary; /* 1=通过二进制分块回传 */
const char *binary_mime; /* 二进制 MIME 类型 */
const uint8_t *binary_data; /* 二进制数据指针 */
int binary_len; /* 二进制数据长度 */
} ha_cmd_result_t;
/* ======================== 命令处理声明 ========================
*
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
*
* command 值说明:
* - "shell" → 处理 shell 类型命令,args 为完整命令字符串
* - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数
* - "screensee" → 处理 homeagent-screensee 命令
* - "speakeruse" → 处理 homeagent-speakeruse 命令
* - "computeruse" → 处理 homeagent-computeruse 命令
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
* - "screensue" → 处理 homeagent-screensue 命令
* - "deviceinfo" → 处理设备信息查询
* - 其他自定义命令名 → 按字符串匹配分发
*
* handler 处理完毕后只需填写 result 结构体,SDK 自动回执。
*/
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
ha_cmd_result_t *result, void *userdata);
typedef struct {
const char *command; /* 命令名,如 "camerasue"、"shell" */
ha_cmd_handler_t handler; /* 处理函数 */
} ha_cmd_handler_def_t;
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
* data 指针在回调返回后失效,如需保存请拷贝。 */
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
const char *mime, const uint8_t *data,
int len, void *userdata);
/* 连接状态变化回调 */
typedef void (*ha_state_callback_t)(int connected, void *userdata);
/* ======================== 客户端配置 ========================
*
* 所有配置在 ha_client_new() 时一次性声明。
* 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。
*/
typedef struct {
ha_transport_t transport; /* 传输层实现(必须) */
ha_device_info_t device; /* 设备声明(必须) */
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
const char *token; /* 接入令牌(必须) */
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
ha_state_callback_t on_state; /* 状态变化回调(可选) */
void *userdata; /* 用户自定义数据,传给所有回调 */
int ping_interval; /* 心跳间隔秒数,0 则默认 30 */
int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */
} ha_config_t;
/* ======================== 客户端 API ======================== */
typedef struct ha_client ha_client_t;
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
ha_client_t *ha_client_new(const ha_config_t *config);
/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
ha_status_t ha_client_start(ha_client_t *client);
/* 主循环处理:必须在用户的主循环中周期性调用。
* - 读取 WS 帧并分发
* - 按 handlers 表查找命令处理函数,自动回执结果
* - 处理心跳 ping/pong
* - 处理断线重连
* 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */
ha_status_t ha_client_process(ha_client_t *client);
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail);
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
void ha_client_send_status(ha_client_t *client, const char *status);
/* ===== 生命周期 ===== */
/* 停止客户端,断开连接。 */
void ha_client_stop(ha_client_t *client);
/* 销毁客户端,释放所有资源。 */
void ha_client_destroy(ha_client_t *client);
/* ======================== 工具函数 ======================== */
/* 解析 homeagent-* 命令,返回能力名和参数。
* command = "camerasue 5" → cap="camerasue", args="5"
* command = "screensee" → cap="screensee", args=""
* command = "computeruse {...}" → cap="computeruse", args="..." */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args);
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
* command = "computeruse {\"action\":\"click\",\"x\":100}"
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str);
/* Base64 编码(用于将二进制数据编码为文本回传)。
* 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
/* 获取版本号 */
const char *ha_version(void);
#ifdef __cplusplus
}
#endif
#endif /* HA_REMOTEDEVICE_H */

View File

@ -1,369 +0,0 @@
#include "ha_json.h"
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdio.h>
/* ======================== 解析器 ======================== */
/* 前向声明 */
static ha_json_node_t *parse_value(const char **pp);
/* 跳过空白 */
static const char *skip_ws(const char *p) {
while (*p && (unsigned char)*p <= ' ') p++;
return p;
}
/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */
static char *parse_string(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '"') return NULL;
p++;
int len = 0;
const char *q = p;
while (*q && *q != '"') {
if (*q == '\\') { q++; if (*q) q++; }
else q++;
len++;
}
if (*q != '"') return NULL;
char *s = (char *)malloc(len + 1);
if (!s) return NULL;
q = p;
int i = 0;
while (*q && *q != '"') {
if (*q == '\\') {
q++;
switch (*q) {
case '"': s[i++] = '"'; break;
case '\\': s[i++] = '\\'; break;
case '/': s[i++] = '/'; break;
case 'b': s[i++] = '\b'; break;
case 'f': s[i++] = '\f'; break;
case 'n': s[i++] = '\n'; break;
case 'r': s[i++] = '\r'; break;
case 't': s[i++] = '\t'; break;
case 'u': q += 4; s[i++] = '?'; continue;
default: s[i++] = *q; break;
}
q++;
} else {
s[i++] = *q++;
}
}
s[i] = '\0';
*pp = q + 1;
return s;
}
static ha_json_node_t *new_node(ha_json_type_t type) {
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
if (n) n->type = type;
return n;
}
/* 解析数字 */
static ha_json_node_t *parse_number(const char **pp) {
const char *p = *pp;
int neg = 0;
if (*p == '-') { neg = 1; p++; }
if (!isdigit((unsigned char)*p)) return NULL;
int val = 0;
while (isdigit((unsigned char)*p)) {
val = val * 10 + (*p - '0');
p++;
}
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
if (*p == 'e' || *p == 'E') {
p++;
if (*p == '+' || *p == '-') p++;
while (isdigit((unsigned char)*p)) p++;
}
*pp = p;
ha_json_node_t *n = new_node(HA_JSON_INT);
if (n) n->int_val = neg ? -val : val;
return n;
}
/* 解析 true/false/null */
static ha_json_node_t *parse_keyword(const char **pp) {
const char *p = *pp;
ha_json_node_t *n = NULL;
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
*pp = p + 4;
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
*pp = p + 5;
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
n = new_node(HA_JSON_NULL);
*pp = p + 4;
}
return n;
}
/* 解析对象 */
static ha_json_node_t *parse_object(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '{') return NULL;
p++;
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
if (!obj) return NULL;
ha_json_node_t **tail = &obj->child;
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
while (*p) {
p = skip_ws(p);
char *key = parse_string(&p);
if (!key) break;
p = skip_ws(p);
if (*p != ':') { free(key); break; }
p++;
ha_json_node_t *val = parse_value(&p);
if (!val) { free(key); break; }
val->key = key;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == '}') break;
}
p = skip_ws(p);
if (*p == '}') { *pp = p + 1; return obj; }
ha_json_free(obj);
return NULL;
}
/* 解析数组 */
static ha_json_node_t *parse_array(const char **pp) {
const char *p = skip_ws(*pp);
if (*p != '[') return NULL;
p++;
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
if (!arr) return NULL;
ha_json_node_t **tail = &arr->child;
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
while (*p) {
ha_json_node_t *val = parse_value(&p);
if (!val) break;
*tail = val;
tail = &val->next;
p = skip_ws(p);
if (*p == ',') { p++; continue; }
if (*p == ']') break;
}
p = skip_ws(p);
if (*p == ']') { *pp = p + 1; return arr; }
ha_json_free(arr);
return NULL;
}
/* 解析值(主入口) */
static ha_json_node_t *parse_value(const char **pp) {
const char *p = skip_ws(*pp);
if (*p == '{') return parse_object(pp);
if (*p == '[') return parse_array(pp);
if (*p == '"') {
char *s = parse_string(pp);
if (!s) return NULL;
ha_json_node_t *n = new_node(HA_JSON_STRING);
if (!n) { free(s); return NULL; }
n->str_val = s;
return n;
}
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
return parse_keyword(pp);
}
/* ======================== 公共 API ======================== */
ha_json_node_t *ha_json_parse(const char *str) {
if (!str) return NULL;
const char *p = str;
return parse_value(&p);
}
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_STRING) return NULL;
return n->str_val;
}
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
ha_json_node_t *n = ha_json_get(obj, key);
if (!n || n->type != HA_JSON_INT) return def;
return n->int_val;
}
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
ha_json_node_t *c = obj->child;
while (c) {
if (c->key && strcmp(c->key, key) == 0) return c;
c = c->next;
}
return NULL;
}
int ha_json_array_len(const ha_json_node_t *arr) {
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
int n = 0;
ha_json_node_t *c = arr->child;
while (c) { n++; c = c->next; }
return n;
}
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
ha_json_node_t *c = arr->child;
int i = 0;
while (c) {
if (i == index) return c;
i++; c = c->next;
}
return NULL;
}
void ha_json_free(ha_json_node_t *root) {
if (!root) return;
ha_json_node_t *c = root->child;
while (c) {
ha_json_node_t *next = c->next;
free(c->key);
if (c->type == HA_JSON_STRING) free(c->str_val);
ha_json_free(c);
c = next;
}
free(root);
}
/* ======================== 构建器 ======================== */
static void json_escape(ha_json_builder_t *jb, const char *s) {
if (!s) { ha_json_builder_raw(jb, "null"); return; }
ha_json_builder_raw(jb, "\"");
for (const char *p = s; *p; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '"': ha_json_builder_raw(jb, "\\\""); break;
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
case '\b': ha_json_builder_raw(jb, "\\b"); break;
case '\f': ha_json_builder_raw(jb, "\\f"); break;
case '\n': ha_json_builder_raw(jb, "\\n"); break;
case '\r': ha_json_builder_raw(jb, "\\r"); break;
case '\t': ha_json_builder_raw(jb, "\\t"); break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
ha_json_builder_raw(jb, buf);
} else {
char buf[2] = { (char)c, 0 };
ha_json_builder_raw(jb, buf);
}
break;
}
}
ha_json_builder_raw(jb, "\"");
}
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
jb->buf = buf;
jb->len = 0;
jb->cap = cap;
jb->depth = 0;
if (cap > 0) buf[0] = '\0';
}
void ha_json_builder_reset(ha_json_builder_t *jb) {
jb->len = 0;
jb->depth = 0;
if (jb->cap > 0) jb->buf[0] = '\0';
}
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
while (*s && jb->len < jb->cap - 1) {
jb->buf[jb->len++] = *s++;
}
jb->buf[jb->len] = '\0';
}
void ha_json_builder_comma(ha_json_builder_t *jb) {
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
ha_json_builder_raw(jb, ",");
}
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
}
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "{");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_object(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "}");
}
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
ha_json_builder_comma(jb);
ha_json_builder_raw(jb, "[");
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
jb->depth++;
}
void ha_json_builder_end_array(ha_json_builder_t *jb) {
jb->depth--;
ha_json_builder_raw(jb, "]");
}
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
ha_json_builder_comma(jb);
json_escape(jb, key);
ha_json_builder_raw(jb, ":");
}
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
json_escape(jb, val);
}
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", val);
ha_json_builder_raw(jb, buf);
}
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
ha_json_builder_raw(jb, val ? "true" : "false");
}
void ha_json_builder_add_null(ha_json_builder_t *jb) {
ha_json_builder_raw(jb, "null");
}
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
ha_json_builder_key(jb, key);
json_escape(jb, val);
}
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_int(jb, val);
}
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
ha_json_builder_key(jb, key);
ha_json_builder_add_bool(jb, val);
}
const char *ha_json_builder_str(ha_json_builder_t *jb) {
return jb->buf;
}
int ha_json_builder_len(ha_json_builder_t *jb) {
return jb->len;
}

View File

@ -1,107 +0,0 @@
#ifndef HA_JSON_H
#define HA_JSON_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== JSON 解析器(DOM 风格) ======================== */
typedef enum {
HA_JSON_NULL,
HA_JSON_BOOL,
HA_JSON_INT,
HA_JSON_STRING,
HA_JSON_ARRAY,
HA_JSON_OBJECT,
} ha_json_type_t;
typedef struct ha_json_node {
ha_json_type_t type;
union {
int bool_val;
int int_val;
char *str_val;
};
struct ha_json_node *next; /* linked list for array/object items */
struct ha_json_node *child; /* first child for array/object */
char *key; /* key for object members */
} ha_json_node_t;
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
ha_json_node_t *ha_json_parse(const char *str);
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
/* 从对象中按 key 获取 int 值,不存在返回 def */
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
/* 从对象中按 key 获取子节点,不存在返回 NULL */
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
/* 获取数组长度 */
int ha_json_array_len(const ha_json_node_t *arr);
/* 获取数组第 index 个元素,越界返回 NULL */
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
/* 释放整个 JSON 树 */
void ha_json_free(ha_json_node_t *root);
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
typedef struct {
char *buf;
int len;
int cap;
int depth;
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
} ha_json_builder_t;
/* 初始化构建器 */
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
/* 清空构建器 */
void ha_json_builder_reset(ha_json_builder_t *jb);
/* 基础写入 */
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
/* 逗号(自动判断是否需要加) */
void ha_json_builder_comma(ha_json_builder_t *jb);
/* 对象 */
void ha_json_builder_begin_object(ha_json_builder_t *jb);
void ha_json_builder_end_object(ha_json_builder_t *jb);
/* 数组 */
void ha_json_builder_begin_array(ha_json_builder_t *jb);
void ha_json_builder_end_array(ha_json_builder_t *jb);
/* 键名 */
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
/* 值 */
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
void ha_json_builder_add_null(ha_json_builder_t *jb);
/* 快捷方法:直接写 "key":"val" */
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
/* 获取当前构建的字符串指针 */
const char *ha_json_builder_str(ha_json_builder_t *jb);
/* 获取当前长度 */
int ha_json_builder_len(ha_json_builder_t *jb);
#ifdef __cplusplus
}
#endif
#endif /* HA_JSON_H */

View File

@ -1,628 +0,0 @@
#include "ha_remotedevice.h"
#include "ha_json.h"
#include "ha_ws.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define HA_VERSION "0.1.0"
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error);
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len);
/* ======================== 内部状态 ======================== */
typedef enum {
HA_STATE_INIT,
HA_STATE_DISCONNECTED,
HA_STATE_CONNECTING,
HA_STATE_WS_UPGRADING,
HA_STATE_HELLO_SENT,
HA_STATE_BIND_SENT,
HA_STATE_READY,
HA_STATE_STOPPING,
} ha_state_t;
/* 语音数据聚合缓冲区 */
typedef struct {
char req_id[128];
char kind[64];
char mime[64];
int total;
uint8_t *data;
int len;
int cap;
} ha_speech_accum_t;
struct ha_client {
ha_config_t config; /* 拷贝的配置 */
ha_state_t state;
int reconnect_cnt; /* 当前重连次数 */
ha_ws_t ws; /* WS 连接 */
/* JSON 构建缓冲区 */
char json_buf[4096];
ha_json_builder_t jb;
/* 语音数据聚合 */
ha_speech_accum_t speech;
};
/* ======================== 辅助函数 ======================== */
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
/* Base64 编码表 */
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
int needed = ((len + 2) / 3) * 4 + 1;
if (out_len < needed) {
if (out_len > 0) out[0] = '\0';
return needed;
}
int i = 0, j = 0;
while (i < len) {
int rem = len - i;
uint8_t b0 = data[i++];
uint8_t b1 = (rem > 1) ? data[i++] : 0;
uint8_t b2 = (rem > 2) ? data[i++] : 0;
out[j++] = b64[b0 >> 2];
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
}
out[j] = '\0';
return j;
}
/* ======================== JSON 构建辅助 ======================== */
static void json_init(ha_client_t *c) {
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
}
/* ======================== WS 发送 JSON ======================== */
static int ws_send_json(ha_client_t *c) {
return ha_ws_send_text(&c->ws, c->json_buf);
}
/* ======================== 协议消息构造 ======================== */
/* 构建 hello 消息 */
static int send_hello(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "hello");
ha_json_builder_key(&c->jb, "device");
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "name", c->config.device.name);
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
/* caps */
ha_json_builder_key(&c->jb, "caps");
ha_json_builder_begin_array(&c->jb);
if (c->config.device.caps) {
for (const char **p = c->config.device.caps; *p; p++) {
ha_json_builder_add_string(&c->jb, *p);
}
}
ha_json_builder_end_array(&c->jb);
/* info 可选 */
if (c->config.device.info_json && c->config.device.info_json[0]) {
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
}
ha_json_builder_end_object(&c->jb); /* device */
ha_json_builder_end_object(&c->jb); /* root */
return ws_send_json(c);
}
/* 构建 bind 消息 */
static int send_bind(ha_client_t *c) {
json_init(c);
ha_json_builder_begin_object(&c->jb);
ha_json_builder_string(&c->jb, "op", "bind");
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
ha_json_builder_string(&c->jb, "token", c->config.token);
ha_json_builder_end_object(&c->jb);
return ws_send_json(c);
}
/* ======================== 消息处理 ======================== */
/* 在 handlers 表中查找命令处理函数 */
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
if (!name || !c->config.handlers) return NULL;
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
if (strcmp(h->command, name) == 0) return h;
}
return NULL;
}
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *command = ha_json_get_string(msg, "command");
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
if (!req_id || !command) return;
if (!cmd_type) cmd_type = "homeagent";
const char *handler_name = NULL;
const char *args = command;
if (strcmp(cmd_type, "shell") == 0) {
handler_name = "shell";
/* args 保持为完整命令字符串 */
} else {
/* homeagent-* 命令:提取能力名作为 handler 名 */
const char *cap = command;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
const char *space = strchr(p, ' ');
if (space) {
args = space + 1;
/* handler_name 用静态缓冲区 */
static char name_buf[128];
int n = (int)(space - p);
if (n > 127) n = 127;
strncpy(name_buf, p, n);
name_buf[n] = '\0';
handler_name = name_buf;
} else {
handler_name = p;
args = "";
}
}
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
if (!def) {
ha_client_send_result(c, req_id, "error", NULL,
"unsupported command");
return;
}
/* 调用 handler,填写 result */
ha_cmd_result_t result;
memset(&result, 0, sizeof(result));
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
/* 自动回执 */
if (st != HA_OK) {
ha_client_send_result(c, req_id, "error", NULL,
result.error ? result.error : "handler failed");
return;
}
if (result.has_binary && result.binary_data && result.binary_len > 0) {
/* 二进制分块回传 */
ha_client_send_data_chunked(c, req_id,
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
result.binary_data, result.binary_len);
} else {
/* 文本回传 */
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
result.output, result.error);
}
}
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
const char *kind = ha_json_get_string(msg, "kind");
const char *mime = ha_json_get_string(msg, "mime");
if (!req_id) return;
/* 释放旧的聚合数据 */
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
c->speech.total = ha_json_get_int(msg, "total", 0);
}
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
const char *req_id = ha_json_get_string(msg, "req_id");
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
c->config.on_binary(c->speech.req_id, c->speech.kind,
c->speech.mime, c->speech.data,
c->speech.len, c->config.userdata);
}
free(c->speech.data);
memset(&c->speech, 0, sizeof(c->speech));
}
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
/* 解析 JSON */
char *tmp = (char *)malloc(len + 1);
if (!tmp) return;
memcpy(tmp, payload, len);
tmp[len] = '\0';
ha_json_node_t *root = ha_json_parse(tmp);
if (!root) { free(tmp); return; }
const char *op = ha_json_get_string(root, "op");
if (!op) { ha_json_free(root); free(tmp); return; }
switch (c->state) {
case HA_STATE_HELLO_SENT:
if (strcmp(op, "hello_ack") == 0) {
c->state = HA_STATE_BIND_SENT;
send_bind(c);
}
break;
case HA_STATE_BIND_SENT:
if (strcmp(op, "bind_ack") == 0) {
c->state = HA_STATE_READY;
if (c->config.on_state) {
c->config.on_state(1, c->config.userdata);
}
}
break;
case HA_STATE_READY:
if (strcmp(op, "cmd") == 0) {
handle_cmd_msg(c, root);
} else if (strcmp(op, "cmd_speech_start") == 0) {
handle_speech_start(c, root);
} else if (strcmp(op, "cmd_speech_end") == 0) {
handle_speech_end(c, root);
}
break;
default:
break;
}
ha_json_free(root);
free(tmp);
}
/* ======================== 连接管理 ======================== */
static int do_connect(ha_client_t *c) {
c->state = HA_STATE_CONNECTING;
c->reconnect_cnt++;
/* 解析 server 地址 */
char host[256] = {0};
uint16_t port = 9890;
const char *p = c->config.server;
if (!p) return -1;
/* 去掉 ws:// 前缀 */
if (strncmp(p, "ws://", 5) == 0) p += 5;
else if (strncmp(p, "wss://", 6) == 0) p += 6;
/* 提取 host:port */
const char *colon = strchr(p, ':');
const char *slash = strchr(p, '/');
if (colon && (!slash || colon < slash)) {
int host_len = (int)(colon - p);
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
port = (uint16_t)atoi(colon + 1);
} else {
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
memcpy(host, p, host_len);
host[host_len] = '\0';
}
c->state = HA_STATE_WS_UPGRADING;
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
"/api/v1/device/ws", c->config.token) != 0) {
c->state = HA_STATE_DISCONNECTED;
return -1;
}
/* 发送 hello */
c->state = HA_STATE_HELLO_SENT;
if (send_hello(c) != 0) {
ha_ws_close(&c->ws);
c->state = HA_STATE_DISCONNECTED;
return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
ha_client_t *ha_client_new(const ha_config_t *config) {
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
if (!c) return NULL;
memcpy(&c->config, config, sizeof(ha_config_t));
c->state = HA_STATE_INIT;
c->reconnect_cnt = 0;
return c;
}
ha_status_t ha_client_start(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
/* 默认心跳间隔 30 秒 */
if (client->config.ping_interval <= 0) {
client->config.ping_interval = 30;
}
if (do_connect(client) != 0) {
return HA_ERR_TRANSPORT;
}
/* 等待 bind_ack(最多 5 秒) */
int wait_ms = 5000;
int step = 50;
while (wait_ms > 0 && client->state != HA_STATE_READY) {
/* 处理一帧 */
ha_status_t st = ha_client_process(client);
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
return st;
}
if (client->state == HA_STATE_READY) return HA_OK;
/* 简单延时:靠 process 中的 recv 阻塞 */
wait_ms -= step;
}
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
}
ha_status_t ha_client_process(ha_client_t *client) {
if (!client) return HA_ERR_INVALID;
if (client->state == HA_STATE_STOPPING) {
return HA_ERR_DISCONNECTED;
}
/* 断线重连 */
if (client->state == HA_STATE_DISCONNECTED ||
client->state == HA_STATE_INIT) {
if (client->config.max_reconnect >= 0 &&
client->reconnect_cnt > client->config.max_reconnect) {
return HA_ERR_DISCONNECTED;
}
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
return HA_ERR_DISCONNECTED;
}
if (!client->ws.connected) {
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
/* 尝试读取一帧 */
const uint8_t *payload = NULL;
int len = 0;
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
if (ret < 0) {
/* 连接断开 */
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
switch (ret) {
case WS_OPCODE_TEXT:
handle_text_message(client, payload, len);
break;
case WS_OPCODE_BINARY:
/* 二进制帧:如果处于语音聚合状态,追加数据 */
if (client->speech.req_id[0] && payload) {
int new_len = client->speech.len + len;
if (new_len > client->speech.cap) {
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
while (new_cap < new_len) new_cap *= 2;
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
if (!nd) break;
client->speech.data = nd;
client->speech.cap = new_cap;
}
memcpy(client->speech.data + client->speech.len, payload, len);
client->speech.len = new_len;
}
break;
case WS_OPCODE_PING:
/* 回复 pong */
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
break;
case WS_OPCODE_PONG:
/* 收到 pong,忽略 */
break;
case WS_OPCODE_CLOSE:
client->state = HA_STATE_DISCONNECTED;
if (client->config.on_state) {
client->config.on_state(0, client->config.userdata);
}
return HA_ERR_DISCONNECTED;
}
return HA_OK;
}
void ha_client_send_result(ha_client_t *client, const char *req_id,
const char *status, const char *output,
const char *error) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_result");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
if (output && output[0]) {
ha_json_builder_string(&client->jb, "output", output);
}
if (error && error[0]) {
ha_json_builder_string(&client->jb, "error", error);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
const char *kind, const char *mime,
const uint8_t *data, int len) {
if (!client || client->state != HA_STATE_READY) return;
/* cmd_data_start */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_int(&client->jb, "chunk_size", 8192);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
/* 二进制帧分块发送 */
int off = 0;
while (off < len) {
int chunk = len - off;
if (chunk > 8192) chunk = 8192;
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
off += chunk;
}
/* cmd_data_end */
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
ha_json_builder_string(&client->jb, "req_id", req_id);
ha_json_builder_string(&client->jb, "status", "ok");
ha_json_builder_int(&client->jb, "total", len);
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_event(ha_client_t *client, const char *type,
const char *detail) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "event");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "type", type ? type : "");
if (detail && detail[0]) {
ha_json_builder_string(&client->jb, "payload", detail);
}
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_send_status(ha_client_t *client, const char *status) {
if (!client || client->state != HA_STATE_READY) return;
json_init(client);
ha_json_builder_begin_object(&client->jb);
ha_json_builder_string(&client->jb, "op", "status");
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
ha_json_builder_string(&client->jb, "status", status ? status : "online");
ha_json_builder_end_object(&client->jb);
ws_send_json(client);
}
void ha_client_stop(ha_client_t *client) {
if (!client) return;
client->state = HA_STATE_STOPPING;
if (client->ws.connected) {
ha_ws_close(&client->ws);
}
}
void ha_client_destroy(ha_client_t *client) {
if (!client) return;
ha_client_stop(client);
free(client->speech.data);
free(client);
}
/* ======================== 工具函数 ======================== */
void ha_cmd_parse_homeagent(const char *command, const char **cap,
const char **args) {
*cap = command;
*args = "";
if (!command) {
*cap = "";
return;
}
/* 去掉 homeagent- 前缀 */
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
/* 按空格分割 */
const char *space = strchr(p, ' ');
if (space) {
/* cap 指向 p 但不包含空格,需要临时拷贝 */
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
/* 用静态缓冲区或让调用方自己处理 */
static char cap_buf[256];
int n = (int)(space - p);
if (n > 255) n = 255;
strncpy(cap_buf, p, n);
cap_buf[n] = '\0';
*cap = cap_buf;
*args = space + 1;
} else {
static char cap_buf[256];
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
cap_buf[sizeof(cap_buf) - 1] = '\0';
*cap = cap_buf;
*args = "";
}
}
void ha_cmd_parse_json(const char *command, const char **action,
const char **json_str) {
*action = "";
*json_str = "";
if (!command) return;
const char *p = command;
if (strncmp(p, "homeagent-", 10) == 0) {
p += 10;
}
const char *brace = strchr(p, '{');
if (brace) {
static char act_buf[256];
int n = (int)(brace - p);
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
if (n > 255) n = 255;
strncpy(act_buf, p, n);
act_buf[n] = '\0';
*action = act_buf;
*json_str = brace;
} else {
static char act_buf[256];
strncpy(act_buf, p, sizeof(act_buf) - 1);
*action = act_buf;
}
}
const char *ha_version(void) {
return HA_VERSION;
}

View File

@ -1,325 +0,0 @@
#include "ha_ws.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* WS GUID 用于计算 Accept 值 */
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
/* ======================== Base64 编码(用于 WS key) ======================== */
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
int i = 0, j = 0;
uint8_t b[3];
while (i < in_len) {
int rem = in_len - i;
if (rem >= 3) {
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
out[j++] = b64t[b[2] & 0x3F];
} else if (rem == 2) {
b[0] = in[i++]; b[1] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
out[j++] = b64t[(b[1] & 0x0F) << 2];
out[j++] = '=';
} else {
b[0] = in[i++];
out[j++] = b64t[b[0] >> 2];
out[j++] = b64t[(b[0] & 0x03) << 4];
out[j++] = '=';
out[j++] = '=';
}
}
out[j] = '\0';
}
/* 简单伪随机数生成器 */
static uint32_t ws_rand_state = 0;
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
static uint32_t ws_rand(void) {
ws_rand_state = ws_rand_state * 1103515245 + 12345;
return ws_rand_state;
}
/* 生成 WS 握手 key */
static void ws_gen_key(char *out) {
uint8_t buf[16];
for (int i = 0; i < 16; i++) {
buf[i] = (uint8_t)(ws_rand() & 0xFF);
}
base64_encode_bin(buf, 16, out);
}
/* ======================== 从传输层接收指定字节数 ======================== */
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
int pos = 0;
while (pos < len) {
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
if (n <= 0) return -1;
pos += n;
}
return 0;
}
/* ======================== 发送 WS 帧 ======================== */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */
int hdr_len = 0;
hdr[0] = 0x80 | opcode; /* FIN + opcode */
hdr_len = 2;
int ext_len = 0;
if (len < 126) {
hdr[1] = 0x80 | len; /* mask bit + length */
} else if (len < 65536) {
hdr[1] = 0x80 | 126;
hdr_len = 4;
hdr[2] = (uint8_t)(len >> 8);
hdr[3] = (uint8_t)(len & 0xFF);
ext_len = 2;
} else {
hdr[1] = 0x80 | 127;
hdr_len = 10;
uint64_t l = (uint64_t)len;
for (int i = 8; i > 0; i--) {
hdr[1 + i] = (uint8_t)(l & 0xFF);
l >>= 8;
}
ext_len = 8;
}
/* mask key */
uint8_t mask_key[4];
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
int mask_off = 2 + ext_len;
hdr[mask_off] = mask_key[0];
hdr[mask_off + 1] = mask_key[1];
hdr[mask_off + 2] = mask_key[2];
hdr[mask_off + 3] = mask_key[3];
hdr_len = mask_off + 4;
/* 发送帧头 */
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
return -1;
}
/* 发送掩码后的 payload */
if (len > 0) {
/* 如果 payload 不大,用栈缓冲区 */
uint8_t stack_buf[2048];
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
if (!masked) return -1;
for (int i = 0; i < len; i++) {
masked[i] = payload[i] ^ mask_key[i & 3];
}
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
if (masked != stack_buf) free(masked);
if (ret != 0) return -1;
}
return 0;
}
/* ======================== 公共 API ======================== */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token) {
memset(ws, 0, sizeof(ha_ws_t));
ws->transport = transport;
ws->connected = 0;
strncpy(ws->host, host, sizeof(ws->host) - 1);
ws->port = port;
strncpy(ws->path, path, sizeof(ws->path) - 1);
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
/* 种子 */
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
/* 1. TCP 连接 */
if (transport->connect(transport->ctx, host, port) != 0) {
return -1;
}
/* 2. 发送 WS 升级请求 */
char key[32];
ws_gen_key(key);
char req[1024];
int n = snprintf(req, sizeof(req),
"GET %s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, host, (unsigned)port, key);
/* 如果 token 存在,加到路径参数中 */
if (token && token[0]) {
n = snprintf(req, sizeof(req),
"GET %s?token=%s HTTP/1.1\r\n"
"Host: %s:%u\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n",
path, token, host, (unsigned)port, key);
}
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
transport->close(transport->ctx);
return -1;
}
/* 3. 读取响应头(直到 \r\n\r\n) */
char resp[1024];
int resp_len = 0;
int found = 0;
while (resp_len < (int)sizeof(resp) - 1) {
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
if (n <= 0) {
transport->close(transport->ctx);
return -1;
}
resp_len += n;
resp[resp_len] = '\0';
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
found = 1;
break;
}
}
if (!found) {
transport->close(transport->ctx);
return -1;
}
/* 4. 检查状态码 101 */
if (strstr(resp, " 101 ") == NULL) {
transport->close(transport->ctx);
return -1;
}
ws->connected = 1;
return 0;
}
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
}
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
}
int ha_ws_send_ping(ha_ws_t *ws) {
if (!ws->connected) return -1;
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
}
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
if (!ws->connected) return -1;
*payload = NULL;
*len = 0;
/* 读取帧头:2 字节 */
uint8_t hdr[2];
if (recv_all(ws, hdr, 2) != 0) {
ws->connected = 0;
return -1;
}
int opcode = hdr[0] & 0x0F;
int masked = (hdr[1] & 0x80) ? 1 : 0;
uint64_t frame_len = hdr[1] & 0x7F;
if (frame_len == 126) {
uint8_t ext[2];
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
} else if (frame_len == 127) {
uint8_t ext[8];
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
frame_len = 0;
for (int i = 0; i < 8; i++) {
frame_len = (frame_len << 8) | ext[i];
}
}
/* 读取 mask key */
uint8_t mask_key[4] = {0, 0, 0, 0};
if (masked) {
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
}
/* 限制帧大小 */
if (frame_len > sizeof(ws->read_buf)) {
/* 帧太大,跳过 payload */
uint64_t skip = frame_len;
uint8_t tmp[256];
while (skip > 0) {
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
skip -= to_skip;
}
return -1; /* 返回错误,帧太大 */
}
/* 读取 payload */
if (frame_len > 0) {
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
ws->connected = 0;
return -1;
}
/* 如果有 mask,解掩码 */
if (masked) {
for (uint64_t i = 0; i < frame_len; i++) {
ws->read_buf[i] ^= mask_key[i & 3];
}
}
}
*payload = ws->read_buf;
*len = (int)frame_len;
switch (opcode) {
case WS_OPCODE_CLOSE:
ws->connected = 0;
return WS_OPCODE_CLOSE;
case WS_OPCODE_PING:
return WS_OPCODE_PING;
case WS_OPCODE_PONG:
return WS_OPCODE_PONG;
case WS_OPCODE_TEXT:
case WS_OPCODE_BINARY:
return opcode;
default:
return -1;
}
}
void ha_ws_close(ha_ws_t *ws) {
if (ws->connected) {
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
ws->connected = 0;
}
ws->transport->close(ws->transport->ctx);
}

View File

@ -1,62 +0,0 @@
#ifndef HA_WS_H
#define HA_WS_H
#include <stdint.h>
#include <stddef.h>
#include "../include/ha_remotedevice.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ======================== WS 帧类型 ======================== */
#define WS_OPCODE_CONTINUATION 0x0
#define WS_OPCODE_TEXT 0x1
#define WS_OPCODE_BINARY 0x2
#define WS_OPCODE_CLOSE 0x8
#define WS_OPCODE_PING 0x9
#define WS_OPCODE_PONG 0xA
/* ======================== WS 连接 ======================== */
typedef struct {
ha_transport_t *transport; /* 用户实现的传输层 */
int connected; /* 是否已连接 */
uint8_t read_buf[8192]; /* 读缓冲区 */
int read_pos; /* 缓冲区中有效数据起始位置 */
int read_len; /* 缓冲区中有效数据长度 */
char host[256]; /* 缓存目标地址 */
uint16_t port;
char path[256];
char token[256];
} ha_ws_t;
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
const char *host, uint16_t port,
const char *path, const char *token);
/* 发送文本帧。返回 0 成功。 */
int ha_ws_send_text(ha_ws_t *ws, const char *text);
/* 发送二进制帧。返回 0 成功。 */
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
/* 发送 ping。返回 0 成功。 */
int ha_ws_send_ping(ha_ws_t *ws);
/* 读取一帧。
* 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
/* 发送原始 WS 帧(内部使用,用于回复 ping) */
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
/* 关闭 WS 连接 */
void ha_ws_close(ha_ws_t *ws);
#ifdef __cplusplus
}
#endif
#endif /* HA_WS_H */

File diff suppressed because it is too large Load Diff