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.
412 lines
11 KiB
Go
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
|
|
}
|
|
|