support thumb url (#62)

This commit is contained in:
Sarv
2025-04-19 18:30:41 +08:00
committed by GitHub
parent a745519451
commit d124086e70
7 changed files with 126 additions and 37 deletions

View File

@@ -158,8 +158,20 @@ GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx
- **联系人列表**`GET /api/v1/contact` - **联系人列表**`GET /api/v1/contact`
- **群聊列表**`GET /api/v1/chatroom` - **群聊列表**`GET /api/v1/chatroom`
- **会话列表**`GET /api/v1/session` - **会话列表**`GET /api/v1/session`
- **多媒体内容**`GET /api/v1/media?msgid=xxx`
### 多媒体内容
聊天记录中的多媒体内容会通过 HTTP 服务进行提供,可通过以下路径访问:
- **图片内容**`GET /image/<id>`
- **视频内容**`GET /video/<id>`
- **文件内容**`GET /file/<id>`
- **语音内容**`GET /voice/<id>`
- **多媒体内容**`GET /data/<data dir relative path>`
当请求图片、视频、文件内容时,将返回 302 跳转到多媒体内容 URL。
当请求语音内容时,将直接返回语音内容,并对原始 SILK 语音做了实时转码 MP3 处理。
多媒体内容 URL 地址为基于`数据目录`的相对地址,请求多媒体内容将直接返回对应文件,并针对加密图片做了实时解密处理。
## MCP 集成 ## MCP 集成

View File

@@ -32,10 +32,10 @@ func (s *Service) initRouter() {
router.StaticFileFS("/", "./index.htm", http.FS(staticDir)) router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
// Media // Media
router.GET("/image/:key", s.GetImage) router.GET("/image/*key", s.GetImage)
router.GET("/video/:key", s.GetVideo) router.GET("/video/*key", s.GetVideo)
router.GET("/file/:key", s.GetFile) router.GET("/file/*key", s.GetFile)
router.GET("/voice/:key", s.GetVoice) router.GET("/voice/*key", s.GetVoice)
router.GET("/data/*path", s.GetMediaData) router.GET("/data/*path", s.GetMediaData)
// MCP Server // MCP Server
@@ -281,30 +281,51 @@ func (s *Service) GetVoice(c *gin.Context) {
} }
func (s *Service) GetMedia(c *gin.Context, _type string) { func (s *Service) GetMedia(c *gin.Context, _type string) {
key := c.Param("key") key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" { if key == "" {
errors.Err(c, errors.InvalidArg(key)) errors.Err(c, errors.InvalidArg(key))
return return
} }
media, err := s.db.GetMedia(_type, key) keys := util.Str2List(key, ",")
if err != nil { if len(keys) == 0 {
errors.Err(c, err) errors.Err(c, errors.InvalidArg(key))
return return
} }
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") != "" { if c.Query("info") != "" {
c.JSON(http.StatusOK, media) c.JSON(http.StatusOK, media)
return return
} }
switch media.Type { switch media.Type {
case "voice": case "voice":
s.HandleVoice(c, media.Data) s.HandleVoice(c, media.Data)
return
default: default:
c.Redirect(http.StatusFound, "/data/"+media.Path) c.Redirect(http.StatusFound, "/data/"+media.Path)
return
}
} }
if _err != nil {
errors.Err(c, _err)
return
}
} }
func (s *Service) GetMediaData(c *gin.Context) { func (s *Service) GetMediaData(c *gin.Context) {
@@ -353,7 +374,8 @@ func (s *Service) HandleDatFile(c *gin.Context, path string) {
case "bmp": case "bmp":
c.Data(http.StatusOK, "image/bmp", out) c.Data(http.StatusOK, "image/bmp", out)
default: default:
c.File(path) c.Data(http.StatusOK, "image/jpg", out)
// c.File(path)
} }
} }

View File

