package v1 // TwitterChannelHandler handles Twitter channel CRUD, OAuth flow, and webhook management. // Reference: Chatwoot app/controllers/api/v1/accounts/channels/twitter_controller.rb // // Chatwoot's TwitterController provides: // - create: authorize Twitter account → create inbox // - destroy: remove Twitter channel // - reauthorize: refresh expired access tokens // // gochat maps these to: // - GET /api/v1/accounts/:id/twitter_channels/authorization → OAuth authorize URL // - POST /api/v1/accounts/:id/twitter_channels/oauth_callback → OAuth token exchange + inbox creation // - DELETE /api/v1/accounts/:id/twitter_channels/:twitter_id → destroy Twitter inbox // - POST /api/v1/accounts/:id/twitter_channels/reauthorize → refresh token // // Additionally, webhook endpoints for Account Activity API: // - GET /api/v1/twitter_webhook/webhook → CRC validation // - POST /api/v1/twitter_webhook/webhook → event processing // // New Chatwoot-style OAuth + Webhook routes (G10): // - GET /api/v1/accounts/:id/twitter/oauth → OAuth authorize URL // - GET /api/v1/accounts/:id/twitter/callback → OAuth callback (redirect) // - POST /api/v1/accounts/:id/twitter/webhooks → Register Twitter webhook // - GET /api/v1/accounts/:id/twitter/webhooks → List registered webhooks import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/hex" "fmt" "io" "net/http" "net/url" "os" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" twitterchannel "github.com/gochat/gochat/internal/channel/twitter" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) var twitterAuthorizationHTTPClient = http.DefaultClient // TwitterChannelHandler handles Twitter/X channel management. type TwitterChannelHandler struct { twService *service.ChannelTwitterService twProvider *twitterchannel.TwitterProvider inboxSvc *service.InboxService twRepo *repository.ChannelTwitterRepo } // NewTwitterChannelHandler creates a new Twitter channel handler. func NewTwitterChannelHandler( twService *service.ChannelTwitterService, twProvider *twitterchannel.TwitterProvider, inboxSvc *service.InboxService, twRepo *repository.ChannelTwitterRepo, ) *TwitterChannelHandler { return &TwitterChannelHandler{ twService: twService, twProvider: twProvider, inboxSvc: inboxSvc, twRepo: twRepo, } } // === OAuth Authorization === // TwitterAuthorizationRequest is the DTO for initiating Twitter OAuth flow. type TwitterAuthorizationRequest struct { RedirectURL string `json:"redirect_url" validate:"required,url"` } // Authorization generates a Twitter OAuth 2.0 authorize URL with PKCE. // GET /api/v1/accounts/:id/twitter_channels/authorization func (h *TwitterChannelHandler) Authorization(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req TwitterAuthorizationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } authURL, err := h.twProvider.BuildAuthURL(c.Request.Context(), uint(accountID), req.RedirectURL) if err != nil { applogger.L().Errorf("Failed to build Twitter auth URL: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to generate authorization URL"}) return } c.JSON(http.StatusOK, gin.H{"authorization_url": authURL}) } // ChatwootAuthorization creates a Twitter OAuth1 authorization URL. // POST /api/v1/accounts/:account_id/twitter/authorization func (h *TwitterChannelHandler) ChatwootAuthorization(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } redirectURL, err := buildTwitterChatwootAuthorizationURL(c.Request.Context(), accountID) if err != nil { applogger.L().Errorf("Failed to build Twitter authorization URL: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false}) return } c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL}) } func buildTwitterChatwootAuthorizationURL(ctx context.Context, accountID uint) (string, error) { consumerKey := strings.TrimSpace(os.Getenv("TWITTER_CONSUMER_KEY")) consumerSecret := strings.TrimSpace(os.Getenv("TWITTER_CONSUMER_SECRET")) if consumerKey == "" || consumerSecret == "" { return "", fmt.Errorf("Twitter OAuth is not configured") } state, err := signedTwitterState(accountID, consumerSecret) if err != nil { return "", err } callbackURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/") + "/twitter/callback?state=" + url.QueryEscape(state) requestTokenURL := envOrDefaultV1("TWITTER_OAUTH_REQUEST_TOKEN_URL", "https://api.twitter.com/oauth/request_token") req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestTokenURL, nil) if err != nil { return "", err } authHeader, err := twitterOAuth1Header(http.MethodPost, requestTokenURL, consumerKey, consumerSecret, callbackURL) if err != nil { return "", err } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "application/x-www-form-urlencoded") resp, err := twitterAuthorizationHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { _, _ = io.Copy(io.Discard, resp.Body) return "", fmt.Errorf("twitter request token failed: %s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } values, err := url.ParseQuery(string(body)) if err != nil { return "", err } oauthToken := strings.TrimSpace(values.Get("oauth_token")) if oauthToken == "" { return "", fmt.Errorf("twitter request token missing oauth_token") } authorizeBase := strings.TrimRight(envOrDefaultV1("TWITTER_API_BASE_URL", "https://api.twitter.com"), "/") return authorizeBase + "/oauth/authorize?oauth_token=" + url.QueryEscape(oauthToken), nil } func signedTwitterState(accountID uint, secret string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": accountID, "iat": time.Now().Unix(), }) return token.SignedString([]byte(secret)) } func twitterOAuth1Header(method, rawURL, consumerKey, consumerSecret, callbackURL string) (string, error) { nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { return "", err } params := map[string]string{ "oauth_callback": callbackURL, "oauth_consumer_key": consumerKey, "oauth_nonce": hex.EncodeToString(nonceBytes), "oauth_signature_method": "HMAC-SHA1", "oauth_timestamp": strconv.FormatInt(time.Now().Unix(), 10), "oauth_version": "1.0", } params["oauth_signature"] = oauth1Signature(method, rawURL, params, consumerSecret) keys := make([]string, 0, len(params)) for key := range params { keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, fmt.Sprintf(`%s="%s"`, oauthPercentEncode(key), oauthPercentEncode(params[key]))) } return "OAuth " + strings.Join(parts, ", "), nil } func oauth1Signature(method, rawURL string, params map[string]string, consumerSecret string) string { keys := make([]string, 0, len(params)) for key := range params { keys = append(keys, key) } sort.Strings(keys) encodedParams := make([]string, 0, len(keys)) for _, key := range keys { encodedParams = append(encodedParams, oauthPercentEncode(key)+"="+oauthPercentEncode(params[key])) } base := strings.ToUpper(method) + "&" + oauthPercentEncode(rawURL) + "&" + oauthPercentEncode(strings.Join(encodedParams, "&")) mac := hmac.New(sha1.New, []byte(oauthPercentEncode(consumerSecret)+"&")) _, _ = mac.Write([]byte(base)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func oauthPercentEncode(value string) string { return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") } func envOrDefaultV1(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } // === OAuth Callback === // TwitterOAuthCallbackRequest is the DTO for Twitter OAuth callback. type TwitterOAuthCallbackRequest struct { Code string `json:"code" validate:"required"` State string `json:"state" validate:"required"` RedirectURL string `json:"redirect_url" validate:"required,url"` Name string `json:"name" validate:"required,min=2"` } // OAuthCallback exchanges the Twitter OAuth 2.0 code for tokens and creates a channel. // POST /api/v1/accounts/:id/twitter_channels/oauth_callback func (h *TwitterChannelHandler) OAuthCallback(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req TwitterOAuthCallbackRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil { if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter inbox"}) return } // Exchange the code for tokens (PKCE verifier stored in state) // For now, use empty verifier since state-based verifier retrieval requires session storage // ExchangeToken interface takes (ctx, code, redirectURL); PKCE is handled internally tokenResult, err := h.twProvider.ExchangeToken(ctx, req.Code, req.RedirectURL) if err != nil { applogger.L().Errorf("Twitter OAuth token exchange failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"}) return } // Get the Twitter user info using the access token valid, err := h.twProvider.ValidateAccessToken(ctx, tokenResult.AccessToken) if err != nil || !valid { applogger.L().Errorf("Twitter token validation failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Twitter token"}) return } // Create the Twitter channel record twChannel := &channelmodel.ChannelTwitter{ AccountID: uint(accountID), AccessToken: tokenResult.AccessToken, RefreshToken: tokenResult.RefreshToken, WebhookEnv: h.twProvider.GetWebhookEnv(), } if err := h.twService.Create(ctx, twChannel); err != nil { applogger.L().Errorf("Failed to create Twitter channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter channel"}) return } // Create the inbox inboxReq := service.CreateInboxRequest{ Name: req.Name, ChannelType: string(model.InboxChannelTypeTwitter), } inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq) if err != nil { applogger.L().Errorf("Failed to create Twitter inbox: %v", err) if delErr := h.twService.Delete(ctx, twChannel.ID); delErr != nil { applogger.L().Warnf("Failed to rollback Twitter channel after inbox creation failure: %v", delErr) } if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter inbox"}) return } // Link the channel to the inbox twChannel.InboxID = inbox.ID if err := h.twService.Update(ctx, twChannel); err != nil { applogger.L().Warnf("Failed to link Twitter channel to inbox: %v", err) } c.JSON(http.StatusCreated, gin.H{ "inbox": gin.H{ "id": inbox.ID, "name": inbox.Name, "channel_type": inbox.ChannelType, }, "twitter_channel": gin.H{ "id": twChannel.ID, "access_token": twChannel.AccessToken, "webhook_env": twChannel.WebhookEnv, }, }) } // === Delete === // Delete removes a Twitter channel and its associated inbox. // DELETE /api/v1/accounts/:id/twitter_channels/:twitter_id func (h *TwitterChannelHandler) Delete(c *gin.Context) { twitterIDStr := c.Param("twitter_id") twitterID, err := strconv.ParseUint(twitterIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid twitter_id"}) return } ctx := c.Request.Context() // Get channel to find inbox for deletion ch, err := h.twService.GetByID(ctx, uint(twitterID)) if err != nil { applogger.L().Errorf("Twitter channel not found: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "twitter channel not found"}) return } // Delete the Twitter webhook if one is registered if ch.WebhookID != "" { if err := h.twProvider.DeleteWebhook(ctx, ch.AccessToken, ch.WebhookID); err != nil { applogger.L().Warnf("Failed to delete Twitter webhook: %v", err) } } // Delete the Twitter channel record if err := h.twService.Delete(ctx, uint(twitterID)); err != nil { applogger.L().Errorf("Failed to delete Twitter channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete twitter channel"}) return } c.JSON(http.StatusOK, gin.H{"message": "twitter channel deleted"}) } // === Webhook CRC Validation === // TwitterWebhookCRCRequest handles Twitter Account Activity CRC validation. // GET /api/v1/twitter_webhook/webhook?crc_token=... func (h *TwitterChannelHandler) WebhookCRC(c *gin.Context) { crcToken := c.Query("crc_token") if crcToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing crc_token"}) return } responseToken := h.twProvider.ValidateCRC(crcToken) c.JSON(http.StatusOK, gin.H{ "response_token": responseToken, }) } // === Webhook Event Processing === // TwitterWebhookEvent processes incoming Twitter Account Activity events. // POST /api/v1/twitter_webhook/webhook func (h *TwitterChannelHandler) WebhookEvent(c *gin.Context) { var payload map[string]interface{} if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } applogger.L().Infof("Twitter webhook event received") // Event processing will be handled by the Twitter incoming message service // (to be implemented in a future milestone) c.JSON(http.StatusOK, gin.H{"status": "received"}) } // === Webhook Registration (G10) === // TwitterRegisterWebhookRequest is the DTO for registering a Twitter webhook. type TwitterRegisterWebhookRequest struct { WebhookURL string `json:"webhook_url" validate:"required,url"` } // RegisterWebhook registers a webhook URL with the Twitter Account Activity API. // POST /api/v1/accounts/:id/twitter/webhooks func (h *TwitterChannelHandler) RegisterWebhook(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req TwitterRegisterWebhookRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Find the Twitter channel for this account to get the access token channels, err := h.twService.ListByAccount(ctx, uint(accountID)) if err != nil || len(channels) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "no twitter channel found for this account"}) return } ch := &channels[0] webhookID, err := h.twProvider.RegisterWebhook(ctx, ch.AccessToken, req.WebhookURL) if err != nil { applogger.L().Errorf("Failed to register Twitter webhook: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to register twitter webhook"}) return } // Update the channel with the webhook ID ch.WebhookID = webhookID if err := h.twService.Update(ctx, ch); err != nil { applogger.L().Warnf("Failed to update Twitter channel webhook_id: %v", err) } c.JSON(http.StatusCreated, gin.H{ "webhook_id": webhookID, "webhook_url": req.WebhookURL, }) } // ListWebhooks lists all registered webhooks for the Twitter Account Activity API. // GET /api/v1/accounts/:id/twitter/webhooks func (h *TwitterChannelHandler) ListWebhooks(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } ctx := c.Request.Context() channels, err := h.twService.ListByAccount(ctx, uint(accountID)) if err != nil || len(channels) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "no twitter channel found for this account"}) return } ch := &channels[0] webhooks, err := h.twProvider.ListWebhooks(ctx, ch.AccessToken) if err != nil { applogger.L().Errorf("Failed to list Twitter webhooks: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list twitter webhooks"}) return } c.JSON(http.StatusOK, gin.H{ "webhooks": webhooks, }) } // === Chatwoot-style GET OAuth callback (G10) === // OAuthCallbackGET handles the OAuth redirect callback from Twitter. // GET /api/v1/accounts/:id/twitter/callback?code=...&state=... // This is the redirect endpoint that the Twitter OAuth provider calls back to. func (h *TwitterChannelHandler) OAuthCallbackGET(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } code := c.Query("code") state := c.Query("state") if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing code parameter"}) return } ctx := c.Request.Context() // Determine redirect URL from the configured OAuth config redirectURL := h.twProvider.GetOAuthRedirectURL() tokenResult, err := h.twProvider.ExchangeToken(ctx, code, redirectURL) if err != nil { applogger.L().Errorf("Twitter OAuth GET callback token exchange failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"}) return } valid, err := h.twProvider.ValidateAccessToken(ctx, tokenResult.AccessToken) if err != nil || !valid { applogger.L().Errorf("Twitter token validation failed: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Twitter token"}) return } // Create the Twitter channel record twChannel := &channelmodel.ChannelTwitter{ AccountID: uint(accountID), AccessToken: tokenResult.AccessToken, RefreshToken: tokenResult.RefreshToken, WebhookEnv: h.twProvider.GetWebhookEnv(), } if err := h.twService.Create(ctx, twChannel); err != nil { applogger.L().Errorf("Failed to create Twitter channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create twitter channel"}) return } c.JSON(http.StatusOK, gin.H{ "channel_id": twChannel.ID, "access_token": twChannel.AccessToken, "webhook_env": twChannel.WebhookEnv, "state": state, }) }