mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
* feat(pancake): add Shopee platform support - Fix Accept header for JSON response negotiation (Shopee GETs) - Platform-prefix-aware pageID parsing (spo_ convID format) - 500-char message limit for Shopee DMs - Rune-safe truncation with slog.Warn on truncation Phase 3 (integration smoke) pending: requires real credentials. * fix(pancake): address PR review feedback - Add RegisterPlatformPrefix() for extensibility (issue #1) - Rename setCommonHeaders → setAcceptJSONHeader (issue #2) - Add testdata fixture with page_id for priority testing (issue #3) * chore: retrigger CI * fix(permissions): classify sessions.compact in RBAC policy Add MethodSessionsCompact to isWriteMethod to fix drift coverage test. Method was added in #958 but missing from permissions classification. * fix(tests): use strings.Contains to avoid redeclaration Remove duplicate contains helper in tts_gemini_live_test.go that conflicts with mcp_grant_revoke_test.go in same package. * refactor(pancake): guard platformPrefixes with RWMutex for concurrent safety RegisterPlatformPrefix is exported for future use (Lazada, Tokopedia) but webhook handler reads the map concurrently. Protect with sync.RWMutex so callers can register prefixes at any time without data races. - Add platformPrefixesMu sync.RWMutex - Wrap RegisterPlatformPrefix with write lock - Extract isKnownPlatformPrefix helper with read lock - Document that RegisterPlatformPrefix is currently unused (extension point) --------- Co-authored-by: viettranx <viettranx@gmail.com>
361 lines
12 KiB
Go
361 lines
12 KiB
Go
package pancake
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
publicAPIBase = "https://pages.fm/api/public_api/v2" // page-level APIs
|
|
userAPIBase = "https://pages.fm/api/v1" // user-level APIs (list pages, etc.)
|
|
httpTimeout = 30 * time.Second
|
|
)
|
|
|
|
// APIClient wraps the Pancake REST API for a single page instance.
|
|
type APIClient struct {
|
|
pageV1BaseURL string
|
|
pageV2BaseURL string
|
|
userBaseURL string
|
|
pageAccessToken string
|
|
apiKey string
|
|
pageID string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewAPIClient creates a new Pancake APIClient for the given page.
|
|
func NewAPIClient(apiKey, pageAccessToken, pageID string) *APIClient {
|
|
return &APIClient{
|
|
pageV1BaseURL: "https://pages.fm/api/public_api/v1",
|
|
pageV2BaseURL: publicAPIBase,
|
|
userBaseURL: userAPIBase,
|
|
pageAccessToken: pageAccessToken,
|
|
apiKey: apiKey,
|
|
pageID: pageID,
|
|
httpClient: &http.Client{Timeout: httpTimeout},
|
|
}
|
|
}
|
|
|
|
// VerifyToken validates the page_access_token via a lightweight API call.
|
|
func (c *APIClient) VerifyToken(ctx context.Context) error {
|
|
url := fmt.Sprintf("%s/pages/%s/conversations?limit=1", c.pageV2BaseURL, c.pageID)
|
|
if err := c.doRequest(ctx, http.MethodGet, url, nil); err != nil {
|
|
return fmt.Errorf("pancake: token verification failed: %w", err)
|
|
}
|
|
slog.Info("pancake: page token verified", "page_id", c.pageID)
|
|
return nil
|
|
}
|
|
|
|
// GetPage fetches page metadata including platform (facebook/zalo/instagram/tiktok/whatsapp/line).
|
|
func (c *APIClient) GetPage(ctx context.Context) (*PageInfo, error) {
|
|
url := fmt.Sprintf("%s/pages", c.userBaseURL)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: build get-pages request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
setAcceptJSONHeader(req)
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: get pages request failed: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: read get-pages response: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
Data []PageInfo `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("pancake: parse get-pages response: %w", err)
|
|
}
|
|
|
|
for i := range result.Data {
|
|
if result.Data[i].ID == c.pageID {
|
|
return &result.Data[i], nil
|
|
}
|
|
}
|
|
|
|
// Page not found in list — return minimal info without platform
|
|
slog.Warn("pancake: page not found in pages list, platform unknown", "page_id", c.pageID)
|
|
return &PageInfo{ID: c.pageID}, nil
|
|
}
|
|
|
|
// SendMessage sends a text message to a conversation.
|
|
func (c *APIClient) SendMessage(ctx context.Context, conversationID, content string) error {
|
|
body, _ := json.Marshal(SendMessageRequest{
|
|
Action: "reply_inbox",
|
|
Message: content,
|
|
})
|
|
url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
|
|
if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
|
|
return fmt.Errorf("pancake: send message: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendAttachmentMessage sends one or more uploaded content IDs to a conversation.
|
|
func (c *APIClient) SendAttachmentMessage(ctx context.Context, conversationID string, contentIDs []string) error {
|
|
body, _ := json.Marshal(SendMessageRequest{
|
|
Action: "reply_inbox",
|
|
ContentIDs: contentIDs,
|
|
})
|
|
url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
|
|
if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
|
|
return fmt.Errorf("pancake: send attachment message: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UploadMedia uploads a file via multipart/form-data and returns the attachment ID.
|
|
func (c *APIClient) UploadMedia(ctx context.Context, filename string, data io.Reader, contentType string) (string, error) {
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
|
|
fw, err := mw.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return "", fmt.Errorf("pancake: create form file: %w", err)
|
|
}
|
|
if _, err := io.Copy(fw, data); err != nil {
|
|
return "", fmt.Errorf("pancake: copy file data: %w", err)
|
|
}
|
|
mw.Close()
|
|
|
|
url := fmt.Sprintf("%s/pages/%s/upload_contents", c.pageV1BaseURL, c.pageID)
|
|
req, err := c.newPageRequest(ctx, http.MethodPost, url, &buf)
|
|
if err != nil {
|
|
return "", fmt.Errorf("pancake: build upload request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("pancake: upload request failed: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("pancake: read upload response: %w", err)
|
|
}
|
|
|
|
var uploadResp UploadResponse
|
|
if err := json.Unmarshal(respBody, &uploadResp); err != nil {
|
|
return "", fmt.Errorf("pancake: parse upload response: %w", err)
|
|
}
|
|
|
|
if uploadResp.ID == "" {
|
|
return "", fmt.Errorf("pancake: upload response missing attachment ID")
|
|
}
|
|
|
|
return uploadResp.ID, nil
|
|
}
|
|
|
|
// ReplyComment sends a reply to a comment on a post (action: "reply_comment").
|
|
// messageID is the ID of the comment being replied to — required by Pancake API.
|
|
func (c *APIClient) ReplyComment(ctx context.Context, conversationID, messageID, content string) error {
|
|
body, _ := json.Marshal(SendMessageRequest{
|
|
Action: "reply_comment",
|
|
Message: content,
|
|
MessageID: messageID,
|
|
})
|
|
url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
|
|
if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
|
|
return fmt.Errorf("pancake: reply comment: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PrivateReply sends a one-time DM to a commenter (first-inbox, action: "private_reply").
|
|
func (c *APIClient) PrivateReply(ctx context.Context, conversationID, content string) error {
|
|
body, _ := json.Marshal(SendMessageRequest{
|
|
Action: "private_reply",
|
|
Message: content,
|
|
})
|
|
url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
|
|
if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
|
|
return fmt.Errorf("pancake: private reply: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPosts fetches recent posts for the page (used by PostFetcher for comment context enrichment).
|
|
// Bypasses doRequest because it needs to parse a JSON response body (doRequest discards the body).
|
|
// Connection reuse is preserved: defer res.Body.Close() + io.ReadAll drain the body fully.
|
|
func (c *APIClient) GetPosts(ctx context.Context, limit int) ([]PancakePost, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
url := fmt.Sprintf("%s/pages/%s/posts?limit=%d", c.pageV2BaseURL, c.pageID, limit)
|
|
req, err := c.newPageRequest(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: build get-posts request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: get posts request failed: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pancake: read get-posts response: %w", err)
|
|
}
|
|
if res.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("pancake: get posts HTTP %d", res.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Data []PancakePost `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("pancake: parse get-posts response: %w", err)
|
|
}
|
|
return result.Data, nil
|
|
}
|
|
|
|
// doRequest executes an authenticated HTTP request using the page_access_token.
|
|
// Always drains and closes the response body to enable connection reuse.
|
|
func (c *APIClient) doRequest(ctx context.Context, method, url string, body io.Reader) error {
|
|
req, err := c.newPageRequest(ctx, method, url, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
// Always read the full body to allow HTTP connection reuse.
|
|
respBody, _ := io.ReadAll(res.Body)
|
|
|
|
if res.StatusCode >= 400 {
|
|
var apiErr apiError
|
|
if jsonErr := json.Unmarshal(respBody, &apiErr); jsonErr == nil && apiErr.Message != "" {
|
|
return &apiErr
|
|
}
|
|
return fmt.Errorf("pancake: HTTP %d", res.StatusCode)
|
|
}
|
|
|
|
// Some Pancake endpoints return HTTP 200 with a JSON body carrying success=false.
|
|
// Treat these as application-level send failures instead of silent success.
|
|
var appResp struct {
|
|
Success *bool `json:"success,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &appResp); err == nil && appResp.Success != nil && !*appResp.Success {
|
|
if appResp.Message != "" {
|
|
return fmt.Errorf("pancake: %s", appResp.Message)
|
|
}
|
|
return fmt.Errorf("pancake: request reported success=false")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *APIClient) newPageRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := req.URL.Query()
|
|
query.Set("page_access_token", c.pageAccessToken)
|
|
req.URL.RawQuery = query.Encode()
|
|
|
|
// Keep the header for compatibility; official docs require the query token.
|
|
req.Header.Set("Authorization", "Bearer "+c.pageAccessToken)
|
|
setAcceptJSONHeader(req)
|
|
return req, nil
|
|
}
|
|
|
|
// ReactComment toggles the page's like on a comment via Pancake user API:
|
|
// POST {userAPI}/pages/{pageID}/conversations/{convID}/messages/{msgID}/likes
|
|
// with multipart body (action=like_toggle, user_likes=false). Server flips the
|
|
// current state, so call once per fresh comment (dedup prevents double-toggle).
|
|
func (c *APIClient) ReactComment(ctx context.Context, conversationID, messageID string) error {
|
|
if conversationID == "" || messageID == "" {
|
|
return fmt.Errorf("pancake: react-comment requires conversation_id and message_id")
|
|
}
|
|
if strings.ContainsAny(conversationID, "/?#\\") || strings.ContainsAny(messageID, "/?#\\") {
|
|
return fmt.Errorf("pancake: invalid character in conversation_id or message_id")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
_ = mw.WriteField("action", "like_toggle")
|
|
_ = mw.WriteField("user_likes", "false")
|
|
mw.Close()
|
|
|
|
url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages/%s/likes",
|
|
c.userBaseURL, c.pageID, conversationID, messageID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
|
if err != nil {
|
|
return fmt.Errorf("pancake: build react-comment request: %w", err)
|
|
}
|
|
q := req.URL.Query()
|
|
q.Set("access_token", c.apiKey)
|
|
req.URL.RawQuery = q.Encode()
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
res, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("pancake: react-comment request failed: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
body, _ := io.ReadAll(res.Body)
|
|
if res.StatusCode >= 400 {
|
|
snippet := string(body)
|
|
if len(snippet) > 300 {
|
|
snippet = snippet[:300]
|
|
}
|
|
return fmt.Errorf("pancake: react-comment HTTP %d: %s", res.StatusCode, snippet)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isAuthError checks if an error is an authentication/authorization failure.
|
|
// Uses errors.As to handle wrapped errors consistently with Facebook channel pattern.
|
|
func isAuthError(err error) bool {
|
|
var ae *apiError
|
|
if !errors.As(err, &ae) {
|
|
return false
|
|
}
|
|
return ae.Code == 401 || ae.Code == 403 || ae.Code == 4001 || ae.Code == 4003
|
|
}
|
|
|
|
// isRateLimitError checks if an error is a rate limit response.
|
|
func isRateLimitError(err error) bool {
|
|
var ae *apiError
|
|
if !errors.As(err, &ae) {
|
|
return false
|
|
}
|
|
return ae.Code == 429 || ae.Code == 4029
|
|
}
|
|
|
|
// setAcceptJSONHeader sets Accept: application/json for JSON response negotiation.
|
|
// Without it, Pancake returns SPA HTML for Shopee GETs (verified 2026-04-20).
|
|
func setAcceptJSONHeader(req *http.Request) {
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|