@@ -35,6 +35,7 @@ type Image struct {
} }
type Video struct { type Video struct {
Md5 string `xml:"md5,attr"`
RawMd5 string `xml:"rawmd5,attr"` RawMd5 string `xml:"rawmd5,attr"`
// Length string `xml:"length,attr"` // Length string `xml:"length,attr"`
// PlayLength string `xml:"playlength,attr"` // PlayLength string `xml:"playlength,attr"`

View File

@@ -79,7 +79,12 @@ func (m *Message) ParseMediaInfo(data string) error {
case 3: case 3:
m.Contents["md5"] = msg.Image.MD5 m.Contents["md5"] = msg.Image.MD5
case 43: 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: case 49:
m.SubType = int64(msg.App.Type) m.SubType = int64(msg.App.Type)
switch m.SubType { switch m.SubType {
@@ -234,7 +239,23 @@ func (m *Message) PlainTextContent() string {
case 1: case 1:
return m.Content return m.Content
case 3: 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: case 34:
if voice, ok := m.Contents["voice"]; ok { if voice, ok := m.Contents["voice"]; ok {
return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice) return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice)
@@ -243,10 +264,28 @@ func (m *Message) PlainTextContent() string {
case 42: case 42:
return "[名片]" return "[名片]"
case 43: case 43:
if path, ok := m.Contents["path"]; ok { keylist := make([]string, 0)
return fmt.Sprintf("![视频](http://%s/data/%s)", m.Contents["host"], path) 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: case 47:
return "[动画表情]" return "[动画表情]"
case 49: case 49:

View File

@@ -96,7 +96,7 @@ func (m *MessageV3) Wrap() *Message {
if len(parts) > 1 { if len(parts) > 1 {
path = strings.Join(parts[1:], "/") path = strings.Join(parts[1:], "/")
} }
_m.Contents["path"] = path _m.Contents["videofile"] = path
} }
} }
} }

View File

@@ -2,7 +2,10 @@ package model
import ( import (
"bytes" "bytes"
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -85,10 +88,14 @@ func (m *MessageV4) Wrap(talker string) *Message {
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.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 { 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))
} }
} }
} }

View File

@@ -17,6 +17,7 @@ import (
// Format defines the header and extension for different image types // Format defines the header and extension for different image types
type Format struct { type Format struct {
Header []byte Header []byte
AesKey []byte
Ext string Ext string
} }
@@ -29,9 +30,12 @@ var (
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}
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 // WeChat v4 related constants
V4XorKey byte = 0x37 // Default XOR key for WeChat v4 dat files 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 JpgTail = []byte{0xFF, 0xD9} // JPG file tail marker
) )
@@ -43,8 +47,12 @@ func Dat2Image(data []byte) ([]byte, string, error) {
} }
// Check if this is a WeChat v4 dat file // Check if this is a WeChat v4 dat file
if len(data) >= 6 && bytes.Equal(data[:4], V4DatHeader) { if len(data) >= 6 {
return Dat2ImageV4(data) for _, format := range V4Formats {
if bytes.Equal(data[:4], format.Header) {
return Dat2ImageV4(data, format.AesKey)
}
}
} }
// For older WeChat versions, use XOR decryption // 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 // 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 return nil
} }
@@ -179,13 +187,13 @@ func ScanAndSetXorKey(dirPath string) (byte, error) {
// Dat2ImageV4 processes WeChat v4 dat image files // Dat2ImageV4 processes WeChat v4 dat image files
// WeChat v4 uses a combination of AES-ECB and XOR encryption // 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 { if len(data) < 15 {
return nil, "", fmt.Errorf("data length is too short for WeChat v4 format: %d", len(data)) return nil, "", fmt.Errorf("data length is too short for WeChat v4 format: %d", len(data))
} }
// Parse dat file header: // 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) AES-ECB128 encryption length
// - 4 bytes: int (little-endian) XOR encryption length // - 4 bytes: int (little-endian) XOR encryption length
// - 1 byte: 0x01 (unknown) // - 1 byte: 0x01 (unknown)
@@ -206,7 +214,7 @@ func Dat2ImageV4(data []byte) ([]byte, string, error) {
} }
// Decrypt AES part // Decrypt AES part
aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], []byte("cfcd208495d565ef")) aesDecryptedData, err := decryptAESECB(fileData[:aesEncryptLen0], aeskey)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("AES decrypt error: %v", err) return nil, "", fmt.Errorf("AES decrypt error: %v", err)
} }