2026-07-13 01:15:30 +00:00
|
|
|
|
package publish
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-07-15 15:23:59 +00:00
|
|
|
|
"net"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
"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"
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
return &MetaTransport{
|
|
|
|
|
|
GraphBase: strings.TrimRight(graphBase, "/"),
|
2026-07-15 15:23:59 +00:00
|
|
|
|
HTTPClient: client,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
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)
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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)
|