1034 lines
34 KiB
Go
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
|
|
}
|