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
2 changes: 1 addition & 1 deletion dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ MySQL 连接格式为 `mysql://user:password@host:3306/database`;PostgreSQL

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

OneBot 11 是当前唯一可运行的适配器,使用正向 WebSocket,支持 Bearer 认证、身份握手、群聊及私聊事件归一化、消息发送和群列表调用、生命周期控制、状态及能力查询、有限退避重连。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。
OneBot 11 是当前唯一可运行的适配器,使用正向 WebSocket,支持 Bearer 认证、身份握手、群聊及私聊事件归一化、消息发送和群列表调用、生命周期控制、状态及能力查询、有限退避重连。心跳超时检测默认开启,未收到协议端心跳前使用 15000 毫秒回退间隔,收到带 `interval` 的心跳事件后自动采用协议端间隔;仍可在配置中显式关闭。程序启动时恢复已启用的配置,API 不返回适配器凭据,编辑时留空会保留已保存的令牌。不支持的平台类型会被拒绝,避免保存成可运行配置。

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

Expand Down
64 changes: 48 additions & 16 deletions dev/internal/adapter/onebot11/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ var Capabilities = []string{"group_message_receive", "private_message_receive",
func ParseConfig(raw json.RawMessage) (Config, error) {
config := Config{}
if len(raw) == 0 || string(raw) == "null" || string(raw) == "{}" {
return Config{WsURL: "ws://localhost:3001/", HeartBeatInterval: 6000}, nil
return Config{WsURL: "ws://localhost:3001/", UseHeartBeat: true, HeartBeatInterval: 15000}, nil
}
if err := json.Unmarshal(raw, &config); err != nil {
return Config{}, &APIError{Code: "adapter_config_invalid", Message: "OneBot 11 配置不是有效 JSON", Retryable: false}
Expand All @@ -93,12 +93,17 @@ func ParseConfig(raw json.RawMessage) (Config, error) {
if config.AccessToken == "" {
_ = json.Unmarshal(aliases["accessToken"], &config.AccessToken)
}
if _, present := aliases["UseHeartBeat"]; !present {
if _, present = aliases["useHeartBeat"]; !present {
config.UseHeartBeat = true
}
}
}
if config.WsURL == "" {
config.WsURL = "ws://localhost:3001/"
}
if config.HeartBeatInterval == 0 {
config.HeartBeatInterval = 6000
config.HeartBeatInterval = 15000
}
if err := config.Validate(); err != nil {
return Config{}, err
Expand All @@ -125,16 +130,17 @@ type Runtime struct {
config Config
onEvent func(Event)

mu sync.RWMutex
status Status
conn *websocket.Conn
writeMu sync.Mutex
pending map[string]chan response
ctx context.Context
cancel context.CancelFunc
done chan struct{}
started bool
sequence atomic.Uint64
mu sync.RWMutex
status Status
conn *websocket.Conn
writeMu sync.Mutex
pending map[string]chan response
ctx context.Context
cancel context.CancelFunc
done chan struct{}
started bool
sequence atomic.Uint64
heartbeatInterval atomic.Int64
}

type response struct {
Expand All @@ -143,7 +149,12 @@ type response struct {
}

func NewRuntime(id string, config Config, onEvent func(Event)) *Runtime {
return &Runtime{id: id, config: config, onEvent: onEvent, status: Status{State: StateStopped}, pending: make(map[string]chan response)}
if config.HeartBeatInterval == 0 {
config.HeartBeatInterval = 15000
}
runtime := &Runtime{id: id, config: config, onEvent: onEvent, status: Status{State: StateStopped}, pending: make(map[string]chan response)}
runtime.heartbeatInterval.Store(int64(config.HeartBeatInterval))
return runtime
}

func TestConnection(ctx context.Context, config Config) (map[string]any, error) {
Expand Down Expand Up @@ -354,12 +365,12 @@ func (r *Runtime) run() {
func (r *Runtime) readLoop(conn *websocket.Conn, result chan<- error) {
if r.config.UseHeartBeat {
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(time.Duration(r.config.HeartBeatInterval+5000) * time.Millisecond))
return conn.SetReadDeadline(r.heartbeatDeadline())
})
}
for {
if r.config.UseHeartBeat {
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(r.config.HeartBeatInterval+5000) * time.Millisecond))
_ = conn.SetReadDeadline(r.heartbeatDeadline())
}
_, raw, err := conn.ReadMessage()
if err != nil {
Expand All @@ -378,7 +389,7 @@ func (r *Runtime) readLoop(conn *websocket.Conn, result chan<- error) {
pending := r.pending[echo]
r.mu.RUnlock()
if pending != nil {
if fmt.Sprint(packet["status"]) == "failed" {
if isFailedAPIResponse(packet) {
pending <- response{err: classifyAPIResponse(packet)}
} else {
pending <- response{packet: packet}
Expand All @@ -391,6 +402,11 @@ func (r *Runtime) readLoop(conn *websocket.Conn, result chan<- error) {
}

func (r *Runtime) handleEvent(raw []byte, packet map[string]any) {
if r.config.UseHeartBeat && dataString(packet, "meta_event_type") == "heartbeat" {
if interval, ok := packet["interval"].(float64); ok && interval >= 1000 && interval <= 300000 {
r.heartbeatInterval.Store(int64(interval))
}
}
r.mu.Lock()
now := time.Now().UTC()
r.status.Received++
Expand Down Expand Up @@ -428,6 +444,14 @@ func (r *Runtime) handleEvent(raw []byte, packet map[string]any) {
}
}

func (r *Runtime) heartbeatDeadline() time.Time {
interval := r.heartbeatInterval.Load()
if interval < 1000 {
interval = 15000
}
return time.Now().Add(time.Duration(interval+5000) * time.Millisecond)
}

func firstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value := dataString(data, key); value != "" {
Expand Down Expand Up @@ -527,6 +551,14 @@ func classifyAPIResponse(packet map[string]any) error {
}
}

func isFailedAPIResponse(packet map[string]any) bool {
if fmt.Sprint(packet["status"]) == "failed" {
return true
}
retcode, ok := packet["retcode"].(float64)
return ok && retcode != 0
}

func safeMessage(err error) string {
if err == nil {
return "OneBot 连接已断开"
Expand Down
63 changes: 63 additions & 0 deletions dev/internal/adapter/onebot11/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ func TestConfigValidationAndLegacyFields(t *testing.T) {
}
}

func TestConfigDefaultsHeartbeatEnabledAndUsesOneBotFallbackInterval(t *testing.T) {
config, err := ParseConfig(json.RawMessage(`{"url":"ws://127.0.0.1:3001/"}`))
if err != nil {
t.Fatal(err)
}
if !config.UseHeartBeat || config.HeartBeatInterval != 15000 {
t.Fatalf("expected heartbeat defaults, got %#v", config)
}
config, err = ParseConfig(json.RawMessage(`{"url":"ws://127.0.0.1:3001/","useHeartBeat":false}`))
if err != nil || config.UseHeartBeat {
t.Fatalf("explicit heartbeat disable was not preserved: %#v, %v", config, err)
}
}

func TestHeartbeatEventUpdatesDetectedInterval(t *testing.T) {
runtime := NewRuntime("adapter-heartbeat", Config{UseHeartBeat: true, HeartBeatInterval: 15000}, nil)
runtime.handleEvent([]byte(`{"meta_event_type":"heartbeat","interval":30000}`), map[string]any{
"meta_event_type": "heartbeat",
"interval": float64(30000),
})
if got := runtime.heartbeatInterval.Load(); got != 30000 {
t.Fatalf("expected detected heartbeat interval, got %d", got)
}
}

func TestOneBotAPIErrorClassification(t *testing.T) {
if err := classifyAPIResponse(map[string]any{"retcode": float64(1403), "message": "credential rejected"}); errorCode(err) != "adapter_auth_failed" {
t.Fatalf("expected auth failure, got %v", err)
Expand All @@ -32,6 +57,44 @@ func TestOneBotAPIErrorClassification(t *testing.T) {
}
}

func TestRuntimeTreatsNonzeroRetcodeAsFailedAPIResponse(t *testing.T) {
server := newOneBotMock(t, func(conn *websocket.Conn) {
for {
var request map[string]any
if conn.ReadJSON(&request) != nil {
return
}
response := map[string]any{"status": "ok", "retcode": 100, "echo": request["echo"], "message": "operation failed"}
if request["action"] == "get_login_info" {
response["retcode"] = 0
response["data"] = map[string]any{"user_id": 7}
}
if conn.WriteJSON(response) != nil {
return
}
}
})
defer server.Close()
runtime := NewRuntime("adapter-retcode", Config{WsURL: "ws" + strings.TrimPrefix(server.URL, "http"), AccessToken: "mock-token", HeartBeatInterval: 6000}, nil)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := waitForStatus(ctx, runtime, StateConnected); err != nil {
t.Fatal(err)
}
_, err := runtime.Send(ctx, "group", "99", "outbound")
if errorCode(err) != "adapter_api_failed" {
t.Fatalf("expected nonzero retcode to fail the API call, got %v", err)
}
stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
if err := runtime.Stop(stopCtx); err != nil {
t.Fatal(err)
}
}

func TestTestConnectionReceivesIdentityAndUsesBearerToken(t *testing.T) {
var auth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading