xapi-cli/cmd/create.go
Soldier 94635e7ace feat: MVP release - OAuth 1.0a CLI for X API
Complete CLI tool with 4 core commands:
- xapi login: Configure OAuth credentials via editor
- xapi status: Test authentication
- xapi search: Search tweets with preview/execute modes
- xapi create: Post tweets with preview/execute modes

Features:
- OAuth 1.0a authentication with HMAC-SHA1 signing
- OAuth 2.0 Client ID/Secret support (for future features)
- TOML-based configuration
- Editor integration for config management
- Helpful error messages for permission issues
- Quota-aware design (no caching to avoid complexity)

Built for developers on Free/Basic X API tiers.
2025-11-13 21:46:18 +00:00

412 lines
11 KiB
Go

/*
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
}