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

256 lines
7.9 KiB
Go

package pancake
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleCommentEvent processes a Pancake COMMENT webhook event.
// Mirrors the inbox handler pattern with additional comment-specific guards.
func (ch *Channel) handleCommentEvent(ctx context.Context, data MessagingData) {
ctx = store.WithTenantID(ctx, ch.TenantID())
// Feature gate — exit if nothing to do.
if !ch.config.Features.CommentReply && !ch.config.Features.AutoReact {
ch.commentReplyDisabledOnce.Do(func() {
slog.Info("pancake: comment ignored because comment_reply and auto_react are disabled",
"page_id", ch.pageID,
"channel_name", ch.Name(),
"hint", "enable config.features.comment_reply or auto_react")
})
return
}
// Self-reply prevention: skip messages from the page itself.
if data.Message.SenderID == ch.pageID {
slog.Debug("pancake: skipping own page comment",
"page_id", ch.pageID, "sender_id", data.Message.SenderID)
return
}
// Skip assigned staff comments.
if isAssignedStaff(data.AssigneeIDs, data.Message.SenderID) {
slog.Debug("pancake: skipping assigned staff comment",
"page_id", ch.pageID, "sender_id", data.Message.SenderID)
return
}
if data.Message.SenderID == "" {
slog.Warn("pancake: comment missing sender_id", "msg_id", data.Message.ID)
return
}
if data.ConversationID == "" {
slog.Warn("pancake: comment missing conversation_id", "msg_id", data.Message.ID)
return
}
// Dedup by message ID (skip when empty to avoid shared slot).
var dedupKey string
if data.Message.ID != "" {
dedupKey = fmt.Sprintf("comment:%s", data.Message.ID)
if ch.isDup(dedupKey) {
slog.Debug("pancake: duplicate comment skipped", "msg_id", data.Message.ID)
return
}
}
// Auto-react BEFORE keyword filter — fires on all valid non-duplicate comments.
// Independent of comment_reply: reacts even if reply is disabled.
if ch.config.Features.AutoReact && ch.platform == "facebook" && data.Message.ID != "" {
if filterAutoReact(&ch.config, data.PostID, data.Message.SenderID) {
select {
case ch.reactSem <- struct{}{}:
go ch.reactCommentAsync(data.ConversationID, data.Message.ID)
default:
slog.Debug("pancake: react semaphore full, dropping reaction",
"page_id", ch.pageID, "comment_id", data.Message.ID)
}
} else {
// Rollout-phase Info log for misconfiguration diagnosis.
// TODO: downgrade to slog.Debug after ~2 weeks post-release.
slog.Info("pancake: auto-react filtered by allow/deny list",
"page_id", ch.pageID,
"comment_id", data.Message.ID,
"post_id", data.PostID,
"sender_id", data.Message.SenderID)
}
}
// Comment reply gate — independent of auto_react above.
if !ch.config.Features.CommentReply {
return
}
if !ch.filterComment(data.Message.Content) {
slog.Debug("pancake: comment filtered out",
"page_id", ch.pageID, "msg_id", data.Message.ID)
return
}
// Echo check before content enrichment.
if ch.isRecentOutboundEcho(data.ConversationID, data.Message.Content) {
slog.Debug("pancake: skipping comment outbound echo",
"page_id", ch.pageID, "msg_id", data.Message.ID)
return
}
// Build content — optionally enriched with post context.
content := ch.buildCommentContent(data)
metadata := map[string]string{
"pancake_mode": "comment",
"conversation_type": data.Type,
"reply_to_comment_id": data.Message.ID,
"sender_id": data.Message.SenderID,
"platform": data.Platform,
"conversation_id": data.ConversationID,
"message_id": dedupKey,
"display_name": channels.SanitizeDisplayName(data.Message.SenderName),
"page_name": ch.pageName,
}
if data.PostID != "" {
metadata["post_id"] = data.PostID
}
// ChatID = ConversationID: Pancake groups COMMENT conversations per sender per post.
ch.HandleMessage(
data.Message.SenderID,
data.ConversationID,
content,
nil,
metadata,
"direct",
)
slog.Debug("pancake: comment event published to bus",
"page_id", ch.pageID,
"conv_id", data.ConversationID,
"sender_id", data.Message.SenderID,
"platform", data.Platform,
)
}
// buildCommentContent assembles the comment content, optionally enriched with post context.
// Uses display name only (no senderID in content — senderID stays in metadata).
func (ch *Channel) buildCommentContent(data MessagingData) string {
commentText := stripHTML(data.Message.Content)
senderName := channels.SanitizeDisplayName(data.Message.SenderName)
senderPrefix := fmt.Sprintf("[From: %s]", senderName)
if !ch.config.CommentReplyOptions.IncludePostContext || ch.postFetcher == nil {
if commentText != "" {
return senderPrefix + " " + commentText
}
return senderPrefix
}
var sb strings.Builder
// Fetch post context best-effort — on failure, fall back to comment text only.
if data.PostID != "" {
post, err := ch.postFetcher.GetPost(ch.stopCtx, data.PostID)
if err != nil {
slog.Debug("pancake: post context fetch failed, using comment only",
"page_id", ch.pageID, "post_id", data.PostID, "err", err)
}
if err == nil && post != nil && post.Message != "" {
sb.WriteString("[Bai dang] ")
sb.WriteString(post.Message)
sb.WriteString("\n\n")
}
}
sb.WriteString("[Comment moi] ")
sb.WriteString(senderPrefix)
if commentText != "" {
sb.WriteString(" ")
sb.WriteString(commentText)
}
return sb.String()
}
// reactCommentAsync likes the comment on Facebook asynchronously.
// Respects channel shutdown via stopCtx with 5s cap; releases the semaphore slot on exit.
func (ch *Channel) reactCommentAsync(conversationID, messageID string) {
defer func() { <-ch.reactSem }()
ctx, cancel := context.WithTimeout(ch.stopCtx, 5*time.Second)
defer cancel()
if err := ch.apiClient.ReactComment(ctx, conversationID, messageID); err != nil {
slog.Warn("pancake: auto-react comment failed",
"comment_id", messageID, "conv_id", conversationID, "page_id", ch.pageID, "err", err)
return
}
slog.Debug("pancake: auto-reacted to comment",
"comment_id", messageID, "page_id", ch.pageID)
}
// filterAutoReact returns true when allow/deny lists permit reacting.
// Only called after the fast-path gate (Features.AutoReact, platform, msgID) passes.
// Nil AutoReactOptions = no scope filter = allow.
// Deny lists evaluated before allow lists.
func filterAutoReact(cfg *pancakeInstanceConfig, postID, senderID string) bool {
opts := cfg.AutoReactOptions
if opts == nil {
return true
}
if containsString(opts.DenyUserIDs, senderID) {
return false
}
if containsString(opts.DenyPostIDs, postID) {
return false
}
if len(opts.AllowUserIDs) > 0 && !containsString(opts.AllowUserIDs, senderID) {
return false
}
if len(opts.AllowPostIDs) > 0 && !containsString(opts.AllowPostIDs, postID) {
return false
}
return true
}
// containsString checks if target is present in ss after whitespace trim.
// Returns false on empty target to prevent false-positive match on blank sender.
func containsString(ss []string, target string) bool {
if target == "" {
return false
}
for _, s := range ss {
if strings.TrimSpace(s) == target {
return true
}
}
return false
}
// filterComment checks if the comment matches the configured filter.
// Returns true if the comment should be processed.
func (ch *Channel) filterComment(content string) bool {
switch ch.config.CommentReplyOptions.Filter {
case "keyword":
if len(ch.config.CommentReplyOptions.Keywords) == 0 {
// No keywords configured = block all (safe default).
slog.Warn("pancake: keyword filter active but no keywords configured, blocking all comments",
"page_id", ch.pageID)
return false
}
lower := strings.ToLower(content)
for _, kw := range ch.config.CommentReplyOptions.Keywords {
if strings.Contains(lower, strings.ToLower(kw)) {
return true
}
}
return false
default: // "all" or empty — process all comments
return true
}
}