Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ EasybotCE 的主程序,使用 Go 提供管理 API、Vue 3 提供网页管理
| --- | --- |
| 管理台、配置管理、登录认证、持久化运行日志 | 已实现 |
| OneBot 11 正向 WebSocket 适配器 | 已实现连接、消息收发接口、状态管理与断线重连;已通过协议模拟器验证,真实账号仍待验收 |
| QQ 官方 Bot 适配器 | 已实现官方 Gateway 接收、QQBot token、群/C2C 消息收发和频道列表;真实账号仍待验收 |
| MySQL、PostgreSQL 存储 | 已提供实现和集成测试;具体版本以对应 CI 验证结果为准 |
| Minecraft 桥接、消息转发、账号绑定 | 尚未实现 |
| JavaScript 插件执行、AI 对话、图片模板渲染 | 尚未实现;已有配置入口不代表运行能力可用 |
Expand Down
14 changes: 11 additions & 3 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,22 @@ MySQL 连接格式为 `mysql://user:password@host:3306/database`;PostgreSQL

管理台和持久化 API 已覆盖适配器、服务器、玩家、账号、群组、允许与拒绝名单、命令定义、AI 服务配置、图片模板目录、JavaScript 插件目录和实例设置。配置数据通过统一记录表保存,启动时执行带版本的数据库迁移;存在配置入口不代表相应运行时已经实现。

OneBot 11 是当前唯一可运行的适配器,使用正向 WebSocket,支持 Bearer 认证、身份握手、群聊及私聊事件归一化、消息发送和群列表调用、生命周期控制、状态及能力查询、有限退避重连。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。
OneBot 11 和 QQ 官方 Bot 是当前可运行的适配器。OneBot 11 使用正向 WebSocket;QQ Bot 使用官方 Gateway,按 `AppID`/`AppSecret` 获取 `QQBot` token,并通过官方 OpenAPI 发送群/C2C 消息和读取频道列表。两者都支持身份握手、事件归一化、生命周期控制、状态及能力查询和有限退避重连。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。

QQ Bot 配置示例:

```json
{"AppID":"你的机器人 AppID","AppSecret":"你的机器人 AppSecret","Intents":335546371}
```

`APIBase`、`GatewayURL` 和 `TokenURL` 仅用于沙箱或协议模拟测试,默认分别使用 QQ 官方 OpenAPI、Gateway 和 token 地址。QQ 官方 Gateway/WebSocket 链路存在逐步下线及 Webhook 灰度安排,生产使用前应以开放平台当前控制台能力为准;本实现暂不声称支持官方 Webhook 回调。

运行日志保留有界内存缓冲,并通过有界异步队列写入 `runtime_logs`。`/api/logs` 支持日志级别、组件、UTC 时间范围、关键词和分页筛选;`/api/logs/status` 提供保留策略和写入失败计数。日志页面可设置持久化开关、保留天数和最大条数,每小时清理一次。日志会遮蔽常见令牌、密码、数据库连接地址及验证码,默认不记录消息正文。

以下能力仍待完成或验证:

- **真实环境验证**:OneBot 11 已通过协议模拟器的认证、握手、事件接收、发送和重连测试;真实账号及平台限流行为尚未验证。
- **尚未实现**:Milky、Telegram、OpenQQ 适配器运行时,Minecraft 桥接及配套插件,消息转发和账号绑定,JavaScript 插件执行、AI 对话、浏览器图片渲染、模板市场、PlaceholderAPI 与统计求值、平台安装器及 Docker 打包。
- **真实环境验证**:OneBot 11 和 QQ Bot 已通过本地协议/API 模拟测试;真实账号、QQ 官方限流、权限和 Gateway 长连接稳定性尚未验证。
- **尚未实现**:QQ 官方 Webhook 回调、Milky、Telegram、OpenQQ 适配器运行时,Minecraft 桥接及配套插件,消息转发和账号绑定,JavaScript 插件执行、AI 对话、浏览器图片渲染、模板市场、PlaceholderAPI 与统计求值、平台安装器及 Docker 打包。
- **参考边界**:原版 NativeAOT 可执行文件无法通过现有反编译资料可靠重建,独立的 EasyBot-Recovered 仓库仅作为管理 API、模块名称和行为的对照依据。

