3 Commits

Author SHA1 Message Date
Shen Junzheng
d92e974ce6 mcp required args 2025-04-09 00:01:13 +08:00
Shen Junzheng
4250057ba8 adjust message handing 2025-04-08 23:29:41 +08:00
Sarv
c12ee8bfce update dat2img (#11) 2025-04-02 14:59:48 +08:00
13 changed files with 941 additions and 510 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/mcp" "github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/chatlog/wechat" "github.com/sjzar/chatlog/internal/chatlog/wechat"
"github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/util/dat2img"
) )
// Manager 管理聊天日志应用 // Manager 管理聊天日志应用
@@ -96,6 +97,11 @@ func (m *Manager) StartService() error {
return err return err
} }
// 如果是 4.0 版本,更新下 xorkey
if m.ctx.Version == 4 {
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
}
// 更新状态 // 更新状态
m.ctx.SetHTTPEnabled(true) m.ctx.SetHTTPEnabled(true)

View File

@@ -26,6 +26,7 @@ var (
"description": "联系人的搜索关键词可以是姓名、备注名或ID。", "description": "联系人的搜索关键词可以是姓名、备注名或ID。",
}, },
}, },
Required: []string{"query"},
}, },
} }
@@ -40,6 +41,7 @@ var (
"description": "群聊的搜索关键词可以是群名称、群ID或相关描述", "description": "群聊的搜索关键词可以是群名称、群ID或相关描述",
}, },
}, },
Required: []string{"query"},
}, },
} }
@@ -67,6 +69,7 @@ var (
"description": "交谈对象可以是联系人或群聊。支持使用ID、昵称、备注名等进行查询。", "description": "交谈对象可以是联系人或群聊。支持使用ID、昵称、备注名等进行查询。",
}, },
}, },
Required: []string{"time", "talker"},
}, },
} }

View File

