package model import ( "encoding/xml" "fmt" "strconv" "strings" "time" "github.com/sjzar/chatlog/pkg/util" ) var Debug = false const ( WeChatV3 = "wechatv3" WeChatV4 = "wechatv4" WeChatDarwinV3 = "wechatdarwinv3" ) const ( // MessageTypeText 文本 MessageTypeText = 1 // MessageTypeImage 图片 MessageTypeImage = 3 // MessageTypeVoice 语音 MessageTypeVoice = 34 // MessageTypeCard 名片 MessageTypeCard = 42 // MessageTypeVideo 视频 MessageTypeVideo = 43 // MessageTypeAnimation 动画表情 MessageTypeAnimation = 47 // MessageTypeLocation 位置 MessageTypeLocation = 48 // MessageTypeShare 分享 MessageTypeShare = 49 // MessageTypeVOIP 语音通话 MessageTypeVOIP = 50 // MessageTypeSystem 系统 MessageTypeSystem = 10000 ) const ( // MessageSubTypeText 文本 MessageSubTypeText = 1 // MessageSubTypeLink 链接分享 MessageSubTypeLink = 4 // MessageSubTypeLink2 链接分享 MessageSubTypeLink2 = 5 // MessageSubTypeFile 文件 MessageSubTypeFile = 6 // MessageSubTypeGIF 动图 MessageSubTypeGIF = 8 // MessageSubTypeMergeForward 合并转发 MessageSubTypeMergeForward = 19 // MessageSubTypeNote 笔记 MessageSubTypeNote = 24 // MessageSubTypeMiniProgram 小程序 MessageSubTypeMiniProgram = 33 // MessageSubTypeMiniProgram2 小程序 MessageSubTypeMiniProgram2 = 36 // MessageSubTypeChannel 视频号 MessageSubTypeChannel = 51 // MessageSubTypeQuote 引用 MessageSubTypeQuote = 57 // MessageSubTypePat 拍一拍 MessageSubTypePat = 62 // MessageSubTypeChannelLive 视频号直播 MessageSubTypeChannelLive = 63 // MessageSubTypeChatRoomNotice 群公告 MessageSubTypeChatRoomNotice = 87 // MessageSubTypeMusic 音乐 MessageSubTypeMusic = 92 // MessageSubTypePay 转账 MessageSubTypePay = 2000 // MessageSubTypeRedEnvelope 红包 MessageSubTypeRedEnvelope = 2001 // MessageSubTypeRedEnvelopeCover 红包封面 MessageSubTypeRedEnvelopeCover = 2003 ) type Message struct { Version string `json:"-"` // 消息版本,内部判断 Seq int64 `json:"seq"` // 消息序号,10位时间戳 + 3位序号 Time time.Time `json:"time"` // 消息创建时间,10位时间戳 Talker string `json:"talker"` // 聊天对象,微信 ID or 群 ID TalkerName string `json:"talkerName"` // 聊天对象名称 IsChatRoom bool `json:"isChatRoom"` // 是否为群聊消息 Sender string `json:"sender"` // 发送人,微信 ID SenderName string `json:"senderName"` // 发送人名称 IsSelf bool `json:"isSelf"` // 是否为自己发送的消息 Type int64 `json:"type"` // 消息类型 SubType int64 `json:"subType"` // 消息子类型 Content string `json:"content"` // 消息内容,文字聊天内容 Contents map[string]interface{} `json:"contents,omitempty"` // 消息内容,多媒体消息,采用更灵活的记录方式 // Debug Info MediaMsg *MediaMsg `json:"mediaMsg,omitempty"` // 原始多媒体消息,XML 格式 SysMsg *SysMsg `json:"sysMsg,omitempty"` // 原始系统消息,XML 格式 } func (m *Message) ParseMediaInfo(data string) error { m.Type, m.SubType = util.SplitInt64ToTwoInt32(m.Type) if m.Type == 1 { m.Content = data return nil } if m.Type == MessageTypeSystem { m.Sender = "系统消息" m.SenderName = "" var sysMsg SysMsg if err := xml.Unmarshal([]byte(data), &sysMsg); err != nil { m.Content = data return nil } if Debug { m.SysMsg = &sysMsg } m.Content = sysMsg.String() return nil } var msg MediaMsg err := xml.Unmarshal([]byte(data), &msg) if err != nil { return err } if m.Contents == nil { m.Contents = make(map[string]interface{}) } if Debug { m.MediaMsg = &msg } switch m.Type { case MessageTypeImage: m.Contents["md5"] = msg.Image.MD5 case MessageTypeVideo: if msg.Video.Md5 != "" { m.Contents["md5"] = msg.Video.Md5 } if msg.Video.RawMd5 != "" { m.Contents["rawmd5"] = msg.Video.RawMd5 } case MessageTypeAnimation: m.Contents["cdnurl"] = msg.Emoji.CdnURL case MessageTypeLocation: m.Contents["x"] = msg.Location.X m.Contents["y"] = msg.Location.Y m.Contents["label"] = msg.Location.Label m.Contents["cityname"] = msg.Location.CityName case MessageTypeShare: m.SubType = int64(msg.App.Type) switch m.SubType { case MessageSubTypeText, MessageSubTypeLink, MessageSubTypeLink2: // 链接 m.Contents["title"] = msg.App.Title m.Contents["desc"] = msg.App.Des m.Contents["url"] = msg.App.URL case MessageSubTypeFile: // 文件 m.Contents["title"] = msg.App.Title m.Contents["md5"] = msg.App.MD5 case MessageSubTypeGIF: // 分享的 GIF/表情(AppMsg type=8),尽量取 URL 作为 cdnurl 用于后续渲染 if msg.App.URL != "" { m.Contents["cdnurl"] = msg.App.URL } else if msg.Emoji.CdnURL != "" { // 兜底:有些场景仍可能走 emoji 结构 m.Contents["cdnurl"] = msg.Emoji.CdnURL } if msg.App.Title != "" { // 可能包含表情描述 m.Contents["title"] = msg.App.Title } case MessageSubTypeMergeForward, MessageSubTypeNote, MessageSubTypeChatRoomNotice: // 合并转发 & 笔记 m.Contents["title"] = msg.App.Title m.Contents["desc"] = msg.App.Des if msg.App.RecordItem == nil { break } recordInfo := &RecordInfo{} err := xml.Unmarshal([]byte(msg.App.RecordItem.CDATA), recordInfo) if err != nil { return err } m.Contents["recordInfo"] = recordInfo case MessageSubTypeMiniProgram, MessageSubTypeMiniProgram2: // 小程序 m.Contents["title"] = msg.App.SourceDisplayName m.Contents["url"] = msg.App.URL case MessageSubTypeChannel: // 视频号 if msg.App.FinderFeed == nil { break } m.Contents["title"] = strings.TrimSpace(strings.ReplaceAll(msg.App.FinderFeed.Desc, "\n", " ")) if len(msg.App.FinderFeed.MediaList.Media) > 0 { m.Contents["url"] = msg.App.FinderFeed.MediaList.Media[0].URL } case MessageSubTypeQuote: // 引用 m.Content = msg.App.Title if msg.App.ReferMsg == nil { break } subMsg := &Message{ Type: int64(msg.App.ReferMsg.Type), Time: time.Unix(msg.App.ReferMsg.CreateTime, 0), Sender: msg.App.ReferMsg.ChatUsr, SenderName: msg.App.ReferMsg.DisplayName, } if subMsg.Sender == "" { subMsg.Sender = msg.App.ReferMsg.FromUsr } if err := subMsg.ParseMediaInfo(msg.App.ReferMsg.Content); err != nil { break } m.Contents["refer"] = subMsg case MessageSubTypePat: // 拍一拍 if msg.App.PatMsg != nil { if len(msg.App.PatMsg.Records.Record) != 0 { m.Sender = msg.App.PatMsg.Records.Record[0].FromUser m.Content = msg.App.PatMsg.Records.Record[0].Templete } } if msg.App.PatInfo != nil { m.Content = msg.App.Title } case MessageSubTypeChannelLive: // 视频号直播 if msg.App.FinderLive == nil { break } m.Contents["title"] = msg.App.FinderLive.Desc case MessageSubTypeMusic: // 音乐 m.Contents["title"] = msg.App.Title m.Contents["desc"] = msg.App.Des m.Contents["url"] = msg.App.URL case MessageSubTypePay: // 微信转账 if msg.App.WCPayInfo == nil { break } // 1 实时转账 // 3 实时转账收钱回执 // 4 转账退还回执 // 5 非实时转账收钱回执 // 7 非实时转账 _type := "" switch msg.App.WCPayInfo.PaySubType { case 1, 7: _type = "发送 " case 3, 5: _type = "接收 " case 4: _type = "退还 " } payMemo := "" if len(msg.App.WCPayInfo.PayMemo) > 0 { payMemo = "(" + msg.App.WCPayInfo.PayMemo + ")" } m.Content = fmt.Sprintf("[转账|%s%s]%s", _type, msg.App.WCPayInfo.FeeDesc, payMemo) } } return nil } func (m *Message) SetContent(key string, value interface{}) { if m.Contents == nil { m.Contents = make(map[string]interface{}) } m.Contents[key] = value } func (m *Message) PlainText(showChatRoom bool, timeFormat string, host string) string { if timeFormat == "" { timeFormat = "01-02 15:04:05" } m.SetContent("host", host) buf := strings.Builder{} sender := m.Sender if m.IsSelf { sender = "我" } if m.SenderName != "" { buf.WriteString(m.SenderName) buf.WriteString("(") buf.WriteString(sender) buf.WriteString(")") } else { buf.WriteString(sender) } buf.WriteString(" ") if m.IsChatRoom && showChatRoom { buf.WriteString("[") if m.TalkerName != "" { buf.WriteString(m.TalkerName) buf.WriteString("(") buf.WriteString(m.Talker) buf.WriteString(")") } else { buf.WriteString(m.Talker) } buf.WriteString("] ") } buf.WriteString(m.Time.Format(timeFormat)) buf.WriteString("\n") buf.WriteString(m.PlainTextContent()) buf.WriteString("\n") return buf.String() } func (m *Message) PlainTextContent() string { // 安全获取 host 字符串,避免 fmt 中出现 %!s(),并提供兜底 host := fmt.Sprint(m.Contents["host"]) if host == "" || host == "" { host = "127.0.0.1:5030" } switch m.Type { case MessageTypeText: return m.Content case MessageTypeImage: keylist := make([]string, 0) if m.Contents["md5"] != nil { if md5, ok := m.Contents["md5"].(string); ok { keylist = append(keylist, md5) } } if m.Contents["path"] != nil { if path, ok := m.Contents["path"].(string); ok { keylist = append(keylist, path) } } if m.Contents["thumbpath"] != nil { if thumbpath, ok := m.Contents["thumbpath"].(string); ok { keylist = append(keylist, thumbpath) } } return fmt.Sprintf("![图片](http://%s/image/%s)", host, strings.Join(keylist, ",")) case MessageTypeVoice: if voice, ok := m.Contents["voice"]; ok { // 可选时长字段(可能来源于不同表:voicelength/voiceduration/length 秒) var durStr string if d, ok2 := m.Contents["voicelength"]; ok2 { durStr = fmt.Sprint(d) } if d, ok2 := m.Contents["voiceduration"]; ok2 { durStr = fmt.Sprint(d) } if d, ok2 := m.Contents["length"]; ok2 && durStr == "" { durStr = fmt.Sprint(d) } if durStr != "" { // 规范成整数秒显示 if strings.Contains(durStr, ".") { if f, err := strconv.ParseFloat(durStr, 64); err == nil { durStr = fmt.Sprint(int(f + 0.5)) } } // >=60s 转换为 XmYYs 形式 if secInt, err := strconv.Atoi(durStr); err == nil { if secInt >= 60 { min := secInt / 60 sec := secInt % 60 fmtDur := fmt.Sprintf("%dm%02ds", min, sec) return fmt.Sprintf("[语音(%s)](http://%s/voice/%s)", fmtDur, host, voice) } } return fmt.Sprintf("[语音(%ss)](http://%s/voice/%s)", durStr, host, voice) } return fmt.Sprintf("[语音](http://%s/voice/%s)", host, voice) } return "[语音]" case MessageTypeCard: return "[名片]" case MessageTypeVideo: keylist := make([]string, 0) if m.Contents["md5"] != nil { if md5, ok := m.Contents["md5"].(string); ok { keylist = append(keylist, md5) } } if m.Contents["rawmd5"] != nil { if rawmd5, ok := m.Contents["rawmd5"].(string); ok { keylist = append(keylist, rawmd5) } } if m.Contents["path"] != nil { if path, ok := m.Contents["path"].(string); ok { keylist = append(keylist, path) } } return fmt.Sprintf("![视频](http://%s/video/%s)", host, strings.Join(keylist, ",")) case MessageTypeAnimation: if m.Contents["cdnurl"] != nil { if cdnURL, ok := m.Contents["cdnurl"].(string); ok { return fmt.Sprintf("![动画表情](%s)", cdnURL) } } return "[动画表情]" case MessageTypeLocation: keylist := make([]string, 0) for _, key := range []string{"label", "cityname", "x", "y"} { if m.Contents[key] != nil { if value, ok := m.Contents[key].(string); ok { keylist = append(keylist, value) } } } return fmt.Sprintf("[位置|%s]", strings.Join(keylist, "|")) case MessageTypeShare: switch m.SubType { case MessageSubTypeText: title := fmt.Sprint(m.Contents["title"]) desc := fmt.Sprint(m.Contents["desc"]) url := fmt.Sprint(m.Contents["url"]) if title == "" { title = "链接" } if desc == "" { desc = "" } if url == "" { url = "" } if url == "" && strings.HasPrefix(desc, "http") { url = desc } if url != "" { text := fmt.Sprintf("[链接|%s](%s)", title, url) if desc != "" && desc != url { text = text + "\n" + desc } return text } if desc != "" { return fmt.Sprintf("[链接|%s]\n%s", title, desc) } return fmt.Sprintf("[链接|%s]", title) case MessageSubTypeLink, MessageSubTypeLink2: return fmt.Sprintf("[链接|%s](%s)", m.Contents["title"], m.Contents["url"]) case MessageSubTypeFile: return fmt.Sprintf("[文件|%s](http://%s/file/%s)", m.Contents["title"], host, m.Contents["md5"]) case MessageSubTypeGIF: if m.Contents["cdnurl"] != nil { if u, ok := m.Contents["cdnurl"].(string); ok && strings.HasPrefix(u, "http") { return fmt.Sprintf("![GIF表情](%s)", u) } } return "[GIF表情]" case MessageSubTypeMergeForward: _recordInfo, ok := m.Contents["recordInfo"] if !ok { return "[合并转发]" } recordInfo, ok := _recordInfo.(*RecordInfo) if !ok { return "[合并转发]" } host := "" if m.Contents["host"] != nil { host = m.Contents["host"].(string) } return recordInfo.String("合并转发", "", host) case MessageSubTypeNote: _recordInfo, ok := m.Contents["recordInfo"] if !ok { return "[笔记]" } recordInfo, ok := _recordInfo.(*RecordInfo) if !ok { return "[笔记]" } host := "" if m.Contents["host"] != nil { host = m.Contents["host"].(string) } return recordInfo.String("笔记", "", host) case MessageSubTypeMiniProgram, MessageSubTypeMiniProgram2: if m.Contents["title"] == "" { return "[小程序]" } return fmt.Sprintf("[小程序|%s](%s)", m.Contents["title"], m.Contents["url"]) case MessageSubTypeChannel: if m.Contents["title"] == "" { return "[视频号]" } else { return fmt.Sprintf("[视频号|%s](%s)", m.Contents["title"], m.Contents["url"]) } case MessageSubTypeQuote: _refer, ok := m.Contents["refer"] if !ok { if m.Content == "" { return "[引用]" } return "> [引用]\n" + m.Content } refer, ok := _refer.(*Message) if !ok { if m.Content == "" { return "[引用]" } return "> [引用]\n" + m.Content } buf := strings.Builder{} host := "" if m.Contents["host"] != nil { host = m.Contents["host"].(string) } referContent := refer.PlainText(false, "", host) for _, line := range strings.Split(referContent, "\n") { if line == "" { continue } buf.WriteString("> ") buf.WriteString(line) buf.WriteString("\n") } buf.WriteString(m.Content) return buf.String() case MessageSubTypePat: return m.Content case MessageSubTypeChannelLive: if m.Contents["title"] != nil { return fmt.Sprintf("[视频号直播|%s]", m.Contents["title"]) } return "[视频号直播]" case MessageSubTypeChatRoomNotice: _recordInfo, ok := m.Contents["recordInfo"] if !ok { return "[群公告]" } recordInfo, ok := _recordInfo.(*RecordInfo) if !ok { return "[群公告]" } host := "" if m.Contents["host"] != nil { host = m.Contents["host"].(string) } return recordInfo.String("群公告", "", host) case MessageSubTypeMusic: return fmt.Sprintf("[音乐|%s](%s)", m.Contents["title"], m.Contents["url"]) case MessageSubTypePay: return m.Content case MessageSubTypeRedEnvelope: return "[红包]" case MessageSubTypeRedEnvelopeCover: return "[红包封面]" default: return "[分享]" } case MessageTypeVOIP: return "[语音通话]" case MessageTypeSystem: return m.Content default: content := m.Content if len(content) > 120 { content = content[:120] + "<...>" } return fmt.Sprintf("Type: %d Content: %s", m.Type, content) } } func (m *Message) CSV(host string) []string { m.SetContent("host", host) return []string{ m.Time.Format("2006-01-02 15:04:05"), m.SenderName, m.Sender, m.TalkerName, m.Talker, m.PlainTextContent(), } }