## 测试与协作
Expand Down
4 changes: 2 additions & 2 deletions dev/cmd/easybot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func run() (code int) {
logger.Info(fmt.Sprintf("[AdapterManager] 发现 %d 个适配器配置", len(adapterConfigs)))
}
adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) {
logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")")
logs.Add("info", "adapter", "Adapter event received: "+event.Type+" (adapter "+id+")")
})
if err := adapters.StartEnabled(ctx); err != nil {
logger.Error("[AdapterManager] 适配器初始化失败", "error", err)
Expand All @@ -152,7 +152,7 @@ func run() (code int) {
} else if state.State == onebot11.StateFailed {
logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 加载失败: %s", item.Name, runtime.Redact(state.LastError)))
} else if state.State == onebot11.StateStopped {
logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 未加载,当前仅支持 OneBot 11", item.Name))
logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 未加载,当前支持的适配器运行时均未建立连接", item.Name))
} else {
logger.Info(fmt.Sprintf("[AdapterManager] 已加载适配器 %q,连接状态以运行时结果为准", item.Name))
}
Expand Down
94 changes: 75 additions & 19 deletions dev/internal/adapter/onebot11/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,21 @@ type Manager struct {
cancel context.CancelFunc
onEvent func(string, Event)
mu sync.RWMutex
items map[string]*Runtime
items map[string]runtimeAdapter
failures map[string]Status
}

func NewManager(parent context.Context, db *store.Store, onEvent func(string, Event)) *Manager {
ctx, cancel := context.WithCancel(parent)
return &Manager{store: db, ctx: ctx, cancel: cancel, onEvent: onEvent, items: make(map[string]*Runtime), failures: make(map[string]Status)}
return &Manager{store: db, ctx: ctx, cancel: cancel, onEvent: onEvent, items: make(map[string]runtimeAdapter), failures: make(map[string]Status)}
}

type runtimeAdapter interface {
Start(context.Context) error
Stop(context.Context) error
Status() Status
Send(context.Context, string, string, string) (string, error)
Groups(context.Context) ([]map[string]any, error)
}

func (m *Manager) StartEnabled(ctx context.Context) error {
Expand All @@ -43,7 +51,7 @@ func (m *Manager) StartEnabled(ctx context.Context) error {
}
continue
}
if adapterType != "Onebot11" || !item.Enabled {
if (adapterType != "Onebot11" && adapterType != "QQBot") || !item.Enabled {
continue
}
if err := m.Start(ctx, item.ID); err != nil {
Expand All @@ -54,15 +62,26 @@ func (m *Manager) StartEnabled(ctx context.Context) error {
}

func (m *Manager) Test(ctx context.Context, id string) (map[string]any, error) {
_, config, err := m.loadConfig(ctx, id)
adapterType, raw, err := m.loadRawConfig(ctx, id)
if err != nil {
return nil, err
}
if adapterType == "QQBot" {
config, parseErr := ParseQQConfig(raw)
if parseErr != nil {
return nil, parseErr
}
return TestQQConnection(ctx, config)
}
config, parseErr := ParseConfig(raw)
if parseErr != nil {
return nil, parseErr
}
return TestConnection(ctx, config)
}

func (m *Manager) Start(ctx context.Context, id string) error {
item, config, err := m.loadConfig(ctx, id)
item, adapterType, raw, err := m.loadRawRecord(ctx, id)
if err != nil {
m.mu.Lock()
m.failures[id] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)}
Expand All @@ -72,6 +91,28 @@ func (m *Manager) Start(ctx context.Context, id string) error {
if !item.Enabled {
return fmt.Errorf("adapter is disabled")
}
var runtime runtimeAdapter
if adapterType == "QQBot" {
config, parseErr := ParseQQConfig(raw)
if parseErr != nil {
return parseErr
}
runtime = NewQQRuntime(id, config, func(event Event) {
if m.onEvent != nil {
m.onEvent(id, event)
}
})
} else {
config, parseErr := ParseConfig(raw)
if parseErr != nil {
return parseErr
}
runtime = NewRuntime(id, config, func(event Event) {
if m.onEvent != nil {
m.onEvent(id, event)
}
})
}
m.mu.Lock()
if existing := m.items[id]; existing != nil {
state := existing.Status().State
Expand All @@ -80,11 +121,6 @@ func (m *Manager) Start(ctx context.Context, id string) error {
return nil
}
}
runtime := NewRuntime(id, config, func(event Event) {
if m.onEvent != nil {
m.onEvent(id, event)
}
})
m.items[id] = runtime
delete(m.failures, id)
m.mu.Unlock()
Expand Down Expand Up @@ -124,9 +160,13 @@ func (m *Manager) Status(id string) (Status, error) {
}

func (m *Manager) Capabilities(ctx context.Context, id string) ([]string, error) {
if _, _, err := m.loadConfig(ctx, id); err != nil {
adapterType, _, err := m.loadRawConfig(ctx, id)
if err != nil {
return nil, err
}
if adapterType == "QQBot" {
return []string{"group_message_receive", "private_message_receive", "group_message_send", "private_message_send", "group_list", "heartbeat"}, nil
}
return append([]string(nil), Capabilities...), nil
}

Expand All @@ -149,7 +189,7 @@ func (m *Manager) Groups(ctx context.Context, id string) ([]map[string]any, erro
func (m *Manager) Shutdown(ctx context.Context) {
m.cancel()
m.mu.RLock()
runtimes := make([]*Runtime, 0, len(m.items))
runtimes := make([]runtimeAdapter, 0, len(m.items))
for _, runtime := range m.items {
runtimes = append(runtimes, runtime)
}
Expand All @@ -165,28 +205,44 @@ func (m *Manager) RuntimeCount() int {
return len(m.items)
}

func (m *Manager) get(id string) *Runtime {
func (m *Manager) get(id string) runtimeAdapter {
m.mu.RLock()
defer m.mu.RUnlock()
return m.items[id]
}

func (m *Manager) loadConfig(ctx context.Context, id string) (store.Record, Config, error) {
item, err := m.getRecord(ctx, id)
if err != nil {
return store.Record{}, Config{}, err
}
adapterType, raw, err := parseRecord(item)
item, adapterType, raw, err := m.loadRawRecord(ctx, id)
if err != nil {
return store.Record{}, Config{}, err
}
if adapterType != "Onebot11" {
return store.Record{}, Config{}, &APIError{Code: "adapter_type_unsupported", Message: "当前运行时仅支持 OneBot 11", Retryable: false}
return store.Record{}, Config{}, &APIError{Code: "adapter_type_unsupported", Message: "该适配器不是 OneBot 11", Retryable: false}
}
config, err := ParseConfig(raw)
return item, config, err
}

func (m *Manager) loadRawConfig(ctx context.Context, id string) (string, json.RawMessage, error) {
_, adapterType, raw, err := m.loadRawRecord(ctx, id)
return adapterType, raw, err
}

func (m *Manager) loadRawRecord(ctx context.Context, id string) (store.Record, string, json.RawMessage, error) {
item, err := m.getRecord(ctx, id)
if err != nil {
return store.Record{}, "", nil, err
}
adapterType, raw, err := parseRecord(item)
if err != nil {
return store.Record{}, "", nil, err
}
if adapterType != "Onebot11" && adapterType != "QQBot" {
return store.Record{}, "", nil, &APIError{Code: "adapter_type_unsupported", Message: "当前运行时不支持该适配器", Retryable: false}
}
return item, adapterType, raw, nil
}

func (m *Manager) getRecord(ctx context.Context, id string) (store.Record, error) {
items, err := m.store.List(ctx, "adapters")
if err != nil {
Expand Down
Loading
Loading