From 67ac9cea5dc45dabd89bdb7b85fecf8a3a0d0524 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Tue, 23 Jun 2026 19:34:53 +0300 Subject: [PATCH] Add opt-in Xquik tweet backend --- src/cli/help/help.go | 3 +- src/cli/tweet/post.go | 51 ++++++++------- src/cli/tweet/xquik.go | 120 ++++++++++++++++++++++++++++++++++ src/cli/tweet/xquik_test.go | 126 ++++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 src/cli/tweet/xquik.go create mode 100644 src/cli/tweet/xquik_test.go diff --git a/src/cli/help/help.go b/src/cli/help/help.go index 33f6aa8..3aba1a8 100644 --- a/src/cli/help/help.go +++ b/src/cli/help/help.go @@ -19,7 +19,8 @@ func Help() { fmt.Println(" -t \"text\" post a tweet") fmt.Println(" -v show version") fmt.Println(" -c clear authorized account") + fmt.Println(" X_BACKEND=xquik uses XQUIK_API_KEY and XQUIK_ACCOUNT") fmt.Println() fmt.Println("LEARN MORE") fmt.Println(" Cheack source code at: https://github.com/devhindo/x") -} \ No newline at end of file +} diff --git a/src/cli/tweet/post.go b/src/cli/tweet/post.go index ff5a473..53903a3 100644 --- a/src/cli/tweet/post.go +++ b/src/cli/tweet/post.go @@ -17,6 +17,13 @@ type Tweet struct { } func POST_tweet(t string) { + if useXquikBackend() { + if err := postTweetWithXquik(t); err != nil { + fmt.Println(err) + os.Exit(1) + } + return + } license, err := lock.ReadLicenseKeyFromFile() @@ -30,28 +37,28 @@ func POST_tweet(t string) { License: license, Tweet: t, } - + postT(url, tweet) } type response struct { - Message string `json:"message"` + Message string `json:"message"` } func postT(url string, t Tweet) { - jsonBytes, err := json.Marshal(t) - if err != nil { - panic(err) - } + jsonBytes, err := json.Marshal(t) + if err != nil { + panic(err) + } - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - if err != nil { - fmt.Println("can't reach server to post a tweet") + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) + if err != nil { + fmt.Println("can't reach server to post a tweet") os.Exit(0) - } + } - defer resp.Body.Close() + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { @@ -59,16 +66,14 @@ func postT(url string, t Tweet) { panic(err) } + var r response + err = json.Unmarshal(body, &r) + if err != nil { + fmt.Printf("rate limit reached thanks Elon! Try again tomorrow when it resets, sorry.") + //Failed to unmarshal response. + return + } - var r response - err = json.Unmarshal(body, &r) - if err != nil { - fmt.Printf("rate limit reached thanks Elon! Try again tomorrow when it resets, sorry.") - //Failed to unmarshal response. - return - } - - - //Convert bytes to String and print - fmt.Println(r.Message) -} \ No newline at end of file + //Convert bytes to String and print + fmt.Println(r.Message) +} diff --git a/src/cli/tweet/xquik.go b/src/cli/tweet/xquik.go new file mode 100644 index 0000000..ce2a4f2 --- /dev/null +++ b/src/cli/tweet/xquik.go @@ -0,0 +1,120 @@ +package tweet + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +const defaultXquikBaseURL = "https://xquik.com" + +type xquikConfig struct { + APIKey string + Account string + BaseURL string +} + +type xquikTweetRequest struct { + Account string `json:"account"` + Text string `json:"text"` +} + +type xquikTweetResponse struct { + TweetID string `json:"tweetId"` + Success bool `json:"success"` + Status string `json:"status"` + WriteActionID string `json:"writeActionId"` + Message string `json:"message"` + Error string `json:"error"` +} + +func useXquikBackend() bool { + backend := os.Getenv("X_BACKEND") + if backend == "" { + backend = os.Getenv("TWITTER_BACKEND") + } + return strings.EqualFold(backend, "xquik") +} + +func xquikConfigFromEnv() (xquikConfig, error) { + cfg := xquikConfig{ + APIKey: strings.TrimSpace(os.Getenv("XQUIK_API_KEY")), + Account: strings.TrimSpace(os.Getenv("XQUIK_ACCOUNT")), + BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("XQUIK_API_BASE_URL")), "/"), + } + + if cfg.APIKey == "" { + return cfg, fmt.Errorf("XQUIK_API_KEY is required when X_BACKEND=xquik") + } + if cfg.Account == "" { + return cfg, fmt.Errorf("XQUIK_ACCOUNT is required when X_BACKEND=xquik") + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultXquikBaseURL + } + return cfg, nil +} + +func postTweetWithXquik(text string) error { + cfg, err := xquikConfigFromEnv() + if err != nil { + return err + } + return postTweetWithXquikClient(http.DefaultClient, cfg, text, os.Stdout) +} + +func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, out io.Writer) error { + payload := xquikTweetRequest{ + Account: cfg.Account, + Text: text, + } + jsonBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", cfg.BaseURL+"/api/v1/x/tweets", bytes.NewBuffer(jsonBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", cfg.APIKey) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("can't reach Xquik to post a tweet") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("can't read Xquik response") + } + + var result xquikTweetResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("can't parse Xquik response") + } + } + + if resp.StatusCode == http.StatusOK && result.TweetID != "" { + fmt.Fprintf(out, "tweet posted with Xquik: %s\n", result.TweetID) + return nil + } + if resp.StatusCode == http.StatusAccepted && result.WriteActionID != "" { + fmt.Fprintf(out, "tweet accepted by Xquik; confirmation pending: %s\n", result.WriteActionID) + return nil + } + if result.Message != "" { + return fmt.Errorf("Xquik request failed: %s", result.Message) + } + if result.Error != "" { + return fmt.Errorf("Xquik request failed: %s", result.Error) + } + return fmt.Errorf("Xquik request failed with status %d", resp.StatusCode) +} diff --git a/src/cli/tweet/xquik_test.go b/src/cli/tweet/xquik_test.go new file mode 100644 index 0000000..5ad9956 --- /dev/null +++ b/src/cli/tweet/xquik_test.go @@ -0,0 +1,126 @@ +package tweet + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUseXquikBackend(t *testing.T) { + t.Setenv("X_BACKEND", "xquik") + t.Setenv("TWITTER_BACKEND", "") + + if !useXquikBackend() { + t.Fatal("expected X_BACKEND=xquik to enable the Xquik backend") + } +} + +func TestXquikConfigFromEnvRequiresCredentials(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "") + t.Setenv("XQUIK_ACCOUNT", "") + t.Setenv("XQUIK_API_BASE_URL", "") + + _, err := xquikConfigFromEnv() + if err == nil || !strings.Contains(err.Error(), "XQUIK_API_KEY") { + t.Fatalf("expected missing API key error, got %v", err) + } +} + +func TestPostTweetWithXquikClientPostsTweet(t *testing.T) { + var gotRequest xquikTweetRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v1/x/tweets" { + t.Fatalf("path = %s, want /api/v1/x/tweets", r.URL.Path) + } + if r.Header.Get("X-API-Key") != "test-key" { + t.Fatalf("X-API-Key header = %q", r.Header.Get("X-API-Key")) + } + if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true,"tweetId":"123"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err != nil { + t.Fatalf("postTweetWithXquikClient returned error: %v", err) + } + if gotRequest.Account != "@example" || gotRequest.Text != "hello" { + t.Fatalf("request = %+v", gotRequest) + } + if out.String() != "tweet posted with Xquik: 123\n" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikClientAcceptsPendingConfirmation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"pending_confirmation","writeActionId":"42"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err != nil { + t.Fatalf("postTweetWithXquikClient returned error: %v", err) + } + if out.String() != "tweet accepted by Xquik; confirmation pending: 42\n" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikClientReturnsAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"invalid API key"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err == nil || !strings.Contains(err.Error(), "invalid API key") { + t.Fatalf("expected API error, got %v", err) + } + if out.String() != "" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikUsesDefaultBaseURL(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "test-key") + t.Setenv("XQUIK_ACCOUNT", "@example") + t.Setenv("XQUIK_API_BASE_URL", "") + + cfg, err := xquikConfigFromEnv() + if err != nil { + t.Fatalf("xquikConfigFromEnv returned error: %v", err) + } + if cfg.BaseURL != defaultXquikBaseURL { + t.Fatalf("BaseURL = %q", cfg.BaseURL) + } +}