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

108 lines
3.1 KiB
Go

package facebook
import (
"context"
"log/slog"
"net/http"
"sync"
)
// webhookRouter routes incoming Facebook webhook events to the correct channel instance by page_id.
// A single HTTP handler is shared across all facebook channel instances on the same server.
//
// Multi-Meta-App note: all page instances registered here are expected to share the same Meta App
// (and thus the same app_secret). If instances with different secrets are registered, ServeHTTP
// tries all known secrets and accepts the payload if any matches.
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
}
func (r *webhookRouter) unregister(pageID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.instances, pageID)
}
// webhookRoute returns the path+handler for the first call; ("", nil) for subsequent calls.
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 Facebook page webhooks.
// Routes each entry to the matching channel instance by page_id.
//
// Multi-Meta-App support: all registered page secrets are collected and tried in order.
// A payload is accepted if its signature matches any known app_secret. In the common
// case (single Meta App) there is exactly one secret and behavior is unchanged.
func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.RLock()
var primarySecret, verifyToken string
var extraSecrets []string
seenSecrets := make(map[string]bool)
for _, ch := range r.instances {
s := ch.webhookH.appSecret
if primarySecret == "" {
primarySecret = s
verifyToken = ch.webhookH.verifyToken
seenSecrets[s] = true
} else if !seenSecrets[s] {
extraSecrets = append(extraSecrets, s)
seenSecrets[s] = true
}
}
r.mu.RUnlock()
if primarySecret == "" {
// No instances registered yet.
w.WriteHeader(http.StatusOK)
return
}
if len(extraSecrets) > 0 {
slog.Warn("security.facebook_multi_meta_app",
"extra_app_count", len(extraSecrets),
"note", "multiple Meta App secrets registered; payloads verified against all known secrets")
}
routingWH := &WebhookHandler{
appSecret: primarySecret,
verifyToken: verifyToken,
extraSecrets: extraSecrets,
}
routingWH.onComment = func(ctx context.Context, entry WebhookEntry, change ChangeValue) {
r.mu.RLock()
target := r.instances[entry.ID]
r.mu.RUnlock()
if target != nil {
target.handleCommentEvent(ctx, entry, change)
}
}
routingWH.onMessage = func(ctx context.Context, entry WebhookEntry, event MessagingEvent) {
r.mu.RLock()
target := r.instances[entry.ID]
r.mu.RUnlock()
if target != nil {
target.handleMessagingEvent(ctx, entry, event)
}
}
routingWH.ServeHTTP(w, req)
}