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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ token.txt
*.db-shm
*.db-wal
*.log

# CodeBuddy
.codebuddy/
4 changes: 2 additions & 2 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ HTTPS 配置示例(PEM 文件,相对路径基于配置文件):

## 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/*`. 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.
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. The Minecraft Bridge runtime is implemented as a WebSocket endpoint on `/bridge` that speaks the JSON packet protocol of the original plugins (`Hello`/`Identify`/`HeartBeat`/`Packet`/`CallBack`, integer `op`, snake_case fields). Each plugin authenticates with the per-server token stored in the server record's `detail` JSON; tokens are generated automatically when a server record is created and can be rotated with `POST /api/servers/{id}/token`. The host tracks observed connection status in `/api/bridge/status`, forwards `SYNC_MESSAGE` chat into matching OneBot 11 groups, and pushes group messages back through `SEND_TO_CHAT`. Routes are `sync-rules` records shaped as `{"serverId","adapterId","groupId","direction","mcFormat","socialFormat"}`.

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; the separate EasyBot-Recovered repository is used as behavioral reference for its WebUI API and module names.
Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, Minecraft plugin binaries, account-binding and command workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The Bridge endpoint has only been exercised against a locally simulated plugin; it has not been validated against a shipped EasyBot plugin build, and binding, command and statistics packets are acknowledged but not implemented. 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; the separate EasyBot-Recovered repository is used as behavioral reference for its WebUI API and module names.

## Verify