@@ -191,9 +191,6 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
talker = v.(string) talker = v.(string)
} }
limit := util.MustAnyToInt(callReq.Arguments["limit"]) limit := util.MustAnyToInt(callReq.Arguments["limit"])
if limit == 0 {
limit = 100
}
offset := util.MustAnyToInt(callReq.Arguments["offset"]) offset := util.MustAnyToInt(callReq.Arguments["offset"])
messages, err := s.db.GetMessages(start, end, talker, limit, offset) messages, err := s.db.GetMessages(start, end, talker, limit, offset)
if err != nil { if err != nil {
@@ -264,9 +261,6 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
return fmt.Errorf("无法解析时间范围") return fmt.Errorf("无法解析时间范围")
} }
limit := util.MustAnyToInt(u.Query().Get("limit")) limit := util.MustAnyToInt(u.Query().Get("limit"))
if limit == 0 {
limit = 100
}
offset := util.MustAnyToInt(u.Query().Get("offset")) offset := util.MustAnyToInt(u.Query().Get("offset"))
messages, err := s.db.GetMessages(start, end, u.Host, limit, offset) messages, err := s.db.GetMessages(start, end, u.Host, limit, offset)
if err != nil { if err != nil {

View File

@@ -92,8 +92,9 @@ type Tool struct {
} }
type ToolSchema struct { type ToolSchema struct {
Type string `json:"type"` Type string `json:"type"`
Properties M `json:"properties"` Properties M `json:"properties"`
Required []string `json:"required,omitempty"`
} }
// { // {

View File

@@ -3,217 +3,17 @@ package model
import ( import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"regexp"
"strings" "strings"
"time"
"github.com/sjzar/chatlog/pkg/util"
) )
type MediaMessage struct { type MediaMsg struct {
Type int64
SubType int
MediaMD5 string
MediaPath string
Title string
Desc string
Content string
URL string
RecordInfo *RecordInfo
ReferDisplayName string
ReferUserName string
ReferCreateTime time.Time
ReferMessage *MediaMessage
Host string
Message XMLMessage
}
func NewMediaMessage(_type int64, data string) (*MediaMessage, error) {
__type, subType := util.SplitInt64ToTwoInt32(_type)
m := &MediaMessage{
Type: __type,
SubType: int(subType),
}
if _type == 1 {
m.Content = data
return m, nil
}
var msg XMLMessage
err := xml.Unmarshal([]byte(data), &msg)
if err != nil {
return nil, err
}
m.Message = msg
if err := m.parse(); err != nil {
return nil, err
}
return m, nil
}
func (m *MediaMessage) parse() error {
switch m.Type {
case 3:
m.MediaMD5 = m.Message.Image.MD5
case 43:
m.MediaMD5 = m.Message.Video.RawMd5
case 49:
m.SubType = m.Message.App.Type
switch m.SubType {
case 5:
m.Title = m.Message.App.Title
m.URL = m.Message.App.URL
case 6:
m.Title = m.Message.App.Title
m.MediaMD5 = m.Message.App.MD5
case 19:
m.Title = m.Message.App.Title
m.Desc = m.Message.App.Des
if m.Message.App.RecordItem == nil {
break
}
recordInfo := &RecordInfo{}
err := xml.Unmarshal([]byte(m.Message.App.RecordItem.CDATA), recordInfo)
if err != nil {
return err
}
m.RecordInfo = recordInfo
case 57:
m.Content = m.Message.App.Title
if m.Message.App.ReferMsg == nil {
break
}
subMsg, err := NewMediaMessage(m.Message.App.ReferMsg.Type, m.Message.App.ReferMsg.Content)
if err != nil {
break
}
m.ReferDisplayName = m.Message.App.ReferMsg.DisplayName
m.ReferUserName = m.Message.App.ReferMsg.ChatUsr
m.ReferCreateTime = time.Unix(m.Message.App.ReferMsg.CreateTime, 0)
m.ReferMessage = subMsg
}
}
return nil
}
func (m *MediaMessage) SetHost(host string) {
m.Host = host
}
func (m *MediaMessage) String() string {
switch m.Type {
case 1:
return m.Content
case 3:
return fmt.Sprintf("![图片](http://%s/image/%s)", m.Host, m.MediaMD5)
case 34:
return "[语音]"
case 43:
if m.MediaPath != "" {
return fmt.Sprintf("![视频](http://%s/data/%s)", m.Host, m.MediaPath)
}
return fmt.Sprintf("![视频](http://%s/video/%s)", m.Host, m.MediaMD5)
case 47:
return "[动画表情]"
case 49:
switch m.SubType {
case 5:
return fmt.Sprintf("[链接|%s](%s)", m.Title, m.URL)
case 6:
return fmt.Sprintf("[文件|%s](http://%s/file/%s)", m.Title, m.Host, m.MediaMD5)
case 8:
return "[GIF表情]"
case 19:
if m.RecordInfo == nil {
return "[合并转发]"
}
buf := strings.Builder{}
for _, item := range m.RecordInfo.DataList.DataItems {
buf.WriteString(item.SourceName + ": ")
switch item.DataType {
case "jpg":
buf.WriteString(fmt.Sprintf("![图片](http://%s/image/%s)", m.Host, item.FullMD5))
default:
buf.WriteString(item.DataDesc)
}
buf.WriteString("\n")
}
return m.Content
case 33, 36:
return "[小程序]"
case 57:
if m.ReferMessage == nil {
if m.Content == "" {
return "[引用]"
}
return "> [引用]\n" + m.Content
}
buf := strings.Builder{}
buf.WriteString("> ")
if m.ReferDisplayName != "" {
buf.WriteString(m.ReferDisplayName)
buf.WriteString("(")
buf.WriteString(m.ReferUserName)
buf.WriteString(")")
} else {
buf.WriteString(m.ReferUserName)
}
buf.WriteString(" ")
buf.WriteString(m.ReferCreateTime.Format("2006-01-02 15:04:05"))
buf.WriteString("\n")
buf.WriteString("> ")
m.ReferMessage.SetHost(m.Host)
buf.WriteString(strings.ReplaceAll(m.ReferMessage.String(), "\n", "\n> "))
buf.WriteString("\n")
buf.WriteString(m.Content)
m.Content = buf.String()
return m.Content
case 63:
return "[视频号]"
case 87:
return "[群公告]"
case 2000:
return "[转账]"
case 2003:
return "[红包封面]"
default:
return "[分享]"
}
case 50:
return "[语音通话]"
case 10000:
return "[系统消息]"
default:
content := m.Content
if len(content) > 120 {
content = content[:120] + "<...>"
}
return fmt.Sprintf("Type: %d Content: %s", m.Type, content)
}
}
type XMLMessage struct {
XMLName xml.Name `xml:"msg"` XMLName xml.Name `xml:"msg"`
Image Image `xml:"img,omitempty"` Image Image `xml:"img,omitempty"`
Video Video `xml:"videomsg,omitempty"` Video Video `xml:"videomsg,omitempty"`
App App `xml:"appmsg,omitempty"` App App `xml:"appmsg,omitempty"`
} }
type XMLImageMessage struct {
XMLName xml.Name `xml:"msg"`
Img Image `xml:"img"`
}
type Image struct { type Image struct {
MD5 string `xml:"md5,attr"` MD5 string `xml:"md5,attr"`
// HdLength string `xml:"hdlength,attr"` // HdLength string `xml:"hdlength,attr"`
@@ -234,11 +34,6 @@ type Image struct {
// CdnThumbAesKey string `xml:"cdnthumbaeskey,attr"` // CdnThumbAesKey string `xml:"cdnthumbaeskey,attr"`
} }
type XMLVideoMessage struct {
XMLName xml.Name `xml:"msg"`
VideoMsg Video `xml:"videomsg"`
}
type Video struct { type Video struct {
RawMd5 string `xml:"rawmd5,attr"` RawMd5 string `xml:"rawmd5,attr"`
// Length string `xml:"length,attr"` // Length string `xml:"length,attr"`
@@ -263,14 +58,18 @@ type Video struct {
} }
type App struct { type App struct {
Type int `xml:"type"` Type int `xml:"type"`
Title string `xml:"title"` Title string `xml:"title"`
Des string `xml:"des"` Des string `xml:"des"`
URL string `xml:"url"` // type 5 分享 URL string `xml:"url"` // type 5 分享
AppAttach AppAttach `xml:"appattach"` // type 6 文件 AppAttach *AppAttach `xml:"appattach,omitempty"` // type 6 文件
MD5 string `xml:"md5"` // type 6 文件 MD5 string `xml:"md5,omitempty"` // type 6 文件
RecordItem *RecordItem `xml:"recorditem,omitempty"` // type 19 合并转发 RecordItem *RecordItem `xml:"recorditem,omitempty"` // type 19 合并转发
ReferMsg *ReferMsg `xml:"refermsg,omitempty"` // type 57 引用 SourceDisplayName string `xml:"sourcedisplayname,omitempty"` // type 33 小程序
FinderFeed *FinderFeed `xml:"finderFeed,omitempty"` // type 51 视频号
ReferMsg *ReferMsg `xml:"refermsg,omitempty"` // type 57 引用
PatMsg *PatMsg `xml:"patMsg,omitempty"` // type 62 拍一拍
WCPayInfo *WCPayInfo `xml:"wcpayinfo,omitempty"` // type 2000 微信转账
} }
// ReferMsg 表示引用消息 // ReferMsg 表示引用消息
@@ -352,4 +151,225 @@ type DataItem struct {
SrcMsgCreateTime string `xml:"srcMsgCreateTime,omitempty"` SrcMsgCreateTime string `xml:"srcMsgCreateTime,omitempty"`
MessageUUID string `xml:"messageuuid,omitempty"` MessageUUID string `xml:"messageuuid,omitempty"`
FromNewMsgID string `xml:"fromnewmsgid,omitempty"` FromNewMsgID string `xml:"fromnewmsgid,omitempty"`
// 套娃合并转发
DataTitle string `xml:"datatitle,omitempty"`
RecordXML *RecordXML `xml:"recordxml,omitempty"`
}
type RecordXML struct {
RecordInfo RecordInfo `xml:"recordinfo,omitempty"`
}
func (r *RecordInfo) String(title, host string) string {
buf := strings.Builder{}
if title == "" {
title = r.Title
}
buf.WriteString(fmt.Sprintf("[合并转发|%s]\n", title))
for _, item := range r.DataList.DataItems {
buf.WriteString(fmt.Sprintf(" %s %s\n", item.SourceName, item.SourceTime))
// 套娃合并转发
if item.DataType == "17" && item.RecordXML != nil {
content := item.RecordXML.RecordInfo.String(item.DataTitle, host)
if content != "" {
for _, line := range strings.Split(content, "\n") {
buf.WriteString(fmt.Sprintf(" %s\n", line))
}
}
continue
}
switch item.DataFmt {
case "pic", "jpg":
buf.WriteString(fmt.Sprintf(" ![图片](http://%s/image/%s)\n", host, item.FullMD5))
default:
for _, line := range strings.Split(item.DataDesc, "\n") {
buf.WriteString(fmt.Sprintf(" %s\n", line))
}
}
buf.WriteString("\n")
}
return buf.String()
}
// PatMsg 拍一拍消息结构
type PatMsg struct {
ChatUser string `xml:"chatUser"` // 被拍的用户
RecordNum int `xml:"recordNum"` // 记录数量
Records Records `xml:"records"` // 拍一拍记录
}
// Records 拍一拍记录集合
type Records struct {
Record []PatRecord `xml:"record"` // 拍一拍记录列表
}
// PatRecord 单条拍一拍记录
type PatRecord struct {
FromUser string `xml:"fromUser"` // 发起拍一拍的用户
PattedUser string `xml:"pattedUser"` // 被拍的用户
Templete string `xml:"templete"` // 模板文本
CreateTime int64 `xml:"createTime"` // 创建时间
SvrId string `xml:"svrId"` // 服务器ID
ReadStatus int `xml:"readStatus"` // 已读状态
}
// WCPayInfo 微信支付信息
type WCPayInfo struct {
PaySubType int `xml:"paysubtype"` // 支付子类型
FeeDesc string `xml:"feedesc"` // 金额描述,如"¥200000.00"
TranscationID string `xml:"transcationid"` // 交易ID
TransferID string `xml:"transferid"` // 转账ID
InvalidTime string `xml:"invalidtime"` // 失效时间
BeginTransferTime string `xml:"begintransfertime"` // 开始转账时间
EffectiveDate string `xml:"effectivedate"` // 生效日期
PayMemo string `xml:"pay_memo"` // 支付备注
ReceiverUsername string `xml:"receiver_username"` // 接收方用户名
PayerUsername string `xml:"payer_username"` // 支付方用户名
}
// FinderFeed 视频号信息
type FinderFeed struct {
ObjectID string `xml:"objectId"`
FeedType string `xml:"feedType"`
Nickname string `xml:"nickname"`
Avatar string `xml:"avatar"`
Desc string `xml:"desc"`
MediaCount string `xml:"mediaCount"`
ObjectNonceID string `xml:"objectNonceId"`
LiveID string `xml:"liveId"`
Username string `xml:"username"`
AuthIconURL string `xml:"authIconUrl"`
AuthIconType int `xml:"authIconType"`
ContactJumpInfoStr string `xml:"contactJumpInfoStr"`
SourceCommentScene int `xml:"sourceCommentScene"`
MediaList FinderMediaList `xml:"mediaList"`
MegaVideo FinderMegaVideo `xml:"megaVideo"`
BizUsername string `xml:"bizUsername"`
BizNickname string `xml:"bizNickname"`
BizAvatar string `xml:"bizAvatar"`
BizUsernameV2 string `xml:"bizUsernameV2"`
BizAuthIconURL string `xml:"bizAuthIconUrl"`
BizAuthIconType int `xml:"bizAuthIconType"`
EcSource string `xml:"ecSource"`
LastGMsgID string `xml:"lastGMsgID"`
ShareBypData string `xml:"shareBypData"`
IsDebug int `xml:"isDebug"`
ContentType int `xml:"content_type"`
FinderForwardSource string `xml:"finderForwardSource"`
}
type FinderMediaList struct {
Media []FinderMedia `xml:"media"`
}
type FinderMedia struct {
ThumbURL string `xml:"thumbUrl"`
FullCoverURL string `xml:"fullCoverUrl"`
VideoPlayDuration string `xml:"videoPlayDuration"`
URL string `xml:"url"`
CoverURL string `xml:"coverUrl"`
Height string `xml:"height"`
MediaType string `xml:"mediaType"`
FullClipInset string `xml:"fullClipInset"`
Width string `xml:"width"`
}
type FinderMegaVideo struct {
ObjectID string `xml:"objectId"`
ObjectNonceID string `xml:"objectNonceId"`
}
type SysMsg struct {
SysMsgTemplate SysMsgTemplate `xml:"sysmsgtemplate"`
}
type SysMsgTemplate struct {
ContentTemplate ContentTemplate `xml:"content_template"`
}
type ContentTemplate struct {
Type string `xml:"type,attr"`
Plain string `xml:"plain"`
Template string `xml:"template"`
LinkList LinkList `xml:"link_list"`
}
type LinkList struct {
Links []Link `xml:"link"`
}
type Link struct {
Name string `xml:"name,attr"`
Type string `xml:"type,attr"`
MemberList MemberList `xml:"memberlist"`
Separator string `xml:"separator"`
}
type MemberList struct {
Members []Member `xml:"member"`
}
type Member struct {
Username string `xml:"username"`
Nickname string `xml:"nickname"`
}
func (s *SysMsg) String() string {
template := s.SysMsgTemplate.ContentTemplate.Template
links := s.SysMsgTemplate.ContentTemplate.LinkList.Links
// 创建一个映射,用于存储占位符名称和对应的替换内容
replacements := make(map[string]string)
// 遍历所有链接,为每个占位符准备替换内容
for _, link := range links {
var replacement string
// 根据链接类型和成员信息生成替换内容
switch link.Type {
case "link_profile":
// 使用自定义分隔符,如果未指定则默认使用"、"
separator := link.Separator
if separator == "" {
separator = "、"
}
// 处理成员信息,格式为 nickname(username)
var memberTexts []string
for _, member := range link.MemberList.Members {
if member.Nickname != "" {
memberText := member.Nickname
if member.Username != "" {
memberText += "(" + member.Username + ")"
}
memberTexts = append(memberTexts, memberText)
}
}
// 使用指定的分隔符连接所有成员文本
replacement = strings.Join(memberTexts, separator)
// 可以根据需要添加其他链接类型的处理逻辑
default:
replacement = ""
}
// 将占位符名称和替换内容存入映射
replacements["$"+link.Name+"$"] = replacement
}
// 使用正则表达式查找并替换所有占位符
re := regexp.MustCompile(`\$([^$]+)\$`)
result := re.ReplaceAllStringFunc(template, func(match string) string {
if replacement, ok := replacements[match]; ok {
return replacement
}
// 如果找不到对应的替换内容,保留原占位符
return match
})
return result
} }

View File

@@ -1,197 +1,215 @@
package model package model
import ( import (
"path/filepath" "encoding/xml"
"fmt"
"strings" "strings"
"time" "time"
"github.com/sjzar/chatlog/internal/model/wxproto" "github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/util/lz4"
"google.golang.org/protobuf/proto"
) )
var Debug = false
const ( const (
// Source
WeChatV3 = "wechatv3" WeChatV3 = "wechatv3"
WeChatV4 = "wechatv4" WeChatV4 = "wechatv4"
WeChatDarwinV3 = "wechatdarwinv3" WeChatDarwinV3 = "wechatdarwinv3"
) )
type Message struct { type Message struct {
Sequence int64 `json:"sequence"` // 消息序号10位时间戳 + 3位序号 Version string `json:"-"` // 消息版本,内部判断
CreateTime time.Time `json:"createTime"` // 消息创建时间10位时间戳 Seq int64 `json:"seq"` // 消息序号10位时间戳 + 3位序号
TalkerID int `json:"talkerID"` // 聊天对象Name2ID 表序号,索引值 Time time.Time `json:"time"` // 消息创建时间10位时间戳
Talker string `json:"talker"` // 聊天对象,微信 ID or 群 ID Talker string `json:"talker"` // 聊天对象,微信 ID or 群 ID
IsSender int `json:"isSender"` // 是否为发送消息0 接收消息1 发送消息 TalkerName string `json:"talkerName"` // 聊天对象名称
Type int64 `json:"type"` // 消息类型 IsChatRoom bool `json:"isChatRoom"` // 是否为群聊消息
SubType int `json:"subType"` // 消息子类型 Sender string `json:"sender"` // 发送人,微信 ID
Content string `json:"content"` // 消息内容,文字聊天内容 或 XML SenderName string `json:"senderName"` // 发送人名称
CompressContent []byte `json:"compressContent"` // 非文字聊天内容,如图片、语音、视频等 IsSelf bool `json:"isSelf"` // 是否为自己发送的消息
IsChatRoom bool `json:"isChatRoom"` // 是否为群聊消息 Type int64 `json:"type"` // 消息类型
ChatRoomSender string `json:"chatRoomSender"` // 群聊消息发送人 SubType int64 `json:"subType"` // 消息子类型
Content string `json:"content"` // 消息内容,文字聊天内容
Contents map[string]interface{} `json:"contents,omitempty"` // 消息内容,多媒体消息,采用更灵活的记录方式
// Fill Info // Debug Info
// 从联系人等信息中填充 MediaMsg *MediaMsg `json:"mediaMsg,omitempty"` // 原始多媒体消息XML 格式
DisplayName string `json:"-"` // 显示名称 SysMsg *SysMsg `json:"sysMsg,omitempty"` // 原始系统消息XML 格式
ChatRoomName string `json:"-"` // 群聊名称
MediaMessage *MediaMessage `json:"-"` // 多媒体消息
Version string `json:"-"` // 消息版本,内部判断
} }
// CREATE TABLE MSG ( func (m *Message) ParseMediaInfo(data string) error {
// localId INTEGER PRIMARY KEY AUTOINCREMENT,
// TalkerId INT DEFAULT 0,
// MsgSvrID INT,
// Type INT,
// SubType INT,
// IsSender INT,
// CreateTime INT,
// Sequence INT DEFAULT 0,
// StatusEx INT DEFAULT 0,
// FlagEx INT,
// Status INT,
// MsgServerSeq INT,
// MsgSequence INT,
// StrTalker TEXT,
// StrContent TEXT,
// DisplayContent TEXT,
// Reserved0 INT DEFAULT 0,
// Reserved1 INT DEFAULT 0,
// Reserved2 INT DEFAULT 0,
// Reserved3 INT DEFAULT 0,
// Reserved4 TEXT,
// Reserved5 TEXT,
// Reserved6 TEXT,
// CompressContent BLOB,
// BytesExtra BLOB,
// BytesTrans BLOB
// )
type MessageV3 struct {
Sequence int64 `json:"Sequence"` // 消息序号10位时间戳 + 3位序号
CreateTime int64 `json:"CreateTime"` // 消息创建时间10位时间戳
TalkerID int `json:"TalkerId"` // 聊天对象Name2ID 表序号,索引值
StrTalker string `json:"StrTalker"` // 聊天对象,微信 ID or 群 ID
IsSender int `json:"IsSender"` // 是否为发送消息0 接收消息1 发送消息
Type int64 `json:"Type"` // 消息类型
SubType int `json:"SubType"` // 消息子类型
StrContent string `json:"StrContent"` // 消息内容,文字聊天内容 或 XML
CompressContent []byte `json:"CompressContent"` // 非文字聊天内容,如图片、语音、视频等
BytesExtra []byte `json:"BytesExtra"` // protobuf 额外数据,记录群聊发送人等信息
// 非关键信息,后续有需要再加入 m.Type, m.SubType = util.SplitInt64ToTwoInt32(m.Type)
// LocalID int64 `json:"localId"`
// MsgSvrID int64 `json:"MsgSvrID"`
// StatusEx int `json:"StatusEx"`
// FlagEx int `json:"FlagEx"`
// Status int `json:"Status"`
// MsgServerSeq int64 `json:"MsgServerSeq"`
// MsgSequence int64 `json:"MsgSequence"`
// DisplayContent string `json:"DisplayContent"`
// Reserved0 int `json:"Reserved0"`
// Reserved1 int `json:"Reserved1"`
// Reserved2 int `json:"Reserved2"`
// Reserved3 int `json:"Reserved3"`
// Reserved4 string `json:"Reserved4"`
// Reserved5 string `json:"Reserved5"`
// Reserved6 string `json:"Reserved6"`
// BytesTrans []byte `json:"BytesTrans"`
}
func (m *MessageV3) Wrap() *Message { if m.Type == 1 {
m.Content = data
_m := &Message{
Sequence: m.Sequence,
CreateTime: time.Unix(m.CreateTime, 0),
TalkerID: m.TalkerID,
Talker: m.StrTalker,
IsSender: m.IsSender,
Type: m.Type,
SubType: m.SubType,
Content: m.StrContent,
CompressContent: m.CompressContent,
Version: WeChatV3,
}
_m.IsChatRoom = strings.HasSuffix(_m.Talker, "@chatroom")
if _m.Type == 49 {
b, err := lz4.Decompress(m.CompressContent)
if err == nil {
_m.Content = string(b)
}
}
if _m.Type != 1 {
mediaMessage, err := NewMediaMessage(_m.Type, _m.Content)
if err == nil {
_m.MediaMessage = mediaMessage
}
}
if len(m.BytesExtra) != 0 {
if bytesExtra := ParseBytesExtra(m.BytesExtra); bytesExtra != nil {
if _m.IsChatRoom {
_m.ChatRoomSender = bytesExtra[1]
}
// FIXME xml 中的 md5 数据无法匹配到 hardlink 记录,所以直接用 proto 数据
if _m.Type == 43 {
path := bytesExtra[4]
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) > 1 {
path = strings.Join(parts[1:], "/")
}
_m.MediaMessage.MediaPath = path
}
}
}
return _m
}
// ParseBytesExtra 解析额外数据
// 按需解析
func ParseBytesExtra(b []byte) map[int]string {
var pbMsg wxproto.BytesExtra
if err := proto.Unmarshal(b, &pbMsg); err != nil {
return nil
}
if pbMsg.Items == nil {
return nil return nil
} }
ret := make(map[int]string, len(pbMsg.Items)) if m.Type == 10000 {
for _, item := range pbMsg.Items { var sysMsg SysMsg
ret[int(item.Type)] = item.Value 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
} }
return ret 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 3:
m.Contents["md5"] = msg.Image.MD5
case 43:
m.Contents["md5"] = msg.Video.RawMd5
case 49:
m.SubType = int64(msg.App.Type)
switch m.SubType {
case 5:
// 链接
m.Contents["title"] = msg.App.Title
m.Contents["url"] = msg.App.URL
case 6:
// 文件
m.Contents["title"] = msg.App.Title
m.Contents["md5"] = msg.App.MD5
case 19:
// 合并转发
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 33, 36:
// 小程序
m.Contents["title"] = msg.App.SourceDisplayName
m.Contents["url"] = msg.App.URL
case 51:
// 视频号
if msg.App.FinderFeed == nil {
break
}
m.Contents["title"] = msg.App.FinderFeed.Desc
if len(msg.App.FinderFeed.MediaList.Media) > 0 {
m.Contents["url"] = msg.App.FinderFeed.MediaList.Media[0].URL
}
case 57:
// 引用
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 62:
// 拍一拍
if msg.App.PatMsg == nil {
break
}
if len(msg.App.PatMsg.Records.Record) == 0 {
break
}
m.Sender = msg.App.PatMsg.Records.Record[0].FromUser
m.Content = msg.App.PatMsg.Records.Record[0].Templete
case 2000:
// 微信转账
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, host string) string { func (m *Message) PlainText(showChatRoom bool, host string) string {
m.SetContent("host", host)
buf := strings.Builder{} buf := strings.Builder{}
talker := m.Talker sender := m.Sender
if m.IsSender == 1 { switch {
talker = "我" case m.Type == 10000:
} else if m.IsChatRoom { sender = "系统消息"
talker = m.ChatRoomSender case m.IsSelf:
sender = "我"
default:
sender = m.Sender
} }
if m.DisplayName != "" { if m.SenderName != "" {
buf.WriteString(m.DisplayName) buf.WriteString(m.SenderName)
buf.WriteString("(") buf.WriteString("(")
buf.WriteString(talker) buf.WriteString(sender)
buf.WriteString(")") buf.WriteString(")")
} else { } else {
buf.WriteString(talker) buf.WriteString(sender)
} }
buf.WriteString(" ") buf.WriteString(" ")
if m.IsChatRoom && showChatRoom { if m.IsChatRoom && showChatRoom {
buf.WriteString("[") buf.WriteString("[")
if m.ChatRoomName != "" { if m.TalkerName != "" {
buf.WriteString(m.ChatRoomName) buf.WriteString(m.TalkerName)
buf.WriteString("(") buf.WriteString("(")
buf.WriteString(m.Talker) buf.WriteString(m.Talker)
buf.WriteString(")") buf.WriteString(")")
@@ -201,17 +219,112 @@ func (m *Message) PlainText(showChatRoom bool, host string) string {
buf.WriteString("] ") buf.WriteString("] ")
} }
buf.WriteString(m.CreateTime.Format("2006-01-02 15:04:05")) buf.WriteString(m.Time.Format("2006-01-02 15:04:05"))
buf.WriteString("\n") buf.WriteString("\n")
if m.MediaMessage != nil { buf.WriteString(m.PlainTextContent())
m.MediaMessage.SetHost(host)
buf.WriteString(m.MediaMessage.String())
} else {
buf.WriteString(m.Content)
}
buf.WriteString("\n") buf.WriteString("\n")
return buf.String() return buf.String()
} }
func (m *Message) PlainTextContent() string {
switch m.Type {
case 1:
return m.Content
case 3:
return fmt.Sprintf("![图片](http://%s/image/%s)", m.Contents["host"], m.Contents["md5"])
case 34:
return "[语音]"
case 42:
return "[名片]"
case 43:
if path, ok := m.Contents["path"]; ok {
return fmt.Sprintf("![视频](http://%s/data/%s)", m.Contents["host"], path)
}
return fmt.Sprintf("![视频](http://%s/video/%s)", m.Contents["host"], m.Contents["md5"])
case 47:
return "[动画表情]"
case 49:
switch m.SubType {
case 5:
return fmt.Sprintf("[链接|%s](%s)", m.Contents["title"], m.Contents["url"])
case 6:
return fmt.Sprintf("[文件|%s](http://%s/file/%s)", m.Contents["title"], m.Contents["host"], m.Contents["md5"])
case 8:
return "[GIF表情]"
case 19:
_recordInfo, ok := m.Contents["recordInfo"]
if !ok {
return "[合并转发]"
}
recordInfo, ok := _recordInfo.(*RecordInfo)
if !ok {
return "[合并转发]"
}
return recordInfo.String("", m.Contents["host"].(string))
case 33, 36:
if m.Contents["title"] == "" {
return "[小程序]"
}
return fmt.Sprintf("[小程序|%s](%s)", m.Contents["title"], m.Contents["url"])
case 51:
if m.Contents["title"] == "" {
return "[视频号]"
} else {
return fmt.Sprintf("[视频号|%s](%s)", m.Contents["title"], m.Contents["url"])
}
case 57:
_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{}
referContent := refer.PlainText(false, m.Contents["host"].(string))
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 62:
return m.Content
case 63:
return "[视频号]"
case 87:
return "[群公告]"
case 2000:
return m.Content
case 2001:
return "[红包]"
case 2003:
return "[红包封面]"
default:
return "[分享]"
}
case 50:
return "[语音通话]"
case 10000:
return m.Content
default:
content := m.Content
if len(content) > 120 {
content = content[:120] + "<...>"
}
return fmt.Sprintf("Type: %d Content: %s", m.Type, content)
}
}

View File

@@ -27,48 +27,31 @@ type MessageDarwinV3 struct {
MsgContent string `json:"msgContent"` MsgContent string `json:"msgContent"`
MessageType int64 `json:"messageType"` MessageType int64 `json:"messageType"`
MesDes int `json:"mesDes"` // 0: 发送, 1: 接收 MesDes int `json:"mesDes"` // 0: 发送, 1: 接收
// MesLocalID int64 `json:"mesLocalID"`
// MesSvrID int64 `json:"mesSvrID"`
// MesStatus int `json:"mesStatus"`
// MesImgStatus int `json:"mesImgStatus"`
// MsgSource string `json:"msgSource"`
// IntRes1 int `json:"IntRes1"`
// IntRes2 int `json:"IntRes2"`
// StrRes1 string `json:"StrRes1"`
// StrRes2 string `json:"StrRes2"`
// MesVoiceText string `json:"mesVoiceText"`
// MesSeq int `json:"mesSeq"`
// CompressContent []byte `json:"CompressContent"`
// ConBlob []byte `json:"ConBlob"`
} }
func (m *MessageDarwinV3) Wrap(talker string) *Message { func (m *MessageDarwinV3) Wrap(talker string) *Message {
_m := &Message{ _m := &Message{
CreateTime: time.Unix(m.MsgCreateTime, 0), Time: time.Unix(m.MsgCreateTime, 0),
Type: m.MessageType, Type: m.MessageType,
IsSender: (m.MesDes + 1) % 2, Talker: talker,
IsChatRoom: strings.HasSuffix(talker, "@chatroom"),
IsSelf: m.MesDes == 0,
Version: WeChatDarwinV3, Version: WeChatDarwinV3,
} }
_m.IsChatRoom = strings.HasSuffix(talker, "@chatroom") content := m.MsgContent
_m.Content = m.MsgContent
if _m.IsChatRoom { if _m.IsChatRoom {
split := strings.SplitN(m.MsgContent, ":\n", 2) split := strings.SplitN(content, ":\n", 2)
if len(split) == 2 { if len(split) == 2 {
_m.ChatRoomSender = split[0] _m.Sender = split[0]
_m.Content = split[1] content = split[1]
} }
} else if !_m.IsSelf {
_m.Sender = talker
} }
if _m.Type != 1 { _m.ParseMediaInfo(content)
mediaMessage, err := NewMediaMessage(_m.Type, _m.Content)
if err == nil {
_m.MediaMessage = mediaMessage
}
}
return _m return _m
} }

View File

@@ -0,0 +1,117 @@
package model
import (
"path/filepath"
"strings"
"time"
"github.com/sjzar/chatlog/internal/model/wxproto"
"github.com/sjzar/chatlog/pkg/util/lz4"
"google.golang.org/protobuf/proto"
)
// CREATE TABLE MSG (
// localId INTEGER PRIMARY KEY AUTOINCREMENT,
// TalkerId INT DEFAULT 0,
// MsgSvrID INT,
// Type INT,
// SubType INT,
// IsSender INT,
// CreateTime INT,
// Sequence INT DEFAULT 0,
// StatusEx INT DEFAULT 0,
// FlagEx INT,
// Status INT,
// MsgServerSeq INT,
// MsgSequence INT,
// StrTalker TEXT,
// StrContent TEXT,
// DisplayContent TEXT,
// Reserved0 INT DEFAULT 0,
// Reserved1 INT DEFAULT 0,
// Reserved2 INT DEFAULT 0,
// Reserved3 INT DEFAULT 0,
// Reserved4 TEXT,
// Reserved5 TEXT,
// Reserved6 TEXT,
// CompressContent BLOB,
// BytesExtra BLOB,
// BytesTrans BLOB
// )
type MessageV3 struct {
Sequence int64 `json:"Sequence"` // 消息序号10位时间戳 + 3位序号
CreateTime int64 `json:"CreateTime"` // 消息创建时间10位时间戳
StrTalker string `json:"StrTalker"` // 聊天对象,微信 ID or 群 ID
IsSender int `json:"IsSender"` // 是否为发送消息0 接收消息1 发送消息
Type int64 `json:"Type"` // 消息类型
SubType int `json:"SubType"` // 消息子类型
StrContent string `json:"StrContent"` // 消息内容,文字聊天内容 或 XML
CompressContent []byte `json:"CompressContent"` // 非文字聊天内容,如图片、语音、视频等
BytesExtra []byte `json:"BytesExtra"` // protobuf 额外数据,记录群聊发送人等信息
}
func (m *MessageV3) Wrap() *Message {
_m := &Message{
Seq: m.Sequence,
Time: time.Unix(m.CreateTime, 0),
Talker: m.StrTalker,
IsChatRoom: strings.HasSuffix(m.StrTalker, "@chatroom"),
IsSelf: m.IsSender == 1,
Type: m.Type,
SubType: int64(m.SubType),
Content: m.StrContent,
Version: WeChatV3,
}
if !_m.IsChatRoom && !_m.IsSelf {
_m.Sender = m.StrTalker
}
if _m.Type == 49 {
b, err := lz4.Decompress(m.CompressContent)
if err == nil {
_m.Content = string(b)
}
}
_m.ParseMediaInfo(_m.Content)
if len(m.BytesExtra) != 0 {
if bytesExtra := ParseBytesExtra(m.BytesExtra); bytesExtra != nil {
if _m.IsChatRoom {
_m.Sender = bytesExtra[1]
}
// FIXME xml 中的 md5 数据无法匹配到 hardlink 记录,所以直接用 proto 数据
if _m.Type == 43 {
path := bytesExtra[4]
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) > 1 {
path = strings.Join(parts[1:], "/")
}
_m.Contents["path"] = path
}
}
}
return _m
}
// ParseBytesExtra 解析额外数据
// 按需解析
func ParseBytesExtra(b []byte) map[int]string {
var pbMsg wxproto.BytesExtra
if err := proto.Unmarshal(b, &pbMsg); err != nil {
return nil
}
if pbMsg.Items == nil {
return nil
}
ret := make(map[int]string, len(pbMsg.Items))
for _, item := range pbMsg.Items {
ret[int(item.Type)] = item.Value
}
return ret
}

View File

@@ -32,75 +32,56 @@ import (
type MessageV4 struct { type MessageV4 struct {
SortSeq int64 `json:"sort_seq"` // 消息序号10位时间戳 + 3位序号 SortSeq int64 `json:"sort_seq"` // 消息序号10位时间戳 + 3位序号
LocalType int64 `json:"local_type"` // 消息类型 LocalType int64 `json:"local_type"` // 消息类型
RealSenderID int `json:"real_sender_id"` // 发送人 ID对应 Name2Id 表序号 UserName string `json:"user_name"` // 发送人,通过 Join Name2Id 表获得
CreateTime int64 `json:"create_time"` // 消息创建时间10位时间戳 CreateTime int64 `json:"create_time"` // 消息创建时间10位时间戳
MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容 MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容
PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto格式与 v3 有差异 PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto格式与 v3 有差异
Status int `json:"status"` // 消息状态2 是已发送4 是已接收,可以用于判断 IsSender猜测 Status int `json:"status"` // 消息状态2 是已发送4 是已接收,可以用于判断 IsSenderFIXME 不准, 需要判断 UserName
// 非关键信息,后续有需要再加入
// LocalID int `json:"local_id"`
// ServerID int64 `json:"server_id"`
// UploadStatus int `json:"upload_status"`
// DownloadStatus int `json:"download_status"`
// ServerSeq int `json:"server_seq"`
// OriginSource int `json:"origin_source"`
// Source string `json:"source"`
// CompressContent string `json:"compress_content"`
} }
func (m *MessageV4) Wrap(id2Name map[int]string, isChatRoom bool) *Message { func (m *MessageV4) Wrap(talker string) *Message {
_m := &Message{ _m := &Message{
Sequence: m.SortSeq, Seq: m.SortSeq,
CreateTime: time.Unix(m.CreateTime, 0), Time: time.Unix(m.CreateTime, 0),
TalkerID: m.RealSenderID, // 依赖 Name2Id 表进行转换为 StrTalker Talker: talker,
IsChatRoom: strings.HasSuffix(talker, "@chatroom"),
Sender: m.UserName,
Type: m.LocalType, Type: m.LocalType,
Contents: make(map[string]interface{}),
Version: WeChatV4, Version: WeChatV4,
} }
if name, ok := id2Name[m.RealSenderID]; ok { // FIXME 后续通过 UserName 判断是否是自己发送的消息,目前可能不准确
_m.Talker = name _m.IsSelf = m.Status == 2 || (!_m.IsChatRoom && talker != m.UserName)
}
if m.Status == 2 {
_m.IsSender = 1
}
content := ""
if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) { if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
if b, err := zstd.Decompress(m.MessageContent); err == nil { if b, err := zstd.Decompress(m.MessageContent); err == nil {
_m.Content = string(b) content = string(b)
} }
} else { } else {
_m.Content = string(m.MessageContent) content = string(m.MessageContent)
} }
if isChatRoom { if _m.IsChatRoom {
_m.IsChatRoom = true split := strings.SplitN(content, ":\n", 2)
split := strings.SplitN(_m.Content, ":\n", 2)
if len(split) == 2 { if len(split) == 2 {
_m.ChatRoomSender = split[0] _m.Sender = split[0]
_m.Content = split[1] content = split[1]
} }
} }
if _m.Type != 1 { _m.ParseMediaInfo(content)
mediaMessage, err := NewMediaMessage(_m.Type, _m.Content)
if err == nil {
_m.MediaMessage = mediaMessage
_m.Type = mediaMessage.Type
_m.SubType = mediaMessage.SubType
}
}
if len(m.PackedInfoData) != 0 { if len(m.PackedInfoData) != 0 {
if packedInfo := ParsePackedInfo(m.PackedInfoData); packedInfo != nil { if packedInfo := ParsePackedInfo(m.PackedInfoData); packedInfo != nil {
// FIXME 尝试解决 v4 版本 xml 数据无法匹配到 hardlink 记录的问题 // FIXME 尝试解决 v4 版本 xml 数据无法匹配到 hardlink 记录的问题
if _m.Type == 3 && packedInfo.Image != nil { if _m.Type == 3 && packedInfo.Image != nil {
_m.MediaMessage.MediaMD5 = packedInfo.Image.Md5 _m.Contents["md5"] = packedInfo.Image.Md5
} }
if _m.Type == 43 && packedInfo.Video != nil { if _m.Type == 43 && packedInfo.Video != nil {
_m.MediaMessage.MediaMD5 = packedInfo.Video.Md5 _m.Contents["md5"] = packedInfo.Video.Md5
} }
} }
} }

View File

@@ -30,7 +30,6 @@ type MessageDBInfo struct {
FilePath string FilePath string
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
ID2Name map[int]string
} }
type DataSource struct { type DataSource struct {
@@ -99,32 +98,10 @@ func (ds *DataSource) initMessageDbs(path string) error {
} }
startTime = time.Unix(timestamp, 0) startTime = time.Unix(timestamp, 0)
// 获取 ID2Name 映射
id2Name := make(map[int]string)
rows, err := db.Query("SELECT user_name FROM Name2Id")
if err != nil {
log.Err(err).Msgf("获取数据库 %s 的 Name2Id 表失败", filePath)
db.Close()
continue
}
i := 1
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
log.Err(err).Msgf("数据库 %s 扫描 Name2Id 行失败", filePath)
continue
}
id2Name[i] = name
i++
}
rows.Close()
// 保存数据库信息 // 保存数据库信息
ds.messageFiles = append(ds.messageFiles, MessageDBInfo{ ds.messageFiles = append(ds.messageFiles, MessageDBInfo{
FilePath: filePath, FilePath: filePath,
StartTime: startTime, StartTime: startTime,
ID2Name: id2Name,
}) })
// 保存数据库连接 // 保存数据库连接
@@ -253,7 +230,7 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
// 对所有消息按时间排序 // 对所有消息按时间排序
sort.Slice(totalMessages, func(i, j int) bool { sort.Slice(totalMessages, func(i, j int) bool {
return totalMessages[i].Sequence < totalMessages[j].Sequence return totalMessages[i].Seq < totalMessages[j].Seq
}) })
// 处理分页 // 处理分页
@@ -288,10 +265,11 @@ func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageD
args := []interface{}{startTime.Unix(), endTime.Unix()} args := []interface{}{startTime.Unix(), endTime.Unix()}
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status SELECT m.sort_seq, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
FROM %s FROM %s m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
WHERE %s WHERE %s
ORDER BY sort_seq ASC ORDER BY m.sort_seq ASC
`, tableName, strings.Join(conditions, " AND ")) `, tableName, strings.Join(conditions, " AND "))
if limit > 0 { if limit > 0 {
@@ -310,14 +288,13 @@ func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageD
// 处理查询结果 // 处理查询结果
messages := []*model.Message{} messages := []*model.Message{}
isChatRoom := strings.HasSuffix(talker, "@chatroom")
for rows.Next() { for rows.Next() {
var msg model.MessageV4 var msg model.MessageV4
err := rows.Scan( err := rows.Scan(
&msg.SortSeq, &msg.SortSeq,
&msg.LocalType, &msg.LocalType,
&msg.RealSenderID, &msg.UserName,
&msg.CreateTime, &msg.CreateTime,
&msg.MessageContent, &msg.MessageContent,
&msg.PackedInfoData, &msg.PackedInfoData,
@@ -327,7 +304,7 @@ func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageD
return nil, errors.ScanRowFailed(err) return nil, errors.ScanRowFailed(err)
} }
messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom)) messages = append(messages, msg.Wrap(talker))
} }
return messages, nil return messages, nil
@@ -359,10 +336,11 @@ func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo
args := []interface{}{startTime.Unix(), endTime.Unix()} args := []interface{}{startTime.Unix(), endTime.Unix()}
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status SELECT m.sort_seq, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
FROM %s FROM %s m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
WHERE %s WHERE %s
ORDER BY sort_seq ASC ORDER BY m.sort_seq ASC
`, tableName, strings.Join(conditions, " AND ")) `, tableName, strings.Join(conditions, " AND "))
// 执行查询 // 执行查询
@@ -378,14 +356,13 @@ func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo
// 处理查询结果 // 处理查询结果
messages := []*model.Message{} messages := []*model.Message{}
isChatRoom := strings.HasSuffix(talker, "@chatroom")
for rows.Next() { for rows.Next() {
var msg model.MessageV4 var msg model.MessageV4
err := rows.Scan( err := rows.Scan(
&msg.SortSeq, &msg.SortSeq,
&msg.LocalType, &msg.LocalType,
&msg.RealSenderID, &msg.UserName,
&msg.CreateTime, &msg.CreateTime,
&msg.MessageContent, &msg.MessageContent,
&msg.PackedInfoData, &msg.PackedInfoData,
@@ -395,7 +372,7 @@ func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo
return nil, errors.ScanRowFailed(err) return nil, errors.ScanRowFailed(err)
} }
messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom)) messages = append(messages, msg.Wrap(talker))
} }
return messages, nil return messages, nil

