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

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
}