Expand Down
37 changes: 36 additions & 1 deletion dev/cmd/easybot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"easybot/internal/adapter/onebot11"
"easybot/internal/auth"
"easybot/internal/bridge"
"easybot/internal/config"
"easybot/internal/runtime"
"easybot/internal/store"
Expand Down Expand Up @@ -135,9 +136,43 @@ func run() (code int) {
} else {
logger.Info(fmt.Sprintf("[AdapterManager] 发现 %d 个适配器配置", len(adapterConfigs)))
}
var eventRouter *bridge.Router
adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) {
logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")")
if eventRouter == nil || event.Type != "message" || event.MessageType != "group" {
return
}
if event.SelfID != "" && event.SenderID == event.SelfID {
return
}
eventRouter.OnSocialMessage(id, event.PeerID, event.SenderName, event.Content)
})
bridges := bridge.NewManager(db, logger, bridge.Callbacks{
OnChat: func(serverID string, packet bridge.SyncMessagePacket) {
logs.Add("info", "bridge", "Minecraft chat from "+packet.Player.PlayerName+": "+packet.Message)
if eventRouter != nil {
eventRouter.OnMinecraftChat(serverID, packet)
}
},
OnEnterExit: func(serverID string, packet bridge.SyncEnterExitMessagePacket) {
state := "left"
if packet.IsEnter {
state = "joined"
}
logs.Add("info", "bridge", "Player "+packet.Player.PlayerName+" "+state+" (server "+serverID+")")
},
OnDeath: func(serverID string, packet bridge.SyncDeathMessagePacket) {
logs.Add("info", "bridge", "Player death on server "+serverID+": "+packet.Raw)
},
OnState: func(serverID string, online bool) {
if online {
logs.Add("info", "bridge", "Minecraft server connected: "+serverID)
return
}
logs.Add("warn", "bridge", "Minecraft server disconnected: "+serverID)
},
})
eventRouter = bridge.NewRouter(db, bridges, adapters, logger)
if err := adapters.StartEnabled(ctx); err != nil {
logger.Error("[AdapterManager] 适配器初始化失败", "error", err)
} else {
Expand Down Expand Up @@ -166,7 +201,7 @@ func run() (code int) {
logger.Info("[WebUI] 正在初始化管理服务...")
server := &http.Server{
Addr: address,
Handler: web.NewForListener(db, logger, logs, adapters, listener.Addr().String(), loginToken, writer),
Handler: web.NewForListenerWithRuntimes(db, logger, logs, adapters, bridges, listener.Addr().String(), loginToken, writer),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
Expand Down
289 changes: 289 additions & 0 deletions dev/internal/bridge/bridge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package bridge

import (
"context"
"io"
"log/slog"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"easybot/internal/store"
"github.com/gorilla/websocket"
)

const testToken = "secret-server-token"

type captureSocial struct {
mu sync.Mutex
calls []sentMessage
}

type sentMessage struct {
adapterID string
message string
peerID string
text string
}

func (c *captureSocial) Send(_ context.Context, adapterID, messageType, peerID, message string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, sentMessage{adapterID: adapterID, message: messageType, peerID: peerID, text: message})
return "message-id", nil
}

func (c *captureSocial) last() (sentMessage, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.calls) == 0 {
return sentMessage{}, false
}
return c.calls[len(c.calls)-1], true
}

func newTestStack(t *testing.T) (*store.Store, *Manager, *Router, *captureSocial, string, string) {
t.Helper()
db, err := store.Open(t.TempDir() + "/easybot.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
server, err := db.Save(ctx, store.Record{Kind: "servers", Name: "生存服", Detail: `{"token":"` + testToken + `","port":25565}`, Enabled: true})
if err != nil {
t.Fatal(err)
}
rule, err := db.Save(ctx, store.Record{
Kind: "sync-rules",
Name: "官方群 <-> 生存服",
Detail: `{"serverId":"` + server.ID + `","adapterId":"adapter-1","groupId":"999","direction":"both"}`,
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
social := &captureSocial{}
manager := NewManager(db, logger, Callbacks{})
router := NewRouter(db, manager, social, logger)
manager.call.OnChat = router.OnMinecraftChat
return db, manager, router, social, server.ID, rule.ID
}

// dialPlugin connects a simulated original plugin and completes the handshake.
func dialPlugin(t *testing.T, manager *Manager, token string) *websocket.Conn {
t.Helper()
server := httptest.NewServer(manager)
t.Cleanup(server.Close)
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/bridge"
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))

var hello map[string]any
if err := conn.ReadJSON(&hello); err != nil {
t.Fatalf("reading hello: %v", err)
}
if op, _ := hello["op"].(float64); OpCode(op) != OpHello {
t.Fatalf("first packet must be Hello, got %#v", hello)
}
for _, field := range []string{"version", "session_id", "token", "interval"} {
if _, ok := hello[field]; !ok {
t.Fatalf("hello is missing %q: %#v", field, hello)
}
}
if err := conn.WriteJSON(map[string]any{
"op": OpIdentify,
"token": token,
"plugin_version": "2.3.0",
"server_description": "Paper 1.21",
}); err != nil {
t.Fatal(err)
}
var success map[string]any
if err := conn.ReadJSON(&success); err != nil {
t.Fatalf("reading identify success: %v", err)
}
if op, _ := success["op"].(float64); OpCode(op) != OpIdentifySuccess {
t.Fatalf("expected IdentifySuccess, got %#v", success)
}
if success["server_name"] != "生存服" {
t.Fatalf("unexpected server_name: %#v", success)
}
return conn
}

func TestPluginHandshakeAndChatForwardingToSocial(t *testing.T) {
_, manager, _, social, serverID, _ := newTestStack(t)
conn := dialPlugin(t, manager, testToken)

status, err := manager.Status(serverID)
if err != nil || status.State != "online" || status.PluginVersion != "2.3.0" {
t.Fatalf("unexpected status: %#v error %v", status, err)
}

if err := conn.WriteJSON(map[string]any{
"op": OpPacket,
"callback_id": "cb-1",
"exec_op": ExecSyncMessage,
"player": map[string]any{"player_name": "Steve", "player_uuid": "uuid-1"},
"message": "大家好",
"use_command": false,
}); err != nil {
t.Fatal(err)
}

deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if call, ok := social.last(); ok {
if call.adapterID != "adapter-1" || call.peerID != "999" || call.message != "group" {
t.Fatalf("wrong routing target: %#v", call)
}
if call.text != "[生存服] Steve: 大家好" {
t.Fatalf("unexpected formatted text: %q", call.text)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("minecraft chat was never forwarded to the social adapter")
}

func TestSocialMessageReachesMinecraftAndDoesNotEcho(t *testing.T) {
_, manager, router, social, _, _ := newTestStack(t)
conn := dialPlugin(t, manager, testToken)

router.OnSocialMessage("adapter-1", "999", "Alice", "群里的消息")

_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
var packet map[string]any
if err := conn.ReadJSON(&packet); err != nil {
t.Fatalf("plugin did not receive the chat push: %v", err)
}
if op, _ := packet["op"].(float64); OpCode(op) != OpPacket {
t.Fatalf("expected an op=Packet frame, got %#v", packet)
}
if packet["exec_op"] != ExecSendToChat {
t.Fatalf("expected SEND_TO_CHAT, got %#v", packet)
}
text, _ := packet["text"].(string)
if text != "[999] Alice: 群里的消息" {
t.Fatalf("unexpected text: %q", text)
}

// A plugin reporting the pushed line back must not bounce into the group.
router.OnMinecraftChat(serverOf(t, manager), SyncMessagePacket{Message: text, Player: PlayerInfo{PlayerName: "Alice"}})
if call, ok := social.last(); ok {
t.Fatalf("echo was forwarded back to the social platform: %#v", call)
}
}

func TestUnknownTokenIsRejected(t *testing.T) {
_, manager, _, _, _, _ := newTestStack(t)
server := httptest.NewServer(manager)
defer server.Close()
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/bridge"
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var hello map[string]any
if err := conn.ReadJSON(&hello); err != nil {
t.Fatal(err)
}
if err := conn.WriteJSON(map[string]any{"op": OpIdentify, "token": "wrong", "plugin_version": "2.3.0"}); err != nil {
t.Fatal(err)
}
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
return
}
t.Fatal("host kept the socket open for an invalid token")
}
}

func TestStatusesListConfiguredServersAsOffline(t *testing.T) {
_, manager, _, _, serverID, _ := newTestStack(t)
statuses, err := manager.Statuses()
if err != nil {
t.Fatal(err)
}
status, ok := statuses[serverID]
if !ok || status.State != "offline" {
t.Fatalf("expected an offline entry, got %#v", statuses)
}
if err := manager.SendToChat(serverID, "x"); err != ErrServerOffline {
t.Fatalf("expected ErrServerOffline, got %v", err)
}
}

func serverOf(t *testing.T, manager *Manager) string {
t.Helper()
statuses, err := manager.Statuses()
if err != nil || len(statuses) != 1 {
t.Fatalf("unexpected statuses: %#v %v", statuses, err)
}
for id := range statuses {
return id
}
return ""
}

func TestServerTokenExtraction(t *testing.T) {
cases := map[string]string{
`{"token":"abc"}`: "abc",
`{"Token":"legacy"}`: "legacy",
`{"serverToken":"snake"}`: "snake",
`{}`: "",
`not json`: "",
}
for detail, want := range cases {
if got := ServerToken(store.Record{Detail: detail}); got != want {
t.Fatalf("ServerToken(%s) = %q, want %q", detail, got, want)
}
}
}

func TestRenderKeepsUnknownPlaceholders(t *testing.T) {
if got := render("", defaultMinecraft, map[string]string{"server": "S", "player": "P", "content": "C"}); got != "[S] P: C" {
t.Fatalf("unexpected default render: %q", got)
}
if got := render("{server} {unknown}", defaultMinecraft, map[string]string{"server": "S"}); got != "S {unknown}" {
t.Fatalf("unknown placeholders must be preserved: %q", got)
}
}

func TestPluginJoinIsAdmittedWithoutBinding(t *testing.T) {
_, manager, _, _, _, _ := newTestStack(t)
conn := dialPlugin(t, manager, testToken)
if err := conn.WriteJSON(map[string]any{
"op": OpPacket,
"callback_id": "join-1",
"exec_op": ExecPlayerJoin,
"player": map[string]any{"player_name": "Steve", "player_uuid": "uuid-1"},
}); err != nil {
t.Fatal(err)
}
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
var callback map[string]any
if err := conn.ReadJSON(&callback); err != nil {
t.Fatalf("no login callback: %v", err)
}
if callback["callback_id"] != "join-1" || callback["kick"] != false {
t.Fatalf("unexpected login callback: %#v", callback)
}
if op, _ := callback["op"].(float64); OpCode(op) != OpCallBack {
t.Fatalf("expected op=CallBack, got %#v", callback)
}
}
Loading
Loading