mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
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>
140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
package pancake
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"html"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// handleMessagingEvent converts a Pancake "messaging" webhook event to bus.InboundMessage.
|
|
func (ch *Channel) handleMessagingEvent(ctx context.Context, data MessagingData) {
|
|
ctx = store.WithTenantID(ctx, ch.TenantID())
|
|
slog.Debug("pancake: handleMessagingEvent called",
|
|
"page_id", ch.pageID,
|
|
"sender_id", data.Message.SenderID,
|
|
"conversation_id", data.ConversationID,
|
|
"type", data.Type,
|
|
"platform", data.Platform,
|
|
"msg_id", data.Message.ID,
|
|
"content_len", len(data.Message.Content))
|
|
|
|
// Dedup by message ID to handle Pancake's at-least-once delivery.
|
|
// Skip dedup when message ID is empty — prevents shared slot "msg:" from
|
|
// silently dropping all subsequent empty-ID messages across conversations.
|
|
var dedupKey string
|
|
if data.Message.ID != "" {
|
|
dedupKey = fmt.Sprintf("msg:%s", data.Message.ID)
|
|
if ch.isDup(dedupKey) {
|
|
slog.Info("pancake: duplicate message skipped", "msg_id", data.Message.ID)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Prevent reply loops: skip messages sent by the page itself.
|
|
if data.Message.SenderID == ch.pageID {
|
|
slog.Info("pancake: skipping own page message",
|
|
"page_id", ch.pageID,
|
|
"sender_id", data.Message.SenderID)
|
|
return
|
|
}
|
|
if isAssignedStaff(data.AssigneeIDs, data.Message.SenderID) {
|
|
slog.Info("pancake: skipping assigned staff message",
|
|
"page_id", ch.pageID,
|
|
"sender_id", data.Message.SenderID,
|
|
"conversation_id", data.ConversationID)
|
|
return
|
|
}
|
|
|
|
if data.Message.SenderID == "" {
|
|
slog.Warn("pancake: message missing sender_id, skipping", "msg_id", data.Message.ID)
|
|
return
|
|
}
|
|
|
|
// Check echo BEFORE buildMessageContent adds the [From: ...] prefix.
|
|
// rememberOutboundEcho stores the raw outbound text; the prefix would cause a
|
|
// key mismatch and silently break loop detection.
|
|
if ch.isRecentOutboundEcho(data.ConversationID, data.Message.Content) {
|
|
slog.Info("pancake: skipping recent outbound echo",
|
|
"page_id", ch.pageID,
|
|
"conversation_id", data.ConversationID,
|
|
"msg_id", data.Message.ID)
|
|
return
|
|
}
|
|
|
|
content := buildMessageContent(data)
|
|
|
|
metadata := map[string]string{
|
|
"pancake_mode": strings.ToLower(data.Type), // "inbox" or "comment"
|
|
"conversation_type": data.Type,
|
|
"platform": data.Platform,
|
|
"conversation_id": data.ConversationID,
|
|
"message_id": dedupKey,
|
|
"display_name": channels.SanitizeDisplayName(data.Message.SenderName),
|
|
"page_name": ch.pageName,
|
|
}
|
|
|
|
ch.HandleMessage(
|
|
data.Message.SenderID,
|
|
data.ConversationID, // ChatID = conversation_id for reply routing
|
|
content,
|
|
nil, // media handled inline via content URLs
|
|
metadata,
|
|
"direct", // Pancake inbox conversations are always treated as direct messages
|
|
)
|
|
|
|
slog.Debug("pancake: inbound message published to bus",
|
|
"page_id", ch.pageID,
|
|
"conv_id", data.ConversationID,
|
|
"sender_id", data.Message.SenderID,
|
|
"platform", data.Platform,
|
|
"type", data.Type,
|
|
"channel_name", ch.Name(),
|
|
)
|
|
}
|
|
|
|
// buildMessageContent combines text content and attachment URLs into a single string.
|
|
// Format: [From: {SenderID} ({SenderName})] {content}
|
|
func buildMessageContent(data MessagingData) string {
|
|
parts := []string{}
|
|
|
|
if data.Message.Content != "" {
|
|
parts = append(parts, stripHTML(data.Message.Content))
|
|
}
|
|
|
|
for _, att := range data.Message.Attachments {
|
|
if att.URL != "" {
|
|
parts = append(parts, att.URL)
|
|
}
|
|
}
|
|
|
|
body := strings.Join(parts, "\n")
|
|
|
|
if data.Message.SenderID != "" {
|
|
prefix := fmt.Sprintf("[From: %s (%s)]", data.Message.SenderID, data.Message.SenderName)
|
|
if body != "" {
|
|
return prefix + " " + body
|
|
}
|
|
return prefix
|
|
}
|
|
return body
|
|
}
|
|
|
|
// stripHTML removes HTML tags and unescapes HTML entities from s.
|
|
func stripHTML(s string) string {
|
|
s = htmlTagRe.ReplaceAllString(s, "")
|
|
return html.UnescapeString(strings.TrimSpace(s))
|
|
}
|
|
|
|
func isAssignedStaff(assigneeIDs []string, senderID string) bool {
|
|
if senderID == "" {
|
|
return false
|
|
}
|
|
return slices.Contains(assigneeIDs, senderID)
|
|
}
|