Files
gochat/internal/channel/twitter/provider.go
T

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
}