620 lines
16 KiB
Go
620 lines
16 KiB
Go
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 == "<nil>" || 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("", 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("", host, strings.Join(keylist, ","))
|
|
case MessageTypeAnimation:
|
|
if m.Contents["cdnurl"] != nil {
|
|
if cdnURL, ok := m.Contents["cdnurl"].(string); ok {
|
|
return fmt.Sprintf("", 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 == "<nil>" {
|
|
title = "链接"
|
|
}
|
|
if desc == "<nil>" {
|
|
desc = ""
|
|
}
|
|
if url == "<nil>" {
|
|
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("", 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(),
|
|
}
|
|
}
|