mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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>
This commit is contained in:
1 parent
5cca9d7ac6
commit
8dc101e425
9 files changed
+243
-17
No files matched your search
@@ -64,6 +64,7 @@ func (c *APIClient) GetPage(ctx context.Context) (*PageInfo, error) {
|
||||
}
|
||||
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 {
|
||||
@@ -283,6 +284,7 @@ func (c *APIClient) newPageRequest(ctx context.Context, method, rawURL string, b
|
||||
|
||||
// Keep the header for compatibility; official docs require the query token.
|
||||
req.Header.Set("Authorization", "Bearer "+c.pageAccessToken)
|
||||
setAcceptJSONHeader(req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -350,3 +352,9 @@ func isRateLimitError(err error) bool {
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -372,3 +372,47 @@ func TestReactComment_RejectsInvalidIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Accept Header Tests (Shopee support) ---
|
||||
|
||||
// TestNewPageRequest_SetsAcceptJSONHeader verifies the Pancake GET negotiation fix:
|
||||
// without Accept: application/json, Pancake returns SPA HTML for Shopee endpoints.
|
||||
func TestNewPageRequest_SetsAcceptJSONHeader(t *testing.T) {
|
||||
client := NewAPIClient("user-token", "page-token", "spo_25409726")
|
||||
req, err := client.newPageRequest(context.Background(), http.MethodGet,
|
||||
"https://pages.fm/api/public_api/v2/pages/spo_25409726/conversations", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newPageRequest: %v", err)
|
||||
}
|
||||
if got := req.Header.Get("Accept"); got != "application/json" {
|
||||
t.Fatalf("Accept header = %q, want %q", got, "application/json")
|
||||
}
|
||||
if got := req.Header.Get("Authorization"); got != "Bearer page-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", got, "Bearer page-token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPage_SetsAcceptJSONHeader — C2 guard. GetPage bypasses newPageRequest
|
||||
// (it builds its own http.NewRequestWithContext for the user-API /pages endpoint).
|
||||
// Without this header, startup auto-detect receives SPA HTML for Shopee pages.
|
||||
func TestGetPage_SetsAcceptJSONHeader(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
resp: &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
|
||||
},
|
||||
}
|
||||
client := NewAPIClient("user-token", "page-token", "spo_25409726")
|
||||
client.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
if _, err := client.GetPage(context.Background()); err != nil {
|
||||
t.Fatalf("GetPage: %v", err)
|
||||
}
|
||||
if transport.req == nil {
|
||||
t.Fatal("expected request to be captured")
|
||||
}
|
||||
if got := transport.req.Header.Get("Accept"); got != "application/json" {
|
||||
t.Fatalf("Accept header on GetPage = %q, want %q", got, "application/json")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package pancake
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -15,8 +16,8 @@ func FormatOutbound(content string, platform string) string {
|
||||
return formatForWhatsApp(content)
|
||||
case "zalo", "instagram", "line":
|
||||
return stripMarkdown(content)
|
||||
case "tiktok":
|
||||
return stripMarkdown(truncateForTikTok(content))
|
||||
case "tiktok", "shopee":
|
||||
return stripMarkdown(truncateRuneSafe(content, 500))
|
||||
default:
|
||||
return stripMarkdown(content)
|
||||
}
|
||||
@@ -62,14 +63,21 @@ func stripMarkdown(content string) string {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// truncateForTikTok truncates content to TikTok DM limit (500 runes).
|
||||
// Uses rune slicing to avoid corrupting multi-byte UTF-8 (CJK, Vietnamese, emoji).
|
||||
func truncateForTikTok(content string) string {
|
||||
const limit = 500
|
||||
// truncateRuneSafe truncates content to `limit` runes, avoiding multi-byte
|
||||
// UTF-8 corruption (CJK, Vietnamese, emoji). Used by platforms with short
|
||||
// DM limits (TikTok, Shopee: 500 runes). Logs a warning when truncation
|
||||
// occurs so the user isn't silently trimmed (M7).
|
||||
func truncateRuneSafe(content string, limit int) string {
|
||||
runes := []rune(content)
|
||||
if len(runes) <= limit {
|
||||
return content
|
||||
}
|
||||
slog.Warn("pancake: message truncated",
|
||||
"orig_runes", len(runes),
|
||||
"limit", limit)
|
||||
if limit <= 3 {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return string(runes[:limit-3]) + "..."
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ func (ch *Channel) handleAPIError(err error) {
|
||||
// maxMessageLength returns the platform-specific character limit.
|
||||
func (ch *Channel) maxMessageLength() int {
|
||||
switch ch.platform {
|
||||
case "tiktok":
|
||||
case "tiktok", "shopee":
|
||||
return 500
|
||||
case "instagram":
|
||||
return 1000
|
||||
|
||||
@@ -373,19 +373,19 @@ func TestAPIClientSendMessageReturnsBodyLevelError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateForTikTok_MultiByteCharacters verifies rune-safe truncation for
|
||||
// TestTruncateRuneSafe_MultiByteCharacters verifies rune-safe truncation for
|
||||
// Vietnamese diacritics and emoji (multi-byte UTF-8 sequences).
|
||||
func TestTruncateForTikTok_MultiByteCharacters(t *testing.T) {
|
||||
func TestTruncateRuneSafe_MultiByteCharacters(t *testing.T) {
|
||||
// Vietnamese text with diacritics (multi-byte UTF-8)
|
||||
input := strings.Repeat("Xin chào ", 100) // ~900 bytes, <500 runes
|
||||
result := truncateForTikTok(input)
|
||||
result := truncateRuneSafe(input, 500)
|
||||
if !utf8.ValidString(result) {
|
||||
t.Fatal("truncateForTikTok produced invalid UTF-8")
|
||||
t.Fatal("truncateRuneSafe produced invalid UTF-8")
|
||||
}
|
||||
|
||||
// Emoji string exceeding 500 runes
|
||||
emoji := strings.Repeat("😊", 600)
|
||||
result = truncateForTikTok(emoji)
|
||||
result = truncateRuneSafe(emoji, 500)
|
||||
runes := []rune(result)
|
||||
if len(runes) > 500 {
|
||||
t.Errorf("expected <=500 runes, got %d", len(runes))
|
||||
@@ -395,6 +395,49 @@ func TestTruncateForTikTok_MultiByteCharacters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shopee platform support tests (Phase 1: TDD red state) ---
|
||||
|
||||
// TestMaxMessageLength_Shopee verifies shopee returns 500 char limit.
|
||||
func TestMaxMessageLength_Shopee(t *testing.T) {
|
||||
ch := &Channel{platform: "shopee"}
|
||||
if got := ch.maxMessageLength(); got != 500 {
|
||||
t.Fatalf("shopee maxMessageLength = %d, want 500", got)
|
||||
}
|
||||
// Regression guards for existing platforms.
|
||||
for _, tc := range []struct {
|
||||
p string
|
||||
want int
|
||||
}{
|
||||
{"tiktok", 500}, {"facebook", 2000}, {"whatsapp", 4096},
|
||||
} {
|
||||
ch.platform = tc.p
|
||||
if got := ch.maxMessageLength(); got != tc.want {
|
||||
t.Fatalf("%s maxMessageLength = %d, want %d", tc.p, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateRuneSafe_Shopee verifies FormatOutbound truncates shopee to 500 runes.
|
||||
// Uses Vietnamese diacritics and emoji to catch byte-vs-rune bugs.
|
||||
func TestTruncateRuneSafe_Shopee(t *testing.T) {
|
||||
// Vietnamese text: 600 "Xin chào " iterations → >500 runes.
|
||||
input := strings.Repeat("Xin chào ", 100)
|
||||
out := FormatOutbound(input, "shopee")
|
||||
if utf8.RuneCountInString(out) > 500 {
|
||||
t.Fatalf("shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
|
||||
}
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatal("shopee truncation produced invalid UTF-8")
|
||||
}
|
||||
|
||||
// Emoji-only input exceeding 500 runes.
|
||||
emoji := strings.Repeat("😊", 600)
|
||||
out = FormatOutbound(emoji, "shopee")
|
||||
if utf8.RuneCountInString(out) > 500 {
|
||||
t.Fatalf("emoji shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageHandlerEmptyMessageID verifies that two messages with empty IDs
|
||||
// from different conversations are both published (not deduped against each other).
|
||||
func TestMessageHandlerEmptyMessageID(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "Assumed-shape fixture — verify against real Pancake payload in Phase 3",
|
||||
"event_type": "messaging",
|
||||
"page_id": "",
|
||||
"data": {
|
||||
"page_id": "",
|
||||
"conversation": {
|
||||
"id": "spo_25409726_109139680425439630",
|
||||
"type": "INBOX",
|
||||
"from": {"id": "109139680425439630", "name": "Test Buyer"}
|
||||
},
|
||||
"message": {
|
||||
"id": "spo_msg_1",
|
||||
"content": "Shop oi con hang khong?",
|
||||
"from": {"id": "109139680425439630"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "Fixture with page_id at top-level — tests priority: event.page_id > data.page_id > convID parse",
|
||||
"event_type": "messaging",
|
||||
"page_id": "spo_25409726",
|
||||
"data": {
|
||||
"page_id": "",
|
||||
"conversation": {
|
||||
"id": "spo_25409726_109139680425439630",
|
||||
"type": "INBOX",
|
||||
"from": {"id": "109139680425439630", "name": "Test Buyer"}
|
||||
},
|
||||
"message": {
|
||||
"id": "spo_msg_1",
|
||||
"content": "Shop oi con hang khong?",
|
||||
"from": {"id": "109139680425439630"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,16 +119,13 @@ func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve page_id: top-level field takes priority, then data-level, then first conv ID segment.
|
||||
// Resolve page_id: top-level field takes priority, then data-level, then conv ID parse.
|
||||
pageID := event.PageID
|
||||
if pageID == "" {
|
||||
pageID = data.PageID
|
||||
}
|
||||
if pageID == "" {
|
||||
// Last resort: extract from conversation ID (format: pageID_senderID for INBOX events).
|
||||
if idx := strings.Index(data.Conversation.ID, "_"); idx > 0 {
|
||||
pageID = data.Conversation.ID[:idx]
|
||||
}
|
||||
pageID = resolvePageIDFromConvID(data.Conversation.ID)
|
||||
}
|
||||
|
||||
// Resolve conversation type.
|
||||
@@ -250,3 +247,65 @@ func truncateBody(body []byte, maxLen int) string {
|
||||
}
|
||||
return string(body[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
// platformPrefixes lists marketplace platform tokens where convID uses a
|
||||
// 2-segment page identifier (e.g. "spo_25409726_senderID").
|
||||
//
|
||||
// Default: "spo" (Shopee) only. "lzd" (Lazada) and "tpd" (Tokopedia) are
|
||||
// NOT added by default because neither has been verified against a live
|
||||
// Pancake payload. Use RegisterPlatformPrefix to add verified platforms.
|
||||
//
|
||||
// Guarded by platformPrefixesMu so RegisterPlatformPrefix can be called
|
||||
// concurrently with webhook handling without data races.
|
||||
var (
|
||||
platformPrefixesMu sync.RWMutex
|
||||
platformPrefixes = map[string]struct{}{
|
||||
"spo": {}, // Shopee — verified via curl 2026-04-20
|
||||
}
|
||||
)
|
||||
|
||||
// RegisterPlatformPrefix registers a marketplace prefix for convID parsing.
|
||||
// Use this to add verified platforms (e.g. "lzd" for Lazada) after capturing
|
||||
// live webhook payloads. Safe to call from any goroutine at any time.
|
||||
//
|
||||
// NOTE: Currently unused — kept as an extension point for future marketplace
|
||||
// platforms (Lazada, Tokopedia, etc.) that may be added in a follow-up PR
|
||||
// once their convID shape is verified against live Pancake payloads.
|
||||
func RegisterPlatformPrefix(prefix string) {
|
||||
platformPrefixesMu.Lock()
|
||||
defer platformPrefixesMu.Unlock()
|
||||
platformPrefixes[prefix] = struct{}{}
|
||||
}
|
||||
|
||||
// isKnownPlatformPrefix reports whether prefix is registered as a marketplace
|
||||
// platform with a 2-segment page identifier. Read-locked for concurrent safety.
|
||||
func isKnownPlatformPrefix(prefix string) bool {
|
||||
platformPrefixesMu.RLock()
|
||||
defer platformPrefixesMu.RUnlock()
|
||||
_, ok := platformPrefixes[prefix]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resolvePageIDFromConvID extracts the page identifier from a Pancake
|
||||
// conversation ID. Facebook/IG use "{pageID}_{senderID}"; Shopee uses
|
||||
// "{prefix}_{pageNumeric}_{senderID}" for buyer DMs and possibly
|
||||
// "{prefix}_{pageNumeric}" for system events without a sender.
|
||||
func resolvePageIDFromConvID(convID string) string {
|
||||
if convID == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(convID, "_")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
knownPrefix := isKnownPlatformPrefix(parts[0])
|
||||
// M2: 2-segment convID with known prefix is a full pageID (system event
|
||||
// without sender). Return as-is — do NOT drop the event.
|
||||
if knownPrefix && len(parts) == 2 {
|
||||
return convID
|
||||
}
|
||||
if knownPrefix && len(parts) >= 3 {
|
||||
return parts[0] + "_" + parts[1]
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pancake
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResolvePageIDFromConvID verifies platform-prefix-aware pageID extraction.
|
||||
// This test will FAIL until Phase 2 introduces the resolvePageIDFromConvID helper.
|
||||
func TestResolvePageIDFromConvID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
convID string
|
||||
want string
|
||||
}{
|
||||
{"facebook_numeric", "123456_789012", "123456"},
|
||||
{"shopee_prefixed", "spo_25409726_109139680425439630", "spo_25409726"},
|
||||
{"shopee_system_2_segments", "spo_25409726", "spo_25409726"}, // M2: system event w/o sender — return as-is
|
||||
{"empty_input", "", ""},
|
||||
{"no_underscore", "abcdef", ""},
|
||||
{"prefix_only_no_underscore", "spo", ""}, // regression: prefix-only without underscore
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolvePageIDFromConvID(tc.convID); got != tc.want {
|
||||
t.Fatalf("resolvePageIDFromConvID(%q) = %q, want %q",
|
||||
tc.convID, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user