348 lines
9.9 KiB
Go
348 lines
9.9 KiB
Go
package publish
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"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"
|
||
}
|
||
return &MetaTransport{
|
||
GraphBase: strings.TrimRight(graphBase, "/"),
|
||
HTTPClient: &http.Client{Timeout: 90 * time.Second},
|
||
}
|
||
}
|
||
|
||
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 {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, imageURL, 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 (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)
|