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.
562 lines
14 KiB
Go
562 lines
14 KiB
Go
/*
|
|
Copyright © 2025 maxtheweb
|
|
*/
|
|
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// SearchConfig structure for search parameters
|
|
type SearchConfig struct {
|
|
Query struct {
|
|
Keywords string `toml:"keywords"`
|
|
Language string `toml:"language"`
|
|
DaysBack int `toml:"days_back"`
|
|
} `toml:"query"`
|
|
|
|
Filters struct {
|
|
IncludeRetweets bool `toml:"include_retweets"`
|
|
MinLikes int `toml:"min_likes"`
|
|
MinRetweets int `toml:"min_retweets"`
|
|
HasLinks bool `toml:"has_links"`
|
|
HasMedia bool `toml:"has_media"`
|
|
HasVideo bool `toml:"has_video"`
|
|
} `toml:"filters"`
|
|
|
|
Source struct {
|
|
FromUser string `toml:"from_user"`
|
|
ToUser string `toml:"to_user"`
|
|
} `toml:"source"`
|
|
|
|
Output struct {
|
|
MaxResults int `toml:"max_results"`
|
|
TweetFields string `toml:"tweet_fields"`
|
|
UserFields string `toml:"user_fields"`
|
|
} `toml:"output"`
|
|
}
|
|
|
|
// SearchResponse from X API
|
|
type SearchResponse struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
AuthorID string `json:"author_id"`
|
|
PublicMetrics struct {
|
|
RetweetCount int `json:"retweet_count"`
|
|
ReplyCount int `json:"reply_count"`
|
|
LikeCount int `json:"like_count"`
|
|
QuoteCount int `json:"quote_count"`
|
|
} `json:"public_metrics"`
|
|
} `json:"data"`
|
|
Includes struct {
|
|
Users []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Username string `json:"username"`
|
|
Verified bool `json:"verified"`
|
|
} `json:"users"`
|
|
} `json:"includes"`
|
|
Meta struct {
|
|
ResultCount int `json:"result_count"`
|
|
NextToken string `json:"next_token"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
var (
|
|
searchExecute bool
|
|
searchDryRun bool
|
|
searchLast bool
|
|
)
|
|
|
|
// searchCmd represents the search command
|
|
var searchCmd = &cobra.Command{
|
|
Use: "search [command]",
|
|
Short: "Search tweets on X",
|
|
Long: `Opens your editor to configure search parameters.
|
|
|
|
The search configuration will be saved to ~/.config/xapi/search.toml
|
|
|
|
Usage:
|
|
xapi search # Edit search configuration
|
|
xapi search now # Execute the configured search
|
|
xapi search again # Rerun the last search
|
|
xapi search preview # Preview the query without executing`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
// Check for command arguments
|
|
if len(args) > 0 {
|
|
switch args[0] {
|
|
case "now":
|
|
searchExecute = true
|
|
case "again":
|
|
searchLast = true
|
|
searchExecute = true
|
|
case "preview":
|
|
searchDryRun = true
|
|
}
|
|
}
|
|
runSearch()
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(searchCmd)
|
|
|
|
// Legacy flags (hidden, but still work for backwards compatibility)
|
|
searchCmd.Flags().BoolVarP(&searchExecute, "execute", "e", false, "Execute the search")
|
|
searchCmd.Flags().BoolVarP(&searchDryRun, "dry-run", "d", false, "Preview the search query")
|
|
searchCmd.Flags().BoolVarP(&searchLast, "last", "l", false, "Use last search configuration")
|
|
|
|
// Hide flags from help - prefer English commands
|
|
searchCmd.Flags().MarkHidden("execute")
|
|
searchCmd.Flags().MarkHidden("dry-run")
|
|
searchCmd.Flags().MarkHidden("last")
|
|
}
|
|
|
|
func runSearch() {
|
|
// Get config directory
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
searchConfigFile := filepath.Join(homeDir, ".config", "xapi", "search.toml")
|
|
|
|
// Handle different modes
|
|
if searchExecute {
|
|
executeSearch(searchConfigFile)
|
|
return
|
|
}
|
|
|
|
if searchDryRun {
|
|
previewSearch(searchConfigFile)
|
|
return
|
|
}
|
|
|
|
if searchLast {
|
|
// Just execute with existing config
|
|
if _, err := os.Stat(searchConfigFile); os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr, "No previous search found. Run 'xapi search' first.\n")
|
|
os.Exit(1)
|
|
}
|
|
executeSearch(searchConfigFile)
|
|
return
|
|
}
|
|
|
|
// Default: Open editor to configure search
|
|
editSearchConfig(searchConfigFile)
|
|
}
|
|
|
|
func editSearchConfig(configFile string) {
|
|
// Ensure config directory exists
|
|
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)
|
|
}
|
|
|
|
// Create template if file doesn't exist
|
|
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
|
template := `# X API Search Configuration
|
|
# Edit this file to define your search, then run 'xapi search now'
|
|
#
|
|
# TIP: Start simple! Just set keywords and leave other fields empty/false
|
|
|
|
[query]
|
|
# What are you searching for? (required)
|
|
keywords = ""
|
|
|
|
# Language filter (optional: en, es, fr, de, ja, etc.)
|
|
# Leave empty for all languages
|
|
language = ""
|
|
|
|
# Time range in days (optional: 1-7 for free tier)
|
|
# Leave empty for last 7 days
|
|
days_back = 0
|
|
|
|
[filters]
|
|
# Include retweets? (default: false)
|
|
include_retweets = false
|
|
|
|
# Only posts with minimum likes (optional, 0 = no filter)
|
|
min_likes = 0
|
|
|
|
# Only posts with minimum retweets (optional, 0 = no filter)
|
|
min_retweets = 0
|
|
|
|
# Must contain (set to true to require)
|
|
has_links = false
|
|
has_media = false
|
|
has_video = false
|
|
|
|
[source]
|
|
# From specific user (optional, without @)
|
|
from_user = ""
|
|
|
|
# To specific user (optional, without @)
|
|
to_user = ""
|
|
|
|
[output]
|
|
# Maximum results per page (1-100, default: 10)
|
|
# Tip: Start with 1-5 results while you learn, then increase as needed
|
|
max_results = 10
|
|
|
|
# Fields to retrieve (advanced - don't change unless needed)
|
|
tweet_fields = "created_at,public_metrics,author_id"
|
|
user_fields = "username,verified"
|
|
`
|
|
if err := os.WriteFile(configFile, []byte(template), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating search config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Get editor using same function from login
|
|
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("\nSearch configuration saved!")
|
|
fmt.Println("\nNext steps:")
|
|
fmt.Println(" xapi search preview # Preview the query")
|
|
fmt.Println(" xapi search now # Run the search")
|
|
}
|
|
|
|
func previewSearch(configFile string) {
|
|
// Load search config
|
|
config, err := loadSearchConfig(configFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err)
|
|
fmt.Println("Run 'xapi search' to configure your search")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate and build query
|
|
query, valid, errors := config.BuildQuery()
|
|
|
|
fmt.Println("\nSEARCH PREVIEW")
|
|
fmt.Println("==============")
|
|
|
|
if !valid {
|
|
fmt.Println("INVALID CONFIGURATION\n")
|
|
for _, err := range errors {
|
|
fmt.Printf(" - %s\n", err)
|
|
}
|
|
fmt.Println("\nRun 'xapi search' to fix the configuration")
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Println("VALID QUERY\n")
|
|
fmt.Printf("Query: %s\n", query)
|
|
fmt.Printf("Max results: %d per request\n", config.Output.MaxResults)
|
|
|
|
// Explain what the query does
|
|
fmt.Println("\nTHIS WILL SEARCH FOR:")
|
|
if config.Query.Keywords != "" {
|
|
fmt.Printf(" - Posts containing: %s\n", config.Query.Keywords)
|
|
}
|
|
if config.Query.Language != "" {
|
|
fmt.Printf(" - Language: %s\n", config.Query.Language)
|
|
}
|
|
if !config.Filters.IncludeRetweets {
|
|
fmt.Println(" - Original posts only (no retweets)")
|
|
}
|
|
if config.Filters.HasLinks {
|
|
fmt.Println(" - Must contain links")
|
|
}
|
|
if config.Filters.HasMedia {
|
|
fmt.Println(" - Must contain images/media")
|
|
}
|
|
if config.Source.FromUser != "" {
|
|
fmt.Printf(" - From user: @%s\n", config.Source.FromUser)
|
|
}
|
|
|
|
fmt.Println("\nReady to execute!")
|
|
fmt.Println("Run: xapi search now")
|
|
}
|
|
|
|
func executeSearch(configFile string) {
|
|
// Load search config
|
|
searchConfig, err := loadSearchConfig(configFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err)
|
|
fmt.Println("Run 'xapi search' to configure your search")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Validate and build query
|
|
query, valid, errors := searchConfig.BuildQuery()
|
|
if !valid {
|
|
fmt.Println("INVALID CONFIGURATION\n")
|
|
for _, err := range errors {
|
|
fmt.Printf(" - %s\n", err)
|
|
}
|
|
fmt.Println("\nRun 'xapi search' 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.Printf("\nExecuting search: %s\n", query)
|
|
|
|
// Build API URL and params
|
|
baseURL := "https://api.x.com/2/tweets/search/recent"
|
|
params := make(map[string]string)
|
|
params["query"] = query
|
|
params["max_results"] = fmt.Sprintf("%d", searchConfig.Output.MaxResults)
|
|
if searchConfig.Output.TweetFields != "" {
|
|
params["tweet.fields"] = searchConfig.Output.TweetFields
|
|
}
|
|
if searchConfig.Output.UserFields != "" {
|
|
params["user.fields"] = searchConfig.Output.UserFields
|
|
params["expansions"] = "author_id"
|
|
}
|
|
|
|
// Build full URL with query params
|
|
urlParams := url.Values{}
|
|
for k, v := range params {
|
|
urlParams.Set(k, v)
|
|
}
|
|
apiURL := baseURL + "?" + urlParams.Encode()
|
|
|
|
// Create HTTP client
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
// Make request
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Generate OAuth 1.0a Authorization header
|
|
oauthClient := NewOAuthClient(apiConfig)
|
|
authHeader := oauthClient.GetAuthorizationHeader("GET", baseURL, params)
|
|
req.Header.Set("Authorization", authHeader)
|
|
|
|
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.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
fmt.Fprintf(os.Stderr, "API error (status %d): %s\n", resp.StatusCode, string(body))
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Parse response
|
|
var searchResp SearchResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Display results
|
|
displaySearchResults(searchResp)
|
|
}
|
|
|
|
func displaySearchResults(resp SearchResponse) {
|
|
fmt.Printf("\nRetrieved %d tweets\n", resp.Meta.ResultCount)
|
|
|
|
if resp.Meta.ResultCount == 0 {
|
|
fmt.Println("\nNo results found. Try adjusting your search parameters.")
|
|
return
|
|
}
|
|
|
|
fmt.Println("\nRESULTS SUMMARY")
|
|
fmt.Println("===============")
|
|
|
|
// Calculate stats
|
|
totalLikes := 0
|
|
totalRetweets := 0
|
|
for _, tweet := range resp.Data {
|
|
totalLikes += tweet.PublicMetrics.LikeCount
|
|
totalRetweets += tweet.PublicMetrics.RetweetCount
|
|
}
|
|
|
|
fmt.Printf(" - Total tweets: %d\n", len(resp.Data))
|
|
fmt.Printf(" - Total engagement: %d likes, %d retweets\n", totalLikes, totalRetweets)
|
|
|
|
if len(resp.Data) > 0 {
|
|
fmt.Printf(" - Latest: %s\n", resp.Data[0].CreatedAt.Format("Jan 2, 15:04"))
|
|
fmt.Printf(" - Oldest: %s\n", resp.Data[len(resp.Data)-1].CreatedAt.Format("Jan 2, 15:04"))
|
|
}
|
|
|
|
// Show sample tweets
|
|
fmt.Println("\nSAMPLE TWEETS (first 3)")
|
|
fmt.Println("=======================")
|
|
|
|
limit := 3
|
|
if len(resp.Data) < limit {
|
|
limit = len(resp.Data)
|
|
}
|
|
|
|
for i := 0; i < limit; i++ {
|
|
tweet := resp.Data[i]
|
|
// Find author
|
|
author := "unknown"
|
|
for _, user := range resp.Includes.Users {
|
|
if user.ID == tweet.AuthorID {
|
|
author = "@" + user.Username
|
|
if user.Verified {
|
|
author += " [verified]"
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n[%d] %s\n", i+1, author)
|
|
|
|
// Truncate tweet if too long
|
|
text := tweet.Text
|
|
if len(text) > 200 {
|
|
text = text[:197] + "..."
|
|
}
|
|
fmt.Printf(" %s\n", strings.ReplaceAll(text, "\n", "\n "))
|
|
fmt.Printf(" Replies: %d Retweets: %d Likes: %d\n",
|
|
tweet.PublicMetrics.ReplyCount,
|
|
tweet.PublicMetrics.RetweetCount,
|
|
tweet.PublicMetrics.LikeCount)
|
|
}
|
|
|
|
if resp.Meta.NextToken != "" {
|
|
fmt.Println("\nMORE RESULTS AVAILABLE")
|
|
fmt.Println("More results available - you can implement pagination if needed")
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
func loadSearchConfig(configFile string) (*SearchConfig, error) {
|
|
var config SearchConfig
|
|
|
|
// Set defaults
|
|
// NOTE: max_results is per-tweet cost! (not per-request)
|
|
// With 100 reads/month, default to 1 to maximize search operations
|
|
config.Output.MaxResults = 1
|
|
config.Output.TweetFields = "created_at,public_metrics,author_id"
|
|
config.Output.UserFields = "username,verified"
|
|
|
|
if _, err := toml.DecodeFile(configFile, &config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate max_results (cap at 100 to avoid burning quota)
|
|
if config.Output.MaxResults < 1 {
|
|
config.Output.MaxResults = 10
|
|
}
|
|
if config.Output.MaxResults > 100 {
|
|
config.Output.MaxResults = 100
|
|
}
|
|
|
|
return &config, nil
|
|
}
|
|
|
|
func (s *SearchConfig) BuildQuery() (string, bool, []string) {
|
|
var errors []string
|
|
|
|
// Keywords are required
|
|
if s.Query.Keywords == "" {
|
|
errors = append(errors, "Keywords are required")
|
|
return "", false, errors
|
|
}
|
|
|
|
// Start with keywords
|
|
query := s.Query.Keywords
|
|
|
|
// Add language
|
|
if s.Query.Language != "" {
|
|
query += fmt.Sprintf(" lang:%s", s.Query.Language)
|
|
}
|
|
|
|
// Add filters
|
|
if !s.Filters.IncludeRetweets {
|
|
query += " -is:retweet"
|
|
}
|
|
|
|
if s.Filters.MinLikes > 0 {
|
|
query += fmt.Sprintf(" min_faves:%d", s.Filters.MinLikes)
|
|
}
|
|
|
|
if s.Filters.MinRetweets > 0 {
|
|
query += fmt.Sprintf(" min_retweets:%d", s.Filters.MinRetweets)
|
|
}
|
|
|
|
if s.Filters.HasLinks {
|
|
query += " has:links"
|
|
}
|
|
|
|
if s.Filters.HasMedia {
|
|
query += " has:media"
|
|
}
|
|
|
|
if s.Filters.HasVideo {
|
|
query += " has:video"
|
|
}
|
|
|
|
// Add source
|
|
if s.Source.FromUser != "" {
|
|
query += fmt.Sprintf(" from:%s", s.Source.FromUser)
|
|
}
|
|
|
|
if s.Source.ToUser != "" {
|
|
query += fmt.Sprintf(" to:%s", s.Source.ToUser)
|
|
}
|
|
|
|
// Check query length (512 chars for free/basic tier)
|
|
if len(query) > 512 {
|
|
errors = append(errors, fmt.Sprintf("Query too long (%d chars). Maximum is 512.", len(query)))
|
|
return query, false, errors
|
|
}
|
|
|
|
return query, true, nil
|
|
} |