Skip to content
Merged
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
4 changes: 3 additions & 1 deletion REMAINING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ P0 是产品开始真实使用的必要闭环。P1 需在 P0 稳定后进入公
- 运行状态能区别“未启动、连接中、运行中、退避重连、停止中、异常”。
- 当前接口中返回 501 的启动/停止行为被真实运行时替代。

**状态:未实现协议运行时。** 当前仓库存在适配器配置 API/类型;README 说明协议连接未实现,启动接口有意返回 501。
**状态:部分完成,真实账号待验收。** 已实现 OneBot 11 正向 WebSocket 适配器、Bearer 鉴权与身份探测、状态/能力/启停/重启/发送/群列表 API、断线退避重连、启动恢复及凭据 API 脱敏。WebSocket 模拟测试覆盖鉴权、身份握手、事件接收、消息发送和断线重连。当前尚无真实 OneBot 测试账号,真实平台收发、限流响应与平台差异仍待验收;Milky、Telegram、OpenQQ 只保留类型目录,不表示已支持。

## 5. P0:Minecraft 桥接端与服务器连接

Expand Down Expand Up @@ -532,6 +532,8 @@ GET /api/adapter/{id}/capabilities

验收:模拟器覆盖握手/鉴权/断线/限流/发送;单适配器故障隔离;至少经授权使用真实测试账号完成收发验证。

实现边界:运行时当前能区分未启动、启动中、已连接、退避重连、停止中和失败配置;平台消息正文不写入运行日志。模拟器已覆盖握手、Bearer token、事件、发送和断线恢复;尚未实现平台限流语义,也未进行真实账号收发,因此 WP-03 保持部分完成。

### WP-04:Minecraft 桥接协议与首个服务端实现

前置决策:确定 Paper/Bukkit、Velocity、Mod 或 MCDR 中一种和支持版本。未确定时只输出协议评估,不同时实现多个桥接端。
Expand Down
4 changes: 2 additions & 2 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ The API listens on `http://127.0.0.1:26990`; Vite serves the UI on its displayed

## Current implementation

The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed.
The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. The first active adapter runtime is OneBot 11 over forward WebSocket, with Bearer authentication, identity handshake, group/private message event normalization, send/group-list calls, lifecycle controls, status and capabilities APIs, and bounded reconnect backoff. Enabled OneBot 11 configurations are restored at startup. Adapter credentials are omitted from adapter API responses and blank edits preserve a saved token. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed.

Protocol connections (OneBot 11, Milky, Telegram, OpenQQ), game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging are not implemented yet. Adapter start intentionally returns HTTP 501. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; `../decompiled` is used as behavioral reference for its WebUI API and module names.
Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; `../decompiled` is used as behavioral reference for its WebUI API and module names.

## Verify

Expand Down
14 changes: 11 additions & 3 deletions dev/cmd/easybot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"syscall"
"time"

"easybot/internal/adapter/onebot11"
"easybot/internal/config"
"easybot/internal/runtime"
"easybot/internal/store"
Expand All @@ -33,6 +34,8 @@ func main() {
logger.Error("listener configuration rejected", "address", address, "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logs := runtime.NewLogBuffer(500)
logSettings, err := db.GetLogSettings(context.Background())
if err != nil {
Expand All @@ -49,15 +52,19 @@ func main() {
}()
logs.Add("info", "runtime", "EasyBot API started")
go maintainLogs(db, logger)
adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) {
logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")")
})
if err := adapters.StartEnabled(ctx); err != nil {
logger.Error("adapter restoration failed", "error", err)
}
server := &http.Server{
Addr: address,
Handler: web.New(db, logger, logs, writer),
Handler: web.NewWithAdapterManager(db, logger, logs, adapters, writer),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
logger.Info("EasyBot web service listening", "address", address, "database", db.Dialect())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serveErr := make(chan error, 1)
go func() {
if cert != "" {
Expand All @@ -70,6 +77,7 @@ func main() {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
adapters.Shutdown(shutdownCtx)
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
Expand Down
1 change: 1 addition & 0 deletions dev/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.37.0
modernc.org/sqlite v1.39.1
Expand Down
2 changes: 2 additions & 0 deletions dev/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
Expand Down
230 changes: 230 additions & 0 deletions dev/internal/adapter/onebot11/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package onebot11

import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"

"easybot/internal/store"
)

var ErrAdapterNotFound = errors.New("adapter not found")

type Manager struct {
store *store.Store
ctx context.Context
cancel context.CancelFunc
onEvent func(string, Event)
mu sync.RWMutex
items map[string]*Runtime
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)}
}

func (m *Manager) StartEnabled(ctx context.Context) error {
items, err := m.store.List(ctx, "adapters")
if err != nil {
return err
}
for _, item := range items {
adapterType, _, err := parseRecord(item)
if err != nil {
if item.Enabled {
m.mu.Lock()
m.failures[item.ID] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)}
m.mu.Unlock()
}
continue
}
if adapterType != "Onebot11" || !item.Enabled {
continue
}
if err := m.Start(ctx, item.ID); err != nil {
continue
}
}
return nil
}

