diff --git a/dupes.go b/dupes.go new file mode 100644 index 0000000..3b1b158 --- /dev/null +++ b/dupes.go @@ -0,0 +1,46 @@ +package sn + +import ( + "encoding/json" + "fmt" +) + +func Dupes(url string) (*[]Dupe, error) { + body := GraphQLPayload{ + Query: ` + query Dupes($url: String!) { + dupes(url: $url) { + id + url + title + user { + name + } + createdAt + sats + ncomments + } + }`, + Variables: map[string]interface{}{ + "url": url, + }, + } + resp, err := MakeStackerNewsRequest(body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var respBody DupesResponse + err = json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + err = fmt.Errorf("error decoding SN dupes: %w", err) + return nil, err + } + err = CheckForErrors(respBody.Errors) + if err != nil { + return nil, err + } + + return &respBody.Data.Dupes, nil +} diff --git a/go.mod b/go.mod index 3317437..1a1f379 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module github.com/ekzyis/sn-goapi go 1.20 + +require ( + github.com/joho/godotenv v1.5.1 // indirect + github.com/namsral/flag v1.7.4-pre // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c16f8b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs= +github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= diff --git a/main.go b/main.go new file mode 100644 index 0000000..02c723f --- /dev/null +++ b/main.go @@ -0,0 +1,74 @@ +package sn + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "github.com/joho/godotenv" + "github.com/namsral/flag" +) + +var ( + SnUrl = "https://stacker.news" + SnApiUrl = "https://stacker.news/api/graphql" + // TODO add API key support + // SnApiKey string + SnAuthCookie string +) + +func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("error loading .env file") + } + flag.StringVar(&SnAuthCookie, "SN_AUTH_COOKIE", "", "Cookie required for authorizing requests to stacker.news/api/graphql") + flag.Parse() + if SnAuthCookie == "" { + log.Fatal("SN_AUTH_COOKIE not set") + } +} + +func MakeStackerNewsRequest(body GraphQLPayload) (*http.Response, error) { + bodyJSON, err := json.Marshal(body) + if err != nil { + err = fmt.Errorf("error encoding SN payload: %w", err) + return nil, err + } + + req, err := http.NewRequest("POST", SnApiUrl, bytes.NewBuffer(bodyJSON)) + if err != nil { + err = fmt.Errorf("error preparing SN request: %w", err) + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("Cookie", SnAuthCookie) + + client := http.DefaultClient + resp, err := client.Do(req) + if err != nil { + err = fmt.Errorf("error posting SN payload: %w", err) + return nil, err + } + + return resp, nil +} + +func CheckForErrors(graphqlErrors []GraphQLError) error { + if len(graphqlErrors) > 0 { + errorMsg, marshalErr := json.Marshal(graphqlErrors) + if marshalErr != nil { + return marshalErr + } + return errors.New(string(errorMsg)) + } + return nil +} + +func FormatLink(id int) string { + return fmt.Sprintf("%s/items/%d", SnUrl, id) +} diff --git a/notes.go b/notes.go new file mode 100644 index 0000000..d7fef52 --- /dev/null +++ b/notes.go @@ -0,0 +1,33 @@ +package sn + +import ( + "encoding/json" + "fmt" +) + +func HasNewNotes() (bool, error) { + body := GraphQLPayload{ + Query: ` + { + hasNewNotes + }`, + } + resp, err := MakeStackerNewsRequest(body) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var respBody HasNewNotesResponse + err = json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + err = fmt.Errorf("error decoding SN hasNewNotes: %w", err) + return false, err + } + err = CheckForErrors(respBody.Errors) + if err != nil { + return false, err + } + + return respBody.Data.HasNewNotes, nil +} diff --git a/post.go b/post.go new file mode 100644 index 0000000..3aaf994 --- /dev/null +++ b/post.go @@ -0,0 +1,73 @@ +package sn + +import ( + "encoding/json" + "fmt" +) + +func PostLink(url string, title string, sub string) (int, error) { + body := GraphQLPayload{ + Query: ` + mutation upsertLink($url: String!, $title: String!, $sub: String!) { + upsertLink(url: $url, title: $title, sub: $sub) { + id + } + }`, + Variables: map[string]interface{}{ + "url": url, + "title": title, + "sub": sub, + }, + } + resp, err := MakeStackerNewsRequest(body) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + var respBody UpsertLinkResponse + err = json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + err = fmt.Errorf("error decoding SN upsertLink: %w", err) + return -1, err + } + err = CheckForErrors(respBody.Errors) + if err != nil { + return -1, err + } + itemId := respBody.Data.UpsertLink.Id + return itemId, nil +} + +func CreateComment(parentId int, text string) (int, error) { + body := GraphQLPayload{ + Query: ` + mutation createComment($text: String!, $parentId: ID!) { + createComment(text: $text, parentId: $parentId) { + id + } + }`, + Variables: map[string]interface{}{ + "text": text, + "parentId": parentId, + }, + } + resp, err := MakeStackerNewsRequest(body) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + var respBody CreateCommentsResponse + err = json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + err = fmt.Errorf("error decoding SN createComment: %w", err) + return -1, err + } + err = CheckForErrors(respBody.Errors) + if err != nil { + return -1, err + } + + return parentId, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..1cfc0f5 --- /dev/null +++ b/types.go @@ -0,0 +1,93 @@ +package sn + +import ( + "fmt" + "time" +) + +type GraphQLPayload struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +type GraphQLError struct { + Message string `json:"message"` +} + +type User struct { + Name string `json:"name"` +} + +type Comment struct { + Id int `json:"id,string"` + Text string `json:"text"` + User User `json:"user"` + Comments []Comment `json:"comments"` +} + +type CreateCommentsResponse struct { + Errors []GraphQLError `json:"errors"` + Data struct { + CreateComment Comment `json:"createComment"` + } `json:"data"` +} + +type Item struct { + Id int `json:"id,string"` + Title string `json:"title"` + Url string `json:"url"` + Sats int `json:"sats"` + CreatedAt time.Time `json:"createdAt"` + Comments []Comment `json:"comments"` + NComments int `json:"ncomments"` +} + +type UpsertLinkResponse struct { + Errors []GraphQLError `json:"errors"` + Data struct { + UpsertLink Item `json:"upsertLink"` + } `json:"data"` +} + +type ItemsResponse struct { + Errors []GraphQLError `json:"errors"` + Data struct { + Items struct { + Items []Item `json:"items"` + Cursor string `json:"cursor"` + } `json:"items"` + } `json:"data"` +} + +type HasNewNotesResponse struct { + Errors []GraphQLError `json:"errors"` + Data struct { + HasNewNotes bool `json:"hasNewNotes"` + } `json:"data"` +} + +type Dupe struct { + Id int `json:"id,string"` + Url string `json:"url"` + Title string `json:"title"` + User User `json:"user"` + CreatedAt time.Time `json:"createdAt"` + Sats int `json:"sats"` + NComments int `json:"ncomments"` +} + +type DupesResponse struct { + Errors []GraphQLError `json:"errors"` + Data struct { + Dupes []Dupe `json:"dupes"` + } `json:"data"` +} + +type DupesError struct { + Url string + Dupes []Dupe +} + +func (e *DupesError) Error() string { + return fmt.Sprintf("found %d dupes for %s", len(e.Dupes), e.Url) +}