View File

@@ -293,7 +293,7 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender, SELECT Sequence, CreateTime, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG FROM MSG
WHERE %s WHERE %s
@@ -316,7 +316,6 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
err := rows.Scan( err := rows.Scan(
&msg.Sequence, &msg.Sequence,
&msg.CreateTime, &msg.CreateTime,
&msg.TalkerID,
&msg.StrTalker, &msg.StrTalker,
&msg.IsSender, &msg.IsSender,
&msg.Type, &msg.Type,
@@ -343,7 +342,7 @@ func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.T
// 对所有消息按时间排序 // 对所有消息按时间排序
sort.Slice(totalMessages, func(i, j int) bool { sort.Slice(totalMessages, func(i, j int) bool {
return totalMessages[i].Sequence < totalMessages[j].Sequence return totalMessages[i].Seq < totalMessages[j].Seq
}) })
// 处理分页 // 处理分页
@@ -378,7 +377,7 @@ func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageD
} }
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender, SELECT Sequence, CreateTime, StrTalker, IsSender,
Type, SubType, StrContent, CompressContent, BytesExtra Type, SubType, StrContent, CompressContent, BytesExtra
FROM MSG FROM MSG
WHERE %s WHERE %s
@@ -409,7 +408,6 @@ func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageD
err := rows.Scan( err := rows.Scan(
&msg.Sequence, &msg.Sequence,
&msg.CreateTime, &msg.CreateTime,
&msg.TalkerID,
&msg.StrTalker, &msg.StrTalker,
&msg.IsSender, &msg.IsSender,
&msg.Type, &msg.Type,

View File

@@ -41,28 +41,24 @@ func (r *Repository) EnrichMessages(ctx context.Context, messages []*model.Messa
// enrichMessage 补充单条消息的额外信息 // enrichMessage 补充单条消息的额外信息
func (r *Repository) enrichMessage(msg *model.Message) { func (r *Repository) enrichMessage(msg *model.Message) {
talker := msg.Talker
// 处理群聊消息 // 处理群聊消息
if msg.IsChatRoom { if msg.IsChatRoom {
talker = msg.ChatRoomSender
// 补充群聊名称 // 补充群聊名称
if chatRoom, ok := r.chatRoomCache[msg.Talker]; ok { if chatRoom, ok := r.chatRoomCache[msg.Talker]; ok {
msg.ChatRoomName = chatRoom.DisplayName() msg.TalkerName = chatRoom.DisplayName()
// 补充发送者在群里的显示名称 // 补充发送者在群里的显示名称
if displayName, ok := chatRoom.User2DisplayName[talker]; ok { if displayName, ok := chatRoom.User2DisplayName[msg.Sender]; ok {
msg.DisplayName = displayName msg.SenderName = displayName
} }
} }
} }
// 如果不是自己发送的消息且还没有显示名称,尝试补充发送者信息 // 如果不是自己发送的消息且还没有显示名称,尝试补充发送者信息
if msg.DisplayName == "" && msg.IsSender != 1 { if msg.SenderName == "" && !msg.IsSelf {
contact := r.getFullContact(talker) contact := r.getFullContact(msg.Sender)
if contact != nil { if contact != nil {
msg.DisplayName = contact.DisplayName() msg.SenderName = contact.DisplayName()
} }
} }
} }

View File

@@ -1,31 +1,53 @@
package dat2img package dat2img
// copy from: https://github.com/tujiaw/wechat_dat_to_image // Implementation based on:
// - https://github.com/tujiaw/wechat_dat_to_image
// - https://github.com/LC044/WeChatMsg/blob/6535ed0/wxManager/decrypt/decrypt_dat.py
import ( import (
"bytes"
"crypto/aes"
"encoding/binary"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
// Format defines the header and extension for different image types
type Format struct { type Format struct {
Header []byte Header []byte
Ext string Ext string
} }
var ( var (
// Common image format definitions
JPG = Format{Header: []byte{0xFF, 0xD8, 0xFF}, Ext: "jpg"} JPG = Format{Header: []byte{0xFF, 0xD8, 0xFF}, Ext: "jpg"}
PNG = Format{Header: []byte{0x89, 0x50, 0x4E, 0x47}, Ext: "png"} PNG = Format{Header: []byte{0x89, 0x50, 0x4E, 0x47}, Ext: "png"}
GIF = Format{Header: []byte{0x47, 0x49, 0x46, 0x38}, Ext: "gif"} GIF = Format{Header: []byte{0x47, 0x49, 0x46, 0x38}, Ext: "gif"}
TIFF = Format{Header: []byte{0x49, 0x49, 0x2A, 0x00}, Ext: "tiff"} TIFF = Format{Header: []byte{0x49, 0x49, 0x2A, 0x00}, Ext: "tiff"}
BMP = Format{Header: []byte{0x42, 0x4D}, Ext: "bmp"} BMP = Format{Header: []byte{0x42, 0x4D}, Ext: "bmp"}
Formats = []Format{JPG, PNG, GIF, TIFF, BMP} Formats = []Format{JPG, PNG, GIF, TIFF, BMP}
// WeChat v4 related constants
V4XorKey byte = 0x37 // Default XOR key for WeChat v4 dat files
V4DatHeader = []byte{0x07, 0x08, 0x56, 0x31} // WeChat v4 dat file header
JpgTail = []byte{0xFF, 0xD9} // JPG file tail marker
) )
// Dat2Image converts WeChat dat file data to image data
// Returns the decoded image data, file extension, and any error encountered
func Dat2Image(data []byte) ([]byte, string, error) { func Dat2Image(data []byte) ([]byte, string, error) {
if len(data) < 4 { if len(data) < 4 {
return nil, "", fmt.Errorf("data length is too short: %d", len(data)) return nil, "", fmt.Errorf("data length is too short: %d", len(data))
} }
// Check if this is a WeChat v4 dat file
if len(data) >= 6 && bytes.Equal(data[:4], V4DatHeader) {
return Dat2ImageV4(data)
}
// For older WeChat versions, use XOR decryption
findFormat := func(data []byte, header []byte) bool { findFormat := func(data []byte, header []byte) bool {
xorBit := data[0] ^ header[0] xorBit := data[0] ^ header[0]
for i := 0; i < len(header); i++ { for i := 0; i < len(header); i++ {
@@ -37,20 +59,21 @@ func Dat2Image(data []byte) ([]byte, string, error) {
} }
var xorBit byte var xorBit byte
var find bool var found bool
var ext string var ext string
for _, format := range Formats { for _, format := range Formats {
if find = findFormat(data, format.Header); find { if found = findFormat(data, format.Header); found {
xorBit = data[0] ^ format.Header[0] xorBit = data[0] ^ format.Header[0]
ext = format.Ext ext = format.Ext
break break
} }
} }
if !find { if !found {
return nil, "", fmt.Errorf("unknown image type: %x %x", data[0], data[1]) return nil, "", fmt.Errorf("unknown image type: %x %x", data[0], data[1])
} }
// Apply XOR decryption
out := make([]byte, len(data)) out := make([]byte, len(data))
for i := range data { for i := range data {
out[i] = data[i] ^ xorBit out[i] = data[i] ^ xorBit
@@ -58,3 +81,222 @@ func Dat2Image(data []byte) ([]byte, string, error) {
return out, ext, nil return out, ext, nil
} }
// calculateXorKeyV4 calculates the XOR key for WeChat v4 dat files
// by analyzing the file tail against known JPG ending bytes (FF D9)
func calculateXorKeyV4(data []byte) (byte, error) {
if len(data) < 2 {
return 0, fmt.Errorf("data too short to calculate XOR key")
}
// Get the last two bytes of the file
fileTail := data[len(data)-2:]
// Assuming it's a JPG file, the tail should be FF D9
xorKeys := make([]byte, 2)
for i := 0; i < 2; i++ {
xorKeys[i] = fileTail[i] ^ JpgTail[i]
}
// Verify that both bytes yield the same XOR key
if xorKeys[0] == xorKeys[1] {
return xorKeys[0], nil
}
// If inconsistent, return the first byte as key with a warning
return xorKeys[0], fmt.Errorf("inconsistent XOR key, using first byte: 0x%x", xorKeys[0])
}
// ScanAndSetXorKey scans a directory for "_t.dat" files to calculate and set
// the global XOR key for WeChat v4 dat files
// Returns the found key and any error encountered
func ScanAndSetXorKey(dirPath string) (byte, error) {
// Walk the directory recursively
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories
if info.IsDir() {
return nil
}
// Only process "_t.dat" files (thumbnail files)
if !strings.HasSuffix(info.Name(), "_t.dat") {
return nil
}
// Read file content
data, err := os.ReadFile(path)
if err != nil {
return nil
}
// Check if it's a WeChat v4 dat file
if len(data) < 6 || !bytes.Equal(data[:4], V4DatHeader) {
return nil
}
// Parse file header
if len(data) < 15 {
return nil
}
// Get XOR encryption length
xorEncryptLen := binary.LittleEndian.Uint32(data[10:14])
// Get data after header
fileData := data[15:]
// Skip if there's no XOR-encrypted part
if xorEncryptLen == 0 || uint32(len(fileData)) <= uint32(len(fileData))-xorEncryptLen {
return nil
}
// Get XOR-encrypted part
xorData := fileData[uint32(len(fileData))-xorEncryptLen:]
// Calculate XOR key
key, err := calculateXorKeyV4(xorData)
if err != nil {
return nil
}
// Set global XOR key
V4XorKey = key
// Stop traversal after finding a valid key
return filepath.SkipAll
})
if err != nil && err != filepath.SkipAll {
return V4XorKey, fmt.Errorf("error scanning directory: %v", err)
}
return V4XorKey, nil
}
// Dat2ImageV4 processes WeChat v4 dat image files
// WeChat v4 uses a combination of AES-ECB and XOR encryption
func Dat2ImageV4(data []byte) ([]byte, string, error) {
if len(data) < 15 {
return nil, "", fmt.Errorf("data length is too short for WeChat v4 format: %d", len(data))
}
// Parse dat file header:
// - 6 bytes: 0x07085631 (dat file identifier)
// - 4 bytes: int (little-endian) AES-ECB128 encryption length
// - 4 bytes: int (little-endian) XOR encryption length
// - 1 byte: 0x01 (unknown)
// Read AES encryption length
aesEncryptLen := binary.LittleEndian.Uint32(data[6:10])
// Read XOR encryption length
xorEncryptLen := binary.LittleEndian.Uint32(data[10:14])
// Data after header
fileData := data[15:]
// AES encrypted part (max 1KB)
// Round up to multiple of 16 bytes for AES block size
aesEncryptLen0 := (aesEncryptLen)/16*16 + 16
if aesEncryptLen0 > uint32(len(fileData)) {
aesEncryptLen0 = uint32(len(fileData))
}
// Decrypt AES part
aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], []byte("cfcd208495d565ef"))
if err != nil {
return nil, "", fmt.Errorf("AES decrypt error: %v", err)
}
// Prepare result buffer
var result []byte
// Add decrypted AES part (remove padding if necessary)
if len(aesDecryptedData) > int(aesEncryptLen) {
result = append(result, aesDecryptedData[:aesEncryptLen]...)
} else {
result = append(result, aesDecryptedData...)
}
// Add unencrypted middle part
middleStart := aesEncryptLen0
middleEnd := uint32(len(fileData)) - xorEncryptLen
if middleStart < middleEnd {
result = append(result, fileData[middleStart:middleEnd]...)
}
// Process XOR-encrypted part (file tail)
if xorEncryptLen > 0 && middleEnd < uint32(len(fileData)) {
xorData := fileData[middleEnd:]
// Apply XOR decryption using global key
xorDecrypted := make([]byte, len(xorData))
for i := range xorData {
xorDecrypted[i] = xorData[i] ^ V4XorKey
}
result = append(result, xorDecrypted...)
}
// Identify image type from decrypted data
imgType := ""
for _, format := range Formats {
if len(result) >= len(format.Header) && bytes.Equal(result[:len(format.Header)], format.Header) {
imgType = format.Ext
break
}
}
if imgType == "" {
return nil, "", fmt.Errorf("unknown image type after decryption")
}
return result, imgType, nil
}
// decryptAESECB decrypts data using AES in ECB mode
func decryptAESECB(data, key []byte) ([]byte, error) {
if len(data) == 0 {
return nil, nil
}
// Create AES cipher
cipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Ensure data length is a multiple of block size
if len(data)%aes.BlockSize != 0 {
return nil, fmt.Errorf("data length is not a multiple of block size")
}
decrypted := make([]byte, len(data))
// ECB mode requires block-by-block decryption
for bs, be := 0, aes.BlockSize; bs < len(data); bs, be = bs+aes.BlockSize, be+aes.BlockSize {
cipher.Decrypt(decrypted[bs:be], data[bs:be])
}
// Handle PKCS#7 padding
padding := int(decrypted[len(decrypted)-1])
if padding > 0 && padding <= aes.BlockSize {
// Validate padding
valid := true
for i := len(decrypted) - padding; i < len(decrypted); i++ {
if decrypted[i] != byte(padding) {
valid = false
break
}
}
if valid {
return decrypted[:len(decrypted)-padding], nil
}
}
return decrypted, nil
}