Astor--0/douyin_video_parse.go

847 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package plugins
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
"io"
"log"
"mime/multipart"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
"github.com/go-resty/resty/v2"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"wechat-robot-client/dto"
"wechat-robot-client/interface/plugin"
"wechat-robot-client/pkg/robot"
"wechat-robot-client/utils"
"wechat-robot-client/vars"
)
type VideoParseResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data VideoParseData `json:"data"`
}
type VideoParseData struct {
Author string `json:"author"`
Avatar string `json:"avatar"`
Title string `json:"title"`
Desc string `json:"desc"`
Digg int32 `json:"digg"`
Comment int32 `json:"comment"`
Play int32 `json:"play"`
CreateTime int64 `json:"create_time"`
Cover string `json:"cover"`
URL string `json:"url"`
Images []string `json:"images"`
MusicURL string `json:"music_url"`
}
type DouyinRouterData struct {
LoaderData map[string]DouyinLoaderPageData `json:"loaderData"`
}
type DouyinLoaderPageData struct {
VideoInfoRes DouyinVideoInfoRes `json:"videoInfoRes"`
}
type DouyinVideoInfoRes struct {
ItemList []DouyinAwemeItem `json:"item_list"`
}
type DouyinAwemeItem struct {
Desc string `json:"desc"`
Author DouyinAuthor `json:"author"`
Music DouyinMusic `json:"music"`
Video DouyinVideo `json:"video"`
Images []DouyinImageInfo `json:"images"`
ImageInfos []DouyinImageInfo `json:"image_infos"`
ImgBitrate []DouyinImageGear `json:"img_bitrate"`
}
type DouyinAuthor struct {
Nickname string `json:"nickname"`
Signature string `json:"signature"`
AvatarThumb DouyinURLResource `json:"avatar_thumb"`
AvatarMedium DouyinURLResource `json:"avatar_medium"`
}
type DouyinMusic struct {
Mid string `json:"mid"`
Title string `json:"title"`
Author string `json:"author"`
PlayURL DouyinURLResource `json:"play_url"`
CoverHD DouyinURLResource `json:"cover_hd"`
CoverLarge DouyinURLResource `json:"cover_large"`
CoverMedium DouyinURLResource `json:"cover_medium"`
CoverThumb DouyinURLResource `json:"cover_thumb"`
}
type DouyinVideo struct {
Duration *int64 `json:"duration"`
PlayAddr DouyinURLResource `json:"play_addr"`
Cover DouyinURLResource `json:"cover"`
}
type DouyinImageInfo struct {
URI string `json:"uri"`
URLList []string `json:"url_list"`
DownloadURLList []string `json:"download_url_list"`
}
type DouyinImageGear struct {
Name string `json:"name"`
Images []DouyinImageInfo `json:"images"`
}
type DouyinURLResource struct {
URI string `json:"uri"`
URLList []string `json:"url_list"`
}
const douyinUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"
var (
douyinRouterDataRegexp = regexp.MustCompile(`(?s)window\._ROUTER_DATA\s*=\s*({.*?})\s*</script>`)
)
type DouyinVideoParsePlugin struct{}
func NewDouyinVideoParsePlugin() plugin.MessageHandler {
return &DouyinVideoParsePlugin{}
}
func (p *DouyinVideoParsePlugin) GetName() string {
return "DouyinVideoParse"
}
func (p *DouyinVideoParsePlugin) GetLabels() []string {
return []string{"text", "douyin"}
}
func (p *DouyinVideoParsePlugin) PreAction(ctx *plugin.MessageContext) bool {
if ctx.Message.IsChatRoom {
next := NewChatRoomCommonPlugin().PreAction(ctx)
if !next {
return false
}
if !ctx.Settings.IsShortVideoParsingEnabled() {
return false
}
}
return true
}
func (p *DouyinVideoParsePlugin) PostAction(ctx *plugin.MessageContext) {
}
func (p *DouyinVideoParsePlugin) Match(ctx *plugin.MessageContext) bool {
return strings.Contains(ctx.Message.Content, "https://v.douyin.com")
}
func (p *DouyinVideoParsePlugin) Run(ctx *plugin.MessageContext) {
if !p.PreAction(ctx) {
return
}
re := regexp.MustCompile(`https://[^\s]+`)
matches := re.FindAllString(ctx.Message.Content, -1)
if len(matches) == 0 {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "未找到抖音链接")
return
}
douyinURL := matches[0]
respData, err := parseDouyinVideo(douyinURL)
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("解析失败: %v", err))
return
}
if respData.Data.URL != "" {
shareLink := robot.ShareLinkMessage{
Title: fmt.Sprintf("抖音视频解析成功 - %s", respData.Data.Author),
Des: respData.Data.Title,
Url: respData.Data.URL,
ThumbUrl: robot.CDATAString("https://mmbiz.qpic.cn/mmbiz_png/NbW0ZIUM8lVHoUbjXw2YbYXbNJDtUH7Sbkibm9Qwo9FhAiaEFG4jY3Q2MEleRpiaWDyDv8BZUfR85AW3kG4ib6DyAw/640?wx_fmt=png"),
}
if respData.Data.Desc != "" {
shareLink.Des = respData.Data.Desc
}
_ = ctx.MessageService.ShareLink(ctx.Message.FromWxID, shareLink)
err = ctx.MessageService.SendVideoMessageByRemoteURL(ctx.Message.FromWxID, respData.Data.URL)
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送抖音视频失败: %v", err.Error()))
}
return
}
if len(respData.Data.Images) > 0 {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("抖音图片解析成功\n作者: %s\n标题: %s\n\n%d张图片正在发送中...", respData.Data.Author, respData.Data.Title, len(respData.Data.Images)))
if respData.Data.MusicURL != "" {
go func(musicURL, title, author string) {
var err error
if isAudioURL(musicURL) {
err = sendMusicMessageByURL(ctx, musicURL, author)
} else {
err = sendFileByRemoteURL(ctx, musicURL)
}
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送抖音音频失败: %v", err))
}
}(respData.Data.MusicURL, respData.Data.Title, respData.Data.Author)
}
imageURLs := respData.Data.Images
batchSize := 20
for i := 0; i < len(imageURLs); i += batchSize {
end := i + batchSize
end = min(end, len(imageURLs))
mergedImage, err := mergeImagesVertical(ctx, imageURLs[i:end])
if err != nil {
if isImageTooLargeError(err) {
p.sendImagesInSmallerBatches(ctx, imageURLs[i:end], 10)
continue
}
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("拼接失败(批次 %d-%d): %v", i+1, end, err))
continue
}
if len(mergedImage) == 0 {
continue
}
err = sendMergedImage(ctx, mergedImage)
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送图片失败: %v", err))
}
}
return
}
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "解析失败,可能是链接已失效或格式不正确")
}
func parseDouyinVideo(rawURL string) (VideoParseResponse, error) {
resolvedURL, err := resolveDouyinRedirect(rawURL)
if err != nil {
return VideoParseResponse{}, err
}
htmlContent, err := fetchDouyinPageHTML(resolvedURL)
if err != nil {
return VideoParseResponse{}, err
}
data, err := parseDouyinPageHTML(htmlContent)
if err != nil {
return VideoParseResponse{}, err
}
return VideoParseResponse{Code: http.StatusOK, Data: data}, nil
}
func resolveDouyinRedirect(rawURL string) (string, error) {
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
if err != nil {
return "", fmt.Errorf("创建抖音短链请求失败: %w", err)
}
req.Header.Set("User-Agent", douyinUserAgent)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("解析抖音短链失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
location, err := resp.Location()
if err != nil {
return rawURL, nil
}
return location.String(), nil
}
return resp.Request.URL.String(), nil
}
func fetchDouyinPageHTML(pageURL string) (string, error) {
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, pageURL, nil)
if err != nil {
return "", fmt.Errorf("创建抖音页面请求失败: %w", err)
}
req.Header.Set("User-Agent", douyinUserAgent)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("获取抖音页面失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("获取抖音页面失败,状态码: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取抖音页面失败: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("抖音页面内容为空")
}
return string(body), nil
}
func parseDouyinPageHTML(htmlContent string) (VideoParseData, error) {
if item, ok := extractDouyinAwemeItem(htmlContent); ok {
if note, ok := parseDouyinNoteItem(item); ok {
return note, nil
}
if video, ok := parseDouyinVideoItem(item); ok {
return video, nil
}
}
return VideoParseData{}, fmt.Errorf("阿拉蕾,解析出错了~")
}
func extractDouyinAwemeItem(htmlContent string) (DouyinAwemeItem, bool) {
match := douyinRouterDataRegexp.FindStringSubmatch(htmlContent)
if len(match) < 2 {
return DouyinAwemeItem{}, false
}
var routerData DouyinRouterData
if err := json.Unmarshal([]byte(match[1]), &routerData); err != nil {
log.Printf("解析抖音 _ROUTER_DATA 失败: %v\n", err)
return DouyinAwemeItem{}, false
}
for _, pageData := range routerData.LoaderData {
if len(pageData.VideoInfoRes.ItemList) > 0 {
return pageData.VideoInfoRes.ItemList[0], true
}
}
return DouyinAwemeItem{}, false
}
func parseDouyinNoteItem(item DouyinAwemeItem) (VideoParseData, bool) {
imageURLGroups := pickDouyinImageURLGroups(item)
if len(imageURLGroups) == 0 {
return VideoParseData{}, false
}
imageURLs := make([]string, 0, len(imageURLGroups))
for _, group := range imageURLGroups {
imageURLs = append(imageURLs, group[0])
}
desc := cleanDouyinText(item.Desc)
return VideoParseData{
Author: cleanDouyinText(item.Author.Nickname),
Avatar: pickDouyinAvatarURL(item.Author),
Title: desc,
Desc: desc,
Images: imageURLs,
MusicURL: pickDouyinNoteMusicURL(item),
}, true
}
func pickDouyinImageURLGroups(item DouyinAwemeItem) [][]string {
imageList := item.Images
if len(imageList) == 0 {
imageList = item.ImageInfos
}
imageURLGroups := make([][]string, 0, len(imageList))
seenGroups := make(map[string]bool)
for _, imageInfo := range imageList {
candidates := make([]string, 0)
seenURLs := make(map[string]bool)
for _, imageURL := range imageInfo.URLList {
if !strings.HasPrefix(imageURL, "http") {
continue
}
decodedURL := html.UnescapeString(imageURL)
if seenURLs[decodedURL] {
continue
}
candidates = append(candidates, decodedURL)
seenURLs[decodedURL] = true
}
groupKey := strings.Join(candidates, "\x00")
if len(candidates) > 0 && !seenGroups[groupKey] {
imageURLGroups = append(imageURLGroups, candidates)
seenGroups[groupKey] = true
}
}
return imageURLGroups
}
func parseDouyinVideoItem(item DouyinAwemeItem) (VideoParseData, bool) {
if item.Video.Duration != nil && *item.Video.Duration == 0 {
return VideoParseData{}, false
}
videoURL := pickDouyinVideoURL(item.Video.PlayAddr.URLList)
if videoURL == "" {
return VideoParseData{}, false
}
desc := cleanDouyinText(item.Desc)
return VideoParseData{
Author: cleanDouyinText(item.Author.Nickname),
Avatar: pickDouyinAvatarURL(item.Author),
Title: desc,
Desc: desc,
Cover: pickPreferredDouyinURL(item.Video.Cover.URLList),
URL: videoURL,
MusicURL: pickPreferredDouyinURL(item.Music.PlayURL.URLList),
}, true
}
func pickDouyinAvatarURL(author DouyinAuthor) string {
if avatarURL := pickPreferredDouyinURL(author.AvatarMedium.URLList); avatarURL != "" {
return avatarURL
}
return pickPreferredDouyinURL(author.AvatarThumb.URLList)
}
func pickDouyinNoteMusicURL(item DouyinAwemeItem) string {
if musicURL := pickPreferredDouyinURL(item.Music.PlayURL.URLList); musicURL != "" {
return musicURL
}
if strings.HasPrefix(item.Video.PlayAddr.URI, "http") {
return decodeDouyinEscapedValue(item.Video.PlayAddr.URI)
}
return pickPreferredDouyinURL(item.Video.PlayAddr.URLList)
}
func pickDouyinVideoURL(urls []string) string {
decodedURLs := make([]string, 0, len(urls))
for _, rawURL := range urls {
if rawURL == "" {
continue
}
decodedURL := strings.ReplaceAll(decodeDouyinEscapedValue(rawURL), "playwm", "play")
decodedURLs = append(decodedURLs, decodedURL)
}
for _, decodedURL := range decodedURLs {
if strings.Contains(decodedURL, "aweme.snssdk.com") {
return decodedURL
}
}
if len(decodedURLs) > 0 {
return decodedURLs[0]
}
return ""
}
func pickPreferredDouyinURL(urls []string) string {
firstURL := ""
for _, rawURL := range urls {
if rawURL == "" {
continue
}
decodedURL := decodeDouyinEscapedValue(rawURL)
if decodedURL == "" {
continue
}
if strings.HasPrefix(decodedURL, "https://p26") {
return decodedURL
}
if firstURL == "" {
firstURL = decodedURL
}
}
return firstURL
}
func matchDouyinJSONString(text string, key string) string {
pattern := regexp.MustCompile(fmt.Sprintf(`"%s":\s*"([^"]*)"`, regexp.QuoteMeta(key)))
match := pattern.FindStringSubmatch(text)
if len(match) < 2 {
return ""
}
return cleanDouyinText(decodeDouyinEscapedValue(match[1]))
}
func decodeDouyinEscapedValue(value string) string {
decodedValue := html.UnescapeString(value)
if strings.Contains(decodedValue, `\`) {
var unquotedValue string
if err := json.Unmarshal([]byte(`"`+strings.ReplaceAll(decodedValue, `"`, `\"`)+`"`), &unquotedValue); err == nil {
decodedValue = unquotedValue
}
}
return html.UnescapeString(decodedValue)
}
func cleanDouyinText(value string) string {
return strings.TrimSpace(html.UnescapeString(value))
}
func nestedString(root map[string]any, keys ...string) string {
current := any(root)
for _, key := range keys {
currentMap, ok := current.(map[string]any)
if !ok {
return ""
}
current = currentMap[key]
}
return stringFromAny(current)
}
func nestedStringList(root map[string]any, keys ...string) []string {
current := any(root)
for _, key := range keys {
currentMap, ok := current.(map[string]any)
if !ok {
return nil
}
current = currentMap[key]
}
return stringListFromAny(current)
}
func stringFromAny(value any) string {
if value == nil {
return ""
}
if str, ok := value.(string); ok {
return str
}
return fmt.Sprint(value)
}
func listFromAny(value any) []any {
if list, ok := value.([]any); ok {
return list
}
return nil
}
func stringListFromAny(value any) []string {
list, ok := value.([]any)
if !ok {
return nil
}
stringsList := make([]string, 0, len(list))
for _, item := range list {
if str, ok := item.(string); ok {
stringsList = append(stringsList, str)
}
}
return stringsList
}
func numberFromAny(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case int:
return float64(number), true
case int64:
return float64(number), true
default:
return 0, false
}
}
func (p *DouyinVideoParsePlugin) sendImagesInSmallerBatches(ctx *plugin.MessageContext, imageURLs []string, batchSize int) {
if batchSize <= 0 {
return
}
for i := 0; i < len(imageURLs); i += batchSize {
end := i + batchSize
end = min(end, len(imageURLs))
mergedImage, err := mergeImagesVertical(ctx, imageURLs[i:end])
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("拼接失败(降级批次 %d-%d): %v", i+1, end, err))
continue
}
if len(mergedImage) == 0 {
continue
}
err = sendMergedImage(ctx, mergedImage)
if err != nil {
ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送图片失败: %v", err))
}
}
}
func mergeImagesVertical(ctx *plugin.MessageContext, imageURLs []string) ([]byte, error) {
if len(imageURLs) == 0 {
return nil, fmt.Errorf("图片地址为空")
}
client := resty.New()
images := make([]image.Image, 0, len(imageURLs))
maxWidth := 0
for _, imageURL := range imageURLs {
resp, err := client.R().
SetHeader("User-Agent", douyinUserAgent).
SetHeader("Referer", "https://www.douyin.com/").
SetDoNotParseResponse(true).
Get(imageURL)
if err != nil {
return nil, fmt.Errorf("下载图片失败: %w", err)
}
if resp.StatusCode() != http.StatusOK {
resp.RawBody().Close()
return nil, fmt.Errorf("下载图片失败HTTP状态码: %d", resp.StatusCode())
}
bodyData := new(bytes.Buffer)
_, err = bodyData.ReadFrom(resp.RawBody())
resp.RawBody().Close()
if err != nil {
return nil, fmt.Errorf("读取响应体失败: %w", err)
}
if utils.IsVideo(bodyData.Bytes()) {
log.Printf("%s 解析到视频,跳过合并,直接发送视频消息\n", imageURL)
go func(toWxID, _imageURL string) {
err2 := ctx.MessageService.SendVideoMessageByRemoteURL(toWxID, _imageURL)
if err2 != nil {
ctx.MessageService.SendTextMessage(toWxID, fmt.Sprintf("发送抖音视频失败: %v", err2.Error()))
}
}(ctx.Message.FromWxID, imageURL)
continue
}
img, _, err := image.Decode(bytes.NewReader(bodyData.Bytes()))
if err != nil {
return nil, fmt.Errorf("解析图片失败: %w", err)
}
bounds := img.Bounds()
width := bounds.Dx()
if width > maxWidth {
maxWidth = width
}
images = append(images, img)
}
// 有可能全是视频
if maxWidth == 0 || len(images) == 0 {
return nil, nil
}
totalHeight := 0
for _, img := range images {
width := img.Bounds().Dx()
height := img.Bounds().Dy()
// 等比缩放计算高度
newHeight := int(float64(height) * float64(maxWidth) / float64(width))
totalHeight += newHeight
}
if maxWidth > jpegMaxDimension || totalHeight > jpegMaxDimension {
return nil, fmt.Errorf("image is too large to encode")
}
canvas := image.NewRGBA(image.Rect(0, 0, maxWidth, totalHeight))
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
currentY := 0
for _, img := range images {
width := img.Bounds().Dx()
height := img.Bounds().Dy()
newHeight := int(float64(height) * float64(maxWidth) / float64(width))
dstRect := image.Rect(0, currentY, maxWidth, currentY+newHeight)
// 使用高质量缩放
xdraw.CatmullRom.Scale(canvas, dstRect, img, img.Bounds(), xdraw.Over, nil)
currentY += newHeight
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: 80}); err != nil {
return nil, fmt.Errorf("图片编码失败: %w", err)
}
return buf.Bytes(), nil
}
const jpegMaxDimension = 65535
var audioExtensions = map[string]bool{
".mp3": true,
".m4a": true,
".aac": true,
".ogg": true,
".flac": true,
".wav": true,
".wma": true,
".amr": true,
}
func isAudioURL(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil {
return false
}
ext := strings.ToLower(path.Ext(parsed.Path))
return audioExtensions[ext]
}
func sendMusicMessageByURL(ctx *plugin.MessageContext, musicURL, author string) error {
const (
appID = "wx8dd6ecd81906fd84"
coverURL = "https://uranus-houhou.oss-cn-beijing.aliyuncs.com/douyin.png"
)
songInfo := robot.SongInfo{}
songInfo.FromUsername = vars.RobotRuntime.WxID
songInfo.AppID = appID
songInfo.Title = "抖音解析背景音乐"
songInfo.Singer = author
songInfo.Url = musicURL
songInfo.MusicUrl = musicURL
songInfo.CoverUrl = coverURL
_, err := vars.RobotRuntime.SendMusicMessage(ctx.Message.FromWxID, songInfo)
return err
}
func isImageTooLargeError(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "image is too large to encode")
}
func sendMergedImage(ctx *plugin.MessageContext, imageData []byte) error {
contentLength := int64(len(imageData))
if contentLength == 0 {
return nil
}
fmt.Printf("抖音图片合并后大小: %dMB\n", contentLength/1024/1024)
clientImgId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano())
chunkSize := vars.UploadImageChunkSize
totalChunks := int((contentLength + chunkSize - 1) / chunkSize)
for chunkIndex := range totalChunks {
start := int64(chunkIndex) * chunkSize
end := min(start+chunkSize, contentLength)
chunkData := imageData[start:end]
req := dto.SendImageMessageRequest{
ToWxid: ctx.Message.FromWxID,
ClientImgId: clientImgId,
FileSize: contentLength,
ChunkIndex: int64(chunkIndex),
TotalChunks: int64(totalChunks),
}
chunkReader := bytes.NewReader(chunkData)
chunkHeader := &multipart.FileHeader{
Filename: fmt.Sprintf("chunk_%d", chunkIndex),
Size: int64(len(chunkData)),
}
if _, err := ctx.MessageService.SendImageMessageStream(context.Background(), req, chunkReader, chunkHeader); err != nil {
return err
}
}
return nil
}
func sendFileByRemoteURL(ctx *plugin.MessageContext, fileURL string) error {
resp, err := resty.New().R().SetDoNotParseResponse(true).Get(fileURL)
if err != nil {
return fmt.Errorf("下载文件失败: %w", err)
}
defer resp.RawBody().Close()
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("下载文件失败HTTP状态码: %d", resp.StatusCode())
}
fileData, err := io.ReadAll(resp.RawBody())
if err != nil {
return fmt.Errorf("读取文件数据失败: %w", err)
}
if len(fileData) == 0 {
return fmt.Errorf("文件数据为空")
}
parsedURL, err := url.Parse(fileURL)
if err != nil {
return fmt.Errorf("解析文件URL失败: %w", err)
}
filename := path.Base(parsedURL.Path)
if filename == "" || filename == "/" || filename == "." {
filename = "douyin_music.mp3"
}
fileMD5Bytes := md5.Sum(fileData)
fileHash := hex.EncodeToString(fileMD5Bytes[:])
fileSize := int64(len(fileData))
chunkSize := vars.UploadFileChunkSize
if chunkSize <= 0 {
chunkSize = 200 * 1000
}
totalChunks := (fileSize + chunkSize - 1) / chunkSize
clientAppDataID := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano())
for chunkIndex := range totalChunks {
start := int64(chunkIndex) * chunkSize
end := min(start+chunkSize, fileSize)
chunkData := fileData[start:end]
req := dto.SendFileMessageRequest{
ToWxid: ctx.Message.FromWxID,
ClientAppDataId: clientAppDataID,
Filename: filename,
FileHash: fileHash,
FileSize: fileSize,
ChunkIndex: int64(chunkIndex),
TotalChunks: totalChunks,
}
chunkReader := bytes.NewReader(chunkData)
chunkHeader := &multipart.FileHeader{
Filename: filename,
Size: int64(len(chunkData)),
}
if err = ctx.MessageService.SendFileMessage(context.Background(), req, chunkReader, chunkHeader); err != nil {
if strings.Contains(err.Error(), "context canceled") || strings.Contains(err.Error(), "context deadline exceeded") {
return fmt.Errorf("发送文件超时")
}
return err
}
}
return nil
}