From 977194b5cfe3c93139074ce05a5933057880354e Mon Sep 17 00:00:00 2001 From: Shen Junzheng Date: Sat, 19 Apr 2025 18:21:02 +0800 Subject: [PATCH] support thumb url --- README.md | 14 +++++++- internal/chatlog/http/route.go | 60 +++++++++++++++++++++++----------- internal/model/mediamessage.go | 1 + internal/model/message.go | 49 ++++++++++++++++++++++++--- internal/model/message_v3.go | 2 +- internal/model/message_v4.go | 11 +++++-- pkg/util/dat2img/dat2img.go | 26 ++++++++++----- 7 files changed, 126 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5ddcf6d..8ca2916 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,20 @@ GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx - **联系人列表**:`GET /api/v1/contact` - **群聊列表**:`GET /api/v1/chatroom` - **会话列表**:`GET /api/v1/session` -- **多媒体内容**:`GET /api/v1/media?msgid=xxx` +### 多媒体内容 + +聊天记录中的多媒体内容会通过 HTTP 服务进行提供,可通过以下路径访问: + +- **图片内容**:`GET /image/` +- **视频内容**:`GET /video/` +- **文件内容**:`GET /file/` +- **语音内容**:`GET /voice/` +- **多媒体内容**:`GET /data/` + +当请求图片、视频、文件内容时,将返回 302 跳转到多媒体内容 URL。 +当请求语音内容时,将直接返回语音内容,并对原始 SILK 语音做了实时转码 MP3 处理。 +多媒体内容 URL 地址为基于`数据目录`的相对地址,请求多媒体内容将直接返回对应文件,并针对加密图片做了实时解密处理。 ## MCP 集成 diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index d32d103..062ec5c 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -32,10 +32,10 @@ func (s *Service) initRouter() { router.StaticFileFS("/", "./index.htm", http.FS(staticDir)) // Media - router.GET("/image/:key", s.GetImage) - router.GET("/video/:key", s.GetVideo) - router.GET("/file/:key", s.GetFile) - router.GET("/voice/:key", s.GetVoice) + router.GET("/image/*key", s.GetImage) + router.GET("/video/*key", s.GetVideo) + router.GET("/file/*key", s.GetFile) + router.GET("/voice/*key", s.GetVoice) router.GET("/data/*path", s.GetMediaData) // MCP Server @@ -281,30 +281,51 @@ func (s *Service) GetVoice(c *gin.Context) { } func (s *Service) GetMedia(c *gin.Context, _type string) { - key := c.Param("key") + key := strings.TrimPrefix(c.Param("key"), "/") if key == "" { errors.Err(c, errors.InvalidArg(key)) return } - media, err := s.db.GetMedia(_type, key) - if err != nil { - errors.Err(c, err) + keys := util.Str2List(key, ",") + if len(keys) == 0 { + errors.Err(c, errors.InvalidArg(key)) return } - if c.Query("info") != "" { - c.JSON(http.StatusOK, media) + var _err error + for _, k := range keys { + if len(k) != 32 { + absolutePath := filepath.Join(s.ctx.DataDir, k) + if _, err := os.Stat(absolutePath); os.IsNotExist(err) { + continue + } + c.Redirect(http.StatusFound, "/data/"+k) + return + } + media, err := s.db.GetMedia(_type, k) + if err != nil { + _err = err + continue + } + if c.Query("info") != "" { + c.JSON(http.StatusOK, media) + return + } + switch media.Type { + case "voice": + s.HandleVoice(c, media.Data) + return + default: + c.Redirect(http.StatusFound, "/data/"+media.Path) + return + } + } + + if _err != nil { + errors.Err(c, _err) return } - - switch media.Type { - case "voice": - s.HandleVoice(c, media.Data) - default: - c.Redirect(http.StatusFound, "/data/"+media.Path) - } - } func (s *Service) GetMediaData(c *gin.Context) { @@ -353,7 +374,8 @@ func (s *Service) HandleDatFile(c *gin.Context, path string) { case "bmp": c.Data(http.StatusOK, "image/bmp", out) default: - c.File(path) + c.Data(http.StatusOK, "image/jpg", out) + // c.File(path) } } diff --git a/internal/model/mediamessage.go b/internal/model/mediamessage.go index c462f36..8b395e9 100644 --- a/internal/model/mediamessage.go +++ b/internal/model/mediamessage.go @@ -35,6 +35,7 @@ type Image struct { } type Video struct { + Md5 string `xml:"md5,attr"` RawMd5 string `xml:"rawmd5,attr"` // Length string `xml:"length,attr"` // PlayLength string `xml:"playlength,attr"` diff --git a/internal/model/message.go b/internal/model/message.go index eb783b8..8cb4d05 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -79,7 +79,12 @@ func (m *Message) ParseMediaInfo(data string) error { case 3: m.Contents["md5"] = msg.Image.MD5 case 43: - m.Contents["md5"] = msg.Video.RawMd5 + if msg.Video.Md5 != "" { + m.Contents["md5"] = msg.Video.Md5 + } + if msg.Video.RawMd5 != "" { + m.Contents["rawmd5"] = msg.Video.RawMd5 + } case 49: m.SubType = int64(msg.App.Type) switch m.SubType { @@ -234,7 +239,23 @@ func (m *Message) PlainTextContent() string { case 1: return m.Content case 3: - return fmt.Sprintf("![图片](http://%s/image/%s)", m.Contents["host"], m.Contents["md5"]) + keylist := make([]string, 0) + if m.Contents["md5"] != nil { + if md5, ok := m.Contents["md5"].(string); ok { + keylist = append(keylist, md5) + } + } + if m.Contents["imgfile"] != nil { + if imgfile, ok := m.Contents["imgfile"].(string); ok { + keylist = append(keylist, imgfile) + } + } + if m.Contents["thumb"] != nil { + if thumb, ok := m.Contents["thumb"].(string); ok { + keylist = append(keylist, thumb) + } + } + return fmt.Sprintf("![图片](http://%s/image/%s)", m.Contents["host"], strings.Join(keylist, ",")) case 34: if voice, ok := m.Contents["voice"]; ok { return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice) @@ -243,10 +264,28 @@ func (m *Message) PlainTextContent() string { case 42: return "[名片]" case 43: - if path, ok := m.Contents["path"]; ok { - return fmt.Sprintf("![视频](http://%s/data/%s)", m.Contents["host"], path) + keylist := make([]string, 0) + if m.Contents["md5"] != nil { + if md5, ok := m.Contents["md5"].(string); ok { + keylist = append(keylist, md5) + } } - return fmt.Sprintf("![视频](http://%s/video/%s)", m.Contents["host"], m.Contents["md5"]) + if m.Contents["rawmd5"] != nil { + if rawmd5, ok := m.Contents["rawmd5"].(string); ok { + keylist = append(keylist, rawmd5) + } + } + if m.Contents["videofile"] != nil { + if videofile, ok := m.Contents["videofile"].(string); ok { + keylist = append(keylist, videofile) + } + } + if m.Contents["thumb"] != nil { + if thumb, ok := m.Contents["thumb"].(string); ok { + keylist = append(keylist, thumb) + } + } + return fmt.Sprintf("![视频](http://%s/video/%s)", m.Contents["host"], strings.Join(keylist, ",")) case 47: return "[动画表情]" case 49: diff --git a/internal/model/message_v3.go b/internal/model/message_v3.go index 1103d53..2471883 100644 --- a/internal/model/message_v3.go +++ b/internal/model/message_v3.go @@ -96,7 +96,7 @@ func (m *MessageV3) Wrap() *Message { if len(parts) > 1 { path = strings.Join(parts[1:], "/") } - _m.Contents["path"] = path + _m.Contents["videofile"] = path } } } diff --git a/internal/model/message_v4.go b/internal/model/message_v4.go index edf271e..5aa116b 100644 --- a/internal/model/message_v4.go +++ b/internal/model/message_v4.go @@ -2,7 +2,10 @@ package model import ( "bytes" + "crypto/md5" + "encoding/hex" "fmt" + "path/filepath" "strings" "time" @@ -85,10 +88,14 @@ func (m *MessageV4) Wrap(talker string) *Message { if packedInfo := ParsePackedInfo(m.PackedInfoData); packedInfo != nil { // FIXME 尝试解决 v4 版本 xml 数据无法匹配到 hardlink 记录的问题 if _m.Type == 3 && packedInfo.Image != nil { - _m.Contents["md5"] = packedInfo.Image.Md5 + _talkerMd5Bytes := md5.Sum([]byte(talker)) + talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:]) + _m.Contents["imgfile"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s.dat", packedInfo.Image.Md5)) + _m.Contents["thumb"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s_t.dat", packedInfo.Image.Md5)) } if _m.Type == 43 && packedInfo.Video != nil { - _m.Contents["md5"] = packedInfo.Video.Md5 + _m.Contents["videofile"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s.mp4", packedInfo.Video.Md5)) + _m.Contents["thumb"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s_thumb.jpg", packedInfo.Video.Md5)) } } } diff --git a/pkg/util/dat2img/dat2img.go b/pkg/util/dat2img/dat2img.go index 71f0e4c..2f17e08 100644 --- a/pkg/util/dat2img/dat2img.go +++ b/pkg/util/dat2img/dat2img.go @@ -17,6 +17,7 @@ import ( // Format defines the header and extension for different image types type Format struct { Header []byte + AesKey []byte Ext string } @@ -29,10 +30,13 @@ var ( BMP = Format{Header: []byte{0x42, 0x4D}, Ext: "bmp"} Formats = []Format{JPG, PNG, GIF, TIFF, BMP} + V4Format1 = Format{Header: []byte{0x07, 0x08, 0x56, 0x31}, AesKey: []byte("cfcd208495d565ef")} + V4Format2 = Format{Header: []byte{0x07, 0x08, 0x56, 0x32}, AesKey: []byte("0000000000000000")} // FIXME + V4Formats = []Format{V4Format1, V4Format2} + // 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 + V4XorKey byte = 0x37 // Default XOR key for WeChat v4 dat files + JpgTail = []byte{0xFF, 0xD9} // JPG file tail marker ) // Dat2Image converts WeChat dat file data to image data @@ -43,8 +47,12 @@ func Dat2Image(data []byte) ([]byte, string, error) { } // Check if this is a WeChat v4 dat file - if len(data) >= 6 && bytes.Equal(data[:4], V4DatHeader) { - return Dat2ImageV4(data) + if len(data) >= 6 { + for _, format := range V4Formats { + if bytes.Equal(data[:4], format.Header) { + return Dat2ImageV4(data, format.AesKey) + } + } } // For older WeChat versions, use XOR decryption @@ -134,7 +142,7 @@ func ScanAndSetXorKey(dirPath string) (byte, error) { } // Check if it's a WeChat v4 dat file - if len(data) < 6 || !bytes.Equal(data[:4], V4DatHeader) { + if len(data) < 6 || (!bytes.Equal(data[:4], V4Format1.Header) && !bytes.Equal(data[:4], V4Format2.Header)) { return nil } @@ -179,13 +187,13 @@ func ScanAndSetXorKey(dirPath string) (byte, error) { // 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) { +func Dat2ImageV4(data []byte, aeskey []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) + // - 6 bytes: 0x07085631 or 0x07085632 (dat file identifier) // - 4 bytes: int (little-endian) AES-ECB128 encryption length // - 4 bytes: int (little-endian) XOR encryption length // - 1 byte: 0x01 (unknown) @@ -206,7 +214,7 @@ func Dat2ImageV4(data []byte) ([]byte, string, error) { } // Decrypt AES part - aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], []byte("cfcd208495d565ef")) + aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], aeskey) if err != nil { return nil, "", fmt.Errorf("AES decrypt error: %v", err) }