commit e8c641414e3b196c8248d7f76ea41711ba5fc862 Author: zhilv Date: Tue May 5 13:41:44 2026 +0800 ✨ feat(core): 初始化 Bot 命令处理服务器 - 基于 Gin 框架搭建 HTTP 服务,接收并处理 Bot 命令请求 - 实现插件化命令系统,支持通过 Plugin 接口扩展新命令 - 内置菜单、启用/禁用、时间查询等基础命令 - 新增图片生成插件,对接 OpenAI Images API - 支持管理员权限控制、命令动态启禁用 - 提供完整配置管理(.env)与 Docker 部署方案 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d5b50a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +.env.example +.git +.claude +images/ +README.md +command-server +command-server.exe diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92e3b98 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Server +SERVER_ADDR=127.0.0.1:8000 + +# OpenAI Image Generation +OPENAI_API_KEY=sk-xxxxxx +OPENAI_BASE_URL=https://api.openai.com +OPENAI_MODEL=gpt-image-2 +REQUEST_TIMEOUT=120 +IMAGE_COUNT=1 +IMAGE_OUTPUT_DIR=images + +# Upload +UPLOAD_URL=http://localhost:8082/upload +UPLOAD_API_KEY=xxxx + +# Proxy (leave empty to disable) +PROXY= + +# Admin (comma-separated user IDs) +ADMIN_USERS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c97b8d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +images/ +command-server +.claude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4189a07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS TARGETARCH + +RUN apk add --no-cache tzdata + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /command-server . + +FROM scratch + +# 时区(固定 Asia/Shanghai) +ENV TZ=Asia/Shanghai +COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + +# CA 证书(HTTPS 请求必需) +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +COPY --from=builder /command-server / + +EXPOSE 8000 + +ENTRYPOINT ["/command-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f2f39c --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# NcatBot Command Server + +基于 Go 的 Bot 命令处理服务器,支持插件化扩展命令。 + +## 项目结构 + +``` +command-server/ +├── main.go # 入口 +├── config/ +│ └── config.go # 配置加载(从 .env) +├── command/ +│ ├── types.go # 共享类型(Req, Resp, Message) +│ ├── plugin.go # Plugin 接口定义 +│ ├── registry.go # 命令注册表 +│ ├── admin.go # 管理员权限检查 +│ ├── init.go # 内置命令初始化 +│ └── builtin.go # 内置命令(菜单/启用/禁用/时间) +├── plugins/ +│ ├── plugins.go # 统一导入所有插件 +│ └── image/ +│ └── image.go # 图片生成插件 +├── Dockerfile +├── docker-compose.yml +├── .env # 环境变量配置 +└── .env.example # 配置模板 +``` + +## 快速开始 + +### 本地运行 + +```bash +# 安装依赖 +go mod download + +# 复制配置文件并填写 +cp .env.example .env + +# 运行 +go run . +``` + +### Docker 运行 + +```bash +docker compose up -d --build + +docker login gitea.kmux.cn +docker buildx build --platform linux/amd64,linux/arm64 -t gitea.kmux.cn/ncatbot/command-server:0.0.1 --no-cache . +docker push gitea.kmux.cn/ncatbot/command-server:0.0.1 +``` + +## 配置说明 + +复制 `.env.example` 为 `.env`,按需修改: + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `SERVER_ADDR` | `127.0.0.1:8000` | 服务监听地址 | +| `OPENAI_API_KEY` | - | OpenAI API 密钥 | +| `OPENAI_BASE_URL` | `https://api.openai.com` | API 基础地址 | +| `OPENAI_MODEL` | `gpt-image-2` | 图片生成模型 | +| `REQUEST_TIMEOUT` | `120` | 请求超时(秒) | +| `IMAGE_COUNT` | `1` | 单次生成图片数 | +| `IMAGE_OUTPUT_DIR` | `images` | 图片本地暂存目录 | +| `UPLOAD_URL` | - | 图片上传接口地址 | +| `UPLOAD_API_KEY` | - | 上传接口 API Key | +| `PROXY` | - | HTTP 代理地址 | +| `ADMIN_USERS` | - | 管理员用户ID,逗号分隔 | + +## 内置命令 + +| 命令 | 说明 | 权限 | +|------|------|------| +| `菜单` | 显示所有可用命令 | 所有人 | +| `启用 <命令名>` | 启用指定命令 | 管理员 | +| `禁用 <命令名>` | 禁用指定命令 | 管理员 | +| `时间` | 查询当前时间 | 所有人 | + +## 插件命令 + +| 命令 | 说明 | 权限 | +|------|------|------| +| `生图 <描述>` | 根据描述生成图片 | 所有人 | + +## 添加新插件 + +### 1. 创建插件文件 + +新建 `plugins/weather/weather.go`: + +```go +package weather + +import ( + "ncatbot-command-server/command" +) + +type Plugin struct{} + +func init() { + command.RegisterPlugin(&Plugin{}) +} + +func (p *Plugin) Name() string { return "天气" } +func (p *Plugin) Description() string { return "查询天气" } +func (p *Plugin) Usage() string { return "天气 <城市>" } +func (p *Plugin) Run(req *command.Req) command.Resp { + return command.Resp{Reply: "今天晴天"} +} +``` + +### 2. 注册插件 + +在 `plugins/plugins.go` 中添加导入: + +```go +import ( + _ "ncatbot-command-server/plugins/image" + _ "ncatbot-command-server/plugins/weather" // 新增 +) +``` + +### Plugin 接口 + +| 接口 | 方法 | 必须实现 | +|------|------|----------| +| `Plugin` | `Name()`, `Description()`, `Usage()`, `Run()` | 是 | +| `PluginWithInit` | 加 `Init()` | 需要初始化时 | +| `PluginAdmin` | 加 `IsAdminOnly()` | 管理员专用时 | + +## 请求格式 + +```json +POST / + +{ + "command": "生图", + "content": "一只猫", + "raw_message": "生图 一只猫", + "user_id": "123456", + "group_id": "789", + "message_id": "abc" +} +``` + +## 响应格式 + +文本回复: + +```json +{ + "reply": "当前时间是: 2026-05-04 16:00:00" +} +``` + +图片回复: + +```json +{ + "messages": [ + { + "type": "image", + "url": "https://example.com/image.png" + } + ] +} +``` + +## License + +MIT diff --git a/command/admin.go b/command/admin.go new file mode 100644 index 0000000..4bf6bbe --- /dev/null +++ b/command/admin.go @@ -0,0 +1,20 @@ +package command + +import ( + "ncatbot-command-server/config" + "strings" +) + +// IsAdmin 检查用户是否为管理员 +func IsAdmin(userID string) bool { + admins := config.Cfg.AdminUsers + if admins == "" { + return false + } + for _, id := range strings.Split(admins, ",") { + if strings.TrimSpace(id) == userID { + return true + } + } + return false +} diff --git a/command/builtin.go b/command/builtin.go new file mode 100644 index 0000000..bddad3a --- /dev/null +++ b/command/builtin.go @@ -0,0 +1,92 @@ +package command + +import ( + "fmt" + "strings" + "time" +) + +// RegisterBuiltinCommands 注册内置命令 +func RegisterBuiltinCommands() { + // 菜单 + Register(Command{ + Name: "菜单", + Description: "显示所有可用命令", + Usage: "菜单", + Handler: handleMenu, + }) + + // 启用(管理员) + Register(Command{ + Name: "启用", + Description: "启用指定命令", + Usage: "启用 <命令名>", + Handler: handleEnable, + AdminOnly: true, + }) + + // 禁用(管理员) + Register(Command{ + Name: "禁用", + Description: "禁用指定命令", + Usage: "禁用 <命令名>", + Handler: handleDisable, + AdminOnly: true, + }) + + // 时间 + Register(Command{ + Name: "时间", + Description: "查询当前时间", + Usage: "时间", + Handler: func(req *Req) Resp { + return Resp{Reply: "当前时间是: " + time.Now().Format("2006-01-02 15:04:05")} + }, + }) +} + +func handleMenu(req *Req) Resp { + var sb strings.Builder + sb.WriteString("可用命令列表:\n\n") + + for _, cmd := range AllCommands() { + status := "✅" + if !cmd.Enabled { + status = "❌" + } + adminTag := "" + if cmd.AdminOnly { + adminTag = " [管理员]" + } + sb.WriteString(fmt.Sprintf("%s %s%s — %s\n", status, cmd.Name, adminTag, cmd.Description)) + sb.WriteString(fmt.Sprintf(" 用法:%s\n\n", cmd.Usage)) + } + + return Resp{Reply: strings.TrimSpace(sb.String())} +} + +func handleEnable(req *Req) Resp { + name := strings.TrimSpace(req.Content) + if name == "" { + return Resp{Reply: "请指定要启用的命令,例如:启用 生图"} + } + + if err := Toggle(name, true); err != nil { + return Resp{Reply: err.Error()} + } + + return Resp{Reply: fmt.Sprintf("已启用命令\"%s\"", name)} +} + +func handleDisable(req *Req) Resp { + name := strings.TrimSpace(req.Content) + if name == "" { + return Resp{Reply: "请指定要禁用的命令,例如:禁用 问候"} + } + + if err := Toggle(name, false); err != nil { + return Resp{Reply: err.Error()} + } + + return Resp{Reply: fmt.Sprintf("已禁用命令\"%s\"", name)} +} diff --git a/command/init.go b/command/init.go new file mode 100644 index 0000000..b8fb164 --- /dev/null +++ b/command/init.go @@ -0,0 +1,11 @@ +package command + +import ( + "log" +) + +// Init 初始化命令系统(仅注册内置命令,插件由 main.go 调用) +func Init() { + RegisterBuiltinCommands() + log.Printf("registered %d commands", len(AllCommands())) +} diff --git a/command/plugin.go b/command/plugin.go new file mode 100644 index 0000000..b539ce7 --- /dev/null +++ b/command/plugin.go @@ -0,0 +1,20 @@ +package command + +// Plugin 命令插件接口 +type Plugin interface { + Name() string // 命令名 + Description() string // 命令说明 + Usage() string // 用法提示 + Run(req *Req) Resp // 执行命令 +} + +// PluginWithInit 支持初始化的插件接口 +type PluginWithInit interface { + Plugin + Init() // 初始化函数 +} + +// PluginAdmin 可选接口:标记是否仅管理员可用 +type PluginAdmin interface { + IsAdminOnly() bool +} diff --git a/command/registry.go b/command/registry.go new file mode 100644 index 0000000..e353d06 --- /dev/null +++ b/command/registry.go @@ -0,0 +1,167 @@ +package command + +import ( + "fmt" + "sync" +) + +var ( + plugins []Plugin + commands map[string]*commandInfo + mu sync.RWMutex +) + +type commandInfo struct { + name string + description string + usage string + handler Handler + initFunc func() + enabled bool + adminOnly bool +} + +func init() { + plugins = make([]Plugin, 0) + commands = make(map[string]*commandInfo) +} + +// RegisterPlugin 注册一个插件(推荐方式) +func RegisterPlugin(p Plugin) { + mu.Lock() + defer mu.Unlock() + + plugins = append(plugins, p) + + info := &commandInfo{ + name: p.Name(), + description: p.Description(), + usage: p.Usage(), + handler: p.Run, + enabled: true, + } + + // 检查是否实现了 PluginWithInit + if initable, ok := p.(PluginWithInit); ok { + info.initFunc = initable.Init + } + + // 检查是否实现了 PluginAdmin + if admin, ok := p.(PluginAdmin); ok { + info.adminOnly = admin.IsAdminOnly() + } + + commands[info.name] = info +} + +// Register 兼容旧方式:直接注册 Command +func Register(cmd Command) { + mu.Lock() + defer mu.Unlock() + commands[cmd.Name] = &commandInfo{ + name: cmd.Name, + description: cmd.Description, + usage: cmd.Usage, + handler: cmd.Handler, + initFunc: cmd.Init, + enabled: true, + adminOnly: cmd.AdminOnly, + } +} + +// InitAll 初始化所有需要初始化的插件 +func InitAll() { + mu.RLock() + defer mu.RUnlock() + for _, info := range commands { + if info.initFunc != nil { + info.initFunc() + } + } +} + +// Handle 查找并执行命令 +func Handle(req *Req) Resp { + mu.RLock() + info, ok := commands[req.Command] + mu.RUnlock() + + if !ok { + return Resp{Reply: "无法识别的命令。发送\"菜单\"查看可用命令。"} + } + + if !info.enabled { + return Resp{Reply: fmt.Sprintf("命令\"%s\"已被禁用。", info.name)} + } + + if info.adminOnly && !IsAdmin(req.UserID) { + return Resp{Reply: "权限不足,该命令仅管理员可用。"} + } + + return info.handler(req) +} + +// AllPlugins 返回所有插件 +func AllPlugins() []Plugin { + mu.RLock() + defer mu.RUnlock() + return append([]Plugin(nil), plugins...) +} + +// AllCommands 返回所有命令信息(用于菜单) +func AllCommands() []CommandInfo { + mu.RLock() + defer mu.RUnlock() + + result := make([]CommandInfo, 0, len(commands)) + for _, info := range commands { + result = append(result, CommandInfo{ + Name: info.name, + Description: info.description, + Usage: info.usage, + Enabled: info.enabled, + AdminOnly: info.adminOnly, + }) + } + return result +} + +// CommandInfo 命令信息(用于菜单显示) +type CommandInfo struct { + Name string + Description string + Usage string + Enabled bool + AdminOnly bool +} + +// Toggle 启用或禁用命令 +func Toggle(name string, enable bool) error { + mu.Lock() + defer mu.Unlock() + + info, ok := commands[name] + if !ok { + return fmt.Errorf("命令\"%s\"不存在", name) + } + + if info.adminOnly { + return fmt.Errorf("管理命令\"%s\"不可被禁用", name) + } + + info.enabled = enable + return nil +} + +// Handler 命令处理函数(兼容旧方式) +type Handler func(req *Req) Resp + +// Command 命令定义(兼容旧方式) +type Command struct { + Name string + Description string + Usage string + Handler Handler + Init func() + AdminOnly bool +} diff --git a/command/types.go b/command/types.go new file mode 100644 index 0000000..23de9eb --- /dev/null +++ b/command/types.go @@ -0,0 +1,35 @@ +package command + +// Req 请求结构体 +type Req struct { + Command string `json:"command"` + Content string `json:"content"` + RawMessage string `json:"raw_message"` + UserID string `json:"user_id"` + GroupID string `json:"group_id"` + MessageID string `json:"message_id"` +} + +// Resp 响应结构体 +type Resp struct { + Reply string `json:"reply,omitempty"` + Messages []Message `json:"messages,omitempty"` + UserID string `json:"user_id,omitempty"` +} + +// Message 消息结构体 +type Message struct { + Type MsgType `json:"type"` + Msg string `json:"msg,omitempty"` + URL string `json:"url,omitempty"` +} + +// MsgType 消息类型 +type MsgType string + +const ( + MsgTypeText MsgType = "text" + MsgTypeImage MsgType = "image" + MsgTypeFile MsgType = "file" + MsgTypeVideo MsgType = "video" +) diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..8495ccc --- /dev/null +++ b/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +var Cfg Config + +// API 路径常量 +const ( + ImageGenerationsPath = "/v1/images/generations" +) + +type Config struct { + // Server + ServerAddr string + + // OpenAI Image + OpenAIBaseURL string // API 基础地址,如 https://api.openai.com + OpenAIAPIKey string + OpenAIModel string + RequestTimeout int + ImageCount int + ImageOutputDir string + + // Upload + UploadURL string + UploadAPIKey string + + // Proxy + Proxy string + + // Admin + AdminUsers string +} + +func Load(envPath string) error { + if _, err := os.Stat(envPath); err == nil { + if err := godotenv.Load(envPath); err != nil { + return err + } + } + + Cfg = Config{ + ServerAddr: getEnv("SERVER_ADDR", "127.0.0.1:8000"), + OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api.openai.com"), + OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), + OpenAIModel: getEnv("OPENAI_MODEL", "gpt-image-2"), + RequestTimeout: getEnvInt("REQUEST_TIMEOUT", 120), + ImageCount: getEnvInt("IMAGE_COUNT", 1), + ImageOutputDir: getEnv("IMAGE_OUTPUT_DIR", "images"), + UploadURL: os.Getenv("UPLOAD_URL"), + UploadAPIKey: os.Getenv("UPLOAD_API_KEY"), + Proxy: os.Getenv("PROXY"), + AdminUsers: os.Getenv("ADMIN_USERS"), + } + + if Cfg.OpenAIAPIKey == "" { + log.Println("warn: OPENAI_API_KEY is empty") + } + + return nil +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getEnvInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + log.Printf("warn: invalid int for %s=%q, using default %d", key, v, def) + return def + } + return n +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b453da --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + command-server: + image: gitea.kmux.cn/ncatbot/command-server:0.0.1 + ports: + - "8000:8000" + env_file: + - .env + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./images:/images + restart: always diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..50ddd18 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module ncatbot-command-server + +go 1.25.6 + +require github.com/gin-gonic/gin v1.12.0 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..76d2e09 --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8a6da13 --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "log" + "ncatbot-command-server/command" + "ncatbot-command-server/config" + + // 统一加载所有插件 + _ "ncatbot-command-server/plugins" + + "github.com/gin-gonic/gin" +) + +func main() { + if err := config.Load(".env"); err != nil { + log.Fatalf("load config: %v", err) + } + + // 注册内置命令 + command.Init() + + // 初始化所有插件 + command.InitAll() + + r := gin.Default() + r.POST("/", handleCommand) + + log.Printf("server starting on %s", config.Cfg.ServerAddr) + if err := r.Run(config.Cfg.ServerAddr); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} + +func handleCommand(c *gin.Context) { + var req command.Req + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + log.Printf("request: command=%s user=%s group=%s", req.Command, req.UserID, req.GroupID) + + resp := command.Handle(&req) + c.JSON(200, resp) +} \ No newline at end of file diff --git a/plugins/image/image.go b/plugins/image/image.go new file mode 100644 index 0000000..7e1ed5e --- /dev/null +++ b/plugins/image/image.go @@ -0,0 +1,211 @@ +package image + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "ncatbot-command-server/command" + "ncatbot-command-server/config" + "os" + "path/filepath" + "time" + + "github.com/go-resty/resty/v2" +) + +// Plugin 图片生成插件 +type Plugin struct{} + +var imgClient *resty.Client +var uploadClient *resty.Client + +func init() { + command.RegisterPlugin(&Plugin{}) +} + +// Name 实现 Plugin 接口 +func (p *Plugin) Name() string { + return "生图" +} + +// Description 实现 Plugin 接口 +func (p *Plugin) Description() string { + return "根据描述生成图片" +} + +// Usage 实现 Plugin 接口 +func (p *Plugin) Usage() string { + return "生图 <描述>" +} + +// Init 实现 PluginWithInit 接口 +func (p *Plugin) Init() { + cfg := config.Cfg + imgClient = newHTTPClient(cfg, true) + uploadClient = newHTTPClient(cfg, false) + os.MkdirAll(cfg.ImageOutputDir, 0755) +} + +// Run 实现 Plugin 接口 +func (p *Plugin) Run(req *command.Req) command.Resp { + if req.Content == "" { + return command.Resp{Reply: "请输入生图描述,例如:生图 一只猫"} + } + + url, err := genImage(req.Content) + if err != nil { + log.Printf("gen image error: %v", err) + return command.Resp{Reply: "生成失败: " + err.Error()} + } + + return command.Resp{ + Messages: []command.Message{ + {Type: command.MsgTypeImage, URL: url}, + }, + } +} + +func newHTTPClient(cfg config.Config, withAuth bool) *resty.Client { + c := resty.New(). + SetTimeout(time.Duration(cfg.RequestTimeout) * time.Second) + if withAuth { + c.SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", "Bearer "+cfg.OpenAIAPIKey) + } + if cfg.Proxy != "" { + c.SetProxy(cfg.Proxy) + } + return c +} + +// --- API Response types --- + +type OpenAIImageResp struct { + Created int64 `json:"created"` + Data []struct { + URL string `json:"url"` + B64JSON string `json:"b64_json"` + RevisedPrompt string `json:"revised_prompt"` + } `json:"data"` +} + +type UploadResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Files []string `json:"files"` + Path string `json:"path"` + } `json:"data"` +} + +// --- Pipeline --- + +func genImage(prompt string) (string, error) { + cfg := config.Cfg + if cfg.OpenAIAPIKey == "" { + return "", errors.New("OPENAI_API_KEY not set") + } + + b64, err := requestImage(prompt) + if err != nil { + return "", err + } + + filePath, err := saveImage(b64) + if err != nil { + return "", err + } + + remotePath, err := uploadImage(filePath) + if err != nil { + return "", fmt.Errorf("upload failed: %w", err) + } + + os.Remove(filePath) + return remotePath, nil +} + +func requestImage(prompt string) (string, error) { + cfg := config.Cfg + + var result OpenAIImageResp + resp, err := imgClient.R(). + SetBody(map[string]any{ + "model": cfg.OpenAIModel, + "prompt": prompt, + "n": cfg.ImageCount, + "response_format": "b64_json", + }). + SetResult(&result). + Post(cfg.OpenAIBaseURL + config.ImageGenerationsPath) + + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode() != 200 { + var errResp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(resp.Body(), &errResp) == nil && errResp.Error.Message != "" { + return "", fmt.Errorf("%s", errResp.Error.Message) + } + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode(), resp.Status()) + } + + if len(result.Data) == 0 { + return "", errors.New("empty image result") + } + + return result.Data[0].B64JSON, nil +} + +func saveImage(b64 string) (string, error) { + cfg := config.Cfg + + imgBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("base64 decode failed: %w", err) + } + + filename := filepath.Join(cfg.ImageOutputDir, fmt.Sprintf("%d.png", time.Now().UnixNano())) + if err := os.WriteFile(filename, imgBytes, 0644); err != nil { + return "", fmt.Errorf("write file failed: %w", err) + } + + return filename, nil +} + +func uploadImage(filePath string) (string, error) { + cfg := config.Cfg + if cfg.UploadURL == "" { + return "", errors.New("UPLOAD_URL not set") + } + + filename := filepath.Base(filePath) + var result UploadResp + + resp, err := uploadClient.R(). + SetHeader("X-API-Key", cfg.UploadAPIKey). + SetFile(filename, filePath). + SetResult(&result). + Post(cfg.UploadURL) + + if err != nil { + return "", fmt.Errorf("upload request failed: %w", err) + } + + if resp.StatusCode() != 200 { + return "", fmt.Errorf("upload failed (status %d): %s", resp.StatusCode(), resp.Status()) + } + + if result.Code != 0 { + return "", fmt.Errorf("upload error: %s", result.Msg) + } + + return result.Data.Path, nil +} diff --git a/plugins/plugins.go b/plugins/plugins.go new file mode 100644 index 0000000..a01780d --- /dev/null +++ b/plugins/plugins.go @@ -0,0 +1,7 @@ +// Package plugins 统一导入所有插件 +package plugins + +import ( + // 导入所有插件(触发 init() 自动注册) + _ "ncatbot-command-server/plugins/image" +)