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 }