From 7c0f4b692c3b712bf4a0da3bbac008ff75c405de Mon Sep 17 00:00:00 2001 From: s Date: Mon, 10 Nov 2025 08:31:32 -0500 Subject: feat: add x replies/search commands and improve api key validation --- internal/client/reddit.go | 20 ++++----- internal/client/x.go | 108 ++++++++++++++++++++++++++++++++++++++++++++++ internal/models/reddit.go | 29 ++++++++----- internal/models/x.go | 29 +++++++++++++ 4 files changed, 166 insertions(+), 20 deletions(-) (limited to 'internal') diff --git a/internal/client/reddit.go b/internal/client/reddit.go index da95782..907e094 100644 --- a/internal/client/reddit.go +++ b/internal/client/reddit.go @@ -7,14 +7,14 @@ import ( "git.db.org.ai/dborg/internal/models" ) -func (c *Client) GetSubredditPosts(params *models.RedditSubredditParams) (*models.RedditResponse, error) { +func (c *Client) GetSubredditPosts(params *models.RedditSubredditParams) (*models.SubredditResponse, error) { path := fmt.Sprintf("/reddit/r/%s", params.Subreddit) data, err := c.Get(path, nil) if err != nil { return nil, err } - var response models.RedditResponse + var response models.SubredditResponse if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } @@ -22,14 +22,14 @@ func (c *Client) GetSubredditPosts(params *models.RedditSubredditParams) (*model return &response, nil } -func (c *Client) GetSubredditComments(params *models.RedditSubredditParams) (*models.RedditResponse, error) { +func (c *Client) GetSubredditComments(params *models.RedditSubredditParams) (*models.SubredditResponse, error) { path := fmt.Sprintf("/reddit/r/%s/comments", params.Subreddit) data, err := c.Get(path, nil) if err != nil { return nil, err } - var response models.RedditResponse + var response models.SubredditResponse if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } @@ -37,14 +37,14 @@ func (c *Client) GetSubredditComments(params *models.RedditSubredditParams) (*mo return &response, nil } -func (c *Client) GetUserPosts(params *models.RedditUserParams) (*models.RedditResponse, error) { +func (c *Client) GetUserPosts(params *models.RedditUserParams) (*models.UserResponse, error) { path := fmt.Sprintf("/reddit/user/%s/posts", params.Username) data, err := c.Get(path, nil) if err != nil { return nil, err } - var response models.RedditResponse + var response models.UserResponse if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } @@ -52,14 +52,14 @@ func (c *Client) GetUserPosts(params *models.RedditUserParams) (*models.RedditRe return &response, nil } -func (c *Client) GetUserComments(params *models.RedditUserParams) (*models.RedditResponse, error) { +func (c *Client) GetUserComments(params *models.RedditUserParams) (*models.UserResponse, error) { path := fmt.Sprintf("/reddit/user/%s/comments", params.Username) data, err := c.Get(path, nil) if err != nil { return nil, err } - var response models.RedditResponse + var response models.UserResponse if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } @@ -67,14 +67,14 @@ func (c *Client) GetUserComments(params *models.RedditUserParams) (*models.Reddi return &response, nil } -func (c *Client) GetUserAbout(params *models.RedditUserParams) (*models.RedditResponse, error) { +func (c *Client) GetUserAbout(params *models.RedditUserParams) (*models.UserResponse, error) { path := fmt.Sprintf("/reddit/user/%s/about", params.Username) data, err := c.Get(path, nil) if err != nil { return nil, err } - var response models.RedditResponse + var response models.UserResponse if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } diff --git a/internal/client/x.go b/internal/client/x.go index bd16692..b6ddba8 100644 --- a/internal/client/x.go +++ b/internal/client/x.go @@ -99,3 +99,111 @@ func (c *Client) FetchTweetsStream(username string, callback func(result json.Ra return nil } + +func (c *Client) FetchRepliesStream(tweetID string, limit int, callback func(result json.RawMessage) error) error { + path := fmt.Sprintf("/x/replies/%s", url.PathEscape(tweetID)) + + params := url.Values{} + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + + fullURL := c.config.BaseURL + path + if len(params) > 0 { + fullURL += "?" + params.Encode() + } + + req, err := http.NewRequest(http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", c.config.UserAgent) + req.Header.Set("Accept", "application/x-ndjson, application/json") + req.Header.Set("X-API-Key", c.config.APIKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + if err := callback(json.RawMessage(line)); err != nil { + return err + } + } + + if err := scanner.Err(); err != nil { + if !strings.Contains(err.Error(), "context deadline exceeded") && !strings.Contains(err.Error(), "timeout") { + return fmt.Errorf("stream reading error: %w", err) + } + } + + return nil +} + +func (c *Client) SearchTweetsStream(query string, limit int, callback func(result json.RawMessage) error) error { + path := fmt.Sprintf("/x/search/%s", url.PathEscape(query)) + + params := url.Values{} + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + + fullURL := c.config.BaseURL + path + if len(params) > 0 { + fullURL += "?" + params.Encode() + } + + req, err := http.NewRequest(http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", c.config.UserAgent) + req.Header.Set("Accept", "application/x-ndjson, application/json") + req.Header.Set("X-API-Key", c.config.APIKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + if err := callback(json.RawMessage(line)); err != nil { + return err + } + } + + if err := scanner.Err(); err != nil { + if !strings.Contains(err.Error(), "context deadline exceeded") && !strings.Contains(err.Error(), "timeout") { + return fmt.Errorf("stream reading error: %w", err) + } + } + + return nil +} diff --git a/internal/models/reddit.go b/internal/models/reddit.go index 4d81aa8..bfe4172 100644 --- a/internal/models/reddit.go +++ b/internal/models/reddit.go @@ -8,14 +8,23 @@ type RedditUserParams struct { Username string `json:"username"` } -type RedditResponse struct { - Subreddit string `json:"subreddit,omitempty"` - Username string `json:"username,omitempty"` - Type string `json:"type,omitempty"` - Results interface{} `json:"results"` - Credits struct { - Remaining int `json:"remaining"` - Unlimited bool `json:"unlimited"` - } `json:"credits"` - Error string `json:"error,omitempty"` +type RedditCredits struct { + Remaining int `json:"remaining"` + Unlimited bool `json:"unlimited"` +} + +type SubredditResponse struct { + Subreddit string `json:"subreddit"` + Type string `json:"type"` + Results interface{} `json:"results"` + Credits RedditCredits `json:"credits"` + Error string `json:"error,omitempty"` +} + +type UserResponse struct { + Username string `json:"username"` + Type string `json:"type"` + Results interface{} `json:"results"` + Credits RedditCredits `json:"credits"` + Error string `json:"error,omitempty"` } diff --git a/internal/models/x.go b/internal/models/x.go index 07c3117..8480d32 100644 --- a/internal/models/x.go +++ b/internal/models/x.go @@ -79,3 +79,32 @@ type NotableFollowersResponse struct { Unlimited bool `json:"unlimited"` } `json:"credits"` } + +type ScrapedReply struct { + ConversationID string `json:"conversation_id"` + Date string `json:"date"` + ID string `json:"id"` + InReplyToID string `json:"in_reply_to_id"` + Languages string `json:"languages"` + Likes string `json:"likes"` + Name string `json:"name"` + Post string `json:"post"` + Quotes string `json:"quotes"` + Reposts string `json:"reposts"` + Type string `json:"type"` + URL string `json:"url"` + Username string `json:"username"` +} + +type ScrapedTweet struct { + Date string `json:"date"` + ID string `json:"id"` + Languages string `json:"languages"` + Likes string `json:"likes"` + Name string `json:"name"` + Post string `json:"post"` + Quotes string `json:"quotes"` + Reposts string `json:"reposts"` + Type string `json:"type"` + URL string `json:"url"` +} -- cgit v1.2.3