package messaging import ( "context" "fmt" "io" "log" "mime" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" ) // reMarkdownImage matches markdown image syntax: ![alt](url) var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) // ExtractImageURLs extracts image URLs from markdown text. func ExtractImageURLs(text string) []string { matches := reMarkdownImage.FindAllStringSubmatch(text, -1) var urls []string for _, m := range matches { url := strings.TrimSpace(m[1]) if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { urls = append(urls, url) } } return urls } // SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message. func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error { // Check if it's a local file if _, err := os.Stat(mediaURL); err == nil { return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken) } // Must be a valid HTTP URL to download if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") { return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL) } data, contentType, err := downloadFile(ctx, mediaURL) if err != nil { return fmt.Errorf("download %s: %w", mediaURL, err) } return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken) } // SendMediaFromPath reads a local file and sends it as a media message. func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", path, err) } return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken) } func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error { if fileName == "" { fileName = "file" } cdnMediaType, itemType := classifyMedia(contentType, source) log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID) uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType) if err != nil { return fmt.Errorf("upload to CDN: %w", err) } media := &ilink.MediaInfo{ EncryptQueryParam: uploaded.DownloadParam, AESKey: AESKeyToBase64(uploaded.AESKeyHex), EncryptType: 1, } var item ilink.MessageItem switch itemType { case ilink.ItemTypeImage: item = ilink.MessageItem{ Type: ilink.ItemTypeImage, ImageItem: &ilink.ImageItem{ Media: media, MidSize: uploaded.CipherSize, }, } case ilink.ItemTypeVideo: item = ilink.MessageItem{ Type: ilink.ItemTypeVideo, VideoItem: &ilink.VideoItem{ Media: media, VideoSize: uploaded.CipherSize, }, } default: item = ilink.MessageItem{ Type: ilink.ItemTypeFile, FileItem: &ilink.FileItem{ Media: media, FileName: fileName, Len: fmt.Sprintf("%d", uploaded.FileSize), }, } } req := &ilink.SendMessageRequest{ Msg: ilink.SendMsg{ FromUserID: client.BotID(), ToUserID: toUserID, ClientID: NewClientID(), MessageType: ilink.MessageTypeBot, MessageState: ilink.MessageStateFinish, ItemList: []ilink.MessageItem{item}, ContextToken: contextToken, }, BaseInfo: ilink.BaseInfo{}, } resp, err := client.SendMessage(ctx, req) if err != nil { return fmt.Errorf("send media message: %w", err) } if resp.Ret != 0 { return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg) } log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source) return nil } func downloadFile(ctx context.Context, url string) ([]byte, string, error) { ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, "", err } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = inferContentType(url) } return data, contentType, nil } func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) { ct := strings.ToLower(contentType) if strings.HasPrefix(ct, "image/") || isImageExt(url) { return ilink.CDNMediaTypeImage, ilink.ItemTypeImage } if strings.HasPrefix(ct, "video/") || isVideoExt(url) { return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo } return ilink.CDNMediaTypeFile, ilink.ItemTypeFile } func isImageExt(url string) bool { ext := strings.ToLower(filepath.Ext(stripQuery(url))) switch ext { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp": return true } return false } func isVideoExt(url string) bool { ext := strings.ToLower(filepath.Ext(stripQuery(url))) switch ext { case ".mp4", ".mov", ".webm", ".mkv", ".avi": return true } return false } func inferContentType(url string) string { ext := filepath.Ext(stripQuery(url)) if ct := mime.TypeByExtension(ext); ct != "" { return ct } return "application/octet-stream" } func filenameFromURL(rawURL string) string { u := stripQuery(rawURL) name := filepath.Base(u) if name == "" || name == "." || name == "/" { return "file" } return name } func stripQuery(rawURL string) string { if i := strings.IndexByte(rawURL, '?'); i >= 0 { return rawURL[:i] } return rawURL }