diff --git a/dev/README.md b/dev/README.md index 0b67b95..8fc0a04 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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` 提供保留策略和写入失败计数。日志页面可设置持久化开关、保留天数和最大条数,每小时清理一次。日志会遮蔽常见令牌、密码、数据库连接地址及验证码,默认不记录消息正文。 diff --git a/dev/internal/adapter/onebot11/runtime.go b/dev/internal/adapter/onebot11/runtime.go index 33ab33a..937f4fb 100644 --- a/dev/internal/adapter/onebot11/runtime.go +++ b/dev/internal/adapter/onebot11/runtime.go @@ -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} @@ -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 @@ -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 { @@ -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) { @@ -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 { @@ -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} @@ -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++ @@ -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 != "" { @@ -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 连接已断开" diff --git a/dev/internal/adapter/onebot11/runtime_test.go b/dev/internal/adapter/onebot11/runtime_test.go index 8aa1dc6..d042275 100644 --- a/dev/internal/adapter/onebot11/runtime_test.go +++ b/dev/internal/adapter/onebot11/runtime_test.go @@ -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) @@ -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) { diff --git a/dev/web/src/App.vue b/dev/web/src/App.vue index 8b0d17d..3e85a66 100644 --- a/dev/web/src/App.vue +++ b/dev/web/src/App.vue @@ -24,7 +24,8 @@ const selected = ref('overview'), entries = ref([]), adapters = ref>({}) const logQuery = ref(''), logLevel = ref(''), logComponent = ref(''), logRequestID = ref(''), logFrom = ref(''), logTo = ref(''), logsTotal = ref(0), logsOffset = ref(0), logStatus = ref(null), logPersistence = ref(true), logRetentionDays = ref(30), logMaxRows = ref(100000), savingLogSettings = ref(false) const authChecked = ref(false), authenticated = ref(false), authRequired = ref(true), authBusy = ref(false), authError = ref(''), authToken = ref('') -const editing = ref(null), name = ref(''), detail = ref('{}'), enabled = ref(true), adapterType = ref('Onebot11') +const editing = ref(null), name = ref(''), detail = ref('{}'), enabled = ref(true), adapterType = ref('Onebot11'), modalError = ref('') +const adapterWsUrl = ref('ws://127.0.0.1:3001/'), adapterAccessToken = ref(''), adapterAccessTokenConfigured = ref(false), adapterUseHeartbeat = ref(true), adapterHeartbeatInterval = ref(15000) const current = computed(() => nav.find(item => item.id === selected.value)!), kind = computed(() => current.value.kind), title = computed(() => current.value.label) const filtered = computed(() => entries.value.filter(item => `${item.name} ${item.detail}`.toLowerCase().includes(query.value.toLowerCase()))) const groups = ['概览', '接入', '消息', '玩家', '自动化', '运维'] @@ -37,6 +38,7 @@ const dashboardStats = computed(() => [ { label: '自定义命令', value: counts.value.commands ?? 0, note: '命令配置', icon: Command, tone: 'rose' }, ]) function formatAdapterType(item: Entry) { try { return JSON.parse(item.detail || '{}').adapterType ?? '社交平台适配器' } catch { return '社交平台适配器' } } +function formatAdapterSummary(item: Entry) { try { const detail = JSON.parse(item.detail || '{}'), config = detail.config ?? detail, url = config.WsUrl ?? config.url ?? '未设置地址', heartbeat = config.UseHeartBeat ?? config.useHeartBeat; return `${url} · ${heartbeat ? `心跳检测 ${config.HeartBeatInterval ?? config.heartBeatInterval ?? 6000} ms` : '未启用心跳检测'}` } catch { return 'OneBot 11 配置' } } async function api(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...init?.headers } }); if (!response.ok) { if (response.status === 401 && !url.startsWith('/api/auth/')) authenticated.value = false; const body = await response.json().catch(() => ({})); throw new Error(body.message ?? `请求失败 (${response.status})`) } if (response.status === 204) return undefined as T; return response.json() as Promise } async function loadLogs(offset = logsOffset.value) { const params = new URLSearchParams({ limit: '100', offset: String(offset) }); if (logLevel.value) params.set('level', logLevel.value); if (logComponent.value) params.set('component', logComponent.value); if (logRequestID.value.trim()) params.set('requestId', logRequestID.value.trim()); if (logQuery.value.trim()) params.set('q', logQuery.value.trim()); if (logFrom.value) params.set('from', new Date(logFrom.value).toISOString()); if (logTo.value) params.set('to', new Date(logTo.value).toISOString()); const [page, status] = await Promise.all([api(`/api/logs?${params}`), api('/api/logs/status')]); logs.value = page.items; logsTotal.value = page.total; logsOffset.value = page.offset; if (!logStatus.value) { logPersistence.value = status.persistent; logRetentionDays.value = status.retentionDays; logMaxRows.value = status.maxRows } logStatus.value = status } async function saveLogSettings() { savingLogSettings.value = true; error.value = ''; try { const saved = await api('/api/logs/settings', { method: 'PUT', body: JSON.stringify({ persistent: logPersistence.value, retentionDays: logRetentionDays.value, maxRows: logMaxRows.value }) }); logStatus.value = saved; toast.value = '日志策略已保存'; setTimeout(() => toast.value = '', 2200); await loadLogs(0) } catch (e) { error.value = e instanceof Error ? e.message : '日志策略保存失败' } finally { savingLogSettings.value = false } } @@ -51,13 +53,21 @@ onMounted(() => { void initializeConsole(); logRefresh = window.setInterval(() = onUnmounted(() => { if (logRefresh) window.clearInterval(logRefresh) }) function openDocs() { window.open('https://docs.inectar.cn/docs/easybot/intro', '_blank', 'noopener') } async function signOut() { try { await api('/api/auth/logout', { method: 'POST' }) } catch { /* 服务不可用时也清理页面状态。 */ } authenticated.value = false; authToken.value = '' } -function newEntry() { editing.value = null; name.value = ''; enabled.value = true; adapterType.value = 'Onebot11'; detail.value = kind.value === 'adapters' ? '{\n "url": "ws://127.0.0.1:3001"\n}' : '{}'; modal.value = true } -function edit(item: Entry) { editing.value = item; name.value = item.name; try { const parsed = JSON.parse(item.detail || '{}'); detail.value = JSON.stringify(parsed.config ?? parsed, null, 2); adapterType.value = parsed.adapterType ?? 'Onebot11' } catch { detail.value = item.detail || '{}' }; enabled.value = item.enabled; modal.value = true } -async function save() { if (!kind.value || !name.value.trim()) return; error.value = ''; try { JSON.parse(detail.value); let saved: Entry - if (kind.value === 'adapters') saved = editing.value ? await api(`/api/adapter/${editing.value.id}`, { method: 'PUT', body: JSON.stringify({ displayName: name.value.trim(), configJson: detail.value }) }) : await api('/api/adapter/create', { method: 'POST', body: JSON.stringify({ adapterType: adapterType.value, displayName: name.value.trim(), configJson: detail.value }) }) +function resetAdapterForm() { adapterWsUrl.value = 'ws://127.0.0.1:3001/'; adapterAccessToken.value = ''; adapterAccessTokenConfigured.value = false; adapterUseHeartbeat.value = true; adapterHeartbeatInterval.value = 15000 } +function newEntry() { editing.value = null; modalError.value = ''; name.value = ''; enabled.value = true; adapterType.value = 'Onebot11'; resetAdapterForm(); detail.value = '{}'; modal.value = true } +function edit(item: Entry) { editing.value = item; modalError.value = ''; name.value = item.name; try { const parsed = JSON.parse(item.detail || '{}'); detail.value = JSON.stringify(parsed.config ?? parsed, null, 2); adapterType.value = parsed.adapterType ?? 'Onebot11'; if (selected.value === 'adapters') { const config = parsed.config ?? parsed; adapterWsUrl.value = config.WsUrl ?? config.url ?? 'ws://127.0.0.1:3001/'; adapterAccessToken.value = ''; adapterAccessTokenConfigured.value = Boolean(config.accessTokenConfigured || config.AccessToken || config.accessToken); adapterUseHeartbeat.value = Boolean(config.UseHeartBeat ?? config.useHeartBeat ?? true); adapterHeartbeatInterval.value = Number(config.HeartBeatInterval ?? config.heartBeatInterval ?? 15000) } } catch { detail.value = item.detail || '{}' }; enabled.value = item.enabled; modal.value = true } +async function save() { if (!kind.value || !name.value.trim()) return; error.value = ''; modalError.value = ''; try { let saved: Entry + if (kind.value === 'adapters') { + const parsedUrl = new URL(adapterWsUrl.value.trim()) + if (!['ws:', 'wss:'].includes(parsedUrl.protocol) || !parsedUrl.host) throw new Error('WebSocket 地址必须以 ws:// 或 wss:// 开头') + if (!Number.isInteger(adapterHeartbeatInterval.value) || adapterHeartbeatInterval.value < 1000 || adapterHeartbeatInterval.value > 300000) throw new Error('心跳超时检测间隔必须在 1000 到 300000 毫秒之间') + const config = { WsUrl: adapterWsUrl.value.trim(), AccessToken: adapterAccessToken.value, UseHeartBeat: adapterUseHeartbeat.value, HeartBeatInterval: adapterHeartbeatInterval.value } + const body = JSON.stringify({ displayName: name.value.trim(), configJson: JSON.stringify(config) }) + saved = editing.value ? await api(`/api/adapter/${editing.value.id}`, { method: 'PUT', body }) : await api('/api/adapter/create', { method: 'POST', body: JSON.stringify({ adapterType: adapterType.value, displayName: name.value.trim(), configJson: JSON.stringify(config) }) }) + } else { const body = { kind: kind.value, name: name.value.trim(), detail: detail.value, enabled: enabled.value }; saved = editing.value ? await api(`/api/records/${kind.value}/${editing.value.id}`, { method: 'PUT', body: JSON.stringify(body) }) : await api('/api/records', { method: 'POST', body: JSON.stringify(body) }) } entries.value = editing.value ? entries.value.map(item => item.id === saved.id ? saved : item) : [saved, ...entries.value]; modal.value = false; toast.value = editing.value ? '配置已保存' : '配置已创建'; setTimeout(() => toast.value = '', 2200); void dashboard(); if (selected.value === 'adapters') void load() -} catch (e) { error.value = e instanceof Error ? e.message : '保存失败' } } +} catch (e) { modalError.value = e instanceof Error ? e.message : '保存失败' } } async function remove(item: Entry) { if (!kind.value || !confirm(`删除“${item.name}”?`)) return; try { await api(`/api/records/${kind.value}/${item.id}`, { method: 'DELETE' }); entries.value = entries.value.filter(row => row.id !== item.id); void dashboard() } catch (e) { error.value = e instanceof Error ? e.message : '删除失败' } } async function toggle(item: Entry) { try { const updated = await api(`/api/records/${item.kind}/${item.id}`, { method: 'PUT', body: JSON.stringify({ name: item.name, detail: item.detail, enabled: !item.enabled }) }); entries.value = entries.value.map(row => row.id === updated.id ? updated : row) } catch (e) { error.value = e instanceof Error ? e.message : '更新失败' } } function adapterStateLabel(state?: AdapterStatus['state']) { return ({ stopped: '已停止', starting: '连接中', connected: '运行中', reconnecting: '重连中', stopping: '停止中', failed: '异常' } as const)[state ?? 'stopped'] } @@ -128,13 +138,13 @@ async function testAdapter(item: Entry) { error.value = ''; try { const result =
{{ error }}
{{ filtered.length }} 项
正在读取配置…
-
名称状态配置摘要最近更新
{{ item.name }}{{ item.id.slice(0, 8) }}
{{ adapterStateLabel(adapterStatuses[item.id]?.state) }}{{ adapterStatuses[item.id]?.lastError }}{{ item.detail || '无附加配置' }}{{ new Date(item.updatedAt).toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) }}
+
名称状态配置摘要最近更新
{{ item.name }}{{ item.id.slice(0, 8) }}
{{ adapterStateLabel(adapterStatuses[item.id]?.state) }}{{ adapterStatuses[item.id]?.lastError }}{{ selected === 'adapters' ? formatAdapterSummary(item) : item.detail || '无附加配置' }}{{ new Date(item.updatedAt).toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }) }}
{{ query ? '没有匹配的项目' : `还没有${title}配置` }}

{{ query ? '尝试其他关键词。' : '创建第一条配置以开始管理。' }}

数据保存在服务端数据库中。运行时行为依赖对应的协议或功能引擎。

-
{{ editing ? '编辑' : '新建' }}配置

{{ editing ? '更新' : '添加' }}{{ title }}

+
{{ editing ? '编辑' : '新建' }}配置

{{ editing ? '更新' : '添加' }}{{ title }}

{{ modalError }}
{{ toast }}