/* Copyright © 2025 maxtheweb */ package cmd import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" "github.com/spf13/cobra" ) // CreateConfig structure for post parameters type CreateConfig struct { Text struct { Content string `toml:"content"` } `toml:"text"` Schedule struct { ScheduleAt string `toml:"schedule_at"` } `toml:"schedule"` Visibility struct { ReplySettings string `toml:"reply_settings"` } `toml:"visibility"` } // TweetResponse from X API type TweetResponse struct { Data struct { ID string `json:"id"` Text string `json:"text"` CreatedAt time.Time `json:"created_at"` } `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors"` } var ( createPreview bool createNow bool ) // createCmd represents the create command var createCmd = &cobra.Command{ Use: "create [command]", Short: "Create and post to X", Long: `Opens your editor to configure your post. The post configuration will be saved to ~/.config/xapi/create.toml Usage: xapi create # Edit post configuration xapi create now # Post immediately xapi create preview # Preview the post without posting`, Run: func(cmd *cobra.Command, args []string) { // Check for command arguments if len(args) > 0 { switch args[0] { case "preview": createPreview = true case "now": createNow = true default: fmt.Printf("Unknown create command: %s\n", args[0]) fmt.Println("Use 'xapi create preview' or 'xapi create now'") os.Exit(1) } } configFile := getCreateConfigPath() if createPreview { previewPost(configFile) } else if createNow { executePost(configFile) } else { editCreateConfig(configFile) } }, } func init() { rootCmd.AddCommand(createCmd) createCmd.Flags().BoolVarP(&createPreview, "preview", "p", false, "Preview the post (hidden)") createCmd.Flags().BoolVarP(&createNow, "now", "n", false, "Post immediately (hidden)") createCmd.Flags().MarkHidden("preview") createCmd.Flags().MarkHidden("now") } func getCreateConfigPath() string { homeDir, _ := os.UserHomeDir() return filepath.Join(homeDir, ".config", "xapi", "create.toml") } func editCreateConfig(configFile string) { // Create config directory if it doesn't exist configDir := filepath.Dir(configFile) if err := os.MkdirAll(configDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err) os.Exit(1) } // Check if config file exists, if not create template if _, err := os.Stat(configFile); os.IsNotExist(err) { template := `# X Post Configuration # Configure your post before posting [text] # What do you want to post? (required, max 280 characters) content = "" [schedule] # Schedule the post for later (optional) # Format: ISO 8601 (e.g., 2025-11-13T15:30:00Z) # Leave empty to post immediately schedule_at = "" [visibility] # Who can reply to this post? (optional) # Options: everyone, mentioned_users, followers # Default: everyone reply_settings = "everyone" ` if err := os.WriteFile(configFile, []byte(template), 0600); err != nil { fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err) os.Exit(1) } } // Get editor using smart detection editor := findEditor() if editor == "" { fmt.Fprintf(os.Stderr, "Error: No text editor found!\n\n") fmt.Println("Please install one of: nvim, vim, vi, nano, emacs") fmt.Println("Or set your preferred editor: export EDITOR=nvim") os.Exit(1) } // Open editor fmt.Printf("Opening %s with %s...\n", configFile, editor) cmd := exec.Command(editor, configFile) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error opening editor: %v\n", err) os.Exit(1) } fmt.Println("\nPost configuration saved!") fmt.Println("\nNext steps:") fmt.Println(" xapi create preview # Preview the post") fmt.Println(" xapi create now # Post immediately") } func previewPost(configFile string) { // Load create config config, err := loadCreateConfig(configFile) if err != nil { fmt.Fprintf(os.Stderr, "Error loading create config: %v\n", err) fmt.Println("Run 'xapi create' to configure your post") os.Exit(1) } // Validate config valid, errors := config.Validate() fmt.Println("\nPOST PREVIEW") fmt.Println("============") if !valid { fmt.Println("INVALID CONFIGURATION\n") for _, err := range errors { fmt.Printf(" - %s\n", err) } fmt.Println("\nRun 'xapi create' to fix the configuration") os.Exit(1) } fmt.Println("VALID POST\n") // Display preview fmt.Println("Content:") fmt.Printf(" %s\n", config.Text.Content) charCount := len([]rune(config.Text.Content)) fmt.Printf("\nStats:") fmt.Printf("\n - Characters: %d / 280\n", charCount) if config.Schedule.ScheduleAt != "" { fmt.Printf(" - Scheduled for: %s\n", config.Schedule.ScheduleAt) } else { fmt.Println(" - Will post immediately") } if config.Visibility.ReplySettings != "everyone" { fmt.Printf(" - Reply settings: %s\n", config.Visibility.ReplySettings) } fmt.Println("\nReady to post!") fmt.Println("Run: xapi create now") } func executePost(configFile string) { // Load create config createConfig, err := loadCreateConfig(configFile) if err != nil { fmt.Fprintf(os.Stderr, "Error loading create config: %v\n", err) fmt.Println("Run 'xapi create' to configure your post") os.Exit(1) } // Validate config valid, errors := createConfig.Validate() if !valid { fmt.Println("INVALID CONFIGURATION\n") for _, err := range errors { fmt.Printf(" - %s\n", err) } fmt.Println("\nRun 'xapi create' to fix the configuration") os.Exit(1) } // Load API config apiConfig, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Error loading API config: %v\n", err) fmt.Println("Run 'xapi login' to configure your credentials") os.Exit(1) } // Check OAuth credentials if apiConfig.OAuth.ConsumerKey == "" { fmt.Fprintf(os.Stderr, "OAuth credentials not configured\n") fmt.Println("Run 'xapi login' to configure your credentials") os.Exit(1) } fmt.Println("\nPosting to X...") // Build request payload payload := map[string]interface{}{ "text": createConfig.Text.Content, } // Add optional fields if createConfig.Schedule.ScheduleAt != "" { payload["scheduled_at"] = createConfig.Schedule.ScheduleAt } if createConfig.Visibility.ReplySettings != "everyone" { payload["reply_settings"] = createConfig.Visibility.ReplySettings } // Marshal to JSON payloadBytes, err := json.Marshal(payload) if err != nil { fmt.Fprintf(os.Stderr, "Error preparing request: %v\n", err) os.Exit(1) } // Create HTTP client client := &http.Client{ Timeout: 30 * time.Second, } // Make request apiURL := "https://api.x.com/2/tweets" req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) if err != nil { fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) os.Exit(1) } // Generate OAuth 1.0a Authorization header (POST with JSON body uses empty params) oauthClient := NewOAuthClient(apiConfig) authHeader := oauthClient.GetAuthorizationHeader("POST", apiURL, map[string]string{}) req.Header.Set("Authorization", authHeader) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Error making API request: %v\n", err) os.Exit(1) } defer resp.Body.Close() // Check status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) bodyStr := string(body) // Check for OAuth permissions error if resp.StatusCode == http.StatusForbidden && strings.Contains(bodyStr, "oauth1-permissions") { fmt.Fprintf(os.Stderr, "\nPERMISSION ERROR\n") fmt.Fprintf(os.Stderr, "================\n\n") fmt.Fprintf(os.Stderr, "Your app only has READ permissions, but posting requires READ + WRITE.\n\n") fmt.Fprintf(os.Stderr, "To fix this:\n") fmt.Fprintf(os.Stderr, " 1. Go to: https://developer.x.com/en/portal/dashboard\n") fmt.Fprintf(os.Stderr, " 2. Select your project and app\n") fmt.Fprintf(os.Stderr, " 3. Go to Settings > User authentication settings\n") fmt.Fprintf(os.Stderr, " 4. Change App permissions from 'Read' to 'Read and Write'\n") fmt.Fprintf(os.Stderr, " 5. Go to 'Keys and tokens' tab\n") fmt.Fprintf(os.Stderr, " 6. REGENERATE your Access Token and Access Token Secret\n") fmt.Fprintf(os.Stderr, " 7. Run: xapi login (with the new tokens)\n\n") fmt.Fprintf(os.Stderr, "Note: Consumer Key/Secret stay the same, only regenerate Access Token pair.\n") os.Exit(1) } fmt.Fprintf(os.Stderr, "API error (status %d): %s\n", resp.StatusCode, bodyStr) os.Exit(1) } // Parse response var tweetResp TweetResponse if err := json.NewDecoder(resp.Body).Decode(&tweetResp); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } // Display success displayPostSuccess(tweetResp, createConfig) } func displayPostSuccess(resp TweetResponse, config *CreateConfig) { if len(resp.Errors) > 0 { fmt.Println("Post failed:") for _, err := range resp.Errors { fmt.Printf(" - %s\n", err.Message) } os.Exit(1) } fmt.Printf("\nPost successful!\n") fmt.Printf(" Tweet ID: %s\n", resp.Data.ID) fmt.Printf(" Posted: %s\n", resp.Data.CreatedAt.Format("Jan 2, 15:04")) fmt.Printf(" Text: %s\n", config.Text.Content) fmt.Println() } func loadCreateConfig(configFile string) (*CreateConfig, error) { var config CreateConfig // Set defaults config.Visibility.ReplySettings = "everyone" if _, err := toml.DecodeFile(configFile, &config); err != nil { return nil, err } // Validate reply_settings value validSettings := map[string]bool{ "everyone": true, "mentioned_users": true, "followers": true, } if config.Visibility.ReplySettings != "" && !validSettings[config.Visibility.ReplySettings] { config.Visibility.ReplySettings = "everyone" } return &config, nil } func (c *CreateConfig) Validate() (bool, []string) { var errors []string // Text is required if strings.TrimSpace(c.Text.Content) == "" { errors = append(errors, "Post content is required") } // Check length charCount := len([]rune(c.Text.Content)) if charCount > 280 { errors = append(errors, fmt.Sprintf("Post is too long: %d / 280 characters", charCount)) } // Validate schedule time if provided if c.Schedule.ScheduleAt != "" { _, err := time.Parse(time.RFC3339, c.Schedule.ScheduleAt) if err != nil { errors = append(errors, fmt.Sprintf("Invalid schedule time format: %s (use ISO 8601)", c.Schedule.ScheduleAt)) } } // Validate reply settings validSettings := map[string]bool{ "everyone": true, "mentioned_users": true, "followers": true, } if c.Visibility.ReplySettings != "" && !validSettings[c.Visibility.ReplySettings] { errors = append(errors, fmt.Sprintf("Invalid reply_settings: %s (use everyone, mentioned_users, or followers)", c.Visibility.ReplySettings)) } return len(errors) == 0, errors }