func (m *Manager) Test(ctx context.Context, id string) (map[string]any, error) {
_, config, err := m.loadConfig(ctx, id)
if err != nil {
return nil, err
}
return TestConnection(ctx, config)
}

func (m *Manager) Start(ctx context.Context, id string) error {
item, config, err := m.loadConfig(ctx, id)
if err != nil {
m.mu.Lock()
m.failures[id] = Status{State: StateFailed, LastError: safeMessage(err), LastErrorCode: errorCode(err)}
m.mu.Unlock()
return err
}
if !item.Enabled {
return fmt.Errorf("adapter is disabled")
}
m.mu.Lock()
if existing := m.items[id]; existing != nil {
state := existing.Status().State
if state == StateConnected || state == StateStarting || state == StateReconnecting {
m.mu.Unlock()
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()
return runtime.Start(m.ctx)
}

func (m *Manager) Stop(ctx context.Context, id string) error {
runtime := m.get(id)
if runtime == nil {
m.mu.Lock()
delete(m.failures, id)
m.mu.Unlock()
return nil
}
return runtime.Stop(ctx)
}

func (m *Manager) Restart(ctx context.Context, id string) error {
_ = m.Stop(ctx, id)
return m.Start(ctx, id)
}

func (m *Manager) Status(id string) (Status, error) {
if _, err := m.getRecord(context.Background(), id); err != nil {
return Status{}, err
}
if runtime := m.get(id); runtime != nil {
return runtime.Status(), nil
}
m.mu.RLock()
failed, hasFailure := m.failures[id]
m.mu.RUnlock()
if hasFailure {
return failed, nil
}
return Status{State: StateStopped}, nil
}

func (m *Manager) Capabilities(ctx context.Context, id string) ([]string, error) {
if _, _, err := m.loadConfig(ctx, id); err != nil {
return nil, err
}
return append([]string(nil), Capabilities...), nil
}

func (m *Manager) Send(ctx context.Context, id, messageType, peerID, message string) (string, error) {
runtime := m.get(id)
if runtime == nil {
return "", &APIError{Code: "adapter_offline", Message: "OneBot 适配器尚未启动", Retryable: true}
}
return runtime.Send(ctx, messageType, peerID, message)
}

func (m *Manager) Groups(ctx context.Context, id string) ([]map[string]any, error) {
runtime := m.get(id)
if runtime == nil {
return nil, &APIError{Code: "adapter_offline", Message: "OneBot 适配器尚未启动", Retryable: true}
}
return runtime.Groups(ctx)
}

func (m *Manager) Shutdown(ctx context.Context) {
m.cancel()
m.mu.RLock()
runtimes := make([]*Runtime, 0, len(m.items))
for _, runtime := range m.items {
runtimes = append(runtimes, runtime)
}
m.mu.RUnlock()
for _, runtime := range runtimes {
_ = runtime.Stop(ctx)
}
}

func (m *Manager) RuntimeCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.items)
}

func (m *Manager) get(id string) *Runtime {
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)
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}
}
config, err := ParseConfig(raw)
return item, config, err
}

func (m *Manager) getRecord(ctx context.Context, id string) (store.Record, error) {
items, err := m.store.List(ctx, "adapters")
if err != nil {
return store.Record{}, err
}
for _, item := range items {
if item.ID == id {
return item, nil
}
}
return store.Record{}, ErrAdapterNotFound
}

func parseRecord(item store.Record) (string, json.RawMessage, error) {
var detail struct {
AdapterType string `json:"adapterType"`
Config json.RawMessage `json:"config"`
}
if err := json.Unmarshal([]byte(item.Detail), &detail); err != nil {
return "", nil, &APIError{Code: "adapter_config_invalid", Message: "适配器配置格式无效", Retryable: false}
}
if detail.Config == nil {
detail.Config = json.RawMessage(item.Detail)
}
return detail.AdapterType, detail.Config, nil
}

func (m *Manager) WaitForState(ctx context.Context, id string, state State) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if status, err := m.Status(id); err == nil && status.State == state {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
Loading
Loading