diff --git a/internal/channels/pancake/api_client.go b/internal/channels/pancake/api_client.go index 42ba502e..2b716afc 100644 --- a/internal/channels/pancake/api_client.go +++ b/internal/channels/pancake/api_client.go @@ -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") +} diff --git a/internal/channels/pancake/api_client_test.go b/internal/channels/pancake/api_client_test.go index 4e357503..ab3a4fe2 100644 --- a/internal/channels/pancake/api_client_test.go +++ b/internal/channels/pancake/api_client_test.go @@ -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") + } +} diff --git a/internal/channels/pancake/formatter.go b/internal/channels/pancake/formatter.go index 609c63ba..542cb5c5 100644 --- a/internal/channels/pancake/formatter.go +++ b/internal/channels/pancake/formatter.go @@ -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]) + "..." } diff --git a/internal/channels/pancake/pancake.go b/internal/channels/pancake/pancake.go index 8a2a785d..968bd24b 100644 --- a/internal/channels/pancake/pancake.go +++ b/internal/channels/pancake/pancake.go @@ -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 diff --git a/internal/channels/pancake/pancake_test.go b/internal/channels/pancake/pancake_test.go index 3752cc4b..0f073ad7 100644 --- a/internal/channels/pancake/pancake_test.go +++ b/internal/channels/pancake/pancake_test.go @@ -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) { diff --git a/internal/channels/pancake/testdata/shopee_inbox_webhook.json b/internal/channels/pancake/testdata/shopee_inbox_webhook.json new file mode 100644 index 00000000..e340bb9e --- /dev/null +++ b/internal/channels/pancake/testdata/shopee_inbox_webhook.json @@ -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"} + } + } +} diff --git a/internal/channels/pancake/testdata/shopee_inbox_webhook_with_page_id.json b/internal/channels/pancake/testdata/shopee_inbox_webhook_with_page_id.json new file mode 100644 index 00000000..182142e7 --- /dev/null +++ b/internal/channels/pancake/testdata/shopee_inbox_webhook_with_page_id.json @@ -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"} + } + } +} diff --git a/internal/channels/pancake/webhook_handler.go b/internal/channels/pancake/webhook_handler.go index dce27a9a..fd5c59e2 100644 --- a/internal/channels/pancake/webhook_handler.go +++ b/internal/channels/pancake/webhook_handler.go @@ -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] +} diff --git a/internal/channels/pancake/webhook_handler_test.go b/internal/channels/pancake/webhook_handler_test.go new file mode 100644 index 00000000..9f4dc478 --- /dev/null +++ b/internal/channels/pancake/webhook_handler_test.go @@ -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) + } + }) + } +}