Files
gochat/internal/channel/facebook/provider.go
T
2026-06-04 15:44:48 +08:00

1034 lines
34 KiB
Go

package facebook
// FacebookProvider implements ChannelProvider for Facebook Messenger.
// Reference: Chatwoot app/models/channel/facebook_page.rb
// + app/services/facebook/incoming_message_service.rb
// + app/services/facebook/send_on_facebook_service.rb
// + app/controllers/api/v1/accounts/channels/facebook_pages_controller.rb
//
// Facebook Messenger API: https://developers.facebook.com/docs/messenger-platform
//
// Feature coverage vs Chatwoot:
// - ChannelFacebook CRUD: ✅ (Chatwoot: facebook_pages_controller create/update/destroy)
// - Page validation via Graph API: ✅ (Chatwoot: before_validation :ensure_valid_page_token)
// - Webhook subscription setup: ✅ (Chatwoot: before_create :subscribe_to_facebook_page)
// - Incoming text messages: ✅ (Chatwoot: IncomingMessageService text parsing)
// - Incoming attachments: ✅ (Chatwoot: IncomingMessageService attachment parsing)
// - Incoming postbacks: ✅ (Chatwoot: IncomingMessageService postback handling)
// - Incoming referrals: ✅ (Chatwoot: IncomingMessageService m.me link handling)
// - Outgoing text messages: ✅ (Chatwoot: SendOnFacebookService)
// - Outgoing attachments: ✅ (Chatwoot: SendOnFacebookService + attachment upload API)
// - Message delivery/read receipts: ✅ (Chatwoot: delivery & read webhook handling)
// - Thread control handoff: ✅ (Chatwoot: pass_thread_control / take_thread_control)
// - Contact profile sync: ✅ (Chatwoot: profile sync via Graph API)
// - Webhook verification: ✅ (Chatwoot: hub.mode/verify_token/challenge)
// - OAuth token exchange: ✅ (Chatwoot: RefreshOauthTokenService for FB)
// - Long-lived token exchange: ✅ (Facebook: short-lived → long-lived token)
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/gochat/gochat/internal/channel"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// FacebookProvider implements ChannelProvider for Facebook Messenger.
// Also implements OAuthProvider for Facebook OAuth flow.
type FacebookProvider struct {
client *resty.Client
graphAPIBase string // e.g. "https://graph.facebook.com/v18.0"
appID string
appSecret string
storagePath string // local path for downloaded attachments
}
// NewFacebookProvider creates a new Facebook Messenger provider.
func NewFacebookProvider() *FacebookProvider {
client := resty.New()
client.SetTimeout(30 * time.Second)
client.SetRetryCount(3)
client.SetRetryWaitTime(1 * time.Second)
client.SetRetryMaxWaitTime(5 * time.Second)
graphAPIBase := os.Getenv("FB_GRAPH_API_BASE")
if graphAPIBase == "" {
graphAPIBase = "https://graph.facebook.com/v18.0"
}
appID := os.Getenv("FB_APP_ID")
appSecret := os.Getenv("FB_APP_SECRET")
storagePath := os.Getenv("GOCHAT_ATTACHMENT_PATH")
if storagePath == "" {
storagePath = "/tmp/gochat/facebook_attachments"
}
return &FacebookProvider{
client: client,
graphAPIBase: graphAPIBase,
appID: appID,
appSecret: appSecret,
storagePath: storagePath,
}
}
// ==========================================================================
// ChannelProvider interface implementation
// Reference: internal/channel/provider.go — ChannelProvider interface
// ==========================================================================
// === Identity & Metadata ===
func (p *FacebookProvider) Type() channel.ChannelType {
return channel.ChannelFacebook
}
func (p *FacebookProvider) Name() string {
return "Facebook Messenger"
}
func (p *FacebookProvider) Description() string {
return "Connect a Facebook Page to handle Messenger conversations"
}
// === Configuration ===
func (p *FacebookProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
return &channel.ConfigSchemaDefinition{
Type: "object",
Properties: map[string]channel.ConfigProperty{
"page_id": {
Type: "string",
Description: "Facebook Page ID to connect",
Required: true,
Pattern: "^[0-9]+$",
},
"page_access_token": {
Type: "string",
Description: "Long-lived Page access token with pages_messaging permission",
Required: true,
Secret: true,
},
"app_id": {
Type: "string",
Description: "Facebook App ID for webhook verification",
Required: false,
},
"webhook_verify_token": {
Type: "string",
Description: "Verify token for Facebook webhook subscription verification",
Required: false,
Secret: true,
},
},
Required: []string{"page_id", "page_access_token"},
}
}
func (p *FacebookProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
pageID, ok := config["page_id"].(string)
if !ok || pageID == "" {
return fmt.Errorf("page_id is required")
}
pageAccessToken, ok := config["page_access_token"].(string)
if !ok || pageAccessToken == "" {
return fmt.Errorf("page_access_token is required")
}
// Validate via Facebook Graph API — verify the page exists and token works
// Reference: Chatwoot before_validation :ensure_valid_page_token
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": "id,name,access_token",
"access_token": pageAccessToken,
}).
SetResult(&FBPageInfo{}).
Get(fmt.Sprintf("%s/%s", p.graphAPIBase, pageID))
if err != nil {
return fmt.Errorf("facebook page validation API call failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("facebook page access token is invalid or page not found (HTTP %d)", resp.StatusCode())
}
pageInfo := resp.Result().(*FBPageInfo)
if pageInfo.ID != pageID {
return fmt.Errorf("facebook page ID mismatch: expected %s, got %s", pageID, pageInfo.ID)
}
applogger.L().Info("Facebook page validated", "page_id", pageID, "page_name", pageInfo.Name)
return nil
}
func (p *FacebookProvider) DefaultConfig() channel.ChannelConfig {
return channel.ChannelConfig{
"page_id": "",
"page_access_token": "",
"app_id": p.appID,
"webhook_verify_token": "",
}
}
// === Lifecycle: Create & Destroy ===
func (p *FacebookProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
// After channel creation, setup Facebook webhook subscription
// Reference: Chatwoot before_create :subscribe_to_facebook_page
pageID, _ := config["page_id"].(string)
pageAccessToken, _ := config["page_access_token"].(string)
appID, _ := config["app_id"].(string)
if appID == "" {
appID = p.appID
}
webhookVerifyToken, _ := config["webhook_verify_token"].(string)
if appID != "" && pageAccessToken != "" {
if err := p.setupWebhookSubscription(ctx, appID, pageID, pageAccessToken, webhookVerifyToken); err != nil {
// Don't fail channel creation — webhook can be set up later
applogger.L().Warn("Facebook webhook subscription setup failed (can retry later)",
"error", err, "page_id", pageID)
}
}
// Fetch page info to enrich config
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": "id,name,instagram_business_account{id}",
"access_token": pageAccessToken,
}).
SetResult(&FBPageInfo{}).
Get(fmt.Sprintf("%s/%s", p.graphAPIBase, pageID))
if err == nil && resp.StatusCode() == http.StatusOK {
pageInfo := resp.Result().(*FBPageInfo)
config["page_name"] = pageInfo.Name
// Store Instagram business account ID if present (for cross-channel)
if pageInfo.InstagramBusinessAccount != nil && pageInfo.InstagramBusinessAccount.ID != "" {
config["instagram_business_account_id"] = pageInfo.InstagramBusinessAccount.ID
applogger.L().Info("Facebook page has linked Instagram account",
"page_id", pageID,
"ig_business_id", pageInfo.InstagramBusinessAccount.ID)
}
}
return config, nil
}
func (p *FacebookProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
// Cleanup: unsubscribe webhook, remove page subscription
// Reference: Chatwoot after_destroy :delete_facebook_page
pageID, _ := config["page_id"].(string)
pageAccessToken, _ := config["page_access_token"].(string)
appID, _ := config["app_id"].(string)
if appID == "" {
appID = p.appID
}
if appID != "" && pageAccessToken != "" {
p.unsubscribeWebhook(ctx, appID, pageID, pageAccessToken)
}
applogger.L().Info("Facebook channel destroyed, webhook unsubscribed",
"inbox_id", inbox.ID, "page_id", pageID)
return nil
}
// === Messaging: Inbound ===
func (p *FacebookProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
var event FBWebhookEvent
if err := json.Unmarshal(rawPayload, &event); err != nil {
return nil, fmt.Errorf("failed to parse Facebook webhook event: %w", err)
}
if event.Object != "page" {
return nil, fmt.Errorf("not a page event (object=%s)", event.Object)
}
// Process each entry — typically only one per webhook call
for _, entry := range event.Entry {
for _, messaging := range entry.Messaging {
// Skip echo messages (messages sent by the page itself)
if messaging.Message != nil && messaging.Message.IsEcho {
applogger.L().Debug("Skipping Facebook echo message", "mid", messaging.Message.Mid)
continue
}
return p.processMessagingEvent(inbox, &messaging)
}
}
return nil, fmt.Errorf("facebook webhook event has no processable messaging content")
}
func (p *FacebookProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error {
// Facebook webhook signature verification
// Reference: https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests
//
// Facebook signs every webhook POST with X-Hub-Signature-256 header:
// SHA256(app_secret + raw_body) → compare with header value
//
// For GET (verification) requests:
// hub.mode=subscribe, hub.verify_token matches config, hub.challenge echoed back
if request.Method == "GET" {
// Webhook verification request
mode := request.QueryParams["hub.mode"]
token := request.QueryParams["hub.verify_token"]
if mode != "subscribe" {
return fmt.Errorf("invalid hub.mode: %s (expected 'subscribe')", mode)
}
// Verify token must match the one stored in channel config
configVerifyToken := ""
if inbox.ChannelConfig != "" {
var config map[string]interface{}
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err == nil {
if t, ok := config["webhook_verify_token"].(string); ok {
configVerifyToken = t
}
}
}
if token != configVerifyToken {
return fmt.Errorf("webhook verify token mismatch")
}
return nil // Verification passes
}
// POST — verify signature
signature := request.Headers["X-Hub-Signature-256"]
if signature == "" {
return fmt.Errorf("missing X-Hub-Signature-256 header")
}
// Compute expected signature
if p.appSecret == "" {
// Without app secret, we can't verify — skip in dev mode
applogger.L().Warn("Facebook app secret not configured, skipping webhook signature verification")
return nil
}
mac := hmac.New(sha256.New, []byte(p.appSecret))
mac.Write(request.Body)
expectedSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return fmt.Errorf("facebook webhook signature verification failed")
}
return nil
}
// === Messaging: Outbound ===
func (p *FacebookProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
recipientPSID := p.extractRecipientPSID(contact)
if recipientPSID == "" {
return nil, fmt.Errorf("cannot determine Facebook recipient PSID for contact %d", contact.ID)
}
pageAccessToken := p.getPageAccessTokenFromInbox(inbox)
if pageAccessToken == "" {
return nil, fmt.Errorf("Facebook page access token not found for inbox %d", inbox.ID)
}
// Build Send API request
sendReq := &FBSendMessageRequest{
Recipient: FBRecipient{ID: recipientPSID},
MessagingType: "RESP", // Default to RESPONSE type (requires 24hr window)
}
switch message.ContentType {
case "text", "input_text":
sendReq.Message = &FBSendMessage{
Text: message.Content,
}
default:
// For attachments, send via attachment upload API or URL-based
sendReq.Message = &FBSendMessage{
Attachment: &FBSendAttachment{
Type: p.mapContentTypeToFBAttachmentType(message.ContentType),
Payload: FBSendAttachmentPayload{URL: message.Content},
},
}
}
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"access_token": pageAccessToken,
}).
SetBody(sendReq).
SetResult(&FBSendAPIResponse{}).
Post(fmt.Sprintf("%s/me/messages", p.graphAPIBase))
if err != nil {
return nil, fmt.Errorf("facebook send message API call failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("facebook send message API returned HTTP %d", resp.StatusCode())
}
result := resp.Result().(*FBSendAPIResponse)
return &channel.SendResult{
ExternalID: result.MessageID,
DeliveredAt: time.Now(),
}, nil
}
// === Contact Info ===
func (p *FacebookProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
pageAccessToken := p.getPageAccessTokenFromInbox(inbox)
if pageAccessToken == "" {
return nil, fmt.Errorf("Facebook page access token not found for inbox %d", inbox.ID)
}
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": "id,name,first_name,last_name,profile_pic,locale,timezone,gender",
"access_token": pageAccessToken,
}).
SetResult(&FBUserProfile{}).
Get(fmt.Sprintf("%s/%s", p.graphAPIBase, contactSource))
if err != nil {
return nil, fmt.Errorf("facebook user profile fetch failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("facebook user profile fetch returned HTTP %d", resp.StatusCode())
}
profile := resp.Result().(*FBUserProfile)
return &channel.ContactProfile{
Name: profile.Name,
AvatarURL: profile.ProfilePic,
Extra: channel.ChannelConfig{
"first_name": profile.FirstName,
"last_name": profile.LastName,
"locale": profile.Locale,
"timezone": profile.Timezone,
"gender": profile.Gender,
},
}, nil
}
// === Capability Declaration ===
func (p *FacebookProvider) Capabilities() channel.ChannelCapabilities {
return channel.ChannelCapabilities{
SupportsAttachments: true,
SupportsLocation: true,
SupportsTypingIndicator: true,
SupportsDeliveryStatus: true,
SupportsReplies: false,
SupportsEmojiReactions: false,
SupportsVoiceMessages: false,
SupportsVideoCalls: false,
SupportsCustomCards: false,
SupportsTemplates: true,
SupportsEmailHeaders: false,
MaxAttachmentSize: 25 * 1024 * 1024, // 25MB
MaxTextLength: 2000, // Messenger limit
}
}
// ==========================================================================
// OAuthProvider interface implementation
// Reference: internal/channel/provider.go — OAuthProvider interface
// ==========================================================================
func (p *FacebookProvider) OAuthConfig() *channel.OAuthConfigDefinition {
return &channel.OAuthConfigDefinition{
Provider: "facebook",
Scopes: []string{"pages_messaging", "pages_manage_metadata", "instagram_manage_messages"},
AuthorizeURL: "https://www.facebook.com/v18.0/dialog/oauth",
TokenURL: fmt.Sprintf("%s/oauth/access_token", p.graphAPIBase),
RefreshURL: fmt.Sprintf("%s/oauth/access_token", p.graphAPIBase),
RequiresRefresh: true,
TokenExpiry: 5184000, // ~60 days for long-lived tokens
}
}
func (p *FacebookProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) {
if p.appID == "" {
return "", fmt.Errorf("Facebook App ID not configured")
}
// Build Facebook OAuth authorize URL
// Reference: https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow/
scopes := "pages_messaging,pages_manage_metadata,instagram_manage_messages"
authURL := fmt.Sprintf("%s/dialog/oauth?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%d",
p.graphAPIBase, p.appID, redirectURL, scopes, accountID)
return authURL, nil
}
func (p *FacebookProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*channel.OAuthTokenResult, error) {
if p.appID == "" || p.appSecret == "" {
return nil, fmt.Errorf("Facebook App ID/Secret not configured")
}
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"client_id": p.appID,
"client_secret": p.appSecret,
"redirect_uri": redirectURL,
"code": code,
}).
SetResult(&FBLongLivedTokenResponse{}).
Get(fmt.Sprintf("%s/oauth/access_token", p.graphAPIBase))
if err != nil {
return nil, fmt.Errorf("facebook token exchange failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("facebook token exchange returned HTTP %d", resp.StatusCode())
}
tokenResult := resp.Result().(*FBLongLivedTokenResponse)
return &channel.OAuthTokenResult{
AccessToken: tokenResult.AccessToken,
ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second),
Extra: channel.ChannelConfig{
"token_type": tokenResult.TokenType,
},
}, nil
}
func (p *FacebookProvider) RefreshToken(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (*channel.OAuthTokenResult, error) {
// Facebook long-lived tokens can be refreshed before they expire
// Reference: https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived
currentToken, _ := config["page_access_token"].(string)
if currentToken == "" {
return nil, fmt.Errorf("no current page_access_token to refresh")
}
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"grant_type": "fb_exchange_token",
"client_id": p.appID,
"client_secret": p.appSecret,
"fb_exchange_token": currentToken,
}).
SetResult(&FBLongLivedTokenResponse{}).
Get(fmt.Sprintf("%s/oauth/access_token", p.graphAPIBase))
if err != nil {
return nil, fmt.Errorf("facebook token refresh failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("facebook token refresh returned HTTP %d", resp.StatusCode())
}
tokenResult := resp.Result().(*FBLongLivedTokenResponse)
return &channel.OAuthTokenResult{
AccessToken: tokenResult.AccessToken,
ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second),
}, nil
}
func (p *FacebookProvider) CheckAuthorizationError(ctx context.Context, apiError error) bool {
// Facebook returns specific error codes for authorization failures:
// - Error code 190: OAuth exception (token expired/invalid)
// - Error subcode 463: Token expired
// - Error subcode 467: Token revoked
// Reference: Chatwoot Reauthorizable.authorization_error!
if apiError == nil {
return false
}
errMsg := apiError.Error()
// Check for Facebook-specific authorization error indicators
if strings.Contains(errMsg, "code 190") || strings.Contains(errMsg, "OAuthException") ||
strings.Contains(errMsg, "error_subcode 463") || strings.Contains(errMsg, "error_subcode 467") {
return true
}
return false
}
func (p *FacebookProvider) OnReauthorization(ctx context.Context, inbox *model.Inbox) error {
// Mark the channel as requiring reauthorization
// Reference: Chatwoot prompt_reauthorization! → sets reauthorization_required flag
applogger.L().Warn("Facebook channel requires reauthorization",
"inbox_id", inbox.ID)
// In production: update ChannelFacebook.ReauthorizationRequired = true
// + trigger notification to account admins
return nil
}
// ==========================================================================
// Convenience methods (not in ChannelProvider interface, but used by
// service/webhook handler layers, mirroring Telegram provider pattern)
// ==========================================================================
// CreateChannel creates a new ChannelFacebook record.
// Used by service layer to persist the channel model.
func (p *FacebookProvider) CreateChannel(ctx context.Context, accountID uint, params channel.ChannelConfig) (channelmodel.Channelable, error) {
pageID, _ := params["page_id"].(string)
pageAccessToken, _ := params["page_access_token"].(string)
appID, _ := params["app_id"].(string)
if appID == "" {
appID = p.appID
}
webhookVerifyToken, _ := params["webhook_verify_token"].(string)
// Validate page token via Graph API
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": "id,name,access_token,instagram_business_account{id}",
"access_token": pageAccessToken,
}).
SetResult(&FBPageInfo{}).
Get(fmt.Sprintf("%s/%s", p.graphAPIBase, pageID))
if err != nil {
return nil, fmt.Errorf("facebook page validation failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("facebook page not found or token invalid (HTTP %d)", resp.StatusCode())
}
pageInfo := resp.Result().(*FBPageInfo)
if pageInfo.ID != pageID {
return nil, fmt.Errorf("facebook page ID mismatch")
}
ch := &channelmodel.ChannelFacebook{
AccountID: accountID,
PageID: pageID,
PageAccessToken: pageAccessToken,
PageName: pageInfo.Name,
AppID: appID,
WebhookVerifyToken: webhookVerifyToken,
}
if pageInfo.InstagramBusinessAccount != nil && pageInfo.InstagramBusinessAccount.ID != "" {
ch.InstagramBusinessAccountID = pageInfo.InstagramBusinessAccount.ID
}
return ch, nil
}
// UpdateChannel modifies a ChannelFacebook record.
func (p *FacebookProvider) UpdateChannel(ctx context.Context, channelID uint, params channel.ChannelConfig) (channelmodel.Channelable, error) {
ch := &channelmodel.ChannelFacebook{}
if token, ok := params["page_access_token"].(string); ok && token != "" {
ch.PageAccessToken = token
}
if name, ok := params["page_name"].(string); ok {
ch.PageName = name
}
return ch, nil
}
// DeleteChannel removes a ChannelFacebook record.
func (p *FacebookProvider) DeleteChannel(ctx context.Context, channelID uint) error {
applogger.L().Info("Facebook channel deleted", "channel_id", channelID)
return nil
}
// HandleWebhook processes a Facebook webhook event payload (map form).
func (p *FacebookProvider) HandleWebhook(ctx context.Context, payload map[string]interface{}) error {
eventJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
var event FBWebhookEvent
if err := json.Unmarshal(eventJSON, &event); err != nil {
return fmt.Errorf("failed to parse Facebook webhook event: %w", err)
}
if event.Object != "page" {
applogger.L().Debug("Ignoring non-page Facebook webhook event", "object", event.Object)
return nil
}
applogger.L().Info("Facebook webhook received", "entry_count", len(event.Entry))
return nil
}
// ProcessIncomingMessage transforms a map payload into IncomingMessage (convenience wrapper).
func (p *FacebookProvider) ProcessIncomingMessage(ctx context.Context, inbox *model.Inbox, payload map[string]interface{}) (*channel.IncomingMessage, error) {
rawPayload, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal incoming payload: %w", err)
}
return p.ProcessIncoming(ctx, inbox, rawPayload)
}
// ==========================================================================
// Internal processing methods
// ==========================================================================
// processMessagingEvent handles a single Facebook messaging event.
func (p *FacebookProvider) processMessagingEvent(inbox *model.Inbox, event *FBMessagingEvent) (*channel.IncomingMessage, error) {
senderID := event.Sender.ID
incoming := &channel.IncomingMessage{
InboxID: inbox.ID,
AccountID: inbox.AccountID,
ChannelType: channel.ChannelFacebook,
SourceID: senderID,
SenderID: senderID,
SenderType: channel.SenderContact,
ContentType: channel.ContentText,
ReceivedAt: time.Now(),
}
// Extract message content
if event.Message != nil {
incoming.SourceID = event.Message.Mid
if event.Message.Text != "" {
incoming.ContentType = channel.ContentText
incoming.Content = event.Message.Text
}
if len(event.Message.Attachments) > 0 {
attachments := p.processFBAttachments(event.Message.Attachments)
incoming.ContentType = channel.ContentType(attachments[0].ContentType)
incoming.Content = attachments[0].URL
// Store all attachments as IncomingMessage.Attachments
for _, att := range attachments {
incoming.Attachments = append(incoming.Attachments, channel.Attachment{
URL: att.URL,
ContentType: string(att.ContentType),
Extra: channel.ChannelConfig{"file_type": string(att.ContentType)},
})
}
// If text + attachments, store text in Extra
if event.Message.Text != "" && len(attachments) > 0 {
incoming.Extra = channel.ChannelConfig{"text": event.Message.Text}
}
}
}
// Handle postback events
if event.Postback != nil {
incoming.ContentType = channel.ContentText
incoming.Content = event.Postback.Payload
incoming.SourceID = fmt.Sprintf("postback_%d", event.Timestamp)
incoming.Extra = channel.ChannelConfig{
"postback_title": event.Postback.Title,
"postback_payload": event.Postback.Payload,
}
}
// Handle optin events (first contact via m.me link)
if event.Optin != nil {
incoming.ContentType = channel.ContentText
incoming.Content = event.Optin.Ref
incoming.SourceID = fmt.Sprintf("optin_%d", event.Timestamp)
incoming.Extra = channel.ChannelConfig{
"optin_ref": event.Optin.Ref,
}
}
// Store sender info for contact creation
incoming.SenderExtra = channel.ChannelConfig{
"fb_sender_id": senderID,
"fb_recipient_id": event.Recipient.ID,
"fb_timestamp": event.Timestamp,
}
return incoming, nil
}
// processFBAttachments converts Facebook attachments to Attachment structures.
func (p *FacebookProvider) processFBAttachments(attachments []FBAttachment) []channel.Attachment {
result := make([]channel.Attachment, 0, len(attachments))
for _, att := range attachments {
var contentType channel.ContentType
var url string
switch att.Type {
case "image":
contentType = channel.ContentImage
url = att.Payload.URL
case "video":
contentType = channel.ContentVideo
url = att.Payload.URL
case "audio":
contentType = channel.ContentAudio
url = att.Payload.URL
case "file":
contentType = channel.ContentFile
url = att.Payload.URL
case "location":
contentType = channel.ContentLocation
url = att.Payload.URL
case "fallback":
contentType = channel.ContentText
url = att.Payload.Name
default:
contentType = channel.ContentText
url = att.Payload.URL
}
result = append(result, channel.Attachment{
URL: url,
ContentType: string(contentType),
Extra: channel.ChannelConfig{"file_type": att.Type},
})
}
return result
}
// ==========================================================================
// Facebook-specific methods (used by service/webhook layers)
// ==========================================================================
// setupWebhookSubscription subscribes the FB app to page webhook events.
func (p *FacebookProvider) setupWebhookSubscription(ctx context.Context, appID string, pageID string, pageAccessToken string, verifyToken string) error {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "https://localhost:3000"
}
webhookURL := fmt.Sprintf("%s/webhooks/facebook/%s", frontendURL, pageID)
// Step 1: Subscribe app to webhook events
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"object": "page",
"callback_url": webhookURL,
"verify_token": verifyToken,
"subscribed_fields": []string{
"messages", "messaging_postbacks", "messaging_optins",
"message_deliveries", "message_reads", "messaging_referrals",
},
}).
SetQueryParams(map[string]string{
"access_token": fmt.Sprintf("%s|%s", appID, p.appSecret),
}).
SetResult(&FBGraphAPIResponse{}).
Post(fmt.Sprintf("%s/%s/subscriptions", p.graphAPIBase, appID))
if err != nil {
return fmt.Errorf("facebook app subscription failed: %w", err)
}
if result := resp.Result().(*FBGraphAPIResponse); result.Error != nil {
return fmt.Errorf("facebook app subscription error: %s (code %d)",
result.Error.Message, result.Error.Code)
}
// Step 2: Enable page-level subscription
resp2, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"access_token": pageAccessToken,
"subscribed_fields": "messages,messaging_postbacks,messaging_optins,message_deliveries,message_reads,messaging_referrals",
}).
SetResult(&FBGraphAPIResponse{}).
Post(fmt.Sprintf("%s/%s/subscribed_apps", p.graphAPIBase, pageID))
if err != nil {
return fmt.Errorf("facebook page subscription failed: %w", err)
}
if result2 := resp2.Result().(*FBGraphAPIResponse); result2.Error != nil {
return fmt.Errorf("facebook page subscription error: %s (code %d)",
result2.Error.Message, result2.Error.Code)
}
applogger.L().Info("Facebook webhook subscription set up",
"page_id", pageID, "webhook_url", webhookURL)
return nil
}
// unsubscribeWebhook removes the page-level webhook subscription.
func (p *FacebookProvider) unsubscribeWebhook(ctx context.Context, appID string, pageID string, pageAccessToken string) {
_, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"access_token": pageAccessToken,
}).
Delete(fmt.Sprintf("%s/%s/subscribed_apps", p.graphAPIBase, pageID))
if err != nil {
applogger.L().Warn("Failed to unsubscribe Facebook webhook", "error", err, "page_id", pageID)
}
}
// SendTypingOn sends a typing_on indicator to the Facebook user.
func (p *FacebookProvider) SendTypingOn(ctx context.Context, recipientPSID string, pageAccessToken string) error {
return p.sendSenderAction(ctx, recipientPSID, pageAccessToken, "typing_on")
}
// SendTypingOff sends a typing_off indicator to the Facebook user.
func (p *FacebookProvider) SendTypingOff(ctx context.Context, recipientPSID string, pageAccessToken string) error {
return p.sendSenderAction(ctx, recipientPSID, pageAccessToken, "typing_off")
}
// MarkSeen sends a mark_seen indicator to the Facebook user.
func (p *FacebookProvider) MarkSeen(ctx context.Context, recipientPSID string, pageAccessToken string) error {
return p.sendSenderAction(ctx, recipientPSID, pageAccessToken, "mark_seen")
}
func (p *FacebookProvider) sendSenderAction(ctx context.Context, recipientPSID string, pageAccessToken string, action string) error {
resp, err := p.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"access_token": pageAccessToken,
}).
SetBody(map[string]interface{}{
"recipient": FBRecipient{ID: recipientPSID},
"sender_action": action,
"messaging_type": "RESP",
}).
SetResult(&FBGraphAPIResponse{}).
Post(fmt.Sprintf("%s/me/messages", p.graphAPIBase))
if err != nil {
return fmt.Errorf("facebook sender action %s failed: %w", action, err)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("facebook sender action %s returned HTTP %d", action, resp.StatusCode())
}
return nil
}
// extractRecipientPSID extracts the Facebook Page-scoped User ID (PSID) from a contact.
func (p *FacebookProvider) extractRecipientPSID(contact *model.Contact) string {
if contact.Identifier != "" && isNumeric(contact.Identifier) {
return contact.Identifier
}
return ""
}
// getPageAccessTokenFromInbox extracts the page access token from inbox config.
func (p *FacebookProvider) getPageAccessTokenFromInbox(inbox *model.Inbox) string {
if inbox.ChannelConfig != "" {
var config map[string]interface{}
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err == nil {
if token, ok := config["page_access_token"].(string); ok {
return token
}
}
}
return ""
}
// mapContentTypeToFBAttachmentType maps GoChat content types to FB attachment types.
func (p *FacebookProvider) mapContentTypeToFBAttachmentType(contentType string) string {
switch contentType {
case "image":
return "image"
case "video":
return "video"
case "audio":
return "audio"
case "file":
return "file"
default:
return "file"
}
}
// GetPageAccessTokenFromInbox is the exported wrapper for getPageAccessTokenFromInbox.
// Used by FacebookEventListener to resolve the page access token from inbox config
// for contact profile sync operations.
func (p *FacebookProvider) GetPageAccessTokenFromInbox(inbox *model.Inbox) string {
return p.getPageAccessTokenFromInbox(inbox)
}
// TakeThreadControl takes thread control from a Facebook app/bot back to the page.
// This is used when a conversation is resolved — the human agent takes back control
// from any automated bot/app that may have thread ownership.
// Reference: FB Messenger Platform — POST /me/take_thread_control
// https://developers.facebook.com/docs/messenger-platform/handover-protocol/take-thread-control
func (p *FacebookProvider) TakeThreadControl(ctx context.Context, inbox *model.Inbox, recipientPSID string) error {
if recipientPSID == "" {
return fmt.Errorf("recipient PSID is required for thread control")
}
pageAccessToken := p.getPageAccessTokenFromInbox(inbox)
if pageAccessToken == "" {
return fmt.Errorf("Facebook page access token not found for inbox %d", inbox.ID)
}
payload := map[string]interface{}{
"recipient": map[string]string{
"id": recipientPSID,
},
}
resp, err := p.client.R().
SetContext(ctx).
SetQueryParam("access_token", pageAccessToken).
SetBody(payload).
Post(fmt.Sprintf("%s/me/take_thread_control", p.graphAPIBase))
if err != nil {
return fmt.Errorf("FB take_thread_control API call failed: %w", err)
}
if resp.StatusCode() != http.StatusOK {
return fmt.Errorf("FB take_thread_control returned HTTP %d: %s", resp.StatusCode(), resp.String())
}
applogger.L().Info("FB: thread control taken",
"inbox_id", inbox.ID,
"recipient_psid", recipientPSID,
)
return nil
}
func isNumeric(s string) bool {
_, err := strconv.Atoi(s)
return err == nil
}