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(`icon`, status.Favicon) + } + + return fmt.Sprintf(` + + + + + + + +
+
+ %s +
Minecraft 服务器状态
+
+ +
+
+ 服务器地址: + %s +
+
+ 延迟: + %d ms +
+
+ 版本: + %s (协议 %d) +
+
+ 在线人数: + %d / %d +
+
+ +
+ 服务器描述:
+ %s +
+ + +
+ + +`, iconHTML, fmt.Sprintf("%s:%d", status.Host, status.Port), status.Latency, status.Version.Name, status.Version.Protocol, status.Players.Online, status.Players.Max, status.Description) +} diff --git a/internal/plugins/mcstatus/ping.go b/internal/plugins/mcstatus/ping.go new file mode 100644 index 0000000..f71a99e --- /dev/null +++ b/internal/plugins/mcstatus/ping.go @@ -0,0 +1,141 @@ +package mcstatus + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + mcnet "github.com/Tnze/go-mc/net" + pk "github.com/Tnze/go-mc/net/packet" +) + +// ServerStatus 服务器状态信息 +type ServerStatus struct { + Host string + Port int + Latency int64 // 延迟(毫秒) + Version Version + Players Players + Description string // MOTD + Favicon string // Base64 编码的图标 +} + +// Version 版本信息 +type Version struct { + Name string + Protocol int +} + +// Players 玩家信息 +type Players struct { + Online int + Max int +} + +// Ping 查询 Minecraft 服务器状态 +func Ping(host string, port int, timeout time.Duration) (*ServerStatus, error) { + // 连接服务器 + startTime := time.Now() + conn, err := mcnet.DialMC(fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return nil, fmt.Errorf("failed to connect: %w", err) + } + defer conn.Close() + + latency := time.Since(startTime).Milliseconds() + + // 发送握手包 + handshake := pk.Marshal( + 0x00, // Handshake packet ID + pk.VarInt(47), // Protocol version (1.8+) + pk.String(host), + pk.UnsignedShort(port), + pk.VarInt(1), // Next state: Status + ) + if err := conn.WritePacket(handshake); err != nil { + return nil, fmt.Errorf("failed to send handshake: %w", err) + } + + // 发送状态请求 + request := pk.Marshal(0x00) // Status request packet ID + if err := conn.WritePacket(request); err != nil { + return nil, fmt.Errorf("failed to send status request: %w", err) + } + + // 读取响应 + var response pk.Packet + if err := conn.ReadPacket(&response); err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // 解析响应 + var statusJSON pk.String + if err := response.Scan(&statusJSON); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // 解析 JSON 响应 + var statusData map[string]interface{} + if err := json.Unmarshal([]byte(statusJSON), &statusData); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + status := &ServerStatus{ + Host: host, + Port: port, + Latency: latency, + } + + // 解析 description + if desc, ok := statusData["description"].(map[string]interface{}); ok { + if text, ok := desc["text"].(string); ok { + status.Description = text + } else if text, ok := desc["extra"].([]interface{}); ok && len(text) > 0 { + // 处理 extra 数组 + var descText strings.Builder + for _, item := range text { + if itemMap, ok := item.(map[string]interface{}); ok { + if text, ok := itemMap["text"].(string); ok { + descText.WriteString(text) + } + } + } + status.Description = descText.String() + } + } else if desc, ok := statusData["description"].(string); ok { + status.Description = desc + } + + // 解析 version + if version, ok := statusData["version"].(map[string]interface{}); ok { + if name, ok := version["name"].(string); ok { + status.Version.Name = name + } + if protocol, ok := version["protocol"].(float64); ok { + status.Version.Protocol = int(protocol) + } + } + + // 解析 players + if players, ok := statusData["players"].(map[string]interface{}); ok { + if online, ok := players["online"].(float64); ok { + status.Players.Online = int(online) + } + if max, ok := players["max"].(float64); ok { + status.Players.Max = int(max) + } + } + + // 解析 favicon + if favicon, ok := statusData["favicon"].(string); ok { + // 移除 data:image/png;base64, 前缀 + if strings.HasPrefix(favicon, "data:image/png;base64,") { + status.Favicon = strings.TrimPrefix(favicon, "data:image/png;base64,") + } else { + status.Favicon = favicon + } + } + + return status, nil +} diff --git a/internal/plugins/welcome/welcome.go b/internal/plugins/welcome/welcome.go index 0ea1f63..62a537d 100644 --- a/internal/plugins/welcome/welcome.go +++ b/internal/plugins/welcome/welcome.go @@ -317,8 +317,8 @@ func buildWelcomeMessage(ctx context.Context, userID, operatorID interface{}, su // 配置截图选项 opts := &utils.ScreenshotOptions{ - Width: 800, - Height: 600, + Width: 1200, // 增加宽度,确保内容有足够空间 + Height: 800, // 增加高度,确保内容完整显示 Timeout: 60 * time.Second, // 增加超时时间到60秒 WaitTime: 3 * time.Second, // 增加等待时间,确保页面完全加载 FullPage: false, diff --git a/internal/protocol/event.go b/internal/protocol/event.go index e84975e..d83f188 100644 --- a/internal/protocol/event.go +++ b/internal/protocol/event.go @@ -1,19 +1,24 @@ package protocol -import "time" +import ( + "context" + "time" + + "go.uber.org/zap" +) // EventType 事件类型 type EventType string const ( // 事件类型常量 - EventTypeMessage EventType = "message" - EventTypeNotice EventType = "notice" - EventTypeRequest EventType = "request" - EventTypeMeta EventType = "meta" - EventTypeMessageSent EventType = "message_sent" - EventTypeNoticeSent EventType = "notice_sent" - EventTypeRequestSent EventType = "request_sent" + EventTypeMessage EventType = "message" + EventTypeNotice EventType = "notice" + EventTypeRequest EventType = "request" + EventTypeMeta EventType = "meta" + EventTypeMessageSent EventType = "message_sent" + EventTypeNoticeSent EventType = "notice_sent" + EventTypeRequestSent EventType = "request_sent" ) // Event 通用事件接口 @@ -31,6 +36,10 @@ type Event interface { GetSelfID() string // GetData 获取事件数据 GetData() map[string]interface{} + // Reply 在消息发生的群/私聊进行回复 + Reply(ctx context.Context, botManager *BotManager, logger *zap.Logger, message MessageChain) error + // ReplyText 在消息发生的群/私聊进行文本回复(便捷方法) + ReplyText(ctx context.Context, botManager *BotManager, logger *zap.Logger, text string) error } // BaseEvent 基础事件结构 @@ -73,6 +82,70 @@ func (e *BaseEvent) GetData() map[string]interface{} { return e.Data } +// Reply 在消息发生的群/私聊进行回复 +func (e *BaseEvent) Reply(ctx context.Context, botManager *BotManager, logger *zap.Logger, message MessageChain) error { + data := e.GetData() + userID, _ := data["user_id"] + groupID, _ := data["group_id"] + + // 获取 bot 实例 + selfID := e.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] + } + + // 根据消息类型发送回复 + var action *BaseAction + if e.GetDetailType() == "private" { + action = &BaseAction{ + Type: ActionTypeSendPrivateMessage, + Params: map[string]interface{}{ + "user_id": userID, + "message": message, + }, + } + } else { + // 群聊或其他有 group_id 的事件 + action = &BaseAction{ + Type: ActionTypeSendGroupMessage, + Params: map[string]interface{}{ + "group_id": groupID, + "message": message, + }, + } + } + + _, err := bot.SendAction(ctx, action) + if err != nil { + logger.Error("Failed to send reply", + zap.Any("user_id", userID), + zap.Any("group_id", groupID), + zap.String("detail_type", e.GetDetailType()), + zap.Error(err)) + return err + } + + logger.Debug("Reply sent", + zap.Any("user_id", userID), + zap.Any("group_id", groupID), + zap.String("detail_type", e.GetDetailType())) + return nil +} + +// ReplyText 在消息发生的群/私聊进行文本回复(便捷方法) +func (e *BaseEvent) ReplyText(ctx context.Context, botManager *BotManager, logger *zap.Logger, text string) error { + message := NewMessageChain( + NewTextSegment(text), + ) + return e.Reply(ctx, botManager, logger, message) +} + // MessageEvent 消息事件 type MessageEvent struct { BaseEvent diff --git a/pkg/utils/screenshot.go b/pkg/utils/screenshot.go index 8902f29..50b0687 100644 --- a/pkg/utils/screenshot.go +++ b/pkg/utils/screenshot.go @@ -74,6 +74,7 @@ func ScreenshotURL(ctx context.Context, url string, opts *ScreenshotOptions) (st var err error if opts.FullPage { err = chromedp.Run(ctx, + chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), // 设置视口大小 chromedp.Navigate(url), chromedp.WaitReady("body", chromedp.ByQuery), // 使用 WaitReady 等待页面完全加载 chromedp.Sleep(opts.WaitTime), @@ -156,6 +157,7 @@ func ScreenshotHTML(ctx context.Context, htmlContent string, opts *ScreenshotOpt opts.Logger.Debug("Taking full page screenshot") } err = chromedp.Run(ctx, + chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), // 设置视口大小 chromedp.Navigate(dataURL), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.Sleep(opts.WaitTime), @@ -166,6 +168,7 @@ func ScreenshotHTML(ctx context.Context, htmlContent string, opts *ScreenshotOpt opts.Logger.Debug("Taking viewport screenshot") } err = chromedp.Run(ctx, + chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), // 设置视口大小 chromedp.Navigate(dataURL), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.Sleep(opts.WaitTime),