package twitter // TwitterProvider implements ChannelProvider for Twitter/X. // Reference: Chatwoot app/models/channel/twitter_profile.rb // + app/controllers/api/v1/accounts/channels/twitter_controller.rb // + app/services/twitter/incoming_message_service.rb // + app/services/twitter/send_on_twitter_service.rb // // Twitter API v2 uses OAuth 2.0 with PKCE for user authentication. // Legacy Twitter API uses OAuth 1.0a — supported for backward compatibility. // // Twitter Account Activity API (webhooks): // - CRC (Challenge-Response Check) for webhook validation // - Event subscriptions for DMs, mentions, tweets // // Feature coverage vs Chatwoot: // - ChannelTwitter CRUD: ✅ (Chatwoot: twitter_controller create/update/destroy) // - OAuth 2.0 PKCE flow: ✅ (Twitter API v2) // - OAuth 1.0a legacy flow: ✅ (backward compatibility) // - Incoming DMs: ✅ (Chatwoot: IncomingMessageService DM parsing) // - Incoming mentions: ✅ (Chatwoot: IncomingMessageService tweet mention) // - Incoming tweets: ✅ (Chatwoot: IncomingMessageService tweet parsing) // - Outgoing DMs: ✅ (Chatwoot: SendOnTwitterService) // - Outgoing tweets: ✅ (Chatwoot: SendOnTwitterService) // - Webhook CRC validation: ✅ (Twitter Account Activity API) // - Webhook subscription management: ✅ import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" ) // TwitterOAuth2Config holds OAuth 2.0 configuration for Twitter API v2. type TwitterOAuth2Config struct { ClientID string ClientSecret string // Not used in PKCE flow but kept for confidential client auth RedirectURL string Scopes string // e.g. "tweet.read users.read dm.read dm.write" } // TwitterProvider implements ChannelProvider for Twitter/X. type TwitterProvider struct { client *resty.Client apiBase string // e.g. "https://api.twitter.com/2" oauth2Config TwitterOAuth2Config oauth1APIKey string // OAuth 1.0a API key (legacy) oauth1APISecret string // OAuth 1.0a API secret (legacy) webhookEnv string // Account Activity API environment name crcSecret string // CRC webhook validation secret storagePath string } // NewTwitterProvider creates a new Twitter provider. func NewTwitterProvider(cfg TwitterOAuth2Config) *TwitterProvider { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second) apiBase := os.Getenv("TWITTER_API_BASE") if apiBase == "" { apiBase = "https://api.twitter.com/2" } oauth1APIKey := os.Getenv("TWITTER_OAUTH1_API_KEY") oauth1APISecret := os.Getenv("TWITTER_OAUTH1_API_SECRET") webhookEnv := os.Getenv("TWITTER_WEBHOOK_ENV") if webhookEnv == "" { webhookEnv = "gochat" } crcSecret := os.Getenv("TWITTER_CRC_SECRET") if crcSecret == "" { // Generate a random CRC secret if not configured b := make([]byte, 32) rand.Read(b) crcSecret = base64.StdEncoding.EncodeToString(b) } storagePath := os.Getenv("GOCHAT_ATTACHMENT_PATH") if storagePath == "" { storagePath = "/tmp/gochat/twitter_attachments" } return &TwitterProvider{ client: client, apiBase: apiBase, oauth2Config: cfg, oauth1APIKey: oauth1APIKey, oauth1APISecret: oauth1APISecret, webhookEnv: webhookEnv, crcSecret: crcSecret, storagePath: storagePath, } } // --- ChannelProvider identity & metadata methods --- // Type returns the channel type identifier. func (p *TwitterProvider) Type() channel.ChannelType { return channel.ChannelType(model.InboxChannelTypeTwitter) } // Name returns the human-readable channel name. func (p *TwitterProvider) Name() string { return "Twitter" } // Description returns a short description of the channel. func (p *TwitterProvider) Description() string { return "Twitter/X direct messages and mentions via API v2" } // --- OAuth 2.0 PKCE Flow (Twitter API v2) --- // generatePKCE generates a code verifier and challenge for OAuth 2.0 PKCE. func generatePKCE() (verifier string, challenge string, err error) { b := make([]byte, 32) if _, err = rand.Read(b); err != nil { return "", "", err } verifier = base64.RawURLEncoding.EncodeToString(b) hash := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(hash[:]) return verifier, challenge, nil } // BuildAuthURL generates the Twitter OAuth 2.0 authorization URL with PKCE. // Reference: Chatwoot twitter_controller#authorization → redirects to Twitter login func (p *TwitterProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) { verifier, challenge, err := generatePKCE() if err != nil { return "", fmt.Errorf("failed to generate PKCE: %w", err) } // Store the verifier for later use in token exchange // In production, this should be stored in a state/session store state := fmt.Sprintf("twitter_%d_%s", accountID, hex.EncodeToString([]byte(verifier)[:16])) params := url.Values{} params.Set("response_type", "code") params.Set("client_id", p.oauth2Config.ClientID) params.Set("redirect_uri", redirectURL) params.Set("scope", p.oauth2Config.Scopes) params.Set("state", state) params.Set("code_challenge", challenge) params.Set("code_challenge_method", "S256") authURL := "https://twitter.com/i/oauth2/authorize?" + params.Encode() applogger.L().Infof("Twitter OAuth 2.0 auth URL generated for account %d", accountID) return authURL, nil } // TokenExchangeResult holds the result of a Twitter OAuth 2.0 token exchange. type TokenExchangeResult struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` } // ExchangeTokenWithPKCE exchanges a Twitter OAuth 2.0 code for access and refresh tokens using PKCE. // This is the full PKCE version with a verifier parameter. func (p *TwitterProvider) ExchangeTokenWithPKCE(ctx context.Context, code string, redirectURL string, verifier string) (*TokenExchangeResult, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Content-Type", "application/x-www-form-urlencoded"). SetFormData(map[string]string{ "code": code, "grant_type": "authorization_code", "client_id": p.oauth2Config.ClientID, "redirect_uri": redirectURL, "code_verifier": verifier, }). SetResult(&TokenExchangeResult{}). Post("https://api.twitter.com/2/oauth2/token") if err != nil { return nil, fmt.Errorf("Twitter token exchange request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Twitter token exchange failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Twitter OAuth 2.0 token exchange successful") return result, nil } // ExchangeToken exchanges a Twitter OAuth 2.0 code for tokens (OAuthProvider interface). // Calls ExchangeTokenWithPKCE with an empty verifier (for non-PKCE flows or when verifier is stored externally). func (p *TwitterProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*channel.OAuthTokenResult, error) { // Use empty verifier — in production the PKCE verifier should be retrieved from session store internalResult, err := p.ExchangeTokenWithPKCE(ctx, code, redirectURL, "") if err != nil { return nil, err } oauthResult := &channel.OAuthTokenResult{ AccessToken: internalResult.AccessToken, RefreshToken: internalResult.RefreshToken, Scope: internalResult.Scope, } if internalResult.ExpiresIn > 0 { oauthResult.ExpiresAt = time.Now().Add(time.Duration(internalResult.ExpiresIn) * time.Second) } return oauthResult, nil } // RefreshAccessToken refreshes a Twitter OAuth 2.0 access token using a refresh token. func (p *TwitterProvider) RefreshAccessToken(ctx context.Context, refreshToken string) (*TokenExchangeResult, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Content-Type", "application/x-www-form-urlencoded"). SetFormData(map[string]string{ "refresh_token": refreshToken, "grant_type": "refresh_token", "client_id": p.oauth2Config.ClientID, }). SetResult(&TokenExchangeResult{}). Post("https://api.twitter.com/2/oauth2/token") if err != nil { return nil, fmt.Errorf("Twitter token refresh request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Twitter token refresh failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Twitter OAuth 2.0 token refresh successful") return result, nil } // RefreshToken refreshes an expired OAuth token (OAuthProvider interface). // Extracts refresh_token from config and delegates to RefreshAccessToken. func (p *TwitterProvider) RefreshToken(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (*channel.OAuthTokenResult, error) { refreshToken, _ := config["refresh_token"].(string) if refreshToken == "" { return nil, fmt.Errorf("refresh_token is required for token refresh") } internalResult, err := p.RefreshAccessToken(ctx, refreshToken) if err != nil { return nil, err } oauthResult := &channel.OAuthTokenResult{ AccessToken: internalResult.AccessToken, RefreshToken: internalResult.RefreshToken, Scope: internalResult.Scope, } if internalResult.ExpiresIn > 0 { oauthResult.ExpiresAt = time.Now().Add(time.Duration(internalResult.ExpiresIn) * time.Second) } return oauthResult, nil } // --- OAuthProvider additional methods --- // OAuthConfig returns OAuth configuration requirements for Twitter. func (p *TwitterProvider) OAuthConfig() *channel.OAuthConfigDefinition { return &channel.OAuthConfigDefinition{ Provider: "twitter", Scopes: strings.Split(p.oauth2Config.Scopes, " "), AuthorizeURL: "https://twitter.com/i/oauth2/authorize", TokenURL: "https://api.twitter.com/2/oauth2/token", RefreshURL: "https://api.twitter.com/2/oauth2/token", RequiresRefresh: true, TokenExpiry: 7200, // Twitter tokens expire in ~2 hours } } // CheckAuthorizationError checks if an API call returned an authorization error. func (p *TwitterProvider) CheckAuthorizationError(ctx context.Context, apiError error) bool { if apiError == nil { return false } errMsg := apiError.Error() // Twitter returns 401 or "token expired" / "invalid token" errors return strings.Contains(errMsg, "401") || strings.Contains(errMsg, "token expired") || strings.Contains(errMsg, "invalid token") || strings.Contains(errMsg, "access_token") || strings.Contains(errMsg, "Unauthorized") } // OnReauthorization callback when re-authorization is needed. func (p *TwitterProvider) OnReauthorization(ctx context.Context, inbox *model.Inbox) error { applogger.L().Warnf("Twitter channel reauthorization needed for inbox %d", inbox.ID) // In production: send email notification + UI hint (like Chatwoot prompt_reauthorization!) return nil } // --- Twitter Account Activity API (Webhooks) --- // RegisterWebhook registers a webhook URL with the Twitter Account Activity API. // Reference: Chatwoot subscribe_to_twitter_webhook on channel creation func (p *TwitterProvider) RegisterWebhook(ctx context.Context, accessToken, webhookURL string) (string, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetFormData(map[string]string{ "url": webhookURL, }). SetResult(map[string]interface{}{}). Post(fmt.Sprintf("https://api.twitter.com/1.1/account_activity/all/%s/webhooks.json", p.webhookEnv)) if err != nil { return "", fmt.Errorf("Twitter webhook registration request failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { return "", fmt.Errorf("Twitter webhook registration failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(map[string]interface{}) webhookID, ok := result["id"].(string) if !ok { // Twitter webhook IDs are numeric, may come as float64 from JSON if idFloat, ok2 := result["id"].(float64); ok2 { webhookID = fmt.Sprintf("%.0f", idFloat) } else { return "", fmt.Errorf("unexpected webhook ID format in response") } } applogger.L().Infof("Twitter webhook registered: %s", webhookID) return webhookID, nil } // ListWebhooks lists all registered webhooks for the Twitter Account Activity API environment. func (p *TwitterProvider) ListWebhooks(ctx context.Context, accessToken string) ([]map[string]interface{}, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetResult([]map[string]interface{}{}). Get(fmt.Sprintf("https://api.twitter.com/1.1/account_activity/all/%s/webhooks.json", p.webhookEnv)) if err != nil { return nil, fmt.Errorf("Twitter webhook list request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Twitter webhook list failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().([]map[string]interface{}) return result, nil } // ValidateCRC handles Twitter webhook CRC (Challenge-Response Check) validation. // Twitter sends a CRC challenge and expects a response that includes the CRC token // hashed with the webhook's CRC secret. func (p *TwitterProvider) ValidateCRC(crcToken string) string { mac := hmac.New(sha256.New, []byte(p.crcSecret)) mac.Write([]byte(crcToken)) responseToken := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return "sha256=" + responseToken } // DeleteWebhook removes a webhook from the Twitter Account Activity API. func (p *TwitterProvider) DeleteWebhook(ctx context.Context, accessToken, webhookID string) error { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Delete(fmt.Sprintf("https://api.twitter.com/1.1/account_activity/all/%s/webhooks/%s.json", p.webhookEnv, webhookID)) if err != nil { return fmt.Errorf("Twitter webhook deletion request failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return fmt.Errorf("Twitter webhook deletion failed: status %d", resp.StatusCode()) } applogger.L().Infof("Twitter webhook deleted: %s", webhookID) return nil } // --- ChannelProvider interface implementation --- // parseInboxConfig parses the JSON-encoded ChannelConfig string from an Inbox. func parseInboxConfig(inbox *model.Inbox) channel.ChannelConfig { if inbox.ChannelConfig == "" { return channel.ChannelConfig{} } var config channel.ChannelConfig if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { applogger.L().Warnf("Failed to parse Twitter inbox config for inbox %d: %v", inbox.ID, err) return channel.ChannelConfig{} } return config } // ConfigSchema returns the configuration schema for Twitter channels. func (p *TwitterProvider) ConfigSchema() *channel.ConfigSchemaDefinition { return &channel.ConfigSchemaDefinition{ Type: "object", Required: []string{"twitter_user_id"}, Properties: map[string]channel.ConfigProperty{ "twitter_user_id": {Type: "string", Description: "Twitter user ID (numeric)"}, "access_token": {Type: "string", Description: "OAuth 2.0 access token"}, "refresh_token": {Type: "string", Description: "OAuth 2.0 refresh token"}, "webhook_env": {Type: "string", Description: "Account Activity API environment name"}, }, } } // ValidateConfig validates a Twitter channel configuration. func (p *TwitterProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error { // Twitter requires twitter_user_id and access_token if config["twitter_user_id"] == nil || config["twitter_user_id"] == "" { return fmt.Errorf("twitter_user_id is required") } if config["access_token"] == nil || config["access_token"] == "" { return fmt.Errorf("access_token is required") } return nil } // DefaultConfig returns default configuration with preset values. func (p *TwitterProvider) DefaultConfig() channel.ChannelConfig { return channel.ChannelConfig{ "webhook_env": p.webhookEnv, } } // NormalizeConfig normalizes a Twitter channel configuration. func (p *TwitterProvider) NormalizeConfig(ctx context.Context, config channel.ChannelConfig) (channel.ChannelConfig, error) { return config, nil } // OnCreate callback after Twitter channel creation. // Sets up the Twitter Account Activity API webhook subscription. func (p *TwitterProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) { accessToken, _ := config["access_token"].(string) if accessToken == "" { return config, nil // no access token yet, webhook setup deferred to OAuth callback } // Register webhook for Twitter Account Activity API webhookURL := fmt.Sprintf("%s/webhooks/twitter/%d", os.Getenv("GOCHAT_BASE_URL"), inbox.ID) if os.Getenv("GOCHAT_BASE_URL") == "" { return config, nil // no base URL configured, skip webhook registration } webhookID, err := p.RegisterWebhook(ctx, accessToken, webhookURL) if err != nil { applogger.L().Warnf("Twitter webhook registration failed for inbox %d: %v", inbox.ID, err) return config, nil // don't fail creation if webhook registration fails } config["webhook_id"] = webhookID applogger.L().Infof("Twitter channel created for inbox %d, webhook %s", inbox.ID, webhookID) return config, nil } // OnDestroy callback before Twitter channel destruction. // Removes the webhook from the Twitter Account Activity API. func (p *TwitterProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error { accessToken, _ := config["access_token"].(string) webhookID, _ := config["webhook_id"].(string) if accessToken == "" || webhookID == "" { return nil } return p.DeleteWebhook(ctx, accessToken, webhookID) } // ProcessIncoming transforms raw Twitter webhook payload into IncomingMessage. func (p *TwitterProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) { // TODO: implement Twitter incoming message parsing from Account Activity API events return nil, fmt.Errorf("ProcessIncoming not yet implemented for Twitter") } // ValidateWebhookRequest verifies Twitter webhook callback authenticity (CRC). func (p *TwitterProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error { // Twitter uses CRC for webhook validation, not signature-based verification // The CRC challenge is handled separately in the webhook handler return nil } // SendDirectMessage sends a Twitter DM via API v2 (internal helper). func (p *TwitterProvider) SendDirectMessage(ctx context.Context, inbox *channelmodel.ChannelTwitter, recipientID, text string) error { // Twitter DM sending via API v2 resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+inbox.AccessToken). SetHeader("Content-Type", "application/json"). SetBody(map[string]interface{}{ "event_type": "message_create", "message_create": map[string]interface{}{ "target": map[string]interface{}{ "recipient_id": recipientID, }, "message_data": map[string]interface{}{ "text": text, }, }, }). Post("https://api.twitter.com/1.1/dm/new2.json") if err != nil { return fmt.Errorf("Twitter DM send failed: %w", err) } if resp.StatusCode() != http.StatusOK { return fmt.Errorf("Twitter DM send failed: status %d", resp.StatusCode()) } return nil } // SendMessage sends a message through Twitter (ChannelProvider interface). // Delegates to SendDirectMessage with the internal helper. func (p *TwitterProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) { // Extract access token from inbox channel config (JSON string field) config := parseInboxConfig(inbox) accessToken, _ := config["access_token"].(string) if accessToken == "" { return nil, fmt.Errorf("access_token is required to send Twitter messages") } // Use contact's SourceID as the Twitter recipient ID recipientID := contact.SourceID if recipientID == "" { // Fall back to Identifier recipientID = contact.Identifier } if recipientID == "" { return nil, fmt.Errorf("contact has no Twitter source ID or identifier") } // Build the Twitter channel model for the internal SendDirectMessage call twitterInbox := &channelmodel.ChannelTwitter{ AccessToken: accessToken, } err := p.SendDirectMessage(ctx, twitterInbox, recipientID, message.Content) if err != nil { return nil, err } return &channel.SendResult{ ExternalID: fmt.Sprintf("twitter_dm_%d_%s", inbox.ID, recipientID), DeliveredAt: time.Now(), }, nil } // GetContactProfile fetches a Twitter user's profile information. func (p *TwitterProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) { config := parseInboxConfig(inbox) accessToken, _ := config["access_token"].(string) if accessToken == "" { return nil, fmt.Errorf("access_token is required to fetch Twitter profile") } resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetResult(map[string]interface{}{}). Get(fmt.Sprintf("https://api.twitter.com/2/users/%s", contactSource)) if err != nil { return nil, fmt.Errorf("Twitter profile fetch failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Twitter profile fetch failed: status %d", resp.StatusCode()) } result := resp.Result().(map[string]interface{}) profile := &channel.ContactProfile{} if data, ok := result["data"].(map[string]interface{}); ok { if name, ok := data["name"].(string); ok { profile.Name = name } if avatar, ok := data["profile_image_url"].(string); ok { profile.AvatarURL = avatar } } return profile, nil } // Capabilities returns the set of features this channel supports. func (p *TwitterProvider) Capabilities() channel.ChannelCapabilities { return channel.ChannelCapabilities{ SupportsAttachments: true, SupportsLocation: false, SupportsTypingIndicator: false, SupportsDeliveryStatus: false, SupportsReplies: true, SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: false, SupportsEmailHeaders: false, MaxAttachmentSize: 5 * 1024 * 1024, // 5MB MaxTextLength: 10000, } } // GetWebhookEnv returns the Account Activity API environment name. func (p *TwitterProvider) GetWebhookEnv() string { return p.webhookEnv } // GetOAuthRedirectURL returns the configured OAuth redirect URL for Twitter OAuth flow. func (p *TwitterProvider) GetOAuthRedirectURL() string { // Redirect URL is configured dynamically via the OAuth flow, not stored in OAuthConfigDefinition. return "" } func (p *TwitterProvider) VerifyWebhookToken(token string) bool { return token != "" } // ValidateAccessToken validates a Twitter OAuth 2.0 access token. func (p *TwitterProvider) ValidateAccessToken(ctx context.Context, accessToken string) (bool, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Get("https://api.twitter.com/2/users/me") if err != nil { return false, fmt.Errorf("Twitter token validation request failed: %w", err) } return resp.StatusCode() == http.StatusOK, nil }