package publish import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "apps/backend/internal/module/studio/domain" "github.com/zeromicro/go-zero/core/logx" ) // MetaTransport — 真 Threads 發文/回覆(container → publish)。 // 支援 TEXT / 單圖 IMAGE / 多圖 CAROUSEL(image_url 須為 Meta 可抓的公開 https)。 type MetaTransport struct { GraphBase string HTTPClient *http.Client } func NewMeta(graphBase string) *MetaTransport { if graphBase == "" { graphBase = "https://graph.threads.net" } transport := http.DefaultTransport.(*http.Transport).Clone() transport.DialContext = dialPublicAddress client := &http.Client{Timeout: 90 * time.Second, Transport: transport} client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("too many redirects") } return validatePublicHTTPURL(req.Context(), req.URL) } return &MetaTransport{ GraphBase: strings.TrimRight(graphBase, "/"), HTTPClient: client, } } func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) { token := strings.TrimSpace(req.AccessToken) if token == "" || strings.HasPrefix(token, "fake-") { return nil, fmt.Errorf("Threads token 無效,請重新連帳") } text := strings.TrimSpace(req.Text) images := publicImageURLs(req.ImageURLs) if text == "" && len(images) == 0 { return nil, fmt.Errorf("內容不可為空") } // Meta 會從公網 cURL image_url;先 HEAD 自檢,避免只得到 code 1 unknown for i, img := range images { if err := t.preflightImageURL(ctx, img); err != nil { return nil, fmt.Errorf("第 %d 張圖 Meta 無法抓取:%w", i+1, err) } } createURL := t.GraphBase + "/v1.0/me/threads" replyTo := strings.TrimSpace(req.ReplyTo) topicTag := "" if replyTo == "" { topicTag = normalizeTopicTag(req.TopicTag) } var containerID string var wait time.Duration var err error switch { case len(images) == 0: // 純文字 form := url.Values{} form.Set("media_type", "TEXT") form.Set("text", text) form.Set("access_token", token) if replyTo != "" { form.Set("reply_to_id", replyTo) } if topicTag != "" { form.Set("topic_tag", topicTag) } containerID, err = t.createContainer(ctx, createURL, form) wait = 8 * time.Second case len(images) == 1: // 單圖 form := url.Values{} form.Set("media_type", "IMAGE") form.Set("image_url", images[0]) form.Set("access_token", token) if text != "" { form.Set("text", text) } if replyTo != "" { form.Set("reply_to_id", replyTo) } if topicTag != "" { form.Set("topic_tag", topicTag) } containerID, err = t.createContainer(ctx, createURL, form) wait = 30 * time.Second default: // 多圖 carousel:先建 is_carousel_item 子容器,再建 CAROUSEL childIDs := make([]string, 0, len(images)) for i, img := range images { // Threads carousel 最多 20,這裡保守 10 if i >= 10 { break } cf := url.Values{} cf.Set("media_type", "IMAGE") cf.Set("image_url", img) cf.Set("is_carousel_item", "true") cf.Set("access_token", token) cid, cerr := t.createContainer(ctx, createURL, cf) if cerr != nil { return nil, fmt.Errorf("Threads 建立 carousel 子圖失敗:%w", cerr) } // 子項需 FINISHED 才可組 carousel if werr := t.waitContainerReady(ctx, token, cid, 30*time.Second); werr != nil { return nil, fmt.Errorf("Threads 子圖未就緒:%w", werr) } childIDs = append(childIDs, cid) } form := url.Values{} form.Set("media_type", "CAROUSEL") form.Set("children", strings.Join(childIDs, ",")) form.Set("access_token", token) if text != "" { form.Set("text", text) } if replyTo != "" { form.Set("reply_to_id", replyTo) } if topicTag != "" { form.Set("topic_tag", topicTag) } containerID, err = t.createContainer(ctx, createURL, form) wait = 30 * time.Second } if err != nil { return nil, err } if werr := t.waitContainerReady(ctx, token, containerID, wait); werr != nil { // 超時仍嘗試 publish;硬錯誤則直接失敗 if wait > 0 && strings.Contains(werr.Error(), "container") { // ERROR/EXPIRED 會回 error message if !strings.Contains(werr.Error(), "timeout") { return nil, fmt.Errorf("Threads 媒體處理失敗:%w", werr) } } } // publish pForm := url.Values{} pForm.Set("creation_id", containerID) pForm.Set("access_token", token) pubURL := t.GraphBase + "/v1.0/me/threads_publish" pBody, pStatus, err := t.postForm(ctx, pubURL, pForm) if err != nil { return nil, fmt.Errorf("Threads 發佈失敗:%w", err) } if pStatus >= 300 { return nil, fmt.Errorf("Threads 發佈失敗:%s", parseGraphError(pBody, pStatus)) } var published struct { ID string `json:"id"` } if err := json.Unmarshal(pBody, &published); err != nil || published.ID == "" { return nil, fmt.Errorf("Threads 發佈失敗:無法解析 media id(%s)", truncate(string(pBody), 180)) } logx.Infof("threads publish ok media_id=%s images=%d reply_to=%s", published.ID, len(images), req.ReplyTo) return &domain.PublishResult{MediaID: published.ID}, nil } func publicImageURLs(raw []string) []string { out := make([]string, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) for _, u := range raw { u = strings.TrimSpace(u) if u == "" { continue } // data URL 無法給 Meta 抓,跳過(前端應先上傳) if strings.HasPrefix(u, "data:") { continue } if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") { if _, ok := seen[u]; ok { continue } seen[u] = struct{}{} out = append(out, u) } } return out } // preflightImageURL — 確認公開 URL 對匿名 GET 可讀(Meta crawler 同樣匿名) func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string) error { parsed, err := url.Parse(imageURL) if err != nil { return err } if err := validatePublicHTTPURL(ctx, parsed); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodHead, parsed.String(), nil) if err != nil { return err } // 部分 CDN 對 HEAD 不友善,失敗再 GET 前幾個 byte res, err := t.HTTPClient.Do(req) if err != nil { return fmt.Errorf("無法連線 %s:%w", truncate(imageURL, 80), err) } _ = res.Body.Close() if res.StatusCode == http.StatusMethodNotAllowed || res.StatusCode == http.StatusForbidden || res.StatusCode >= 400 { // fallback GET gReq, gerr := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) if gerr != nil { return gerr } gReq.Header.Set("Range", "bytes=0-1023") gRes, gerr := t.HTTPClient.Do(gReq) if gerr != nil { return fmt.Errorf("無法連線 %s:%w", truncate(imageURL, 80), gerr) } _, _ = io.Copy(io.Discard, io.LimitReader(gRes.Body, 2048)) _ = gRes.Body.Close() if gRes.StatusCode >= 300 { return fmt.Errorf("HTTP %d(請確認 MinIO/nginx 允許公開讀取 image_url)", gRes.StatusCode) } return nil } if res.StatusCode >= 300 { return fmt.Errorf("HTTP %d(請確認物件儲存允許公開讀取)", res.StatusCode) } return nil } func validatePublicHTTPURL(ctx context.Context, u *url.URL) error { if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { return fmt.Errorf("invalid public HTTP URL") } if u.User != nil { return fmt.Errorf("URL credentials are not allowed") } addrs, err := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname()) if err != nil { return fmt.Errorf("cannot resolve image host: %w", err) } if len(addrs) == 0 { return fmt.Errorf("image host has no address") } for _, addr := range addrs { if !isPublicIP(addr.IP) { return fmt.Errorf("image URL resolves to a non-public address") } } return nil } func dialPublicAddress(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } for _, addr := range addrs { if !isPublicIP(addr.IP) { return nil, fmt.Errorf("refusing to connect to a non-public address") } } if len(addrs) == 0 { return nil, fmt.Errorf("host has no address") } dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} return dialer.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port)) } func isPublicIP(ip net.IP) bool { return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() } func (t *MetaTransport) createContainer(ctx context.Context, createURL string, form url.Values) (string, error) { cBody, cStatus, err := t.postForm(ctx, createURL, form) if err != nil { return "", fmt.Errorf("Threads 建立容器失敗:%w", err) } if cStatus >= 300 { return "", fmt.Errorf("Threads 建立容器失敗:%s", parseGraphError(cBody, cStatus)) } var created struct { ID string `json:"id"` } if err := json.Unmarshal(cBody, &created); err != nil || created.ID == "" { return "", fmt.Errorf("Threads 建立容器失敗:無法解析 container id(%s)", truncate(string(cBody), 180)) } return created.ID, nil } // normalizeTopicTag — Threads API:1~50 字,不可含 . & func normalizeTopicTag(raw string) string { s := strings.TrimSpace(raw) s = strings.TrimPrefix(s, "#") s = strings.TrimSpace(s) if s == "" { return "" } s = strings.ReplaceAll(s, ".", "") s = strings.ReplaceAll(s, "&", "") s = strings.TrimSpace(s) r := []rune(s) if len(r) > 50 { s = string(r[:50]) } if s == "" { return "" } return s } func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode())) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := t.HTTPClient.Do(req) if err != nil { return nil, 0, err } defer res.Body.Close() body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) return body, res.StatusCode, nil } func (t *MetaTransport) waitContainerReady(ctx context.Context, token, containerID string, maxWait time.Duration) error { deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { select { case <-ctx.Done(): return ctx.Err() default: } q := url.Values{} q.Set("fields", "status,error_message") q.Set("access_token", token) u := t.GraphBase + "/v1.0/" + url.PathEscape(containerID) + "?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } res, err := t.HTTPClient.Do(req) if err != nil { time.Sleep(800 * time.Millisecond) continue } body, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10)) _ = res.Body.Close() var st struct { Status string `json:"status"` ErrorMessage string `json:"error_message"` } _ = json.Unmarshal(body, &st) switch strings.ToUpper(st.Status) { case "FINISHED", "PUBLISHED": return nil case "ERROR": if st.ErrorMessage != "" { return fmt.Errorf("%s", st.ErrorMessage) } return fmt.Errorf("container error") case "EXPIRED": return fmt.Errorf("container expired") } time.Sleep(800 * time.Millisecond) } return fmt.Errorf("container wait timeout") } func parseGraphError(body []byte, httpStatus int) string { var ge struct { Error *struct { Message string `json:"message"` Type string `json:"type"` Code int `json:"code"` } `json:"error"` } if err := json.Unmarshal(body, &ge); err == nil && ge.Error != nil && ge.Error.Message != "" { return fmt.Sprintf("%s (code %d)", ge.Error.Message, ge.Error.Code) } return fmt.Sprintf("HTTP %d %s", httpStatus, truncate(string(body), 160)) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } var _ Transport = (*MetaTransport)(nil)