diff --git a/config/config.go b/config/config.go index 4d10ff09..6d2a9634 100644 --- a/config/config.go +++ b/config/config.go @@ -46,6 +46,7 @@ type Settings struct { DeveloperLog bool `yaml:"developer_log"` Username string `yaml:"server_user_name"` Password string `yaml:"server_user_password"` + ImageLimit int `yaml:"image_sizelimit"` } // LoadConfig 从文件中加载配置并初始化单例配置 @@ -367,3 +368,16 @@ func GetServerUserPassword() string { } return instance.Settings.Password } + +// GetImageLimit 返回 ImageLimit 的值 +func GetImageLimit() int { + mu.Lock() + defer mu.Unlock() + + if instance == nil { + mylog.Println("Warning: instance is nil when trying to get image limit value.") + return 0 // 或者返回一个默认的 ImageLimit 值 + } + + return instance.Settings.ImageLimit +} diff --git a/go.mod b/go.mod index 85327ffa..69c24bbb 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/google/uuid v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mvdan/xurls v1.1.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect ) replace github.com/tencent-connect/botgo => ./botgo diff --git a/go.sum b/go.sum index 84585040..4f06c76d 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mvdan/xurls v1.1.0 h1:OpuDelGQ1R1ueQ6sSryzi6P+1RtBpfQHM8fJwlE45ww= github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/handlers/get_group_member_info.go b/handlers/get_group_member_info.go new file mode 100644 index 00000000..3b1ce185 --- /dev/null +++ b/handlers/get_group_member_info.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "github.com/hoshinonyaruko/gensokyo/callapi" + "github.com/hoshinonyaruko/gensokyo/mylog" + "github.com/tencent-connect/botgo/openapi" +) + +// 初始化handler,在程序启动时会被调用 +func init() { + callapi.RegisterHandler("get_group_member_info", getGroupMemberInfo) +} + +// 成员信息的结构定义 +type MemberInfo struct { + UserID int64 `json:"user_id"` + GroupID int64 `json:"group_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` + Sex string `json:"sex"` + Age int32 `json:"age"` + Area string `json:"area"` + JoinTime int32 `json:"join_time"` + LastSentTime int32 `json:"last_sent_time"` + Level string `json:"level"` + Role string `json:"role"` + Unfriendly bool `json:"unfriendly"` + Title string `json:"title"` + TitleExpireTime int64 `json:"title_expire_time"` + CardChangeable bool `json:"card_changeable"` + ShutUpTimestamp int64 `json:"shut_up_timestamp"` +} + +// 构建单个成员的响应数据 +func buildResponseForSingleMember(memberInfo *MemberInfo, echoValue interface{}) map[string]interface{} { + // 构建成员数据的映射 + memberMap := map[string]interface{}{ + "group_id": memberInfo.GroupID, + "user_id": memberInfo.UserID, + "nickname": memberInfo.Nickname, + "card": memberInfo.Card, + "sex": memberInfo.Sex, + "age": memberInfo.Age, + "area": memberInfo.Area, + "join_time": memberInfo.JoinTime, + "last_sent_time": memberInfo.LastSentTime, + "level": memberInfo.Level, + "role": memberInfo.Role, + "unfriendly": memberInfo.Unfriendly, + "title": memberInfo.Title, + "title_expire_time": memberInfo.TitleExpireTime, + "card_changeable": memberInfo.CardChangeable, + "shut_up_timestamp": memberInfo.ShutUpTimestamp, + } + + // 构建完整的响应映射 + response := map[string]interface{}{ + "retcode": 0, + "status": "ok", + "data": memberMap, + "echo": echoValue, + } + + return response +} + +// getGroupMemberInfo是处理获取群成员信息的函数 +func getGroupMemberInfo(client callapi.Client, api openapi.OpenAPI, apiv2 openapi.OpenAPI, message callapi.ActionMessage) { + // 使用虚拟数据构造 MemberInfo + memberInfo := &MemberInfo{ + UserID: 123456789, // 虚拟的 QQ 号 + GroupID: 987654321, // 虚拟的群号 + Nickname: "主人", // 虚拟昵称 + Card: "主人", + Sex: "unknown", // 性别未知 + Age: 20, // 虚拟年龄 + Area: "虚拟地区", + JoinTime: 1630416000, // 虚拟加群时间戳 + LastSentTime: 1630502400, // 虚拟最后发言时间戳 + Level: "1", // 虚拟成员等级 + Role: "member", // 角色为普通成员 + Unfriendly: false, // 没有不良记录 + Title: "虚拟头衔", + TitleExpireTime: 1630598800, // 虚拟头衔过期时间 + CardChangeable: true, // 允许修改群名片 + ShutUpTimestamp: 0, // 不在禁言中 + } + + // 构建响应JSON + responseJSON := buildResponseForSingleMember(memberInfo, message.Echo) + mylog.Printf("get_group_member_info: %s\n", responseJSON) + + // 发送响应回去 + err := client.SendMessage(responseJSON) + if err != nil { + mylog.Printf("发送消息时出错: %v", err) + } +} diff --git a/handlers/send_group_msg.go b/handlers/send_group_msg.go index 3eae0096..b59cc2bb 100644 --- a/handlers/send_group_msg.go +++ b/handlers/send_group_msg.go @@ -158,9 +158,19 @@ func generateGroupMessage(id string, foundItems map[string][]string, messageText MsgType: 0, // 默认文本类型 } } + // 首先压缩图片 默认不压缩 + compressedData, err := images.CompressSingleImage(imageData) + if err != nil { + mylog.Printf("Error compressing image: %v", err) + return &dto.MessageToCreate{ + Content: "错误: 压缩图片失败", + MsgID: id, + MsgType: 0, // 默认文本类型 + } + } // base64编码 - base64Encoded := base64.StdEncoding.EncodeToString(imageData) + base64Encoded := base64.StdEncoding.EncodeToString(compressedData) // 上传base64编码的图片并获取其URL imageURL, err := images.UploadBase64ImageToServer(base64Encoded) @@ -204,8 +214,18 @@ func generateGroupMessage(id string, foundItems map[string][]string, messageText mylog.Printf("failed to decode base64 image: %v", err) return nil } + // 首先压缩图片 默认不压缩 + compressedData, err := images.CompressSingleImage(fileImageData) + if err != nil { + mylog.Printf("Error compressing image: %v", err) + return &dto.MessageToCreate{ + Content: "错误: 压缩图片失败", + MsgID: id, + MsgType: 0, // 默认文本类型 + } + } // 将解码的图片数据转换回base64格式并上传 - imageURL, err := images.UploadBase64ImageToServer(base64.StdEncoding.EncodeToString(fileImageData)) + imageURL, err := images.UploadBase64ImageToServer(base64.StdEncoding.EncodeToString(compressedData)) if err != nil { mylog.Printf("failed to upload base64 image: %v", err) return nil diff --git a/handlers/send_guild_channel_msg.go b/handlers/send_guild_channel_msg.go index 8c011be7..28c3cc4b 100644 --- a/handlers/send_guild_channel_msg.go +++ b/handlers/send_guild_channel_msg.go @@ -8,6 +8,7 @@ import ( "github.com/hoshinonyaruko/gensokyo/callapi" "github.com/hoshinonyaruko/gensokyo/config" "github.com/hoshinonyaruko/gensokyo/idmap" + "github.com/hoshinonyaruko/gensokyo/images" "github.com/hoshinonyaruko/gensokyo/mylog" "github.com/hoshinonyaruko/gensokyo/echo" @@ -143,9 +144,18 @@ func generateReplyMessage(id string, foundItems map[string][]string, messageText } return &reply, false } - + // 首先压缩图片 + compressedData, err := images.CompressSingleImage(imageData) + if err != nil { + mylog.Printf("Error compressing image: %v", err) + return &dto.MessageToCreate{ + Content: "错误: 压缩图片失败", + MsgID: id, + MsgType: 0, // 默认文本类型 + }, false + } //base64编码 - base64Encoded := base64.StdEncoding.EncodeToString(imageData) + base64Encoded := base64.StdEncoding.EncodeToString(compressedData) // 当作base64图来处理 reply = dto.MessageToCreate{ diff --git a/handlers/send_private_msg.go b/handlers/send_private_msg.go index 3db98c8b..2e67f50b 100644 --- a/handlers/send_private_msg.go +++ b/handlers/send_private_msg.go @@ -73,7 +73,7 @@ func handleSendPrivateMsg(client callapi.Client, api openapi.OpenAPI, apiv2 open // 优先发送文本信息 if messageText != "" { - groupReply := generatePrivateMessage(messageID, nil, messageText) + groupReply := generateGroupMessage(messageID, nil, messageText) // 进行类型断言 groupMessage, ok := groupReply.(*dto.MessageToCreate) @@ -96,7 +96,8 @@ func handleSendPrivateMsg(client callapi.Client, api openapi.OpenAPI, apiv2 open var singleItem = make(map[string][]string) singleItem[key] = urls - groupReply := generatePrivateMessage(messageID, singleItem, "") + //先试试用群里一样的处理逻辑,看看能跑不 + groupReply := generateGroupMessage(messageID, singleItem, "") // 进行类型断言 richMediaMessage, ok := groupReply.(*dto.RichMediaMessage) @@ -119,6 +120,7 @@ func handleSendPrivateMsg(client callapi.Client, api openapi.OpenAPI, apiv2 open } } +// 这里是只有群私聊会用到 func generatePrivateMessage(id string, foundItems map[string][]string, messageText string) interface{} { if imageURLs, ok := foundItems["local_image"]; ok && len(imageURLs) > 0 { // 本地发图逻辑 todo 适配base64图片 diff --git a/images/Compress.go b/images/Compress.go new file mode 100644 index 00000000..00ca902b --- /dev/null +++ b/images/Compress.go @@ -0,0 +1,207 @@ +package images + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + "image/gif" + "image/jpeg" + "image/png" + "io" + "sync" +) + +type Compressor struct { + QualityStep int // Quality adjustment step + MinQuality int // Minimum quality + MaxQuality int // Maximum quality + ThresholdKB int // Size threshold in KB +} + +func NewCompressor(thresholdKB, qualityStep, minQuality, maxQuality int) *Compressor { + return &Compressor{ + QualityStep: qualityStep, + MinQuality: minQuality, + MaxQuality: maxQuality, + ThresholdKB: thresholdKB, + } +} + +// CompressImage handles image compression based on format. +func (c *Compressor) CompressImage(imageData io.Reader) ([]byte, error) { + if c.ThresholdKB == 0 { + return io.ReadAll(imageData) + } + + // Create a buffer to copy the imageData and determine the image format. + buffer := bytes.NewBuffer(nil) + tee := io.TeeReader(imageData, buffer) + + // Decode image using the buffer so we don't lose the initial bytes. + img, format, err := image.Decode(tee) + if err != nil { + return nil, fmt.Errorf("decoding image failed: %w", err) + } + + // For GIFs, use the buffer which contains all bytes read from the original imageData. + if format == "gif" { + return c.handleGIF(buffer) + } + + // For non-GIFs, check the initial size using a fresh buffer. + buf := &bytes.Buffer{} + switch format { + case "jpeg": + err = jpeg.Encode(buf, img, nil) + case "png": + err = png.Encode(buf, img) + default: + return nil, fmt.Errorf("unsupported image format: %s", format) + } + + if err != nil { + return nil, fmt.Errorf("encoding image failed: %w", err) + } + + if buf.Len() <= c.ThresholdKB*1024 { + // If the image is already below the threshold, return the original encoded bytes. + return buf.Bytes(), nil + } + + // Apply format-specific compression. + switch format { + case "jpeg": + return c.compressJPEG(img) + case "png": + return c.compressPNG(img) + } + + return nil, fmt.Errorf("unsupported image format: %s", format) +} + +// handleGIF decodes and processes a GIF image. +func (c *Compressor) handleGIF(imageData io.Reader) ([]byte, error) { + gifImg, err := gif.DecodeAll(imageData) + if err != nil { + return nil, fmt.Errorf("decoding GIF image failed: %w", err) + } + return c.compressGIF(gifImg) +} + +func (c *Compressor) compressJPEG(img image.Image) ([]byte, error) { + quality := c.MaxQuality + buf := &bytes.Buffer{} + + for { + opts := jpeg.Options{Quality: quality} + buf.Reset() + err := jpeg.Encode(buf, img, &opts) + if err != nil { + return nil, fmt.Errorf("JPEG encoding failed at quality %d: %w", quality, err) + } + + if buf.Len() <= c.ThresholdKB*1024 || quality <= c.MinQuality { + break + } + + quality -= c.QualityStep + if quality < c.MinQuality { + quality = c.MinQuality + } + } + + return buf.Bytes(), nil +} + +func (c *Compressor) compressPNG(img image.Image) ([]byte, error) { + // Convert PNG to JPEG with a white background + b := img.Bounds() + whiteBg := image.NewRGBA(b) + draw.Draw(whiteBg, b, image.NewUniform(color.White), image.Point{}, draw.Src) + draw.Draw(whiteBg, b, img, b.Min, draw.Over) + return c.compressJPEG(whiteBg) +} + +func (c *Compressor) compressGIF(originalGIF *gif.GIF) ([]byte, error) { + // Create a new GIF to hold the compressed frames + var compressedGIF gif.GIF + compressedGIF.LoopCount = originalGIF.LoopCount + compressedGIF.Disposal = originalGIF.Disposal + compressedGIF.Config = originalGIF.Config + compressedGIF.BackgroundIndex = originalGIF.BackgroundIndex + + for i, srcFrame := range originalGIF.Image { + // Convert frame to RGBA to avoid paletted color issues + b := srcFrame.Bounds() + frame := image.NewRGBA(b) + draw.Draw(frame, b, srcFrame, b.Min, draw.Over) + + // Create a white image same size of the frame + whiteImage := image.NewRGBA(b) + draw.Draw(whiteImage, b, &image.Uniform{color.White}, image.ZP, draw.Src) + + // Draw the frame onto the white image to remove transparency + draw.Draw(whiteImage, b, frame, b.Min, draw.Over) + + // Compress the frame + compressedFrame, err := c.compressJPEG(whiteImage) + if err != nil { + return nil, fmt.Errorf("compressing GIF frame failed: %w", err) + } + + // Decode the compressed frame back to image + jpgFrame, _, err := image.Decode(bytes.NewReader(compressedFrame)) + if err != nil { + return nil, fmt.Errorf("decoding JPEG frame failed: %w", err) + } + + // Convert back to paletted image for GIF + palettedFrame := image.NewPaletted(b, srcFrame.Palette) + draw.FloydSteinberg.Draw(palettedFrame, b, jpgFrame, b.Min) + + compressedGIF.Image = append(compressedGIF.Image, palettedFrame) + compressedGIF.Delay = append(compressedGIF.Delay, originalGIF.Delay[i]) + } + + var buf bytes.Buffer + if err := gif.EncodeAll(&buf, &compressedGIF); err != nil { + return nil, fmt.Errorf("encoding compressed GIF failed: %w", err) + } + + return buf.Bytes(), nil +} + +func ProcessImages(imageData []io.Reader, compressor *Compressor) ([][]byte, error) { + var wg sync.WaitGroup + mu := &sync.Mutex{} + compressedImages := make([][]byte, len(imageData)) + errChan := make(chan error, len(imageData)) // 错误通道,缓冲以避免阻塞 + + wg.Add(len(imageData)) + for i, data := range imageData { + go func(idx int, imgData io.Reader) { + defer wg.Done() + compressed, err := compressor.CompressImage(imgData) + mu.Lock() + compressedImages[idx] = compressed + mu.Unlock() + if err != nil { + errChan <- fmt.Errorf("compressing image at index %d failed: %w", idx, err) + } + }(i, data) + } + + wg.Wait() + close(errChan) // 处理完所有goroutine后关闭错误通道 + + // 检查错误通道中是否有错误 + for err := range errChan { + if err != nil { + return nil, err // 可以返回第一个错误或累积所有错误 + } + } + + return compressedImages, nil +} diff --git a/images/easycompress.go b/images/easycompress.go new file mode 100644 index 00000000..c428f4eb --- /dev/null +++ b/images/easycompress.go @@ -0,0 +1,39 @@ +package images + +import ( + "bytes" + + "github.com/hoshinonyaruko/gensokyo/config" +) + +// 默认压缩参数 +const ( + defaultQualityStep = 10 + defaultMinQuality = 25 + defaultMaxQuality = 75 +) + +// CompressSingleImage 接收一个图片的 []byte 数据,并根据设定阈值返回压缩后的数据或原始数据。 +func CompressSingleImage(imageBytes []byte) ([]byte, error) { + // 获取压缩阈值 + thresholdKB := config.GetImageLimit() + + // 如果阈值为0,则直接返回原始图片数据,不进行压缩 + if thresholdKB == 0 { + return imageBytes, nil + } + + // 创建压缩器实例 + compressor := NewCompressor(thresholdKB, defaultQualityStep, defaultMinQuality, defaultMaxQuality) + + // 创建一个读取器来读取 imageBytes 数据 + reader := bytes.NewReader(imageBytes) + + // 调用 CompressImage 方法来压缩图片 + compressedImage, err := compressor.CompressImage(reader) + if err != nil { + return nil, err // 压缩出错时返回错误 + } + + return compressedImage, nil // 返回压缩后的图片数据 +} diff --git a/main.go b/main.go index db0332ae..ae0b22cf 100644 --- a/main.go +++ b/main.go @@ -186,7 +186,7 @@ func main() { // 确保所有wsClients都已初始化 if len(wsClients) != len(conf.Settings.WsAddress) { log.Println("Error: Not all wsClients are initialized!") - log.Fatalln("Failed to initialize all WebSocketClients.") + //log.Fatalln("Failed to initialize all WebSocketClients.") } else { log.Println("All wsClients are successfully initialized.") p = Processor.NewProcessor(api, apiV2, &conf.Settings, wsClients) diff --git a/template/config_template.go b/template/config_template.go index 3c2e1f10..3b5f63ab 100644 --- a/template/config_template.go +++ b/template/config_template.go @@ -47,6 +47,7 @@ settings: developer_log : false #开启开发者日志 默认关闭 server_user_name : "useradmin" #默认网页面板用户名 server_user_password : "admin" #默认网页面板密码 + image_sizelimit : 0 #代表kb 腾讯api要求图片1500ms完成传输 如果图片发不出 请提升上行或设置此值 默认为0 不压缩 ` const Logo = ` ' diff --git a/template/config_template.yml b/template/config_template.yml index 0b3055e7..6c8d0d52 100644 --- a/template/config_template.yml +++ b/template/config_template.yml @@ -38,4 +38,5 @@ settings: key: "" #密钥路径 Apache(crt文件、key文件)示例: "C:\\123.key" \需要双写成\\ developer_log : true #开启开发者日志 server_user_name : "useradmin" #默认网页面板用户名 - server_user_password : "admin" #默认网页面板密码 \ No newline at end of file + server_user_password : "admin" #默认网页面板密码 + image_sizelimit : 0 #代表kb 腾讯api要求图片1500ms完成传输 如果图片发不出 请提升上行或设置此值 默认为0 不压缩 \ No newline at end of file diff --git a/wsclient/ws.go b/wsclient/ws.go index a2c292fd..0622ec50 100644 --- a/wsclient/ws.go +++ b/wsclient/ws.go @@ -134,18 +134,36 @@ func (c *WebSocketClient) sendHeartbeat(ctx context.Context, botID uint64) { return case <-time.After(10 * time.Second): message := map[string]interface{}{ - "meta_event_type": "heartbeat", "post_type": "meta_event", - "self_id": botID, - "status": "ok", + "meta_event_type": "heartbeat", "time": int(time.Now().Unix()), + "self_id": botID, + "status": map[string]interface{}{ + "app_enabled": true, + "app_good": true, + "app_initialized": true, + "good": true, + "online": true, + "plugins_good": nil, + "stat": map[string]int{ + "packet_received": 34933, + "packet_sent": 8513, + "packet_lost": 0, + "message_received": 24674, + "message_sent": 1663, + "disconnect_times": 0, + "lost_times": 0, + "last_message_time": int(time.Now().Unix()) - 10, // 假设最后一条消息是10秒前收到的 + }, + }, + "interval": 10000, // 以毫秒为单位 } c.SendMessage(message) } } } -const maxRetryAttempts = 5 +const maxRetryAttempts = 30 // NewWebSocketClient 创建 WebSocketClient 实例,接受 WebSocket URL、botID 和 openapi.OpenAPI 实例 func NewWebSocketClient(urlStr string, botID uint64, api openapi.OpenAPI, apiv2 openapi.OpenAPI) (*WebSocketClient, error) {