mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +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>
256 lines
7.9 KiB
Go
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
|
|
}
|
|
}
|