Files
Bruno ClermontandBruno Clermont 72fcf52f5d fix(channels): propagate tenant context through all channel handlers (#1278)
Pairing requests and message handlers were silently using MasterTenantID
instead of the correct tenant because context.Context had no tenant set.

Root cause: instance_loader.go passed bare ctx (no tenant) to
startChannelWithTimeout; channel handlers also missing store.WithTenantID
at entry points.

Channels fixed:
- instance_loader.go: pass instCtx (with tenant) to startChannelWithTimeout
- Telegram, Bitrix24, Feishu: add store.WithTenantID at handler entry
- Facebook: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler
- Pancake: add ctx param to handleMessagingEvent/handleCommentEvent + webhook_handler

Also fix consolidation pruner: PruneExpired SQL had tenant_id filter that
caused zero rows to ever be deleted (context had no tenant). Remove tenant
filter — pruning is a global maintenance operation.

Add regression tests:
- internal/channels/pancake/tenant_context_test.go (6 tests)
- internal/channels/facebook/tenant_context_test.go (4 tests)
- internal/consolidation/prune_expired_test.go (5 tests)

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
2026-06-25 07:38:51 +07:00

329 lines
9.4 KiB
Go

package pancake
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"sync"
)
const (
webhookPath = "/channels/pancake/webhook"
maxBodyBytes = 1 << 20 // 1 MB — prevent abuse
)
// verifyHMAC verifies a Pancake HMAC-SHA256 signature.
// Expected header format: "sha256=<hex-digest>"
func verifyHMAC(body []byte, secret, signature string) bool {
const prefix = "sha256="
if len(signature) <= len(prefix) {
return false
}
got, err := hex.DecodeString(signature[len(prefix):])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
return hmac.Equal(got, expected)
}
func webhookReplayKey(body []byte) string {
sum := sha256.Sum256(body)
return "webhook:" + hex.EncodeToString(sum[:])
}
// --- Global webhook router for multi-page support ---
// webhookRouter routes incoming Pancake webhook events to the correct channel instance by page_id.
// A single HTTP handler is shared across all pancake channel instances.
type webhookRouter struct {
mu sync.RWMutex
instances map[string]*Channel // pageID → channel
routeHandled bool // true after first webhookRoute() call
}
var globalRouter = &webhookRouter{
instances: make(map[string]*Channel),
}
func (r *webhookRouter) register(ch *Channel) {
r.mu.Lock()
defer r.mu.Unlock()
r.instances[ch.pageID] = ch
if ch.webhookPageID != "" && ch.webhookPageID != ch.pageID {
r.instances[ch.webhookPageID] = ch
}
}
func (r *webhookRouter) unregister(pageID string, webhookPageID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.instances, pageID)
if webhookPageID != "" && webhookPageID != pageID {
delete(r.instances, webhookPageID)
}
}
// webhookRoute returns the path+handler on first call; ("", nil) for subsequent calls.
// The HTTP mux retains the route once registered — routeHandled prevents duplicate mounts.
func (r *webhookRouter) webhookRoute() (string, http.Handler) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.routeHandled {
r.routeHandled = true
return webhookPath, r
}
return "", nil
}
// ServeHTTP is the shared handler for all Pancake page webhooks.
// Always returns HTTP 200 — Pancake suspends webhooks if >80% errors in a 30-min window.
func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
slog.Info("pancake: webhook request received",
"method", req.Method,
"remote_addr", req.RemoteAddr,
"content_length", req.ContentLength)
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusOK)
return
}
lr := io.LimitReader(req.Body, maxBodyBytes+1)
body, err := io.ReadAll(lr)
if err != nil {
slog.Warn("pancake: router read body error", "err", err)
w.WriteHeader(http.StatusOK)
return
}
slog.Debug("pancake: webhook body received",
"body_len", len(body),
"body_preview", truncateBody(body, 1000))
// Parse top-level envelope.
var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
slog.Warn("pancake: router parse event error", "err", err, "body_preview", truncateBody(body, 300))
w.WriteHeader(http.StatusOK)
return
}
// Parse the nested "data" object containing conversation + message.
var data WebhookData
if err := json.Unmarshal(event.Data, &data); err != nil {
slog.Warn("pancake: router parse data error", "err", err)
w.WriteHeader(http.StatusOK)
return
}
// 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 == "" {
pageID = resolvePageIDFromConvID(data.Conversation.ID)
}
// Resolve conversation type.
convType := strings.ToUpper(data.Conversation.Type)
if event.EventType != "" && !strings.EqualFold(event.EventType, "messaging") {
slog.Debug("pancake: skipping non-messaging webhook event",
"event_type", event.EventType,
"page_id", pageID)
w.WriteHeader(http.StatusOK)
return
}
slog.Debug("pancake: webhook event parsed",
"event_type", event.EventType,
"resolved_page_id", pageID,
"conv_id", data.Conversation.ID,
"conv_type", convType,
"sender_id", data.Conversation.From.ID,
"msg_id", data.Message.ID)
if pageID == "" {
slog.Warn("pancake: could not determine page_id from webhook payload",
"event_page_id", event.PageID,
"data_page_id", data.PageID,
"conv_id", data.Conversation.ID)
w.WriteHeader(http.StatusOK)
return
}
r.mu.RLock()
target := r.instances[pageID]
r.mu.RUnlock()
if target == nil {
r.mu.RLock()
var registered []string
for pid := range r.instances {
registered = append(registered, pid)
}
r.mu.RUnlock()
slog.Warn("pancake: no channel instance for page_id",
"page_id", pageID,
"event_page_id", event.PageID,
"data_page_id", data.PageID,
"conv_id", data.Conversation.ID,
"registered_pages", registered)
w.WriteHeader(http.StatusOK)
return
}
if target.webhookSecret == "" {
slog.Warn("security.pancake_webhook_missing_secret",
"page_id", pageID,
"remote_addr", req.RemoteAddr)
w.WriteHeader(http.StatusOK)
return
}
sig := req.Header.Get("X-Pancake-Signature")
if !verifyHMAC(body, target.webhookSecret, sig) {
slog.Warn("security.pancake_webhook_signature_mismatch",
"page_id", pageID,
"remote_addr", req.RemoteAddr)
w.WriteHeader(http.StatusOK)
return
}
if target.isDup(webhookReplayKey(body)) {
slog.Info("pancake: duplicate webhook skipped", "page_id", pageID)
w.WriteHeader(http.StatusOK)
return
}
// Build normalized MessagingData from actual Pancake payload.
msgContent := data.Message.Message
if msgContent == "" {
msgContent = data.Message.OriginalMessage
}
if msgContent == "" {
msgContent = data.Message.Content
}
if msgContent == "" {
msgContent = data.Conversation.Snippet
}
senderID := data.Conversation.From.ID
senderName := data.Conversation.From.Name
if data.Message.From != nil {
if data.Message.From.ID != "" {
senderID = data.Message.From.ID
}
if data.Message.From.Name != "" {
senderName = data.Message.From.Name
}
}
normalized := MessagingData{
PageID: pageID,
ConversationID: data.Conversation.ID,
PostID: data.Conversation.PostID, // present for COMMENT events
Type: convType,
Platform: target.platform,
AssigneeIDs: append([]string(nil), data.Conversation.AssigneeIDs...),
Message: MessagingMessage{
ID: data.Message.ID,
Content: msgContent,
SenderID: senderID,
SenderName: senderName,
Attachments: data.Message.Attachments,
},
}
// Route by conversation type.
switch convType {
case "INBOX":
target.handleMessagingEvent(req.Context(), normalized)
case "COMMENT":
target.handleCommentEvent(req.Context(), normalized)
default:
slog.Debug("pancake: skipping unknown conversation type",
"page_id", pageID, "conv_type", convType)
}
w.WriteHeader(http.StatusOK)
}
// truncateBody returns a string preview of body, truncated to maxLen bytes.
func truncateBody(body []byte, maxLen int) string {
if len(body) <= maxLen {
return string(body)
}
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
"tt": {}, // TikTok Livestream AIO
"ttm": {}, // TikTok Business Messaging
"tts": {}, // TikTok Shop
}
)
// 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]
}