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

104 lines
2.8 KiB
Go

package facebook
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleMessagingEvent processes a Messenger inbox event.
func (ch *Channel) handleMessagingEvent(ctx context.Context, entry WebhookEntry, event MessagingEvent) {
ctx = store.WithTenantID(ctx, ch.TenantID())
// Feature gate.
if !ch.config.Features.MessengerAutoReply {
return
}
// Page routing guard (before dedup write).
if entry.ID != ch.pageID {
return
}
// Track admin (page) replies: when the page itself sends a message,
// record the recipient's chat ID so the bot skips auto-reply for that
// conversation during the cooldown window.
if event.Sender.ID == ch.pageID {
if event.Recipient.ID != "" {
eventAt := messagingEventTime(event.Timestamp)
if ch.isBotEcho(event.Recipient.ID, eventAt) {
slog.Debug("facebook: bot echo ignored", "chat_id", event.Recipient.ID)
return
}
ch.adminReplied.Store(event.Recipient.ID, eventAt)
slog.Debug("facebook: admin reply tracked", "chat_id", event.Recipient.ID)
}
return
}
// Skip delivery/read receipts and other non-content events.
if event.Message == nil && event.Postback == nil {
return
}
// Dedup by message MID or postback signature (include payload to reduce collision risk).
var eventKey string
switch {
case event.Message != nil:
eventKey = "msg:" + event.Message.MID
case event.Postback != nil:
eventKey = fmt.Sprintf("postback:%s:%d:%s", event.Sender.ID, event.Timestamp, event.Postback.Payload)
}
if ch.isDup(eventKey) {
slog.Debug("facebook: duplicate messaging event skipped", "key", eventKey)
return
}
// Check if admin already replied to this conversation recently.
senderID := event.Sender.ID
if ch.adminRepliedRecently(senderID, time.Now()) {
slog.Info("facebook: skipping auto-reply (admin replied recently)", "chat_id", senderID)
return
}
// Extract text content.
var content string
switch {
case event.Message != nil && event.Message.Text != "":
content = event.Message.Text
case event.Postback != nil:
content = event.Postback.Title
default:
// Attachment-only message — skip for now.
return
}
// Messenger sessions are 1:1: chatID = senderID (channel name scopes the session).
chatID := senderID
metadata := map[string]string{
"fb_mode": "messenger",
"message_id": eventKey,
"page_id": ch.pageID,
"sender_id": senderID,
}
if ch.config.MessengerOptions.SessionTimeout != "" {
metadata["session_timeout"] = ch.config.MessengerOptions.SessionTimeout
}
ch.HandleMessage(senderID, chatID, content, nil, metadata, "direct")
}
func messagingEventTime(ts int64) time.Time {
switch {
case ts > 1_000_000_000_000:
return time.UnixMilli(ts)
case ts > 0:
return time.Unix(ts, 0)
default:
return time.Now()
}
}