mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 07:43:58 +00:00
feat: complete HomeAgent architecture v2
- IO abstraction layer with OutputChannel routing and capability validation - Three-layer memory (Context-Document-Graph) with TF-IDF relevance pruning - OneBot V11 QQ protocol plugin with Reverse WebSocket client - Plugin system with hot-reload (SKILL.md + native factories) - Knowledge system with TF-IDF vector indexing - Personality system (personal.md) - Text memory (JSONL with rotation) - Change tracker (overlayfs) with rollback - Lua adapter VM - Design document (DESIGN.md) Module: gitcode.com/JianFeeeee/HomeAgent
This commit is contained in:
244
internal/onebot/client.go
Normal file
244
internal/onebot/client.go
Normal file
@ -0,0 +1,244 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// EventHandler 处理 OneBot 推送的事件
|
||||
type EventHandler func(event *Event)
|
||||
|
||||
// Client 是 OneBot 反向 WebSocket 客户端
|
||||
// 连接到 OneBot 兼容前端(如 go-cqhttp、Lagrange)的 WS 地址
|
||||
type Client struct {
|
||||
url string
|
||||
accessToken string
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
|
||||
eventHandler EventHandler
|
||||
|
||||
// 等待响应的 Action 调用
|
||||
pending sync.Map
|
||||
echoCount int64
|
||||
|
||||
connected atomic.Bool
|
||||
}
|
||||
|
||||
// NewClient 创建 OneBot 反向 WS 客户端
|
||||
// wsURL: ws://host:port/onebot/v11/ws
|
||||
func NewClient(wsURL, accessToken string) *Client {
|
||||
return &Client{
|
||||
url: wsURL,
|
||||
accessToken: accessToken,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetEventHandler 注册事件处理函数
|
||||
func (c *Client) SetEventHandler(h EventHandler) {
|
||||
c.eventHandler = h
|
||||
}
|
||||
|
||||
// Connected 返回是否已连接
|
||||
func (c *Client) Connected() bool {
|
||||
return c.connected.Load()
|
||||
}
|
||||
|
||||
// Connect 连接到 OneBot 前端(阻塞直到连接建立或失败)
|
||||
func (c *Client) Connect() error {
|
||||
header := make(map[string][]string)
|
||||
if c.accessToken != "" {
|
||||
header["Authorization"] = []string{"Bearer " + c.accessToken}
|
||||
}
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(c.url, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
c.connected.Store(true)
|
||||
log.Printf("[onebot] connected to %s", c.url)
|
||||
|
||||
go c.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconnect 自动重连
|
||||
func (c *Client) reconnect() {
|
||||
c.connected.Store(false)
|
||||
backoff := time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
log.Printf("[onebot] reconnecting in %v...", backoff)
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("[onebot] reconnect failed: %v, retry", err)
|
||||
backoff *= 2
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Printf("[onebot] reconnected")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
defer c.connected.Store(false)
|
||||
defer c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
defer func() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[onebot] read error: %v", err)
|
||||
go c.reconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试解析为 ActionResponse(有 echo 字段)
|
||||
var resp ActionResponse
|
||||
if err := json.Unmarshal(message, &resp); err == nil && resp.Echo != "" {
|
||||
if ch, ok := c.pending.Load(resp.Echo); ok {
|
||||
ch.(chan *ActionResponse) <- &resp
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析为 Event
|
||||
var evt Event
|
||||
if err := json.Unmarshal(message, &evt); err != nil {
|
||||
log.Printf("[onebot] parse error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if c.eventHandler != nil {
|
||||
c.eventHandler(&evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendAction 发送一个 OneBot API 请求并等待响应
|
||||
func (c *Client) SendAction(action string, params map[string]interface{}, timeout time.Duration) (*ActionResponse, error) {
|
||||
echo := atomic.AddInt64(&c.echoCount, 1)
|
||||
echoStr := formatInt64(echo)
|
||||
|
||||
msg := Action{
|
||||
Action: action,
|
||||
Params: params,
|
||||
Echo: echoStr,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan *ActionResponse, 1)
|
||||
c.pending.Store(echoStr, ch)
|
||||
defer c.pending.Delete(echoStr)
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn == nil {
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
err = c.conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if resp.Status == "failed" {
|
||||
return resp, fmt.Errorf("onebot action %s failed: retcode=%d", action, resp.RetCode)
|
||||
}
|
||||
return resp, nil
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("onebot action %s timeout", action)
|
||||
}
|
||||
}
|
||||
|
||||
// SendPrivateMessage 发送私聊消息(便捷方法)
|
||||
func (c *Client) SendPrivateMessage(userID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
"auto_escape": autoEscape,
|
||||
}
|
||||
return c.SendAction("send_private_msg", params, 0)
|
||||
}
|
||||
|
||||
// SendGroupMessage 发送群消息(便捷方法)
|
||||
func (c *Client) SendGroupMessage(groupID int64, message interface{}, autoEscape bool) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"message": message,
|
||||
"auto_escape": autoEscape,
|
||||
}
|
||||
return c.SendAction("send_group_msg", params, 0)
|
||||
}
|
||||
|
||||
// GetLoginInfo 获取登录号信息
|
||||
func (c *Client) GetLoginInfo() (*ActionResponse, error) {
|
||||
return c.SendAction("get_login_info", nil, 0)
|
||||
}
|
||||
|
||||
// GetGroupMemberInfo 获取群成员信息
|
||||
func (c *Client) GetGroupMemberInfo(groupID, userID int64) (*ActionResponse, error) {
|
||||
return c.SendAction("get_group_member_info", map[string]interface{}{
|
||||
"group_id": groupID,
|
||||
"user_id": userID,
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// GetGroupList 获取群列表
|
||||
func (c *Client) GetGroupList() (*ActionResponse, error) {
|
||||
return c.SendAction("get_group_list", nil, 0)
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *Client) Close() error {
|
||||
close(c.done)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatInt64(n int64) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
222
internal/onebot/device.go
Normal file
222
internal/onebot/device.go
Normal file
@ -0,0 +1,222 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// Device 将 OneBot 客户端包装为 IO 抽象层的 Device
|
||||
// 作为 QQ 通道与 HomeAgent 之间的桥梁
|
||||
type Device struct {
|
||||
name string
|
||||
desc string
|
||||
client *Client
|
||||
iom *agentIO.IOManager
|
||||
}
|
||||
|
||||
// NewDevice 创建 OneBot IO 设备
|
||||
// name: 设备名称(如 "qq")
|
||||
// wsURL: OneBot 前端 WebSocket 地址(如 "ws://127.0.0.1:6700")
|
||||
// accessToken: OneBot 鉴权令牌(可选)
|
||||
func NewDevice(name, wsURL, accessToken string, iom *agentIO.IOManager) *Device {
|
||||
return &Device{
|
||||
name: name,
|
||||
desc: fmt.Sprintf("OneBot 标准 QQ 通道 (%s)", wsURL),
|
||||
client: NewClient(wsURL, accessToken),
|
||||
iom: iom,
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回设备名称
|
||||
func (d *Device) Name() string { return d.name }
|
||||
|
||||
// Type 返回设备类型(双向 IO)
|
||||
func (d *Device) Type() agentIO.DeviceType { return agentIO.DeviceIO }
|
||||
|
||||
// Description 返回设备描述
|
||||
func (d *Device) Description() string { return d.desc }
|
||||
|
||||
// OutputCapabilities 返回输出能力(文本+文件+图片)
|
||||
func (d *Device) OutputCapabilities() agentIO.OutputCapability {
|
||||
return agentIO.CapText | agentIO.CapFile | agentIO.CapImage
|
||||
}
|
||||
|
||||
// Tools 返回 OneBot 标准 API 的工具定义
|
||||
// AI 可以通过这些工具调用 OneBot 功能
|
||||
func (d *Device) Tools() []agentIO.ToolDef {
|
||||
return []agentIO.ToolDef{
|
||||
{
|
||||
Name: d.name + "_send_private_msg",
|
||||
Description: "发送 QQ 私聊消息",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "目标 QQ 号"},
|
||||
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码,如 [CQ:image,file=xxx.jpg])"},
|
||||
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送(不解析 CQ 码)"},
|
||||
},
|
||||
"required": []string{"user_id", "message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: d.name + "_send_group_msg",
|
||||
Description: "发送 QQ 群消息",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "目标群号"},
|
||||
"message": map[string]interface{}{"type": "string", "description": "消息内容(支持 CQ 码)"},
|
||||
"auto_escape": map[string]interface{}{"type": "boolean", "description": "是否作为纯文本发送"},
|
||||
},
|
||||
"required": []string{"group_id", "message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: d.name + "_get_group_member_info",
|
||||
Description: "获取 QQ 群成员信息",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"group_id": map[string]interface{}{"type": "integer", "description": "群号"},
|
||||
"user_id": map[string]interface{}{"type": "integer", "description": "QQ 号"},
|
||||
},
|
||||
"required": []string{"group_id", "user_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: d.name + "_get_group_list",
|
||||
Description: "获取 QQ 群列表",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 执行 OneBot 工具调用
|
||||
func (d *Device) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if !d.client.Connected() {
|
||||
return nil, fmt.Errorf("onebot 未连接到前端")
|
||||
}
|
||||
|
||||
// 去掉名称前缀以匹配方法名
|
||||
method := strings.TrimPrefix(tool, d.name+"_")
|
||||
|
||||
switch method {
|
||||
case "send_private_msg":
|
||||
userID, _ := toInt64(args["user_id"])
|
||||
message, _ := args["message"].(string)
|
||||
autoEscape, _ := args["auto_escape"].(bool)
|
||||
return d.client.SendPrivateMessage(userID, message, autoEscape)
|
||||
|
||||
case "send_group_msg":
|
||||
groupID, _ := toInt64(args["group_id"])
|
||||
message, _ := args["message"].(string)
|
||||
autoEscape, _ := args["auto_escape"].(bool)
|
||||
return d.client.SendGroupMessage(groupID, message, autoEscape)
|
||||
|
||||
case "get_group_member_info":
|
||||
groupID, _ := toInt64(args["group_id"])
|
||||
userID, _ := toInt64(args["user_id"])
|
||||
return d.client.GetGroupMemberInfo(groupID, userID)
|
||||
|
||||
case "get_group_list":
|
||||
return d.client.GetGroupList()
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown onebot tool: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
// Start 连接到 OneBot 前端
|
||||
func (d *Device) Start() error {
|
||||
// 非阻塞连接
|
||||
go func() {
|
||||
if err := d.client.Connect(); err != nil {
|
||||
log.Printf("[onebot] %s initial connect failed, will retry: %v", d.name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 注册事件处理:OneBot 事件 → IO InputEvent
|
||||
d.client.SetEventHandler(func(evt *Event) {
|
||||
d.handleEvent(evt)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 断开连接
|
||||
func (d *Device) Stop() error {
|
||||
return d.client.Close()
|
||||
}
|
||||
|
||||
// handleEvent 将 OneBot 事件转换为 IO InputEvent
|
||||
func (d *Device) handleEvent(evt *Event) {
|
||||
if d.iom == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch evt.PostType {
|
||||
case "message":
|
||||
var text string
|
||||
if evt.RawMessage != "" {
|
||||
text = evt.RawMessage
|
||||
} else if s, ok := evt.Message.(string); ok {
|
||||
text = s
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 构造输入源标识
|
||||
source := d.name
|
||||
payload := map[string]interface{}{
|
||||
"content": text,
|
||||
"source": source,
|
||||
"user_id": evt.UserID,
|
||||
"sender": evt.Sender,
|
||||
}
|
||||
if evt.MessageType == "group" {
|
||||
payload["group_id"] = evt.GroupID
|
||||
payload["label"] = fmt.Sprintf("group:%d:%d", evt.GroupID, evt.UserID)
|
||||
} else {
|
||||
payload["label"] = fmt.Sprintf("private:%d", evt.UserID)
|
||||
}
|
||||
|
||||
d.iom.InjectText(source, text)
|
||||
// 同时注册输出路由:QQ 消息默认回复到 QQ 通道
|
||||
d.iom.RegisterOutputRoute(source, d.name)
|
||||
|
||||
case "notice":
|
||||
log.Printf("[onebot] notice from %s: type=%s", d.name, evt.NoticeType)
|
||||
|
||||
case "request":
|
||||
log.Printf("[onebot] request from %s: type=%s flag=%s", d.name, evt.RequestType, evt.Flag)
|
||||
|
||||
case "meta_event":
|
||||
if evt.MetaEventType == "heartbeat" {
|
||||
log.Printf("[onebot] %s heartbeat: online=%v", d.name, evt.Status != nil && evt.Status.Online)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toInt64(v interface{}) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n, true
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int:
|
||||
return int64(n), true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
return i, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
94
internal/onebot/types.go
Normal file
94
internal/onebot/types.go
Normal file
@ -0,0 +1,94 @@
|
||||
// Package onebot 实现 OneBot V11 标准协议(Reverse WebSocket 通信)
|
||||
// 参考: https://github.com/botuniverse/onebot-11
|
||||
package onebot
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Action 是 OneBot 标准 API 请求
|
||||
type Action struct {
|
||||
Action string `json:"action"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
Echo string `json:"echo,omitempty"`
|
||||
}
|
||||
|
||||
// ActionResponse 是 OneBot 标准 API 响应
|
||||
type ActionResponse struct {
|
||||
Status string `json:"status"`
|
||||
RetCode int `json:"retcode"`
|
||||
Data interface{} `json:"data"`
|
||||
Echo string `json:"echo,omitempty"`
|
||||
}
|
||||
|
||||
// Event 是 OneBot 推送的事件
|
||||
type Event struct {
|
||||
Time int64 `json:"time"`
|
||||
SelfID int64 `json:"self_id"`
|
||||
PostType string `json:"post_type"` // message, notice, request, meta_event
|
||||
DetailType string `json:"-"`
|
||||
|
||||
// 消息事件字段
|
||||
MessageType string `json:"message_type,omitempty"` // private, group
|
||||
SubType string `json:"sub_type,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
GroupID int64 `json:"group_id,omitempty"`
|
||||
Message interface{} `json:"message,omitempty"` // string 或 []MessageSegment
|
||||
RawMessage string `json:"raw_message,omitempty"`
|
||||
Font int `json:"font,omitempty"`
|
||||
Sender *Sender `json:"sender,omitempty"`
|
||||
|
||||
// 通知事件字段
|
||||
NoticeType string `json:"notice_type,omitempty"`
|
||||
|
||||
// 请求事件字段
|
||||
RequestType string `json:"request_type,omitempty"`
|
||||
Flag string `json:"flag,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
|
||||
// 元事件字段
|
||||
MetaEventType string `json:"meta_event_type,omitempty"`
|
||||
Interval int64 `json:"interval,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Sex string `json:"sex,omitempty"`
|
||||
Age int `json:"age,omitempty"`
|
||||
Card string `json:"card,omitempty"` // 群名片
|
||||
Area string `json:"area,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Role string `json:"role,omitempty"` // owner, admin, member
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
AppInitialized bool `json:"app_initialized"`
|
||||
AppEnabled bool `json:"app_enabled"`
|
||||
PluginsGood bool `json:"plugins_good"`
|
||||
AppGood bool `json:"app_good"`
|
||||
Online bool `json:"online"`
|
||||
Good bool `json:"good"`
|
||||
}
|
||||
|
||||
// MessageSegment 表示 OneBot 消息段(数组格式)
|
||||
type MessageSegment struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// MessageText 快速构造纯文本消息段
|
||||
func MessageText(text string) MessageSegment {
|
||||
return MessageSegment{Type: "text", Data: map[string]string{"text": text}}
|
||||
}
|
||||
|
||||
// MessageImage 构造图片消息段
|
||||
func MessageImage(file string) MessageSegment {
|
||||
return MessageSegment{Type: "image", Data: map[string]string{"file": file}}
|
||||
}
|
||||
|
||||
// MessageAt 构造 @ 消息段
|
||||
func MessageAt(userID int64) MessageSegment {
|
||||
return MessageSegment{Type: "at", Data: map[string]string{"qq": fmt.Sprintf("%d", userID)}}
|
||||
}
|
||||
Reference in New Issue
Block a user