654 lines
24 KiB
Go
654 lines
24 KiB
Go
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
|
|
}
|