feat(core): 初始化 Bot 命令处理服务器

- 基于 Gin 框架搭建 HTTP 服务,接收并处理 Bot 命令请求
- 实现插件化命令系统,支持通过 Plugin 接口扩展新命令
- 内置菜单、启用/禁用、时间查询等基础命令
- 新增图片生成插件,对接 OpenAI Images API
- 支持管理员权限控制、命令动态启禁用
- 提供完整配置管理(.env)与 Docker 部署方案
This commit is contained in:
2026-05-05 13:41:44 +08:00
commit e8c641414e
18 changed files with 1071 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.env
.env.example
.git
.claude
images/
README.md
command-server
command-server.exe

20
.env.example Normal file
View File

@@ -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=

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
images/
command-server
.claude

28
Dockerfile Normal file
View File

@@ -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"]

173
README.md Normal file
View File

@@ -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

20
command/admin.go Normal file
View File

@@ -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
}

92
command/builtin.go Normal file
View File

@@ -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)}
}

11
command/init.go Normal file
View File

@@ -0,0 +1,11 @@
package command
import (
"log"
)
// Init 初始化命令系统(仅注册内置命令,插件由 main.go 调用)
func Init() {
RegisterBuiltinCommands()
log.Printf("registered %d commands", len(AllCommands()))
}

20
command/plugin.go Normal file
View File

@@ -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
}

167
command/registry.go Normal file
View File

@@ -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
}

35
command/types.go Normal file
View File

@@ -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"
)

87
config/config.go Normal file
View File

@@ -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
}

12
docker-compose.yml Normal file
View File

@@ -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

39
go.mod Normal file
View File

@@ -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
)

93
go.sum Normal file
View File

@@ -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=

44
main.go Normal file
View File

@@ -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)
}

211
plugins/image/image.go Normal file
View File

@@ -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
}

7
plugins/plugins.go Normal file
View File

@@ -0,0 +1,7 @@
// Package plugins 统一导入所有插件
package plugins
import (
// 导入所有插件(触发 init() 自动注册)
_ "ncatbot-command-server/plugins/image"
)