mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 00:17:27 +00:00
335 lines
10 KiB
Go
335 lines
10 KiB
Go
package facebook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// Compile-time interface assertions.
|
|
var (
|
|
_ channels.Channel = (*Channel)(nil)
|
|
_ channels.WebhookChannel = (*Channel)(nil)
|
|
)
|
|
|
|
const (
|
|
webhookPath = "/v1/channels/facebook/webhook"
|
|
dedupTTL = 24 * time.Hour // matches Facebook's max retry window
|
|
dedupCleanEvery = 5 * time.Minute // how often to evict stale dedup entries
|
|
adminReplyCooldown = 5 * time.Minute
|
|
botEchoWindow = 15 * time.Second
|
|
)
|
|
|
|
// Channel implements channels.Channel and channels.WebhookChannel for Facebook Fanpage.
|
|
// Supports comment auto-reply, Messenger auto-reply, and first inbox DM.
|
|
type Channel struct {
|
|
*channels.BaseChannel
|
|
config facebookInstanceConfig
|
|
graphClient *GraphClient
|
|
webhookH *WebhookHandler
|
|
pageID string
|
|
|
|
// dedup prevents processing duplicate webhook deliveries.
|
|
// Value is the time.Time the event was first seen; entries are evicted after dedupTTL.
|
|
dedup sync.Map // eventKey(string) → time.Time
|
|
|
|
// firstInboxSent tracks which senders have already received a first-inbox DM (in-memory).
|
|
firstInboxSent sync.Map // senderID(string) → struct{}
|
|
|
|
// adminReplied tracks conversations where admin (page) sent a message recently.
|
|
// Bot skips auto-reply for these conversations to avoid duplicate responses.
|
|
adminReplied sync.Map // chatID(string) → time.Time
|
|
|
|
// botSentAt tracks when bot last sent a reply to each conversation.
|
|
// Used to distinguish bot replies from admin replies in Graph API checks.
|
|
botSentAt sync.Map // chatID(string) → time.Time
|
|
|
|
// postFetcher caches post content to enrich comment context.
|
|
postFetcher *PostFetcher
|
|
|
|
// stopCh + stopCtx serve complementary roles:
|
|
// stopCh for select{} in goroutines (runDedupCleaner), stopCtx for HTTP client cancellation.
|
|
stopCh chan struct{}
|
|
stopCtx context.Context
|
|
stopFn context.CancelFunc
|
|
}
|
|
|
|
// New creates a Facebook channel from parsed credentials and config.
|
|
func New(cfg facebookInstanceConfig, creds facebookCreds,
|
|
msgBus *bus.MessageBus, _ store.PairingStore) (*Channel, error) {
|
|
|
|
if creds.PageAccessToken == "" {
|
|
return nil, fmt.Errorf("facebook: page_access_token is required")
|
|
}
|
|
if cfg.PageID == "" {
|
|
return nil, fmt.Errorf("facebook: page_id is required")
|
|
}
|
|
if creds.AppSecret == "" {
|
|
return nil, fmt.Errorf("facebook: app_secret is required")
|
|
}
|
|
if creds.VerifyToken == "" {
|
|
return nil, fmt.Errorf("facebook: verify_token is required")
|
|
}
|
|
|
|
base := channels.NewBaseChannel(channels.TypeFacebook, msgBus, cfg.AllowFrom)
|
|
|
|
graphClient := NewGraphClient(creds.PageAccessToken, cfg.PageID)
|
|
postFetcher := NewPostFetcher(graphClient, cfg.PostContextCacheTTL)
|
|
|
|
stopCtx, stopFn := context.WithCancel(context.Background())
|
|
|
|
ch := &Channel{
|
|
BaseChannel: base,
|
|
config: cfg,
|
|
graphClient: graphClient,
|
|
pageID: cfg.PageID,
|
|
postFetcher: postFetcher,
|
|
stopCh: make(chan struct{}),
|
|
stopCtx: stopCtx,
|
|
stopFn: stopFn,
|
|
}
|
|
|
|
wh := NewWebhookHandler(creds.AppSecret, creds.VerifyToken)
|
|
wh.onComment = ch.handleCommentEvent
|
|
wh.onMessage = ch.handleMessagingEvent
|
|
ch.webhookH = wh
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
// Factory creates a Facebook Channel from DB instance data.
|
|
// Implements channels.ChannelFactory.
|
|
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
|
|
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
|
|
|
|
var c facebookCreds
|
|
if err := json.Unmarshal(creds, &c); err != nil {
|
|
return nil, fmt.Errorf("facebook: decode credentials: %w", err)
|
|
}
|
|
|
|
var ic facebookInstanceConfig
|
|
if len(cfg) > 0 {
|
|
if err := json.Unmarshal(cfg, &ic); err != nil {
|
|
return nil, fmt.Errorf("facebook: decode config: %w", err)
|
|
}
|
|
}
|
|
|
|
ch, err := New(ic, c, msgBus, pairingSvc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ch.SetName(name)
|
|
return ch, nil
|
|
}
|
|
|
|
// Start connects the channel: verifies the page token, subscribes webhooks, marks healthy.
|
|
func (ch *Channel) Start(ctx context.Context) error {
|
|
ch.MarkStarting("connecting to Facebook page")
|
|
|
|
if err := ch.graphClient.VerifyToken(ctx); err != nil {
|
|
ch.MarkFailed("token invalid", err.Error(), channels.ChannelFailureKindAuth, false)
|
|
return err
|
|
}
|
|
|
|
// Best-effort: subscribe app to webhooks.
|
|
if err := ch.graphClient.SubscribeApp(ctx); err != nil {
|
|
slog.Warn("facebook: webhook subscription failed (check app install on page)", "err", err)
|
|
}
|
|
|
|
globalRouter.register(ch)
|
|
ch.MarkHealthy("connected to page " + ch.pageID)
|
|
ch.SetRunning(true)
|
|
|
|
// Background goroutine: evict stale dedup entries to prevent memory growth.
|
|
go ch.runDedupCleaner()
|
|
|
|
slog.Info("facebook channel started", "page_id", ch.pageID, "name", ch.Name())
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully shuts down the channel.
|
|
func (ch *Channel) Stop(_ context.Context) error {
|
|
globalRouter.unregister(ch.pageID)
|
|
ch.stopFn() // cancel stopCtx → cancels inflight Graph API calls
|
|
close(ch.stopCh) // stop background goroutines
|
|
ch.SetRunning(false)
|
|
ch.MarkStopped("stopped")
|
|
slog.Info("facebook channel stopped", "page_id", ch.pageID, "name", ch.Name())
|
|
return nil
|
|
}
|
|
|
|
func (ch *Channel) adminRepliedRecently(chatID string, now time.Time) bool {
|
|
val, ok := ch.adminReplied.Load(chatID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
repliedAt, ok := val.(time.Time)
|
|
if !ok {
|
|
ch.adminReplied.Delete(chatID)
|
|
return false
|
|
}
|
|
if now.Sub(repliedAt) < adminReplyCooldown {
|
|
return true
|
|
}
|
|
ch.adminReplied.Delete(chatID)
|
|
return false
|
|
}
|
|
|
|
func (ch *Channel) isBotEcho(chatID string, eventAt time.Time) bool {
|
|
val, ok := ch.botSentAt.Load(chatID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
sentAt, ok := val.(time.Time)
|
|
if !ok {
|
|
ch.botSentAt.Delete(chatID)
|
|
return false
|
|
}
|
|
return eventAt.Sub(sentAt).Abs() < botEchoWindow
|
|
}
|
|
|
|
// Send delivers an outbound message. Dispatches to comment reply or Messenger based on fb_mode metadata.
|
|
func (ch *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
// NO_REPLY / suppressed-error path: empty content with no media means the
|
|
// caller wants downstream cleanup (placeholder, typing) but no user-visible
|
|
// message. Graph API rejects empty text, so short-circuit here — matches
|
|
// the pattern used by Telegram, Discord and Slack.
|
|
if msg.Content == "" && len(msg.Media) == 0 {
|
|
return nil
|
|
}
|
|
|
|
mode := msg.Metadata["fb_mode"]
|
|
|
|
switch mode {
|
|
case "messenger":
|
|
if ch.adminRepliedRecently(msg.ChatID, time.Now()) {
|
|
slog.Info("facebook: skipping bot reply (admin already responded)",
|
|
"chat_id", msg.ChatID)
|
|
return nil
|
|
}
|
|
|
|
text := FormatForMessenger(msg.Content)
|
|
parts := splitMessage(text, messengerMaxChars)
|
|
sentAt := time.Now()
|
|
ch.botSentAt.Store(msg.ChatID, sentAt)
|
|
sentAny := false
|
|
for _, part := range parts {
|
|
if _, err := ch.graphClient.SendMessage(ctx, msg.ChatID, part); err != nil {
|
|
if !sentAny {
|
|
ch.botSentAt.Delete(msg.ChatID)
|
|
}
|
|
ch.handleAPIError(err)
|
|
return err
|
|
}
|
|
sentAny = true
|
|
ch.botSentAt.Store(msg.ChatID, time.Now())
|
|
}
|
|
|
|
default: // "comment"
|
|
commentID := msg.Metadata["reply_to_comment_id"]
|
|
if commentID == "" {
|
|
return fmt.Errorf("facebook: reply_to_comment_id missing in outbound metadata")
|
|
}
|
|
text := FormatForComment(msg.Content)
|
|
if _, err := ch.graphClient.ReplyToComment(ctx, commentID, text); err != nil {
|
|
ch.handleAPIError(err)
|
|
return err
|
|
}
|
|
|
|
// First inbox: send a private DM after comment reply (best-effort).
|
|
if ch.config.Features.FirstInbox {
|
|
senderID := msg.Metadata["sender_id"]
|
|
if senderID != "" {
|
|
ch.sendFirstInbox(ctx, senderID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WebhookHandler returns the shared webhook path and the global router as handler.
|
|
// Only the first facebook instance mounts the route; others return ("", nil).
|
|
func (ch *Channel) WebhookHandler() (string, http.Handler) {
|
|
return globalRouter.webhookRoute()
|
|
}
|
|
|
|
// handleAPIError maps Graph API errors to channel health states.
|
|
func (ch *Channel) handleAPIError(err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
switch {
|
|
case IsAuthError(err):
|
|
ch.MarkFailed("token expired", err.Error(), channels.ChannelFailureKindAuth, false)
|
|
case IsPermissionError(err):
|
|
ch.MarkFailed("permission denied", err.Error(), channels.ChannelFailureKindAuth, false)
|
|
case IsRateLimitError(err):
|
|
ch.MarkDegraded("rate limited", err.Error(), channels.ChannelFailureKindNetwork, true)
|
|
default:
|
|
ch.MarkDegraded("api error", err.Error(), channels.ChannelFailureKindUnknown, true)
|
|
}
|
|
}
|
|
|
|
// sendFirstInbox sends a one-time private DM after a comment reply (best-effort).
|
|
func (ch *Channel) sendFirstInbox(ctx context.Context, senderID string) {
|
|
if _, alreadySent := ch.firstInboxSent.LoadOrStore(senderID, struct{}{}); alreadySent {
|
|
return
|
|
}
|
|
message := ch.config.FirstInboxMessage
|
|
if message == "" {
|
|
message = "Cảm ơn bạn đã comment! Mình có thể hỗ trợ thêm qua tin nhắn riêng."
|
|
}
|
|
if _, err := ch.graphClient.SendMessage(ctx, senderID, message); err != nil {
|
|
slog.Warn("facebook: first inbox send failed", "sender_id", senderID, "err", err)
|
|
ch.firstInboxSent.Delete(senderID) // allow retry on next comment
|
|
}
|
|
}
|
|
|
|
// runDedupCleaner evicts stale entries from dedup, adminReplied, and botSentAt
|
|
// maps every dedupCleanEvery to prevent unbounded memory growth.
|
|
func (ch *Channel) runDedupCleaner() {
|
|
ticker := time.NewTicker(dedupCleanEvery)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ch.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
now := time.Now()
|
|
ch.dedup.Range(func(k, v any) bool {
|
|
if t, ok := v.(time.Time); ok && now.Sub(t) > dedupTTL {
|
|
ch.dedup.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
ch.adminReplied.Range(func(k, v any) bool {
|
|
if t, ok := v.(time.Time); ok && now.Sub(t) > adminReplyCooldown {
|
|
ch.adminReplied.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
ch.botSentAt.Range(func(k, v any) bool {
|
|
if t, ok := v.(time.Time); ok && now.Sub(t) > botEchoWindow {
|
|
ch.botSentAt.Delete(k)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// isDup checks and records a dedup key. Returns true if the key was already seen.
|
|
func (ch *Channel) isDup(key string) bool {
|
|
_, loaded := ch.dedup.LoadOrStore(key, time.Now())
|
|
return loaded
|
|
}
|