diff --git a/.codebuddy/plans/cellbot-multibot-server_ec830ef3.md b/.codebuddy/plans/cellbot-multibot-server_ec830ef3.md
deleted file mode 100644
index c73e8ca..0000000
--- a/.codebuddy/plans/cellbot-multibot-server_ec830ef3.md
+++ /dev/null
@@ -1,209 +0,0 @@
----
-name: cellbot-multibot-server
-overview: 基于Go语言的多机器人服务端,参考OneBot12协议设计通用框架,采用分层架构和依赖注入设计模式。
-todos:
- - id: setup-project
- content: 初始化Go模块项目结构,创建基础目录和go.mod
- status: completed
- - id: implement-config
- content: 实现配置模块,支持TOML解析和fsnotify热重载
- status: completed
- dependencies:
- - setup-project
- - id: define-protocol
- content: 定义通用协议接口,提取OneBot12核心设计理念
- status: completed
- dependencies:
- - setup-project
- - id: build-eventbus
- content: 实现基于channel的高性能事件总线
- status: completed
- dependencies:
- - define-protocol
- - id: create-di-container
- content: 集成Uber Fx依赖注入容器,管理应用生命周期
- status: completed
- dependencies:
- - implement-config
- - build-eventbus
- - id: implement-fasthttp
- content: 封装fasthttp网络层,处理连接和通信
- status: completed
- dependencies:
- - create-di-container
- - id: unit-tests
- content: 编写核心模块的单元测试和并发基准测试
- status: completed
- dependencies:
- - build-eventbus
- - implement-config
----
-
-## Product Overview
-
-基于Go语言构建的高性能、可扩展的多机器人服务端,参考OneBot12协议的设计理念,旨在提供统一的机器人管理与消息分发框架。
-
-## Core Features
-
-- **协议适配层**:提取OneBot12核心设计,支持通用接口定义与扩展
-- **多机器人管理**:支持同时连接和管理多个不同实现的机器人实例
-- **事件总线**:基于channel的高性能发布订阅机制,实现模块间解耦通信
-- **依赖注入容器**:管理组件生命周期,降低耦合度
-- **配置管理**:支持TOML格式配置,具备热重载能力
-- **反向WebSocket**:支持高性能反向WebSocket通信连接
-
-## Tech Stack
-
-- **语言**: Go 1.21+
-- **网络库**: fasthttp (valyala/fasthttp)
-- **配置**: BurntSushi/toml + fsnotify (热重载)
-- **依赖注入**: Uber-go/fx 或 uber-go/dig
-- **测试**: 标准库 testing + testify
-- **日志**: uber-go/zap
-
-## Tech Architecture
-
-### System Architecture
-
-采用分层架构结合依赖注入模式。
-
-- **协议层**: 定义OneBot12通用接口规范。
-- **适配层**: 实现不同平台的具体协议适配。
-- **核心层**: 事件总线、生命周期管理、依赖注入容器。
-- **网络层**: 基于fasthttp的高并发连接处理。
-
-```mermaid
-graph TD
- A[Client/Bot] -->|WebSocket/TCP| B[Network Layer: fasthttp]
- B --> C[Adapter Layer: Protocol Implementation]
- C -->|Event Message| D[Event Bus: Channel Pub/Sub]
- D --> E[Core Layer: Business Logic]
- F[Config Manager: TOML + Hot Reload] --> E
- G[DI Container: fx/dig] --> B
- G --> C
- G --> D
- G --> E
-```
-
-### Module Division
-
-- **internal/protocol**: 定义OneBot12核心接口(Event, Action, API)。
-- **internal/adapter**: 协议适配器实现(如OneBot11适配器)。
-- **internal/engine**: 核心引擎,包含事件总线与Bot管理。
-- **internal/config**: 配置加载与热重载逻辑。
-- **internal/di**: 依赖注入容器封装。
-- **pkg/fasthttp**: fasthttp网络服务封装。
-
-### Data Flow
-
-外部连接 -> fasthttp处理 -> 协议适配器解析 -> 事件总线分发 -> 订阅者处理 -> 结果返回。
-
-```mermaid
-flowchart LR
- A[Incoming Message] --> B[fasthttp Handler]
- B --> C[Adapter Parse]
- C --> D{Event Bus Channel}
- D -->|Subscribe| E[Handler 1]
- D -->|Subscribe| F[Handler 2]
- E --> G[Response]
- F --> G
-```
-
-## Implementation Details
-
-### Core Directory Structure
-
-```
-cellbot-multibot-server/
-├── cmd/
-│ └── server/
-│ └── main.go # 程序入口,注入fx应用
-├── internal/
-│ ├── config/ # 配置模块
-│ │ ├── config.go # 配置结构体定义
-│ │ └── loader.go # TOML加载与fsnotify热重载
-│ ├── protocol/ # 通用协议层
-│ │ └── onebot12.go # 核心接口定义
-│ ├── adapter/ # 适配器层
-│ │ ├── base.go # 适配器基类
-│ │ └── onebot11.go # OneBot11实现示例
-│ ├── engine/ # 核心引擎
-│ │ ├── eventbus.go # Channel发布订阅
-│ │ └── bot.go # 机器人实例管理
-│ └── di/ # 依赖注入
-│ └── wire.go # Provider定义
-├── pkg/
-│ └── net/ # 网络封装
-│ └── server.go # fasthttp服务器封装
-├── configs/
-│ └── config.toml # 默认配置文件
-└── go.mod
-```
-
-### Key Code Structures
-
-**Event Bus (Channel-based)**: 使用类型安全的channel进行事件分发,支持并发订阅与取消订阅。
-
-```
-type EventBus struct {
- subscribers map[string][]chan Event
- mu sync.RWMutex
-}
-
-func (eb *EventBus) Publish(eventType string, event Event) {
- // 发布逻辑
-}
-
-func (eb *EventBus) Subscribe(eventType string) chan Event {
- // 订阅逻辑
-}
-```
-
-**Dependency Injection (Fx)**: 使用Uber Fx管理应用生命周期,提供优雅的启动与关闭。
-
-```
-// 提供Config实例
-func ProvideConfig() *Config {
- return LoadConfig("config.toml")
-}
-
-// 提供EventBus实例
-func ProvideEventBus() *EventBus {
- return NewEventBus()
-}
-```
-
-### Technical Implementation Plan
-
-1. **配置与热重载**
-
-- 定义Config结构体,支持TOML映射。
-- 使用fsnotify监听文件变化,实现平滑热重载。
-
-2. **通用协议框架**
-
-- 抽象OneBot12核心概念:Action, Event, API。
-- 定义统一的接口契约。
-
-3. **事件总线设计**
-
-- 基于buffered channel实现高吞吐量。
-- 实现带类型检查的订阅机制。
-
-4. **fasthttp集成**
-
-- 封装fasthttp Server,处理WebSocket升级。
-- 实现连接池管理以优化性能。
-
-5. **测试策略**
-
-- 单元测试覆盖核心逻辑(EventBus, Config)。
-- 基准测试验证并发性能。
-
-## Agent Extensions
-
-### SubAgent
-
-- **code-explorer**
-- Purpose: 搜索和分析现有项目结构,确保新代码与现有模式一致
-- Expected outcome: 确认当前目录结构和代码风格,生成符合规范的代码
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index d1d785c..e945807 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ logs/
# Config (local overrides)
config/local.toml
+
+# Database
+data/
\ No newline at end of file
diff --git a/docs/plugin_guide.md b/docs/plugin_guide.md
deleted file mode 100644
index b346036..0000000
--- a/docs/plugin_guide.md
+++ /dev/null
@@ -1,306 +0,0 @@
-# CellBot 插件开发指南
-
-## 快速开始
-
-CellBot 提供了类似 ZeroBot 风格的插件注册方式,可以在一个包内注册多个处理函数。
-
-### ZeroBot 风格(推荐)
-
-在 `init` 函数中使用 `OnXXX().Handle()` 注册处理函数,一个包内可以注册多个:
-
-```go
-package echo
-
-import (
- "context"
- "cellbot/internal/engine"
- "cellbot/internal/protocol"
- "go.uber.org/zap"
-)
-
-func init() {
- // 处理私聊消息
- engine.OnPrivateMessage().
- Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
- data := event.GetData()
- message := data["message"]
- userID := data["user_id"]
-
- // 获取 bot 实例
- bot, _ := botManager.Get(event.GetSelfID())
-
- // 发送回复
- action := &protocol.BaseAction{
- Type: protocol.ActionTypeSendPrivateMessage,
- Params: map[string]interface{}{
- "user_id": userID,
- "message": message,
- },
- }
-
- return bot.SendAction(ctx, action)
- })
-
- // 可以继续注册更多处理函数
- engine.OnGroupMessage().
- Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
- // 处理群消息
- return nil
- })
-
- // 处理命令
- engine.OnCommand("/help").
- Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
- // 处理 /help 命令
- return nil
- })
-}
-```
-
-**注意**:需要在 `internal/di/providers.go` 中导入插件包以触发 `init` 函数:
-
-```go
-import (
- _ "cellbot/internal/plugins/echo" // 导入插件以触发 init
-)
-```
-
-### 方式二:传统方式
-
-实现 `protocol.EventHandler` 接口:
-
-```go
-package myplugin
-
-import (
- "context"
- "cellbot/internal/protocol"
- "go.uber.org/zap"
-)
-
-type MyPlugin struct {
- logger *zap.Logger
- botManager *protocol.BotManager
-}
-
-func NewMyPlugin(logger *zap.Logger, botManager *protocol.BotManager) *MyPlugin {
- return &MyPlugin{
- logger: logger.Named("my-plugin"),
- botManager: botManager,
- }
-}
-
-func (p *MyPlugin) Name() string {
- return "MyPlugin"
-}
-
-func (p *MyPlugin) Description() string {
- return "我的插件"
-}
-
-func (p *MyPlugin) Priority() int {
- return 100
-}
-
-func (p *MyPlugin) Match(event protocol.Event) bool {
- return event.GetType() == protocol.EventTypeMessage
-}
-
-func (p *MyPlugin) Handle(ctx context.Context, event protocol.Event) error {
- // 处理逻辑
- return nil
-}
-```
-
-## 内置匹配器
-
-提供了以下便捷的匹配器函数:
-
-- `engine.OnPrivateMessage()` - 匹配私聊消息
-- `engine.OnGroupMessage()` - 匹配群消息
-- `engine.OnMessage()` - 匹配所有消息
-- `engine.OnNotice()` - 匹配通知事件
-- `engine.OnRequest()` - 匹配请求事件
-- `engine.OnCommand(prefix)` - 匹配命令(以指定前缀开头)
-- `engine.OnPrefix(prefix)` - 匹配以指定前缀开头的消息
-- `engine.OnSuffix(suffix)` - 匹配以指定后缀结尾的消息
-- `engine.OnKeyword(keyword)` - 匹配包含指定关键词的消息
-- `engine.On(matchFunc)` - 自定义匹配器
-
-### 自定义匹配器
-
-```go
-func init() {
- engine.On(func(event protocol.Event) bool {
- // 自定义匹配逻辑
- if event.GetType() != protocol.EventTypeMessage {
- return false
- }
-
- data := event.GetData()
- message, ok := data["raw_message"].(string)
- if !ok {
- return false
- }
-
- // 只匹配以 "/" 开头的消息
- return len(message) > 0 && message[0] == '/'
- }).
- Priority(50). // 可以设置优先级
- Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
- // 处理命令
- return nil
- })
-}
-```
-
-## 注册插件
-
-插件通过 `init` 函数自动注册,只需在 `internal/di/providers.go` 中导入插件包:
-
-```go
-import (
- _ "cellbot/internal/plugins/echo" // 导入插件以触发 init
- _ "cellbot/internal/plugins/other" // 可以导入多个插件包
-)
-```
-
-插件会在应用启动时自动加载,无需手动注册。
-
-## 插件优先级
-
-优先级数值越小,越先执行。建议:
-
-- 0-50: 高优先级(预处理、权限检查等)
-- 51-100: 中等优先级(普通功能插件)
-- 101-200: 低优先级(日志记录、统计等)
-
-## 完整示例
-
-### 示例 1:关键词回复插件
-
-```go
-package keyword
-
-import (
- "context"
- "cellbot/internal/engine"
- "cellbot/internal/protocol"
- "go.uber.org/zap"
-)
-
-func init() {
- keywords := map[string]string{
- "你好": "你好呀!",
- "再见": "再见~",
- "帮助": "发送 /help 查看帮助",
- }
-
- engine.OnMessage().
- Priority(80).
- Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
- data := event.GetData()
- message, ok := data["raw_message"].(string)
- if !ok {
- return nil
- }
-
- // 检查关键词
- reply, found := keywords[message]
- if !found {
- return nil
- }
-
- // 获取 bot 和用户信息
- bot, _ := botManager.Get(event.GetSelfID())
- userID := data["user_id"]
-
- // 发送回复
- action := &protocol.BaseAction{
- Type: protocol.ActionTypeSendPrivateMessage,
- Params: map[string]interface{}{
- "user_id": userID,
- "message": reply,
- },
- }
-
- _, err := bot.SendAction(ctx, action)
- return err
- })
-}
-```
-
-### 示例 2:命令插件
-
-```go
-func RegisterCommandPlugin(registry *engine.PluginRegistry, botManager *protocol.BotManager, logger *zap.Logger) {
- plugin := engine.NewPlugin("CommandPlugin").
- Description("命令处理插件").
- Priority(50).
- Match(func(event protocol.Event) bool {
- if event.GetType() != protocol.EventTypeMessage {
- return false
- }
- data := event.GetData()
- message, ok := data["raw_message"].(string)
- return ok && len(message) > 0 && message[0] == '/'
- }).
- Handle(func(ctx context.Context, event protocol.Event) error {
- data := event.GetData()
- message := data["raw_message"].(string)
- userID := data["user_id"]
-
- bot, _ := botManager.Get(event.GetSelfID())
-
- var reply string
- switch message {
- case "/help":
- reply = "可用命令:\n/help - 显示帮助\n/ping - 测试连接\n/time - 显示时间"
- case "/ping":
- reply = "pong!"
- case "/time":
- reply = time.Now().Format("2006-01-02 15:04:05")
- default:
- reply = "未知命令,发送 /help 查看帮助"
- }
-
- action := &protocol.BaseAction{
- Type: protocol.ActionTypeSendPrivateMessage,
- Params: map[string]interface{}{
- "user_id": userID,
- "message": reply,
- },
- }
-
- _, err := bot.SendAction(ctx, action)
- return err
- }).
- Build()
-
- registry.Register(plugin)
-}
-```
-
-## 最佳实践
-
-1. **使用简化方式**:对于简单插件,使用 `engine.NewPlugin` 构建器
-2. **使用传统方式**:对于复杂插件(需要状态管理、配置等),使用传统方式
-3. **合理设置优先级**:确保插件按正确顺序执行
-4. **错误处理**:在 Handle 函数中妥善处理错误
-5. **日志记录**:使用 logger 记录关键操作
-6. **避免阻塞**:Handle 函数应快速返回,耗时操作应使用 goroutine
-
-## 插件生命周期
-
-插件在应用启动时注册,在应用运行期间持续监听事件。目前不支持热重载。
-
-## 调试技巧
-
-1. 使用 `logger.Debug` 记录调试信息
-2. 在 Match 函数中添加日志,确认匹配逻辑
-3. 检查事件数据结构,确保字段存在
-4. 使用 `zap.Any` 打印完整事件数据
-
-```go
-logger.Debug("Event data", zap.Any("data", event.GetData()))
-```
diff --git a/go.mod b/go.mod
index 8ed5a0e..7ea20bc 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
require github.com/robfig/cron/v3 v3.0.1
require (
+ github.com/Tnze/go-mc v1.20.2 // indirect
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
github.com/chromedp/chromedp v0.14.2 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
@@ -25,6 +26,12 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ gorm.io/driver/sqlite v1.6.0 // indirect
+ gorm.io/gorm v1.31.1 // indirect
)
require (
diff --git a/go.sum b/go.sum
index eadc814..0b0defb 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/Tnze/go-mc v1.20.2 h1:arHCE/WxLCxY73C/4ZNLdOymRYtdwoXE05ohB7HVN6Q=
+github.com/Tnze/go-mc v1.20.2/go.mod h1:geoRj2HsXSkB3FJBuhr7wCzXegRlzWsVXd7h7jiJ6aQ=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
@@ -35,10 +37,16 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -83,9 +91,15 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/internal/database/database.go b/internal/database/database.go
new file mode 100644
index 0000000..affab80
--- /dev/null
+++ b/internal/database/database.go
@@ -0,0 +1,125 @@
+package database
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "go.uber.org/zap"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+// Database 数据库接口,预留其他数据库接入
+type Database interface {
+ // GetDB 获取指定 bot 的数据库连接(使用表前缀区分不同 bot 的数据)
+ GetDB(botID string) (*gorm.DB, error)
+ // Close 关闭所有数据库连接
+ Close() error
+}
+
+// SQLiteDatabase SQLite 数据库实现
+// 使用表前缀来区分不同 bot 的数据,所有 bot 共享同一个数据库文件和连接
+type SQLiteDatabase struct {
+ mu sync.RWMutex
+ db *gorm.DB // 共享的数据库连接
+ dbs map[string]*gorm.DB // botID -> db (缓存,实际都指向同一个连接)
+ logger *zap.Logger
+ dbPath string
+ options []gorm.Option
+}
+
+// NewSQLiteDatabase 创建 SQLite 数据库实例
+// dbPath: 数据库文件路径(可以为空,使用默认路径)
+// 所有 bot 共享同一个数据库文件,通过表前缀区分
+func NewSQLiteDatabase(logger *zap.Logger, dbPath string, options ...gorm.Option) Database {
+ if dbPath == "" {
+ dbPath = "data/cellbot.db"
+ }
+ return &SQLiteDatabase{
+ dbs: make(map[string]*gorm.DB),
+ logger: logger.Named("database"),
+ dbPath: dbPath,
+ options: options,
+ }
+}
+
+// GetDB 获取指定 bot 的数据库连接
+// 使用表前缀来区分不同 bot 的数据,所有 bot 共享同一个数据库文件和连接
+func (d *SQLiteDatabase) GetDB(botID string) (*gorm.DB, error) {
+ if botID == "" {
+ return nil, fmt.Errorf("botID cannot be empty")
+ }
+
+ // 初始化共享数据库连接(如果还没有)
+ d.mu.Lock()
+ if d.db == nil {
+ // 确保数据库目录存在
+ dir := filepath.Dir(d.dbPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ d.mu.Unlock()
+ return nil, fmt.Errorf("failed to create database directory: %w", err)
+ }
+
+ // 所有 bot 共享同一个数据库文件和连接
+ // 通过表前缀区分不同 bot 的数据
+ db, err := gorm.Open(sqlite.Open(d.dbPath), d.options...)
+ if err != nil {
+ d.mu.Unlock()
+ return nil, fmt.Errorf("failed to open database: %w", err)
+ }
+
+ d.db = db
+ d.logger.Info("Shared database connection created",
+ zap.String("db_path", d.dbPath))
+ }
+ d.mu.Unlock()
+
+ // 所有 bot 返回同一个连接(通过缓存避免重复查找)
+ d.mu.RLock()
+ if cached, ok := d.dbs[botID]; ok {
+ d.mu.RUnlock()
+ return cached, nil
+ }
+ d.mu.RUnlock()
+
+ // 缓存连接引用(实际都指向同一个连接)
+ d.mu.Lock()
+ if cached, ok := d.dbs[botID]; ok {
+ d.mu.Unlock()
+ return cached, nil
+ }
+ d.dbs[botID] = d.db
+ d.mu.Unlock()
+
+ return d.db, nil
+}
+
+// Close 关闭所有数据库连接
+func (d *SQLiteDatabase) Close() error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ // 关闭共享的数据库连接
+ if d.db != nil {
+ if sqlDB, err := d.db.DB(); err == nil {
+ if err := sqlDB.Close(); err != nil {
+ d.logger.Error("Failed to close database", zap.Error(err))
+ return err
+ }
+ }
+ d.db = nil
+ }
+
+ // 清空缓存
+ d.dbs = make(map[string]*gorm.DB)
+
+ d.logger.Info("Database connection closed")
+ return nil
+}
+
+// GetDBPath 获取数据库文件路径
+func (d *SQLiteDatabase) GetDBPath() string {
+ return d.dbPath
+}
diff --git a/internal/database/table_prefix.go b/internal/database/table_prefix.go
new file mode 100644
index 0000000..a6bb61f
--- /dev/null
+++ b/internal/database/table_prefix.go
@@ -0,0 +1,38 @@
+package database
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+// GetTableName 获取带前缀的表名
+// 格式:{botID}_{tableName}
+func GetTableName(botID, tableName string) string {
+ if botID == "" {
+ return tableName
+ }
+ // 清理 botID 中的特殊字符,确保表名合法
+ cleanBotID := sanitizeTableName(botID)
+ return fmt.Sprintf("%s_%s", cleanBotID, tableName)
+}
+
+// WithTablePrefix 为查询添加表前缀
+func WithTablePrefix(db *gorm.DB, botID, tableName string) *gorm.DB {
+ prefixedTableName := GetTableName(botID, tableName)
+ return db.Table(prefixedTableName)
+}
+
+// sanitizeTableName 清理表名中的特殊字符
+func sanitizeTableName(name string) string {
+ // 移除或替换 SQL 不安全的字符
+ result := ""
+ for _, r := range name {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
+ result += string(r)
+ } else {
+ result += "_"
+ }
+ }
+ return result
+}
diff --git a/internal/di/providers.go b/internal/di/providers.go
index ac23736..c39bdaf 100644
--- a/internal/di/providers.go
+++ b/internal/di/providers.go
@@ -2,13 +2,17 @@ package di
import (
"context"
+ "fmt"
"cellbot/internal/adapter/milky"
"cellbot/internal/adapter/onebot11"
"cellbot/internal/config"
+ "cellbot/internal/database"
"cellbot/internal/engine"
- _ "cellbot/internal/plugins/echo" // 导入插件以触发 init 函数
- _ "cellbot/internal/plugins/welcome" // 导入插件以触发 init 函数
+ _ "cellbot/internal/plugins/echo" // 导入插件以触发 init 函数
+ "cellbot/internal/plugins/mcstatus"
+ _ "cellbot/internal/plugins/mcstatus" // 导入插件以触发 init 函数
+ _ "cellbot/internal/plugins/welcome" // 导入插件以触发 init 函数
"cellbot/internal/protocol"
"cellbot/pkg/net"
@@ -82,6 +86,52 @@ func ProvideServer(cfg *config.Config, logger *zap.Logger, botManager *protocol.
return net.NewServer(cfg.Server.Host, cfg.Server.Port, logger, botManager, eventBus)
}
+// ProvideDatabase 提供数据库服务
+func ProvideDatabase(logger *zap.Logger) database.Database {
+ return database.NewSQLiteDatabase(logger, "data/cellbot.db")
+}
+
+// InitMCStatusDatabase 初始化 MC 状态插件的数据库
+func InitMCStatusDatabase(dbService database.Database, logger *zap.Logger, botManager *protocol.BotManager) error {
+ // 为每个 bot 初始化数据库表
+ bots := botManager.GetAll()
+ for _, bot := range bots {
+ botID := bot.GetID()
+ db, err := dbService.GetDB(botID)
+ if err != nil {
+ logger.Error("Failed to get database for bot",
+ zap.String("bot_id", botID),
+ zap.Error(err))
+ continue
+ }
+
+ // 创建表(使用原始 SQL 避免循环依赖)
+ // 注意:虽然使用 fmt.Sprintf,但 tableName 已经通过 sanitizeTableName 清理过,相对安全
+ tableName := database.GetTableName(botID, "mc_server_binds")
+ // 使用参数化查询更安全,但 SQLite 的 CREATE TABLE 不支持参数化表名
+ // 所以这里使用清理过的表名是合理的
+ if err := db.Exec(fmt.Sprintf(`
+ CREATE TABLE IF NOT EXISTS %s (
+ id TEXT PRIMARY KEY,
+ server_ip TEXT NOT NULL
+ )
+ `, tableName)).Error; err != nil {
+ logger.Error("Failed to create table",
+ zap.String("bot_id", botID),
+ zap.String("table", tableName),
+ zap.Error(err))
+ } else {
+ logger.Info("Database table initialized",
+ zap.String("bot_id", botID),
+ zap.String("table", tableName))
+ }
+ }
+
+ // 初始化插件数据库
+ mcstatus.InitDatabase(dbService)
+ return nil
+}
+
func ProvideMilkyBots(cfg *config.Config, logger *zap.Logger, eventBus *engine.EventBus, wsManager *net.WebSocketManager, botManager *protocol.BotManager, lc fx.Lifecycle) error {
for _, botCfg := range cfg.Bots {
if botCfg.Protocol == "milky" && botCfg.Enabled {
@@ -214,10 +264,12 @@ var Providers = fx.Options(
ProvidePluginRegistry,
ProvideBotManager,
ProvideWebSocketManager,
+ ProvideDatabase,
ProvideServer,
),
fx.Invoke(ProvideMilkyBots),
fx.Invoke(ProvideOneBot11Bots),
fx.Invoke(LoadPlugins),
fx.Invoke(LoadScheduledJobs),
+ fx.Invoke(InitMCStatusDatabase),
)
diff --git a/internal/engine/dispatcher.go b/internal/engine/dispatcher.go
index d142d7d..ce93990 100644
--- a/internal/engine/dispatcher.go
+++ b/internal/engine/dispatcher.go
@@ -33,41 +33,26 @@ type Dispatcher struct {
scheduler *Scheduler
metrics DispatcherMetrics
mu sync.RWMutex
- workerPool chan struct{} // 工作池,限制并发数
- maxWorkers int
- async bool // 是否异步处理
totalTime int64 // 总处理时间(纳秒)
}
// NewDispatcher 创建事件分发器
func NewDispatcher(eventBus *EventBus, logger *zap.Logger) *Dispatcher {
- return NewDispatcherWithConfig(eventBus, logger, 100, true)
-}
-
-// NewDispatcherWithScheduler 创建带调度器的事件分发器
-func NewDispatcherWithScheduler(eventBus *EventBus, logger *zap.Logger, scheduler *Scheduler) *Dispatcher {
- dispatcher := NewDispatcherWithConfig(eventBus, logger, 100, true)
- dispatcher.scheduler = scheduler
- return dispatcher
-}
-
-// NewDispatcherWithConfig 使用配置创建事件分发器
-func NewDispatcherWithConfig(eventBus *EventBus, logger *zap.Logger, maxWorkers int, async bool) *Dispatcher {
- if maxWorkers <= 0 {
- maxWorkers = 100
- }
-
return &Dispatcher{
handlers: make([]protocol.EventHandler, 0),
middlewares: make([]protocol.Middleware, 0),
logger: logger.Named("dispatcher"),
eventBus: eventBus,
- workerPool: make(chan struct{}, maxWorkers),
- maxWorkers: maxWorkers,
- async: async,
}
}
+// NewDispatcherWithScheduler 创建带调度器的事件分发器
+func NewDispatcherWithScheduler(eventBus *EventBus, logger *zap.Logger, scheduler *Scheduler) *Dispatcher {
+ dispatcher := NewDispatcher(eventBus, logger)
+ dispatcher.scheduler = scheduler
+ return dispatcher
+}
+
// RegisterHandler 注册事件处理器
func (d *Dispatcher) RegisterHandler(handler protocol.EventHandler) {
d.mu.Lock()
@@ -155,29 +140,42 @@ func (d *Dispatcher) GetScheduler() *Scheduler {
// eventLoop 事件循环
func (d *Dispatcher) eventLoop(ctx context.Context, eventChan chan protocol.Event) {
+ // 使用独立的 context,避免应用关闭时取消正在处理的事件
+ // 即使应用关闭,也要让正在处理的事件完成
+ shutdown := false
+
for {
select {
case event, ok := <-eventChan:
if !ok {
+ d.logger.Info("Event channel closed, stopping event loop")
return
}
- if d.IsAsync() {
- // 异步处理,使用工作池限制并发
- d.workerPool <- struct{}{} // 获取工作槽位
- go func(e protocol.Event) {
- defer func() {
- <-d.workerPool // 释放工作槽位
- }()
- d.handleEvent(ctx, e)
- }(event)
- } else {
- // 同步处理
- d.handleEvent(ctx, event)
- }
+ d.logger.Debug("Event received in eventLoop",
+ zap.String("type", string(event.GetType())),
+ zap.String("detail_type", event.GetDetailType()),
+ zap.String("self_id", event.GetSelfID()),
+ zap.Bool("shutdown", shutdown))
+
+ // 为每个事件创建独立的 context,避免应用关闭时取消正在处理的事件
+ // 使用独立的 context,允许处理完成
+ handlerCtx, handlerCancel := context.WithTimeout(context.Background(), 5*time.Minute)
+
+ // 直接使用 goroutine 处理事件,Go 的调度器会自动管理
+ go func(e protocol.Event) {
+ defer handlerCancel()
+ d.handleEvent(handlerCtx, e)
+ }(event)
case <-ctx.Done():
- return
+ // 当应用关闭时,标记为关闭状态,但继续处理 channel 中的事件
+ if !shutdown {
+ d.logger.Info("Context cancelled, will continue processing events until channel closes")
+ shutdown = true
+ }
+ // 继续处理 channel 中的事件,直到 channel 关闭
+ // 不再检查 ctx.Done(),只等待 channel 关闭
}
}
}
@@ -347,17 +345,3 @@ func (d *Dispatcher) LogMetrics() {
zap.Int("handler_count", d.GetHandlerCount()),
zap.Int("middleware_count", d.GetMiddlewareCount()))
}
-
-// SetAsync 设置是否异步处理
-func (d *Dispatcher) SetAsync(async bool) {
- d.mu.Lock()
- defer d.mu.Unlock()
- d.async = async
-}
-
-// IsAsync 是否异步处理
-func (d *Dispatcher) IsAsync() bool {
- d.mu.RLock()
- defer d.mu.RUnlock()
- return d.async
-}
diff --git a/internal/engine/eventbus.go b/internal/engine/eventbus.go
index 49581f1..9a168d8 100644
--- a/internal/engine/eventbus.go
+++ b/internal/engine/eventbus.go
@@ -243,7 +243,16 @@ func (eb *EventBus) dispatchEvent(event protocol.Event) {
atomic.AddInt64(&eb.metrics.DroppedTotal, 1)
eb.logger.Warn("Subscription channel full, event dropped",
zap.String("sub_id", sub.ID),
- zap.String("event_type", key))
+ zap.String("event_type", key),
+ zap.String("detail_type", event.GetDetailType()),
+ zap.String("raw_message", func() string {
+ if data := event.GetData(); data != nil {
+ if msg, ok := data["raw_message"].(string); ok {
+ return msg
+ }
+ }
+ return ""
+ }()))
}
}
}
diff --git a/internal/plugins/echo/echo_new.go b/internal/plugins/echo/echo_new.go
index 122db8a..13aba1d 100644
--- a/internal/plugins/echo/echo_new.go
+++ b/internal/plugins/echo/echo_new.go
@@ -2,6 +2,7 @@ package echo
import (
"context"
+ "strings"
"cellbot/internal/engine"
"cellbot/internal/protocol"
@@ -10,56 +11,27 @@ import (
)
func init() {
- // 在 init 函数中注册多个处理函数(类似 ZeroBot 风格)
-
- // 处理私聊消息(使用 OnlyPrivate 中间件,虽然 OnPrivateMessage 已经匹配私聊,这里作为示例)
- engine.OnPrivateMessage().
- Use(engine.OnlyPrivate()). // 只在私聊中响应
+ // 注册 /echo 命令
+ engine.OnCommand("/echo").
Handle(func(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
// 获取消息内容
data := event.GetData()
- message, ok := data["message"]
+ rawMessage, ok := data["raw_message"].(string)
if !ok {
return nil
}
- userID, ok := data["user_id"]
- if !ok {
- return nil
+ // 解析命令参数(/echo 后面的内容)
+ parts := strings.Fields(rawMessage)
+ if len(parts) < 2 {
+ // 如果没有参数,返回提示
+ return event.ReplyText(ctx, botManager, logger, "用法: /echo <消息内容>")
}
- // 获取 bot 实例
- selfID := event.GetSelfID()
- bot, ok := botManager.Get(selfID)
- if !ok {
- bots := botManager.GetAll()
- if len(bots) == 0 {
- logger.Error("No bot instance available")
- return nil
- }
- bot = bots[0]
- }
+ // 获取要回显的内容
+ echoContent := strings.Join(parts[1:], " ")
- // 发送回复
- action := &protocol.BaseAction{
- Type: protocol.ActionTypeSendPrivateMessage,
- Params: map[string]interface{}{
- "user_id": userID,
- "message": message,
- },
- }
-
- _, err := bot.SendAction(ctx, action)
- if err != nil {
- logger.Error("Failed to send reply", zap.Error(err))
- return err
- }
-
- logger.Info("Echo reply sent", zap.Any("user_id", userID))
- return nil
+ // 使用 ReplyText 发送回复
+ return event.ReplyText(ctx, botManager, logger, echoContent)
})
-
- // 可以继续注册更多处理函数
- // engine.OnGroupMessage().Handle(...)
- // engine.OnCommand("help").Handle(...)
}
diff --git a/internal/plugins/mcstatus/mcstatus.go b/internal/plugins/mcstatus/mcstatus.go
new file mode 100644
index 0000000..a29dd64
--- /dev/null
+++ b/internal/plugins/mcstatus/mcstatus.go
@@ -0,0 +1,375 @@
+package mcstatus
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "cellbot/internal/database"
+ "cellbot/internal/engine"
+ "cellbot/internal/protocol"
+ "cellbot/pkg/utils"
+
+ "go.uber.org/zap"
+)
+
+// ServerBind 服务器绑定模型
+type ServerBind struct {
+ ID string `gorm:"primaryKey"` // group_id
+ ServerIP string `gorm:"not null"` // 服务器IP
+}
+
+// TableName 指定表名(基础表名,实际使用时需要添加 botID 前缀)
+func (ServerBind) TableName() string {
+ return "mc_server_binds"
+}
+
+var dbService database.Database
+
+func init() {
+ // 注册命令(数据库将在依赖注入时初始化)
+ engine.OnCommand("/mcs").
+ Priority(100).
+ Handle(handleMCSCommand)
+
+ engine.OnCommand("/mcsBind").
+ Priority(100).
+ Handle(handleMCSBindCommand)
+}
+
+// InitDatabase 初始化数据库(由依赖注入调用)
+func InitDatabase(database database.Database) error {
+ dbService = database
+ return nil
+}
+
+// handleMCSCommand 处理 /mcs 命令
+func handleMCSCommand(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
+ logger.Info("handleMCSCommand called",
+ zap.String("self_id", event.GetSelfID()),
+ zap.String("detail_type", event.GetDetailType()))
+
+ data := event.GetData()
+
+ // 获取原始消息内容
+ rawMessage, ok := data["raw_message"].(string)
+ if !ok {
+ logger.Warn("raw_message not found in event data")
+ return nil
+ }
+
+ logger.Info("Processing /mcs command",
+ zap.String("raw_message", rawMessage))
+
+ // 解析命令参数(/mcs 后面的内容)
+ var serverIP string
+ parts := strings.Fields(rawMessage)
+ if len(parts) > 1 {
+ serverIP = strings.Join(parts[1:], " ")
+ }
+
+ // 如果没有提供 IP,尝试从群绑定或默认配置获取
+ if serverIP == "" {
+ groupID, _ := data["group_id"]
+ botID := event.GetSelfID()
+ if groupID != nil && dbService != nil && botID != "" {
+ db, err := dbService.GetDB(botID)
+ if err != nil {
+ logger.Warn("Failed to get database for bot",
+ zap.String("bot_id", botID),
+ zap.Error(err))
+ } else {
+ var bind ServerBind
+ tableName := database.GetTableName(botID, "mc_server_binds")
+ if err := db.Table(tableName).Where("id = ?", fmt.Sprintf("%v", groupID)).First(&bind).Error; err != nil {
+ logger.Debug("No server bind found for group",
+ zap.String("bot_id", botID),
+ zap.Any("group_id", groupID),
+ zap.Error(err))
+ } else {
+ serverIP = bind.ServerIP
+ }
+ }
+ }
+ // 如果还是没有,使用默认值
+ if serverIP == "" {
+ serverIP = "mc.hypixel.net" // 默认服务器
+ }
+ }
+
+ // 解析服务器地址和端口
+ host := serverIP
+ port := 25565
+ if strings.Contains(serverIP, ":") {
+ parts := strings.Split(serverIP, ":")
+ host = parts[0]
+ fmt.Sscanf(parts[1], "%d", &port)
+ }
+
+ logger.Info("Querying Minecraft server",
+ zap.String("host", host),
+ zap.Int("port", port))
+
+ // 查询服务器状态
+ status, err := Ping(host, port, 10*time.Second)
+ if err != nil {
+ logger.Error("Failed to ping server", zap.Error(err))
+
+ // 使用 ReplyText 发送错误消息
+ errorMsg := fmt.Sprintf("查询失败: %v", err)
+ event.ReplyText(ctx, botManager, logger, errorMsg)
+ return err
+ }
+
+ // 构建 HTML 模板
+ htmlTemplate := buildStatusHTML(status)
+
+ // 配置截图选项
+ opts := &utils.ScreenshotOptions{
+ Width: 1200,
+ Height: 800,
+ Timeout: 60 * time.Second,
+ WaitTime: 3 * time.Second,
+ FullPage: false,
+ Quality: 90,
+ Logger: logger,
+ }
+
+ // 使用独立的 context 进行截图,避免受 dispatcher context 影响
+ // 如果 dispatcher context 被取消,截图操作仍能完成
+ screenshotCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ // 渲染并截图
+ chain, err := utils.ScreenshotHTMLToMessageChain(screenshotCtx, htmlTemplate, opts)
+ if err != nil {
+ logger.Error("Failed to render status image", zap.Error(err))
+ return err
+ }
+
+ // 使用 Reply 发送图片
+ err = event.Reply(ctx, botManager, logger, chain)
+ if err != nil {
+ logger.Error("Failed to send status image", zap.Error(err))
+ return err
+ }
+
+ logger.Info("Minecraft server status sent",
+ zap.String("host", host),
+ zap.Int("port", port))
+
+ return nil
+}
+
+// handleMCSBindCommand 处理 /mcsBind 命令
+func handleMCSBindCommand(ctx context.Context, event protocol.Event, botManager *protocol.BotManager, logger *zap.Logger) error {
+ data := event.GetData()
+
+ // 只允许群聊使用
+ if event.GetDetailType() != "group" {
+ return event.ReplyText(ctx, botManager, logger, "此命令只能在群聊中使用")
+ }
+
+ // 获取原始消息内容
+ rawMessage, ok := data["raw_message"].(string)
+ if !ok {
+ return nil
+ }
+
+ // 解析命令参数(/mcsBind 后面的内容)
+ parts := strings.Fields(rawMessage)
+ if len(parts) < 2 {
+ groupID, _ := data["group_id"]
+ errorMsg := protocol.NewMessageChain(
+ protocol.NewTextSegment("用法: /mcsBind <服务器IP>"),
+ )
+
+ action := &protocol.BaseAction{
+ Type: protocol.ActionTypeSendGroupMessage,
+ Params: map[string]interface{}{
+ "group_id": groupID,
+ "message": errorMsg,
+ },
+ }
+
+ selfID := event.GetSelfID()
+ bot, ok := botManager.Get(selfID)
+ if !ok {
+ bots := botManager.GetAll()
+ if len(bots) > 0 {
+ bot = bots[0]
+ }
+ }
+ if bot != nil {
+ bot.SendAction(ctx, action)
+ }
+ return nil
+ }
+
+ serverIP := strings.Join(parts[1:], " ")
+ groupID, _ := data["group_id"]
+ groupIDStr := fmt.Sprintf("%v", groupID)
+ botID := event.GetSelfID()
+
+ // 保存绑定到数据库
+ if dbService != nil && botID != "" {
+ db, err := dbService.GetDB(botID)
+ if err == nil {
+ // 自动迁移表(如果不存在)
+ tableName := database.GetTableName(botID, "mc_server_binds")
+ if err := db.Table(tableName).AutoMigrate(&ServerBind{}); err != nil {
+ logger.Error("Failed to migrate table", zap.Error(err))
+ } else {
+ bind := ServerBind{
+ ID: groupIDStr,
+ ServerIP: serverIP,
+ }
+ // 使用 Save 方法,如果存在则更新,不存在则创建
+ if err := db.Table(tableName).Save(&bind).Error; err != nil {
+ logger.Error("Failed to save server bind", zap.Error(err))
+ }
+ }
+ }
+ }
+
+ // 使用 ReplyText 发送确认消息
+ successMsg := fmt.Sprintf("已绑定服务器 %s 到本群", serverIP)
+ err := event.ReplyText(ctx, botManager, logger, successMsg)
+ if err != nil {
+ return err
+ }
+
+ logger.Info("Minecraft server bound",
+ zap.String("group_id", groupIDStr),
+ zap.String("server_ip", serverIP))
+ return nil
+}
+
+// buildStatusHTML 构建服务器状态 HTML
+func buildStatusHTML(status *ServerStatus) string {
+ iconHTML := ""
+ if status.Favicon != "" {
+ iconHTML = fmt.Sprintf(``, status.Favicon)
+ }
+
+ return fmt.Sprintf(`
+
+
+