package service import ( "bytes" "context" "crypto/md5" "crypto/sha256" "encoding/hex" "encoding/xml" "errors" "fmt" "io" "log" "math/rand" "mime/multipart" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "time" "github.com/go-resty/resty/v2" "github.com/google/uuid" "github.com/openai/openai-go/v3" "wechat-robot-client/dto" "wechat-robot-client/interface/plugin" "wechat-robot-client/interface/settings" "wechat-robot-client/model" "wechat-robot-client/pkg/robot" "wechat-robot-client/repository" "wechat-robot-client/vars" ) type MessageService struct { ctx context.Context msgRepo *repository.Message crmRepo *repository.ChatRoomMember sysmsgRepo *repository.SystemMessage robotAdminRepo *repository.RobotAdmin } var _ plugin.MessageServiceIface = (*MessageService)(nil) func NewMessageService(ctx context.Context) *MessageService { return &MessageService{ ctx: ctx, msgRepo: repository.NewMessageRepo(ctx, vars.DB), crmRepo: repository.NewChatRoomMemberRepo(ctx, vars.DB), sysmsgRepo: repository.NewSystemMessageRepo(ctx, vars.DB), robotAdminRepo: repository.NewRobotAdminRepo(ctx, vars.AdminDB), } } func buildMessageLogPreview(content string) string { preview := strings.ReplaceAll(strings.TrimSpace(content), "\n", `\n`) previewRunes := []rune(preview) if len(previewRunes) > 80 { return string(previewRunes[:80]) + "..." } return preview } func shouldLogPluginMatch(messagePlugin plugin.MessageHandler) bool { return !slices.Contains(messagePlugin.GetLabels(), "chat") } func (s *MessageService) logPluginMatch(messagePlugin plugin.MessageHandler, msgCtx *plugin.MessageContext) { if msgCtx == nil || msgCtx.Message == nil || !shouldLogPluginMatch(messagePlugin) { return } log.Printf("[PluginMatch] plugin=%s labels=%v msg_id=%d from=%s sender=%s is_chat_room=%t app_msg_type=%d content=%q", messagePlugin.GetName(), messagePlugin.GetLabels(), msgCtx.Message.MsgId, msgCtx.Message.FromWxID, msgCtx.Message.SenderWxID, msgCtx.Message.IsChatRoom, msgCtx.Message.AppMsgType, buildMessageLogPreview(msgCtx.MessageContent), ) } // ProcessTextMessage 处理文本消息 func (s *MessageService) ProcessTextMessage(message *model.Message, msgSettings settings.Settings) { msgCtx := &plugin.MessageContext{ Context: s.ctx, Settings: msgSettings, Message: message, MessageContent: message.Content, MessageService: s, } for _, messagePlugin := range vars.MessagePlugin.Plugins { if !slices.Contains(messagePlugin.GetLabels(), "text") { continue } match := messagePlugin.Match(msgCtx) if !match { continue } s.logPluginMatch(messagePlugin, msgCtx) messagePlugin.Run(msgCtx) } } // ProcessImageMessage 处理图片消息 func (s *MessageService) ProcessImageMessage(message *model.Message, msgSettings settings.Settings) { msgCtx := &plugin.MessageContext{ Context: s.ctx, Settings: msgSettings, Message: message, MessageContent: message.Content, MessageService: s, } for _, messagePlugin := range vars.MessagePlugin.Plugins { if !slices.Contains(messagePlugin.GetLabels(), "image") { continue } match := messagePlugin.Match(msgCtx) if !match { continue } s.logPluginMatch(messagePlugin, msgCtx) messagePlugin.Run(msgCtx) } } // ProcessVoiceMessage 处理语音消息 func (s *MessageService) ProcessVoiceMessage(message *model.Message) { } // ProcessVideoMessage 处理视频消息 func (s *MessageService) ProcessVideoMessage(message *model.Message) { } // ProcessEmojiMessage 处理表情消息 func (s *MessageService) ProcessEmojiMessage(message *model.Message) { } // ProcessReferMessage 处理引用消息 func (s *MessageService) ProcessReferMessage(message *model.Message, msgSettings settings.Settings) { var xmlMessage robot.XmlMessage err := vars.RobotRuntime.XmlDecoder(message.Content, &xmlMessage) if err != nil { log.Printf("解析引用消息失败: %v", err) return } referMessageID, err := strconv.ParseInt(xmlMessage.AppMsg.ReferMsg.SvrID, 10, 64) if err != nil { log.Printf("解析引用消息ID失败: %v", err) return } referMessage, err := s.msgRepo.GetByMsgID(referMessageID) if err != nil { log.Printf("获取引用消息失败: %v", err) return } if referMessage == nil { log.Printf("获取引用消息为空") return } msgCtx := &plugin.MessageContext{ Context: s.ctx, Settings: msgSettings, Message: message, MessageContent: xmlMessage.AppMsg.Title, ReferMessage: referMessage, MessageService: s, } for _, messagePlugin := range vars.MessagePlugin.Plugins { if !slices.Contains(messagePlugin.GetLabels(), "text") { continue } match := messagePlugin.Match(msgCtx) if !match { continue } s.logPluginMatch(messagePlugin, msgCtx) messagePlugin.Run(msgCtx) } } func (s *MessageService) ProcessRedEnvelopesMessage(message *model.Message, msgSettings settings.Settings) { msgCtx := &plugin.MessageContext{ Context: s.ctx, Settings: msgSettings, Message: message, MessageContent: message.Content, MessageService: s, } for _, messagePlugin := range vars.MessagePlugin.Plugins { if !slices.Contains(messagePlugin.GetLabels(), "red-envelopes") { continue } match := messagePlugin.Match(msgCtx) if !match { continue } s.logPluginMatch(messagePlugin, msgCtx) messagePlugin.Run(msgCtx) } } // ProcessAppMessage 处理应用消息 func (s *MessageService) ProcessAppMessage(message *model.Message, msgSettings settings.Settings) { if message.AppMsgType == model.AppMsgTypequote { s.ProcessReferMessage(message, msgSettings) return } if message.AppMsgType == model.AppMsgTypeRedEnvelopes { s.ProcessRedEnvelopesMessage(message, msgSettings) return } if message.AppMsgType == model.AppMsgTypeUrl { xmlMessage, err := s.XmlDecoder(message.Content) if err != nil { log.Printf("解析应用消息失败: %v", err) return } if xmlMessage.AppMsg.Title == "邀请你加入群聊" || xmlMessage.AppMsg.Title == "Group Chat Invitation" { now := time.Now().Unix() err := s.sysmsgRepo.Create(&model.SystemMessage{ MsgID: message.MsgId, ClientMsgID: message.ClientMsgId, Type: model.SystemMessageTypeJoinChatRoom, ImageURL: xmlMessage.AppMsg.ThumbURL, Description: xmlMessage.AppMsg.Des, Content: message.Content, FromWxid: message.FromWxID, ToWxid: message.ToWxID, Status: 0, IsRead: false, CreatedAt: now, UpdatedAt: now, }) if err != nil { log.Printf("入库邀请进群通知消息失败: %v", err) return } if message.ID > 0 { // 消息已经没什么用了,删除掉 err := s.msgRepo.Delete(message) if err != nil { log.Printf("删除消息失败: %v", err) return } } return } return } } // ProcessShareCardMessage 处理分享名片消息 func (s *MessageService) ProcessShareCardMessage(message *model.Message) { } // ProcessFriendVerifyMessage 处理好友添加请求通知消息 func (s *MessageService) ProcessFriendVerifyMessage(message *model.Message) { now := time.Now().Unix() var xmlMessage robot.NewFriendMessage err := vars.RobotRuntime.XmlDecoder(message.Content, &xmlMessage) if err != nil { log.Printf("解析好友添加请求消息失败: %v", err) return } systeMessage := model.SystemMessage{ MsgID: message.MsgId, ClientMsgID: message.ClientMsgId, Type: model.SystemMessageTypeVerify, ImageURL: xmlMessage.BigHeadImgURL, Description: xmlMessage.Content, Content: message.Content, FromWxid: message.FromWxID, ToWxid: message.ToWxID, Status: 0, IsRead: false, CreatedAt: now, UpdatedAt: now, } err = s.sysmsgRepo.Create(&systeMessage) if err != nil { log.Printf("入库好友添加请求通知消息失败: %v", err) return } // 自动通过好友 go func(systemSettingsID int64) { err := NewContactService(context.Background()).FriendAutoPassVerify(systemSettingsID) if err != nil { log.Printf("自动通过好友验证失败: %v", err) } }(systeMessage.ID) if message.ID > 0 { // 消息已经没什么用了,删除掉 err := s.msgRepo.Delete(message) if err != nil { log.Printf("删除消息失败: %v", err) return } } } // ProcessRecalledMessage 处理撤回消息 func (s *MessageService) ProcessRecalledMessage(message *model.Message, msgXml robot.SystemMessage) { oldMsg, err := s.msgRepo.GetByMsgID(msgXml.RevokeMsg.NewMsgID) if err != nil { log.Printf("获取撤回的消息失败: %v", err) return } if oldMsg != nil { oldMsg.IsRecalled = true err = s.msgRepo.Update(oldMsg) if err != nil { log.Printf("标记撤回消息失败: %v", err) } else { if message.ID > 0 { // 消息已经没什么用了,删除掉 err := s.msgRepo.Delete(message) if err != nil { log.Printf("删除消息失败: %v", err) return } } } return } } // ProcessPatMessage 处理拍一拍消息 func (s *MessageService) ProcessPatMessage(message *model.Message, msgXml robot.SystemMessage, msgSettings settings.Settings) { msgCtx := &plugin.MessageContext{ Context: s.ctx, Settings: msgSettings, Message: message, MessageContent: message.Content, Pat: message.IsChatRoom && msgXml.Pat.PattedUsername == vars.RobotRuntime.WxID, MessageService: s, } for _, messagePlugin := range vars.MessagePlugin.Plugins { if slices.Contains(messagePlugin.GetLabels(), "pat") { match := messagePlugin.Match(msgCtx) if !match { continue } s.logPluginMatch(messagePlugin, msgCtx) messagePlugin.Run(msgCtx) } } } func (s *MessageService) ProcessNewChatRoomMemberMessage(message *model.Message, msgXml robot.SystemMessage) { var newMemberWechatIds []string if len(msgXml.SysMsgTemplate.ContentTemplate.LinkList.Links) > 0 { links := msgXml.SysMsgTemplate.ContentTemplate.LinkList.Links for _, link := range links { if link.Name == "names" || link.Name == "adder" { if link.MemberList != nil { for _, member := range link.MemberList.Members { newMemberWechatIds = append(newMemberWechatIds, member.Username) } } } } } newMembers, err := NewChatRoomService(s.ctx).UpdateChatRoomMembersOnNewMemberJoinIn(message.FromWxID, newMemberWechatIds) if err != nil { log.Printf("邀请新成员加入群聊时,更新群成员失败: %v", err) } if len(newMembers) == 0 { log.Println("根据新成员微信ID获取群成员信息失败,没查询到有效的成员信息") } welcomeConfig, err := NewChatRoomSettingsService(s.ctx).GetChatRoomWelcomeConfig(message.FromWxID) if err != nil { log.Printf("获取群聊欢迎配置失败: %v", err) return } if welcomeConfig.WelcomeEnabled != nil && !*welcomeConfig.WelcomeEnabled { log.Printf("[%s]群聊欢迎消息未启用", message.FromWxID) return } if welcomeConfig.WelcomeType == model.WelcomeTypeText { s.SendTextMessage(message.FromWxID, welcomeConfig.WelcomeText) } if welcomeConfig.WelcomeType == model.WelcomeTypeEmoji { s.SendEmoji(message.FromWxID, welcomeConfig.WelcomeEmojiMD5, int32(welcomeConfig.WelcomeEmojiLen)) } if welcomeConfig.WelcomeType == model.WelcomeTypeImage { resp, err := resty.New().R().SetDoNotParseResponse(true).Get(welcomeConfig.WelcomeImageURL) if err != nil { log.Println("获取欢迎图片失败: ", err) return } defer resp.RawBody().Close() // 创建临时文件 tempFile, err := os.CreateTemp("", "welcome_image_*") if err != nil { log.Println("创建临时文件失败: ", err) return } defer tempFile.Close() defer os.Remove(tempFile.Name()) // 清理临时文件 // 将图片数据写入临时文件 _, err = io.Copy(tempFile, resp.RawBody()) if err != nil { log.Println("将图片数据写入临时文件失败: ", err) return } _, err = s.MsgUploadImg(message.FromWxID, tempFile) if err != nil { log.Println("发送欢迎图片消息失败: ", err) return } } if welcomeConfig.WelcomeType == model.WelcomeTypeURL { if len(newMembers) == 0 { return } var title string if len(newMembers) > 1 { title = fmt.Sprintf("欢迎%d位家人加入群聊", len(newMembers)) } else if newMembers[0].Nickname != "" { title = fmt.Sprintf("欢迎%s加入群聊", newMembers[0].Nickname) } else { title = "欢迎新成员加入群聊" } err := s.ShareLink(message.FromWxID, robot.ShareLinkMessage{ Title: title, Des: welcomeConfig.WelcomeText, Url: welcomeConfig.WelcomeURL, ThumbUrl: robot.CDATAString(newMembers[0].Avatar), }) if err != nil { log.Println("发送欢迎链接消息失败: ", err) } } } // ProcessSystemMessage 处理系统消息 func (s *MessageService) ProcessSystemMessage(message *model.Message, msgSettings settings.Settings) { var msgXml robot.SystemMessage err := vars.RobotRuntime.XmlDecoder(message.Content, &msgXml) if err != nil { return } if msgXml.Type == "revokemsg" { s.ProcessRecalledMessage(message, msgXml) return } if msgXml.Type == "pat" { s.ProcessPatMessage(message, msgXml, msgSettings) return } if msgXml.Type == "sysmsgtemplate" && (strings.Contains(msgXml.SysMsgTemplate.ContentTemplate.Template, "加入了群聊") || strings.Contains(msgXml.SysMsgTemplate.ContentTemplate.Template, "分享的二维码加入群聊") || strings.Contains(msgXml.SysMsgTemplate.ContentTemplate.Template, "joined group chat")) { s.ProcessNewChatRoomMemberMessage(message, msgXml) return } } // ProcessLocationMessage 处理位置消息 func (s *MessageService) ProcessLocationMessage(message *model.Message) { } // ProcessPromptMessage 处理提示消息 func (s *MessageService) ProcessPromptMessage(message *model.Message) { } func (s *MessageService) ProcessMessageSender(message *model.Message) { self := vars.RobotRuntime.WxID // 处理一下自己发的消息 // 自己发发到群聊 if message.FromWxID == self && strings.HasSuffix(message.ToWxID, "@chatroom") { from := message.FromWxID to := message.ToWxID message.FromWxID = to message.ToWxID = from } // 群聊消息 if strings.HasSuffix(message.FromWxID, "@chatroom") { message.IsChatRoom = true splitContents := strings.SplitN(message.Content, ":\n", 2) if len(splitContents) > 1 { message.Content = splitContents[1] message.SenderWxID = splitContents[0] } else { // 绝对是自己发的消息! qwq message.Content = splitContents[0] message.SenderWxID = self } } else { message.IsChatRoom = false message.SenderWxID = message.FromWxID if message.FromWxID == self { message.FromWxID = message.ToWxID message.ToWxID = self } } } func (s *MessageService) ProcessMessageShouldInsertToDB(message *model.Message) bool { if message.Type == model.MsgTypeInit || message.Type == model.MsgTypeUnknow { return false } if message.Type == model.MsgTypeSystem && message.SenderWxID == "weixin" { return false } if message.Type == model.MsgTypeApp { var xmlmsg robot.XmlMessage if err := vars.RobotRuntime.XmlDecoder(message.Content, &xmlmsg); err != nil { return true } message.AppMsgType = model.AppMessageType(xmlmsg.AppMsg.Type) if message.AppMsgType == model.AppMsgTypeAttachUploading { // 如果是上传中的应用消息,则不入库 return false } } return true } // ProcessMentionedMeMessage 处理下艾特我的消息 func (s *MessageService) ProcessMentionedMeMessage(message *model.Message, msgSource string) { self := vars.RobotRuntime.WxID // 是否艾特我的消息 var msgsource robot.MessageSource err := vars.RobotRuntime.XmlDecoder(message.MessageSource, &msgsource) if err != nil { return } if msgsource.AtUserList != "" { atMembers := strings.Split(msgsource.AtUserList, ",") for _, at := range atMembers { if strings.Trim(at, " ") == self { message.IsAtMe = true break } } } } func (s *MessageService) InitSettingsByMessage(message *model.Message) (settings settings.Settings) { if message.IsChatRoom { settings = NewChatRoomSettingsService(s.ctx) } else { settings = NewFriendSettingsService(s.ctx) } err := settings.InitByMessage(message) if err != nil { log.Println("初始化设置失败: ", err) return nil } return } func (s *MessageService) ProcessMessage(syncResp robot.SyncMessage) { for _, message := range syncResp.AddMsgs { m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: message.MsgType, Content: *message.Content.String, DisplayFullContent: message.PushContent, MessageSource: message.MsgSource, FromWxID: *message.FromUserName.String, ToWxID: *message.ToUserName.String, CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } s.ProcessMessageSender(&m) if !s.ProcessMessageShouldInsertToDB(&m) { continue } s.ProcessMentionedMeMessage(&m, message.MsgSource) settings := s.InitSettingsByMessage(&m) if settings == nil { continue } err := s.msgRepo.Create(&m) if err != nil { log.Printf("入库消息失败: %v", err) continue } if m.Type == model.MsgTypeText && vars.MemoryService != nil { go vars.MemoryService.NotifyMessage(context.Background(), &m) } switch m.Type { case model.MsgTypeText: go s.ProcessTextMessage(&m, settings) case model.MsgTypeImage: go s.ProcessImageMessage(&m, settings) case model.MsgTypeVoice: go s.ProcessVoiceMessage(&m) case model.MsgTypeVideo: go s.ProcessVideoMessage(&m) case model.MsgTypeEmoticon: go s.ProcessEmojiMessage(&m) case model.MsgTypeApp: go s.ProcessAppMessage(&m, settings) case model.MsgTypeShareCard: go s.ProcessShareCardMessage(&m) case model.MsgTypeVerify: // 好友添加请求通知消息 go s.ProcessFriendVerifyMessage(&m) case model.MsgTypeSystem: go s.ProcessSystemMessage(&m, settings) case model.MsgTypeLocation: go s.ProcessLocationMessage(&m) case model.MsgTypePrompt: go s.ProcessPromptMessage(&m) default: // 未知消息类型 log.Printf("未知消息类型: %d, 内容: %s", m.Type, m.Content) } go func() { // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) if strings.HasSuffix(m.FromWxID, "@chatroom") { NewChatRoomService(s.ctx).UpsertChatRoomMember(&model.ChatRoomMember{ ChatRoomID: m.FromWxID, WechatID: m.SenderWxID, }) } }() } for _, contact := range syncResp.ModContacts { if contact.UserName.String != nil { if strings.HasSuffix(*contact.UserName.String, "@chatroom") { // 群成员信息有变化,更新群聊成员(防抖,5 秒内只执行最后一次) NewChatRoomService(context.Background()).DebounceSyncChatRoomMember(*contact.UserName.String) } else { // 更新联系人信息 NewContactService(context.Background()).DebounceSyncContact(*contact.UserName.String) // 检测昵称变更并通知所在群 s.detectAndNotifyNicknameChange(contact) } } } for _, contact := range syncResp.DelContacts { if contact.UserName.String != nil { err := NewContactService(context.Background()).DeleteContactByContactID(*contact.UserName.String) if err != nil { log.Println("删除联系人失败: ", err) } } } // webhook 回调 s.MessageWebhook(syncResp) } func (s *MessageService) MessageWebhook(syncResp robot.SyncMessage) { if vars.Webhook.URL != "" { req := resty.New().R(). SetHeader("Content-Type", "application/json;chartset=utf-8"). SetBody(syncResp) // 设置自定义 headers if vars.Webhook.Headers != nil { for k, v := range vars.Webhook.Headers { switch val := v.(type) { case string: // 单个字符串值 req.SetHeader(k, val) case []string: // 字符串数组,设置多个相同 key 的 header for _, headerVal := range val { req.SetHeader(k, headerVal) } case []any: // any 数组,尝试转换为字符串 for _, item := range val { if strVal, ok := item.(string); ok { req.SetHeader(k, strVal) } } } } } webhookUrl := vars.Webhook.URL if strings.Contains(webhookUrl, "?") { webhookUrl += fmt.Sprintf("&robot_id=%d&robot_code=%s&robot_wxid=%s", vars.RobotRuntime.RobotID, vars.RobotRuntime.RobotCode, vars.RobotRuntime.WxID) } else { webhookUrl += fmt.Sprintf("?robot_id=%d&robot_code=%s&robot_wxid=%s", vars.RobotRuntime.RobotID, vars.RobotRuntime.RobotCode, vars.RobotRuntime.WxID) } _, err := req.Post(webhookUrl) if err != nil { log.Println("消息 webhook 调用失败: ", err.Error()) } } } func (s *MessageService) SyncMessage() { // 获取新消息 syncResp, err := vars.RobotRuntime.SyncMessage() if err != nil { // 有可能是用户退出了,或者掉线了,这里不处理,由心跳机制处理机器人在线/离线状态 log.Println("获取新消息失败: ", err) return } if len(syncResp.AddMsgs) == 0 { // 没有消息,直接返回 return } s.ProcessMessage(syncResp) } func (s *MessageService) XmlDecoder(content string) (robot.XmlMessage, error) { var xmlMessage robot.XmlMessage err := vars.RobotRuntime.XmlDecoder(content, &xmlMessage) if err != nil { return xmlMessage, err } return xmlMessage, nil } func (s *MessageService) MessageRevoke(req dto.MessageCommonRequest) error { message, err := s.msgRepo.GetByID(req.MessageID) if err != nil { return fmt.Errorf("获取消息失败: %w", err) } if message == nil { return errors.New("消息不存在") } // 两分钟前 if message.CreatedAt+120 < time.Now().Unix() { return errors.New("消息已过期") } return vars.RobotRuntime.MessageRevoke(*message) } func (s *MessageService) SendTextMessage(toWxID, content string, at ...string) error { atContent := "" if len(at) > 0 { // 手动拼接上 @ 符号和昵称 for index, wxid := range at { var targetNickname string if strings.HasSuffix(toWxID, "@chatroom") { // 群聊消息,昵称优先取群备注,备注取不到或者取失败了,再去取联系人的昵称 chatRoomMember, err := s.crmRepo.GetChatRoomMember(toWxID, wxid) if err != nil || chatRoomMember == nil { r, err := vars.RobotRuntime.GetContactDetail("", []string{wxid}) if err != nil || len(r.ContactList) == 0 { continue } if r.ContactList[0].NickName.String == nil { continue } targetNickname = *r.ContactList[0].NickName.String } else { if chatRoomMember.Remark != "" { targetNickname = chatRoomMember.Remark } else { targetNickname = chatRoomMember.Nickname } } } else { // 私聊消息 r, err := vars.RobotRuntime.GetContactDetail("", []string{wxid}) if err != nil || len(r.ContactList) == 0 { continue } if r.ContactList[0].NickName.String == nil { continue } targetNickname = *r.ContactList[0].NickName.String } if targetNickname == "" { continue } if index > 0 { atContent += " " } atContent += fmt.Sprintf("@%s%s", targetNickname, "\u2005") } } content = atContent + content newMessages, err := vars.RobotRuntime.SendTextMessage(toWxID, content, at...) if err != nil { return err } // 通过机器人发送的消息,消息同步接口获取不到,所以这里需要手动入库 if len(newMessages.List) > 0 { for _, message := range newMessages.List { if message.Ret == 0 { m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.ClientMsgid, Type: model.MsgTypeText, Content: content, DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.Createtime, UpdatedAt: time.Now().Unix(), } if m.IsChatRoom && len(at) > 0 { m.ReplyWxID = at[0] } err = s.msgRepo.Create(&m) if err != nil { log.Printf("入库消息失败: %v", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) } } } return nil } func (s *MessageService) ToolsCompleted(toWxID, replyWxID string) error { now := time.Now() m := model.Message{ MsgId: now.UnixNano() + rand.Int63n(1000), ClientMsgId: now.Unix(), Type: model.MsgTypeText, Content: "成功完成工具调用", DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, ReplyWxID: replyWxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: now.Unix(), UpdatedAt: now.Unix(), } return s.msgRepo.Create(&m) } // MsgSendGroupMassMsgText 文本消息群发接口 func (s *MessageService) MsgSendGroupMassMsgText(toWxID []string, content string) error { _, err := vars.RobotRuntime.MsgSendGroupMassMsgText(robot.MsgSendGroupMassMsgTextRequest{ ToWxid: toWxID, Content: content, }) if err != nil { return err } return nil } func (s *MessageService) SendAppMessage(toWxID string, appMsgType int, appMsgXml string) error { message, err := vars.RobotRuntime.SendAppMessage(toWxID, appMsgType, appMsgXml) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMessageType(appMsgType), Content: message.Content, DisplayFullContent: "", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } // 发送图片信息 func (s *MessageService) MsgUploadImg(toWxID string, image io.Reader) (*model.Message, error) { imageBytes, err := io.ReadAll(image) if err != nil { return nil, fmt.Errorf("读取文件内容失败: %w", err) } message, err := vars.RobotRuntime.MsgUploadImg(toWxID, imageBytes) if err != nil { return nil, err } m := model.Message{ MsgId: message.Newmsgid, ClientMsgId: message.Msgid, Type: model.MsgTypeImage, Content: "", // 获取不到图片的 xml 内容 DisplayFullContent: "", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return &m, nil } // SendImageMessageByRemoteURL 根据远程URL发送图片(优先使用分片下载,不支持则回退到普通下载) func (s *MessageService) SendImageMessageByRemoteURL(toWxID string, imageURL string) error { // 使用 Range 请求第一个字节来探测是否支持分片下载 rangeHeader := "bytes=0-0" testResp, err := resty.New().R(). SetHeader("Range", rangeHeader). SetDoNotParseResponse(true). Get(imageURL) if err != nil { return fmt.Errorf("获取图片信息失败: %w", err) } testResp.RawBody().Close() if testResp.StatusCode() != 206 && testResp.StatusCode() != 200 { log.Printf("获取图片信息失败,HTTP状态码: %d\n", testResp.StatusCode()) return fmt.Errorf("获取图片信息失败,HTTP状态码: %d", testResp.StatusCode()) } // 如果返回 206,说明支持 Range 请求 supportsRange := testResp.StatusCode() == 206 if !supportsRange { log.Println("服务器不支持 Range 请求,使用普通下载方式") return s.sendImageByNormalDownload(toWxID, imageURL) } // 从 Content-Range 获取文件总大小 contentLength := testResp.RawResponse.ContentLength contentRange := testResp.Header().Get("Content-Range") if contentRange != "" { // Content-Range 格式: bytes 0-0/总大小 parts := strings.Split(contentRange, "/") if len(parts) == 2 { total, err := strconv.ParseInt(parts[1], 10, 64) if err == nil { contentLength = total } } } if contentLength <= 1 { log.Println("无法获取图片大小,使用普通下载方式") return s.sendImageByNormalDownload(toWxID, imageURL) } // 生成唯一的客户端图片ID clientImgId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) // 计算分片数量 chunkSize := vars.UploadImageChunkSize totalChunks := (contentLength + chunkSize - 1) / chunkSize // 分片下载并上传 for chunkIndex := range totalChunks { start := int64(chunkIndex) * chunkSize end := start + chunkSize - 1 if end >= contentLength { end = contentLength - 1 } // 使用 Range 请求下载分片 rangeHeader := fmt.Sprintf("bytes=%d-%d", start, end) resp, err := resty.New().R(). SetHeader("Range", rangeHeader). SetDoNotParseResponse(true). Get(imageURL) if err != nil { return fmt.Errorf("下载图片分片失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } // 如果第一个分片就不支持 Range,回退到普通下载 if chunkIndex == 0 && resp.StatusCode() != 206 && resp.StatusCode() != 200 { resp.RawBody().Close() log.Printf("Range 请求返回状态码 %d,回退到普通下载方式", resp.StatusCode()) return s.sendImageByNormalDownload(toWxID, imageURL) } if resp.StatusCode() != 206 && resp.StatusCode() != 200 { resp.RawBody().Close() return fmt.Errorf("下载图片分片失败,HTTP状态码: %d (chunk %d/%d)", resp.StatusCode(), chunkIndex+1, totalChunks) } // 读取分片数据 chunkData, err := io.ReadAll(resp.RawBody()) resp.RawBody().Close() if err != nil { return fmt.Errorf("读取分片数据失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } // 创建分片请求 req := dto.SendImageMessageRequest{ ToWxid: toWxID, ClientImgId: clientImgId, FileSize: contentLength, ChunkIndex: int64(chunkIndex), TotalChunks: totalChunks, ImageURL: imageURL, } // 创建分片 reader chunkReader := io.NopCloser(strings.NewReader(string(chunkData))) chunkHeader := &multipart.FileHeader{ Filename: fmt.Sprintf("chunk_%d", chunkIndex), Size: int64(len(chunkData)), } // 发送分片 _, err = s.SendImageMessageStream(s.ctx, req, chunkReader, chunkHeader) if err != nil { return fmt.Errorf("发送图片分片失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } } return nil } // sendImageByNormalDownload 普通下载方式(一次性下载,分片上传) func (s *MessageService) sendImageByNormalDownload(toWxID string, imageURL string) error { resp, err := resty.New().R().SetDoNotParseResponse(true).Get(imageURL) if err != nil { return fmt.Errorf("下载图片失败: %w", err) } defer resp.RawBody().Close() if resp.StatusCode() != 200 { return fmt.Errorf("下载图片失败,HTTP状态码: %d", resp.StatusCode()) } // 读取整个图片到内存 imageData, err := io.ReadAll(resp.RawBody()) if err != nil { return fmt.Errorf("读取图片数据失败: %w", err) } contentLength := int64(len(imageData)) if contentLength == 0 { return fmt.Errorf("图片数据为空") } // 生成唯一的客户端图片ID clientImgId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) // 计算分片数量 chunkSize := vars.UploadImageChunkSize totalChunks := (contentLength + chunkSize - 1) / chunkSize // 分片上传 for chunkIndex := range totalChunks { start := int64(chunkIndex) * chunkSize end := start + chunkSize if end > contentLength { end = contentLength } // 提取当前分片数据 chunkData := imageData[start:end] // 创建分片请求 req := dto.SendImageMessageRequest{ ToWxid: toWxID, ClientImgId: clientImgId, FileSize: contentLength, ChunkIndex: int64(chunkIndex), TotalChunks: totalChunks, ImageURL: imageURL, } // 创建分片 reader chunkReader := io.NopCloser(strings.NewReader(string(chunkData))) chunkHeader := &multipart.FileHeader{ Filename: fmt.Sprintf("chunk_%d", chunkIndex), Size: int64(len(chunkData)), } // 发送分片 _, err = s.SendImageMessageStream(s.ctx, req, chunkReader, chunkHeader) if err != nil { return fmt.Errorf("发送图片分片失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } } return nil } // 分片发送图片信息 func (s *MessageService) SendImageMessageStream(ctx context.Context, req dto.SendImageMessageRequest, file io.Reader, fileHeader *multipart.FileHeader) (*model.Message, error) { message, err := vars.RobotRuntime.SendImageMessageStream(robot.SendImageMessageStreamRequest{ ToWxid: req.ToWxid, ClientImgId: req.ClientImgId, TotalLen: req.FileSize, StartPos: req.ChunkIndex * vars.UploadImageChunkSize, }, file, fileHeader) if err != nil { return nil, err } // 图片还没上传完 if message == nil { return nil, nil } m := model.Message{ MsgId: message.Newmsgid, ClientMsgId: message.Msgid, Type: model.MsgTypeImage, Content: "", // 获取不到图片的 xml 内容 DisplayFullContent: "", MessageSource: message.MsgSource, FromWxID: req.ToWxid, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(req.ToWxid, "@chatroom"), AttachmentUrl: req.ImageURL, CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return &m, nil } func (s *MessageService) SendImageMessageByLocalPath(toWxID string, imagePath string) error { _, _, err := s.ValidateLocalFileForSend(imagePath, map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, }, 0, "图片") if err != nil { return err } clientImgId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) return s.StreamLocalFileChunks(imagePath, vars.UploadImageChunkSize, func(chunkIndex, totalChunks, totalSize int64, chunkReader io.Reader, fileHeader *multipart.FileHeader) error { _, err := s.SendImageMessageStream(s.ctx, dto.SendImageMessageRequest{ ToWxid: toWxID, ClientImgId: clientImgId, FileSize: totalSize, ChunkIndex: chunkIndex, TotalChunks: totalChunks, }, chunkReader, fileHeader) if err != nil { return fmt.Errorf("发送图片分片失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } return nil }) } func (s *MessageService) MsgSendVideo(toWxID string, video io.Reader, videoExt string) error { videoBytes, err := io.ReadAll(video) if err != nil { return fmt.Errorf("读取文件内容失败: %w", err) } _, err = vars.RobotRuntime.MsgSendVideo(toWxID, videoBytes, videoExt) if err != nil { return err } msgid := time.Now().UnixNano() m := model.Message{ MsgId: msgid, ClientMsgId: msgid, Type: model.MsgTypeVideo, Content: "", // 获取不到视频的 xml 内容 DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendVideoMessageByLocalPath(toWxID string, videoPath string) error { _, _, err := s.ValidateLocalFileForSend(videoPath, map[string]bool{ ".mp4": true, ".avi": true, ".mov": true, ".mkv": true, ".flv": true, ".webm": true, }, 0, "视频") if err != nil { return err } message, err := vars.RobotRuntime.MsgSendVideoFromLocal(toWxID, videoPath) if err != nil { return err } if message == nil { return errors.New("发送视频失败,获取视频结果为空") } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.Msgid, Type: model.MsgTypeVideo, Content: "", DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendVideoMessageByRemoteURL(toWxID string, videoURL string) error { tempFile, err := os.CreateTemp("", "video_*") if err != nil { return fmt.Errorf("创建临时文件失败: %w", err) } tempFilePath := tempFile.Name() defer os.Remove(tempFilePath) const defaultUA = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1" // 尝试分片下载 chunkSize := int64(1024 * 1024) // 先尝试请求第一个分片,检测是否支持 Range rangeHeader := fmt.Sprintf("bytes=0-%d", chunkSize-1) resp, err := resty.New().R(). SetHeader("User-Agent", defaultUA). SetHeader("Referer", "https://www.douyin.com/"). SetHeader("Range", rangeHeader). SetDoNotParseResponse(true). Get(videoURL) if err != nil { tempFile.Close() return fmt.Errorf("下载视频失败: %w", err) } // 如果返回 206,说明支持分片下载 if resp.StatusCode() == 206 { log.Println("服务器支持 Range 请求,使用分片下载") // 获取文件总大小 contentLength := resp.RawResponse.ContentLength contentRange := resp.Header().Get("Content-Range") if contentRange != "" { // Content-Range 格式: bytes 0-1048575/总大小 parts := strings.Split(contentRange, "/") if len(parts) == 2 { total, err := strconv.ParseInt(parts[1], 10, 64) if err == nil { contentLength = total } } } // 写入第一个分片 _, err = io.Copy(tempFile, resp.RawBody()) resp.RawBody().Close() if err != nil { tempFile.Close() return fmt.Errorf("写入第一个分片失败: %w", err) } // 下载剩余分片 for start := chunkSize; start < contentLength; start += chunkSize { end := start + chunkSize - 1 if end >= contentLength { end = contentLength - 1 } rangeHeader := fmt.Sprintf("bytes=%d-%d", start, end) chunkResp, err := resty.New().R(). SetHeader("User-Agent", defaultUA). SetHeader("Referer", "https://www.douyin.com/"). SetHeader("Range", rangeHeader). SetDoNotParseResponse(true). Get(videoURL) if err != nil { tempFile.Close() return fmt.Errorf("下载视频分片失败 (bytes %d-%d): %w", start, end, err) } if chunkResp.StatusCode() != 206 && chunkResp.StatusCode() != 200 { chunkResp.RawBody().Close() tempFile.Close() return fmt.Errorf("下载视频分片失败,HTTP状态码: %d (bytes %d-%d)", chunkResp.StatusCode(), start, end) } _, err = io.Copy(tempFile, chunkResp.RawBody()) chunkResp.RawBody().Close() if err != nil { tempFile.Close() return fmt.Errorf("写入视频分片失败 (bytes %d-%d): %w", start, end, err) } } } else if resp.StatusCode() == 200 { log.Println("服务器不支持 Range 请求,使用普通下载方式") _, err = io.Copy(tempFile, resp.RawBody()) resp.RawBody().Close() if err != nil { tempFile.Close() return fmt.Errorf("写入视频数据失败: %w", err) } } else { resp.RawBody().Close() tempFile.Close() return fmt.Errorf("下载视频失败,HTTP状态码: %d", resp.StatusCode()) } tempFile.Close() // 检查视频大小,超过 20MB 则用 ffmpeg 压缩 const maxVideoSize = 20 * 1024 * 1024 sendPath := tempFilePath fileInfo, statErr := os.Stat(tempFilePath) if statErr == nil && fileInfo.Size() > maxVideoSize { compressedPath := tempFilePath + "_compressed.mp4" log.Printf("[视频压缩] 原始大小: %dMB,开始压缩...", fileInfo.Size()/1024/1024) if compressErr := compressVideoWithFFmpeg(tempFilePath, compressedPath, maxVideoSize); compressErr != nil { log.Printf("[视频压缩] 压缩失败: %v,尝试直接发送原始文件", compressErr) } else { sendPath = compressedPath defer os.Remove(compressedPath) if ci, err2 := os.Stat(compressedPath); err2 == nil { log.Printf("[视频压缩] 压缩完成: %dMB -> %dMB", fileInfo.Size()/1024/1024, ci.Size()/1024/1024) } } } message, err := vars.RobotRuntime.MsgSendVideoFromLocal(toWxID, sendPath) if err != nil { return err } if message == nil { return errors.New("发送视频失败,获取视频结果为空") } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.Msgid, Type: model.MsgTypeVideo, Content: "", // 获取不到视频的 xml 内容 DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), AttachmentUrl: videoURL, CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) MsgSendVoice(toWxID string, voice io.Reader, voiceExt string) error { videoBytes, err := io.ReadAll(voice) if err != nil { return fmt.Errorf("读取文件内容失败: %w", err) } message, err := vars.RobotRuntime.MsgSendVoice(toWxID, videoBytes, voiceExt) if err != nil { return err } clientMsgId, _ := strconv.ParseInt(message.ClientMsgId, 10, 64) m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: clientMsgId, Type: model.MsgTypeVoice, Content: "", // 获取不到音频的 xml 内容 DisplayFullContent: "", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendVoiceMessageByLocalPath(toWxID string, voicePath string) error { _, voiceExt, err := s.ValidateLocalFileForSend(voicePath, map[string]bool{ ".amr": true, ".mp3": true, ".wav": true, }, 50*1024*1024, "音频") if err != nil { return err } voiceFile, err := os.Open(voicePath) if err != nil { return fmt.Errorf("打开本地音频文件失败: %w", err) } defer voiceFile.Close() return s.MsgSendVoice(toWxID, voiceFile, voiceExt) } func (s *MessageService) SendLongTextMessage(toWxID string, longText string) error { currentRobot, err := s.robotAdminRepo.GetByWeChatID(vars.RobotRuntime.WxID) if err != nil { return err } if currentRobot == nil || currentRobot.Nickname == nil { return fmt.Errorf("未找到机器人信息") } dataID := uuid.New().String() fiveMinuteAgo := time.Now().Add(-5 * time.Minute) recordInfo := robot.RecordInfo{ Info: fmt.Sprintf("%s: %s", *currentRobot.Nickname, longText), IsChatRoom: 1, Desc: fmt.Sprintf("%s: %s", *currentRobot.Nickname, longText), FromScene: 3, DataList: robot.DataList{ Count: 1, Items: []robot.DataItem{ { DataType: 1, DataID: strings.ReplaceAll(dataID, "-", ""), SrcMsgLocalID: rand.Intn(90000) + 10000, SourceTime: fiveMinuteAgo.Format("2006-1-2 15:04"), FromNewMsgID: time.Now().UnixNano() / 100, SrcMsgCreateTime: fiveMinuteAgo.Unix(), DataDesc: longText, DataItemSource: &robot.DataItemSource{ HashUsername: fmt.Sprintf("%x", sha256.Sum256([]byte(vars.RobotRuntime.WxID))), }, SourceName: *currentRobot.Nickname, SourceHeadURL: *currentRobot.Avatar, }, }, }, } recordInfoBytes, err := xml.MarshalIndent(recordInfo, "", " ") if err != nil { return err } newMsg := robot.ChatHistoryMessage{ AppMsg: robot.ChatHistoryAppMsg{ AppID: "", SDKVer: "0", Title: "群聊的聊天记录", Type: 19, URL: "https://support.weixin.qq.com/cgi-bin/mmsupport-bin/readtemplate?t=page/favorite_record__w_unsupport", Des: fmt.Sprintf("%s: %s", *currentRobot.Nickname, longText), RecordItem: robot.ChatHistoryRecordItem{XML: fmt.Sprintf(``, string(recordInfoBytes))}, }, } message, err := vars.RobotRuntime.SendChatHistoryMessage(toWxID, newMsg) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMsgTypeChatHistory, Content: message.Content, DisplayFullContent: "", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendMusicMessage(toWxID string, songTitle string) error { var resp robot.MusicSearchResponse _, err := resty.New().R(). SetHeader("Content-Type", "application/json"). SetQueryParam("msg", songTitle). SetQueryParam("type", "json"). SetQueryParam("n", "1"). SetQueryParam("br", "7"). SetResult(&resp). Get(vars.MusicSearchApi) if err != nil { return fmt.Errorf("获取歌曲信息失败: %w", err) } result := resp.Data if result.Title == nil { return fmt.Errorf("没有搜索到歌曲 %s", songTitle) } songInfo := robot.SongInfo{} songInfo.FromUsername = vars.RobotRuntime.WxID songInfo.AppID = "wx8dd6ecd81906fd84" songInfo.Title = *result.Title songInfo.Singer = result.Singer songInfo.Url = result.Link songInfo.MusicUrl = result.MusicURL if result.Cover != nil { songInfo.CoverUrl = *result.Cover } if result.Lrc != nil { songInfo.Lyric = *result.Lrc } message, err := vars.RobotRuntime.SendMusicMessage(toWxID, songInfo) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMsgTypeMusic, DisplayFullContent: "机器人分享了一首歌曲", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } // 发送文件信息 func (s *MessageService) SendFileMessage(ctx context.Context, req dto.SendFileMessageRequest, file io.Reader, fileHeader *multipart.FileHeader) error { message, err := vars.RobotRuntime.MsgSendFile(robot.SendFileMessageRequest{ ToWxid: req.ToWxid, ClientAppDataId: req.ClientAppDataId, Filename: req.Filename, FileMD5: req.FileHash, TotalLen: req.FileSize, StartPos: req.ChunkIndex * vars.UploadFileChunkSize, TotalChunks: req.TotalChunks, }, file, fileHeader) if err != nil { return err } // 文件还没上传完 if message == nil { return nil } clientMsgId, _ := strconv.ParseInt(message.ClientMsgId, 10, 64) m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: clientMsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMsgTypeAttach, Content: message.Content, DisplayFullContent: "机器人发送了一个文件", MessageSource: message.MsgSource, FromWxID: req.ToWxid, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(req.ToWxid, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendFileMessageByLocalPath(toWxID string, localFilePath string) error { _, _, err := s.ValidateLocalFileForSend(localFilePath, nil, 0, "文件") if err != nil { return err } fileHash, err := s.CalculateFileMD5(localFilePath) if err != nil { return fmt.Errorf("计算文件哈希失败: %w", err) } clientAppDataId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) filename := filepath.Base(localFilePath) return s.StreamLocalFileChunks(localFilePath, vars.UploadFileChunkSize, func(chunkIndex, totalChunks, totalSize int64, chunkReader io.Reader, fileHeader *multipart.FileHeader) error { err := s.SendFileMessage(s.ctx, dto.SendFileMessageRequest{ ToWxid: toWxID, ClientAppDataId: clientAppDataId, Filename: filename, FileHash: fileHash, FileSize: totalSize, ChunkIndex: chunkIndex, TotalChunks: totalChunks, }, chunkReader, fileHeader) if err != nil { return fmt.Errorf("发送文件分片失败 (chunk %d/%d): %w", chunkIndex+1, totalChunks, err) } return nil }) } func (s *MessageService) ValidateLocalFileForSend(filePath string, allowedExts map[string]bool, maxSize int64, fileType string) (os.FileInfo, string, error) { trimmedPath := strings.TrimSpace(filePath) if trimmedPath == "" { return nil, "", errors.New("本地文件路径不能为空") } fileInfo, err := os.Stat(trimmedPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", errors.New("本地文件不存在") } return nil, "", fmt.Errorf("读取本地%s信息失败: %w", fileType, err) } if fileInfo.IsDir() { return nil, "", errors.New("本地文件路径不能是目录") } if fileInfo.Size() <= 0 { return nil, "", fmt.Errorf("本地%s内容为空", fileType) } if maxSize > 0 && fileInfo.Size() > maxSize { return nil, "", fmt.Errorf("%s大小不能超过%dMB", fileType, maxSize/(1024*1024)) } fileExt := strings.ToLower(filepath.Ext(trimmedPath)) if len(allowedExts) == 0 { return fileInfo, fileExt, nil } if allowedExts[fileExt] { return fileInfo, fileExt, nil } detectedExt, err := s.DetectFileExtByMagic(trimmedPath) if err != nil { return nil, "", fmt.Errorf("检测本地%s类型失败: %w", fileType, err) } if allowedExts[detectedExt] { return fileInfo, detectedExt, nil } return nil, "", fmt.Errorf("不支持的%s格式", fileType) } func (s *MessageService) DetectFileExtByMagic(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("打开本地文件失败: %w", err) } defer file.Close() header := make([]byte, 512) n, err := file.Read(header) if err != nil && !errors.Is(err, io.EOF) { return "", fmt.Errorf("读取文件头失败: %w", err) } header = header[:n] switch { case len(header) >= 3 && bytes.Equal(header[:3], []byte{0xFF, 0xD8, 0xFF}): return ".jpg", nil case len(header) >= 8 && bytes.Equal(header[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}): return ".png", nil case len(header) >= 6 && (bytes.Equal(header[:6], []byte("GIF87a")) || bytes.Equal(header[:6], []byte("GIF89a"))): return ".gif", nil case len(header) >= 12 && bytes.Equal(header[:4], []byte("RIFF")) && bytes.Equal(header[8:12], []byte("WEBP")): return ".webp", nil case len(header) >= 9 && (bytes.Equal(header[:6], []byte("#!AMR\n")) || bytes.Equal(header[:9], []byte("#!AMR-WB\n"))): return ".amr", nil case len(header) >= 12 && bytes.Equal(header[:4], []byte("RIFF")) && bytes.Equal(header[8:12], []byte("WAVE")): return ".wav", nil case len(header) >= 3 && bytes.Equal(header[:3], []byte("ID3")): return ".mp3", nil case len(header) >= 2 && header[0] == 0xFF && header[1]&0xE0 == 0xE0: return ".mp3", nil case len(header) >= 12 && bytes.Equal(header[:4], []byte("RIFF")) && bytes.Equal(header[8:11], []byte("AVI")): return ".avi", nil case len(header) >= 3 && bytes.Equal(header[:3], []byte("FLV")): return ".flv", nil case len(header) >= 4 && bytes.Equal(header[:4], []byte{0x1A, 0x45, 0xDF, 0xA3}): return ".mkv", nil case len(header) >= 12 && bytes.Equal(header[4:8], []byte("ftyp")): brand := string(header[8:12]) if strings.HasPrefix(brand, "qt") { return ".mov", nil } return ".mp4", nil default: return "", nil } } func (s *MessageService) CalculateFileMD5(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("打开本地文件失败: %w", err) } defer file.Close() hash := md5.New() if _, err = io.Copy(hash, file); err != nil { return "", fmt.Errorf("读取本地文件失败: %w", err) } return hex.EncodeToString(hash.Sum(nil)), nil } func (s *MessageService) StreamLocalFileChunks(filePath string, chunkSize int64, handler func(chunkIndex, totalChunks, totalSize int64, chunkReader io.Reader, fileHeader *multipart.FileHeader) error) error { if chunkSize <= 0 { return errors.New("分片大小必须大于0") } file, err := os.Open(filePath) if err != nil { return fmt.Errorf("打开本地文件失败: %w", err) } defer file.Close() fileInfo, err := file.Stat() if err != nil { return fmt.Errorf("读取本地文件信息失败: %w", err) } if fileInfo.Size() <= 0 { return errors.New("本地文件内容为空") } totalSize := fileInfo.Size() totalChunks := (totalSize + chunkSize - 1) / chunkSize filename := filepath.Base(filePath) for chunkIndex := range totalChunks { currentChunkSize := chunkSize remaining := totalSize - chunkIndex*chunkSize if remaining < currentChunkSize { currentChunkSize = remaining } chunkData := make([]byte, int(currentChunkSize)) n, err := io.ReadFull(file, chunkData) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { return fmt.Errorf("读取本地文件分片失败: %w", err) } if n == 0 { return errors.New("读取本地文件分片失败: 数据为空") } if err := handler(chunkIndex, totalChunks, totalSize, bytes.NewReader(chunkData[:n]), &multipart.FileHeader{ Filename: filename, Size: int64(n), }); err != nil { return err } } return nil } func (s *MessageService) SendEmoji(toWxID string, md5 string, totalLen int32) error { message, err := vars.RobotRuntime.SendEmoji(robot.SendEmojiRequest{ ToWxid: toWxID, Md5: md5, TotalLen: totalLen, }) if err != nil { return err } for _, emojiItem := range message.EmojiItem { if emojiItem.Ret != 0 { continue } m := model.Message{ MsgId: emojiItem.NewMsgId, ClientMsgId: emojiItem.MsgId, Type: model.MsgTypeEmoticon, Content: "", DisplayFullContent: "机器人发送了一个表情", MessageSource: "", FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) } return nil } func (s *MessageService) ShareLink(toWxID string, shareLinkInfo robot.ShareLinkMessage) error { message, xmlStr, err := vars.RobotRuntime.ShareLink(toWxID, shareLinkInfo) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMsgTypeUrl, Content: xmlStr, DisplayFullContent: "机器人分享了一个链接", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendCDNFile(toWxID string, content string) error { message, err := vars.RobotRuntime.SendCDNFile(robot.SendCDNAttachmentRequest{ ToWxid: toWxID, Content: content, }) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeApp, AppMsgType: model.AppMsgTypeAttach, Content: "", DisplayFullContent: "机器人转发了一个文件", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendCDNImg(toWxID string, content string) error { message, err := vars.RobotRuntime.SendCDNImg(robot.SendCDNAttachmentRequest{ ToWxid: toWxID, Content: content, }) if err != nil { return err } m := model.Message{ MsgId: message.Newmsgid, ClientMsgId: message.Msgid, Type: model.MsgTypeImage, Content: "", DisplayFullContent: "机器人发送了一张图片", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: message.CreateTime, UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) SendCDNVideo(toWxID string, content string) error { message, err := vars.RobotRuntime.SendCDNVideo(robot.SendCDNAttachmentRequest{ ToWxid: toWxID, Content: content, }) if err != nil { return err } m := model.Message{ MsgId: message.NewMsgId, ClientMsgId: message.MsgId, Type: model.MsgTypeVideo, Content: "", DisplayFullContent: "机器人发送了一个视频", MessageSource: message.MsgSource, FromWxID: toWxID, ToWxID: vars.RobotRuntime.WxID, SenderWxID: vars.RobotRuntime.WxID, IsChatRoom: strings.HasSuffix(toWxID, "@chatroom"), CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), } err = s.msgRepo.Create(&m) if err != nil { log.Println("入库消息失败: ", err) } // 插入一条联系人记录,获取联系人列表接口获取不到未保存到通讯录的群聊 NewContactService(s.ctx).InsertOrUpdateContactActiveTime(m.FromWxID) return nil } func (s *MessageService) aiTextMessage(isAssistant bool, content string) openai.ChatCompletionMessageParamUnion { if isAssistant { return openai.AssistantMessage(content) } return openai.UserMessage(content) } func (s *MessageService) aiTextPartMessage(isAssistant bool, texts ...string) openai.ChatCompletionMessageParamUnion { if isAssistant { parts := make([]openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion, 0, len(texts)) for _, text := range texts { parts = append(parts, openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{ OfText: &openai.ChatCompletionContentPartTextParam{Text: text}, }) } return openai.AssistantMessage(parts) } parts := make([]openai.ChatCompletionContentPartUnionParam, 0, len(texts)) for _, text := range texts { parts = append(parts, openai.TextContentPart(text)) } return openai.UserMessage(parts) } func (s *MessageService) buildQuoteAIMessage(msg *model.Message, isAssistant bool) (openai.ChatCompletionMessageParamUnion, bool) { var xmlMessage robot.XmlMessage if err := vars.RobotRuntime.XmlDecoder(msg.Content, &xmlMessage); err != nil { return openai.ChatCompletionMessageParamUnion{}, false } switch xmlMessage.AppMsg.ReferMsg.Type { case int(model.MsgTypeText): return s.aiTextPartMessage(isAssistant, xmlMessage.AppMsg.ReferMsg.Content, xmlMessage.AppMsg.Title), true case int(model.MsgTypeImage): referMsg, ok := s.getReferMessageByMsgID(xmlMessage.AppMsg.ReferMsg.SvrID) if !ok { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextPartMessage(isAssistant, xmlMessage.AppMsg.Title+"\n\n 图片地址: "+referMsg.AttachmentUrl), true case int(model.MsgTypeVideo): referMsg, ok := s.getReferMessageByMsgID(xmlMessage.AppMsg.ReferMsg.SvrID) if !ok { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextMessage(isAssistant, "视频地址: "+referMsg.AttachmentUrl+"\n\n"+xmlMessage.AppMsg.Title), true case int(model.AppMsgTypequote): referMsg, ok := s.getReferMessageByID(xmlMessage.AppMsg.ReferMsg.SvrID) if !ok { return openai.ChatCompletionMessageParamUnion{}, false } var subXmlMessage robot.XmlMessage if err := vars.RobotRuntime.XmlDecoder(referMsg.Content, &subXmlMessage); err != nil { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextPartMessage(isAssistant, subXmlMessage.AppMsg.Title, xmlMessage.AppMsg.Title), true case int(model.MsgTypeEmoticon): if strings.TrimSpace(xmlMessage.AppMsg.Title) == "" { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextMessage(isAssistant, xmlMessage.AppMsg.Title), true case int(model.MsgTypeApp): referMsg, ok := s.getReferMessageByMsgID(xmlMessage.AppMsg.ReferMsg.SvrID) if !ok { return openai.ChatCompletionMessageParamUnion{}, false } if referMsg.AppMsgType == model.AppMsgTypeEmoji { if strings.TrimSpace(xmlMessage.AppMsg.Title) == "" { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextMessage(isAssistant, xmlMessage.AppMsg.Title), true } } return openai.ChatCompletionMessageParamUnion{}, false } func (s *MessageService) getReferMessageByMsgID(referMsgIDStr string) (*model.Message, bool) { referMsgID, err := strconv.ParseInt(referMsgIDStr, 10, 64) if err != nil { return nil, false } referMsg, err := s.msgRepo.GetByMsgID(referMsgID) if err != nil || referMsg == nil { return nil, false } return referMsg, true } func (s *MessageService) getReferMessageByID(referMsgIDStr string) (*model.Message, bool) { referMsgID, err := strconv.ParseInt(referMsgIDStr, 10, 64) if err != nil { return nil, false } referMsg, err := s.msgRepo.GetByID(referMsgID) if err != nil || referMsg == nil { return nil, false } return referMsg, true } func (s *MessageService) buildAIMessageContextMessage(msg *model.Message) (openai.ChatCompletionMessageParamUnion, bool) { isAssistant := msg.SenderWxID == vars.RobotRuntime.WxID switch { case msg.Type == model.MsgTypeText: if strings.TrimSpace(msg.Content) == "" { return openai.ChatCompletionMessageParamUnion{}, false } return s.aiTextMessage(isAssistant, msg.Content), true case msg.Type == model.MsgTypeImage && msg.AttachmentUrl != "": return s.aiTextPartMessage(isAssistant, "图片地址: "+msg.AttachmentUrl), true case msg.Type == model.MsgTypeVideo && msg.AttachmentUrl != "": return s.aiTextMessage(isAssistant, "视频地址: "+msg.AttachmentUrl), true case msg.Type == model.MsgTypeApp && msg.AppMsgType == model.AppMsgTypequote: return s.buildQuoteAIMessage(msg, isAssistant) default: return openai.ChatCompletionMessageParamUnion{}, false } } func (s *MessageService) ProcessAIMessageContext(messages []*model.Message) []openai.ChatCompletionMessageParamUnion { aiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)) messageCtxMap := make(map[int64]bool) for _, msg := range messages { if messageCtxMap[msg.MsgId] { continue } aiMessage, ok := s.buildAIMessageContextMessage(msg) if !ok { continue } messageCtxMap[msg.MsgId] = true aiMessages = append(aiMessages, aiMessage) } return aiMessages } func (s *MessageService) SetMessageIsInContext(message *model.Message) error { return s.msgRepo.SetMessageIsInContext(message) } func (s *MessageService) GetFriendAIMessageContext(message *model.Message) ([]openai.ChatCompletionMessageParamUnion, error) { messages, err := s.msgRepo.GetFriendAIMessageContext(message) if err != nil { return nil, err } if !slices.ContainsFunc(messages, func(m *model.Message) bool { return m.ID == message.ID }) { messages = append(messages, message) } return s.ProcessAIMessageContext(messages), nil } func (s *MessageService) ResetFriendAIMessageContext(message *model.Message) error { return s.msgRepo.ResetFriendAIMessageContext(message) } func (s *MessageService) GetChatRoomAIMessageContext(message *model.Message) ([]openai.ChatCompletionMessageParamUnion, error) { messages, err := s.msgRepo.GetChatRoomAIMessageContext(message) if err != nil { return nil, err } if !slices.ContainsFunc(messages, func(m *model.Message) bool { return m.ID == message.ID }) { messages = append(messages, message) } return s.ProcessAIMessageContext(messages), nil } func (s *MessageService) UpdateMessage(message *model.Message) error { return s.msgRepo.Update(message) } func (s *MessageService) ResetChatRoomAIMessageContext(message *model.Message) error { return s.msgRepo.ResetChatRoomAIMessageContext(message) } func (s *MessageService) GetAIMessageContext(message *model.Message) ([]openai.ChatCompletionMessageParamUnion, error) { if message.IsChatRoom { return s.GetChatRoomAIMessageContext(message) } return s.GetFriendAIMessageContext(message) } func (s *MessageService) GetYesterdayChatRommRank(chatRoomID string) ([]*dto.ChatRoomRank, error) { return s.msgRepo.GetYesterdayChatRommRank(vars.RobotRuntime.WxID, chatRoomID) } func (s *MessageService) GetLastWeekChatRommRank(chatRoomID string) ([]*dto.ChatRoomRank, error) { return s.msgRepo.GetLastWeekChatRommRank(vars.RobotRuntime.WxID, chatRoomID) } func (s *MessageService) GetLastMonthChatRommRank(chatRoomID string) ([]*dto.ChatRoomRank, error) { return s.msgRepo.GetLastMonthChatRommRank(vars.RobotRuntime.WxID, chatRoomID) } func (s *MessageService) ChatRoomAIDisabled(chatRoomID string) error { chatRoomSettingsSvc := NewChatRoomSettingsService(s.ctx) chatRoomSettings, err := chatRoomSettingsSvc.GetChatRoomSettings(chatRoomID) if err != nil { return err } if chatRoomSettings == nil || chatRoomSettings.ChatAIEnabled == nil || !*chatRoomSettings.ChatAIEnabled { return nil } disabled := false chatRoomSettings.ChatAIEnabled = &disabled err = chatRoomSettingsSvc.SaveChatRoomSettings(chatRoomSettings) if err != nil { return err } return nil } // detectAndNotifyNicknameChange 检测联系人昵称变更并通知所在群 func (s *MessageService) detectAndNotifyNicknameChange(contact *robot.Contact) { // 获取新的昵称 if contact.NickName.String == nil || *contact.NickName.String == "" { return } newNickname := *contact.NickName.String wechatID := *contact.UserName.String // 获取该联系人所在的所有群(未离开的群成员记录) members, err := s.crmRepo.GetChatRoomMemberByWeChatID(wechatID) if err != nil { log.Printf("[昵称变更] 查询群成员记录失败: %v", err) return } if len(members) == 0 { return } // 遍历每个群,检查昵称是否有变化 for _, member := range members { if member.IsLeaved != nil && *member.IsLeaved { continue } oldNickname := member.Nickname remark := member.Remark if oldNickname == "" && remark != "" { oldNickname = remark } if oldNickname == newNickname || oldNickname == "" { continue } // 昵称确实变了,更新数据库中的昵称 err = s.crmRepo.UpdateMemberInfo(member.ChatRoomID, wechatID, map[string]any{ "nickname": newNickname, }) if err != nil { log.Printf("[昵称变更] 更新群成员昵称失败: %v", err) } // 发送通知到群 notifyMsg := fmt.Sprintf("📋 群成员变动通知:\n📝 昵称修改:%s(%s → %s)", newNickname, oldNickname, newNickname) err = s.SendTextMessage(member.ChatRoomID, notifyMsg) if err != nil { log.Printf("[昵称变更] 发送通知失败: %v", err) } else { log.Printf("[昵称变更] %s 在群 %s 中昵称已变更: %s -> %s", wechatID, member.ChatRoomID, oldNickname, newNickname) } } } func (s *MessageService) GetChatRoomMember(chatRoomID string, wechatID string) (*model.ChatRoomMember, error) { return s.crmRepo.GetChatRoomMember(chatRoomID, wechatID) } // compressVideoWithFFmpeg 使用 ffmpeg 压缩视频到目标大小以内 func compressVideoWithFFmpeg(inputPath, outputPath string, targetSize int64) error { // 先获取视频时长 probeCmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", inputPath) durationOutput, err := probeCmd.Output() if err != nil { return fmt.Errorf("获取视频时长失败: %w", err) } durationStr := strings.TrimSpace(string(durationOutput)) duration, err := strconv.ParseFloat(durationStr, 64) if err != nil || duration <= 0 { duration = 60 // 默认假设60秒 } // 计算目标码率 (bits/s),留 10% 余量给音频 targetBitrate := int64(float64(targetSize) * 8 * 0.9 / duration) if targetBitrate < 100000 { targetBitrate = 100000 // 最低 100kbps } bitrateStr := fmt.Sprintf("%dk", targetBitrate/1000) // 使用 ffmpeg 压缩:降低码率 + 缩小分辨率 ffmpegCmd := exec.Command("ffmpeg", "-y", "-i", inputPath, "-c:v", "libx264", "-preset", "fast", "-b:v", bitrateStr, "-vf", "scale='min(720,iw)':-2", "-c:a", "aac", "-b:a", "64k", "-movflags", "+faststart", "-max_muxing_queue_size", "1024", outputPath, ) output, err := ffmpegCmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg 压缩失败: %w, output: %s", err, string(output)) } // 验证输出文件 outInfo, err := os.Stat(outputPath) if err != nil { return fmt.Errorf("压缩后文件不存在: %w", err) } if outInfo.Size() == 0 { return fmt.Errorf("压缩后文件为空") } return nil }