Files
Plateau Nguyenandviettranx 8dc101e425 feat(pancake): add Shopee platform support (#975)
* 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>
2026-04-24 08:37:40 +07:00

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")
}