diff --git a/discord.go b/discord.go index 49a9a32..bd878f0 100644 --- a/discord.go +++ b/discord.go @@ -8,6 +8,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/dustin/go-humanize" + "github.com/ekzyis/sn-goapi" "github.com/joho/godotenv" "github.com/namsral/flag" ) @@ -64,7 +65,7 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { story, err := FetchStoryById(hackerNewsId) _, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false}) if err != nil { - var dupesErr *DupesError + var dupesErr *sn.DupesError if errors.As(err, &dupesErr) { SendDupesErrorToDiscord(hackerNewsId, dupesErr) return @@ -107,7 +108,7 @@ func onMessageReact(s *discordgo.Session, reaction *discordgo.MessageReactionAdd } } -func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *DupesError) { +func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *sn.DupesError) { msg := fmt.Sprint(dupesErr) log.Println(msg) @@ -123,7 +124,7 @@ func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *DupesError) { }, &discordgo.MessageEmbedField{ Name: "Id", - Value: StackerNewsItemLink(dupe.Id), + Value: sn.FormatLink(dupe.Id), Inline: true, }, &discordgo.MessageEmbedField{ diff --git a/go.mod b/go.mod index 9b1b253..0b66747 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/bwmarrin/discordgo v0.27.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ekzyis/sn-goapi v0.1.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect diff --git a/go.sum b/go.sum index 2099a9b..be672bc 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ekzyis/sn-goapi v0.1.0 h1:B/v120DgYlpuzqkyiRTMb6C7JIoU3RaYE9M8D6m6OLQ= +github.com/ekzyis/sn-goapi v0.1.0/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/main.go b/main.go index 428512c..f981315 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "errors" "log" "time" + + "github.com/ekzyis/sn-goapi" ) func WaitUntilNextHour() { @@ -23,7 +25,7 @@ func WaitUntilNextMinute() { func CheckNotifications() { var prevHasNewNotes bool for { - hasNewNotes, err := FetchHasNewNotes() + hasNewNotes, err := sn.HasNewNotes() if err != nil { SendErrorToDiscord(err) } else { @@ -56,7 +58,7 @@ func main() { for _, story := range *filtered { _, err := PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false}) if err != nil { - var dupesErr *DupesError + var dupesErr *sn.DupesError if errors.As(err, &dupesErr) { SendDupesErrorToDiscord(story.ID, dupesErr) continue diff --git a/sn.go b/sn.go index 7f83ab3..5c52ba9 100644 --- a/sn.go +++ b/sn.go @@ -1,150 +1,15 @@ package main import ( - "bytes" - "encoding/json" - "errors" "fmt" "log" - "net/http" "time" "github.com/bwmarrin/discordgo" "github.com/dustin/go-humanize" - "github.com/joho/godotenv" - "github.com/namsral/flag" + "github.com/ekzyis/sn-goapi" ) -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 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) -} - -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"` -} - -var ( - StackerNewsUrl = "https://stacker.news" - SnApiUrl = "https://stacker.news/api/graphql" - 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 CurateContentForStackerNews(stories *[]Story) *[]Story { // TODO: filter by relevance @@ -152,60 +17,6 @@ func CurateContentForStackerNews(stories *[]Story) *[]Story { return &slice } -func CheckForErrors(graphqlErrors []GraphQLError) error { - if len(graphqlErrors) > 0 { - errorMsg, marshalErr := json.Marshal(graphqlErrors) - if marshalErr != nil { - return marshalErr - } - return errors.New(fmt.Sprintf("error fetching SN dupes: %s", string(errorMsg))) - } - return nil -} - -func FetchStackerNewsDupes(url string) (*[]Dupe, error) { - log.Printf("Fetching SN dupes (url=%s) ...\n", url) - - 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 dupesResp DupesResponse - err = json.NewDecoder(resp.Body).Decode(&dupesResp) - if err != nil { - err = fmt.Errorf("error decoding SN dupes: %w", err) - return nil, err - } - err = CheckForErrors(dupesResp.Errors) - if err != nil { - return nil, err - } - - log.Printf("Fetching SN dupes (url=%s) ... OK\n", url) - return &dupesResp.Data.Dupes, nil -} - type PostStoryOptions struct { SkipDupes bool } @@ -214,45 +25,19 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) log.Printf("Posting to SN (url=%s) ...\n", story.Url) if !options.SkipDupes { - dupes, err := FetchStackerNewsDupes(story.Url) + dupes, err := sn.Dupes(story.Url) if err != nil { return -1, err } if len(*dupes) > 0 { - return -1, &DupesError{story.Url, *dupes} + return -1, &sn.DupesError{Url: story.Url, Dupes: *dupes} } } - body := GraphQLPayload{ - Query: ` - mutation upsertLink($url: String!, $title: String!, $sub: String!) { - upsertLink(url: $url, title: $title, sub: $sub) { - id - } - }`, - Variables: map[string]interface{}{ - "url": story.Url, - "title": story.Title, - "sub": "bitcoin", - }, - } - resp, err := MakeStackerNewsRequest(body) + parentId, err := sn.PostLink(story.Url, story.Title, "bitcoin") if err != nil { - return -1, err + return -1, fmt.Errorf("error posting link: %w", err) } - defer resp.Body.Close() - - var upsertLinkResp UpsertLinkResponse - err = json.NewDecoder(resp.Body).Decode(&upsertLinkResp) - if err != nil { - err = fmt.Errorf("error decoding SN upsertLink: %w", err) - return -1, err - } - err = CheckForErrors(upsertLinkResp.Errors) - if err != nil { - return -1, err - } - parentId := upsertLinkResp.Data.UpsertLink.Id log.Printf("Posting to SN (url=%s) ... OK \n", story.Url) SendStackerNewsEmbedToDiscord(story.Title, parentId) @@ -265,53 +50,13 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) HackerNewsItemLink(story.ID), story.Score, story.Descendants, ) - CommentStackerNewsPost(comment, parentId) + sn.CreateComment(parentId, comment) return parentId, nil } -func StackerNewsItemLink(id int) string { - return fmt.Sprintf("https://stacker.news/items/%d", id) -} - -func CommentStackerNewsPost(text string, parentId int) (*http.Response, error) { - log.Printf("Commenting SN post (parentId=%d) ...\n", parentId) - - 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 nil, err - } - defer resp.Body.Close() - - var createCommentsResp CreateCommentsResponse - err = json.NewDecoder(resp.Body).Decode(&createCommentsResp) - if err != nil { - err = fmt.Errorf("error decoding SN upsertLink: %w", err) - return nil, err - } - err = CheckForErrors(createCommentsResp.Errors) - if err != nil { - return nil, err - } - - log.Printf("Commenting SN post (parentId=%d) ... OK\n", parentId) - return resp, nil -} - func SendStackerNewsEmbedToDiscord(title string, id int) { Timestamp := time.Now().Format(time.RFC3339) - url := StackerNewsItemLink(id) + url := sn.FormatLink(id) color := 0xffc107 embed := discordgo.MessageEmbed{ Title: title, @@ -341,42 +86,3 @@ func SendNotificationsEmbedToDiscord() { } SendEmbedToDiscord(&embed) } - -func FetchHasNewNotes() (bool, error) { - log.Println("Checking notifications ...") - - body := GraphQLPayload{ - Query: ` - { - hasNewNotes - }`, - } - resp, err := MakeStackerNewsRequest(body) - if err != nil { - return false, err - } - defer resp.Body.Close() - - var hasNewNotesResp HasNewNotesResponse - err = json.NewDecoder(resp.Body).Decode(&hasNewNotesResp) - if err != nil { - err = fmt.Errorf("error decoding SN hasNewNotes: %w", err) - return false, err - } - err = CheckForErrors(hasNewNotesResp.Errors) - if err != nil { - return false, err - } - - hasNewNotes := hasNewNotesResp.Data.HasNewNotes - - msg := "Checking notifications ... OK - " - if hasNewNotes { - msg += "NEW" - } else { - msg += "NONE" - } - log.Println(msg) - - return hasNewNotes, nil -} diff --git a/sn_test.go b/sn_test.go deleted file mode 100644 index 0fe68dd..0000000 --- a/sn_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "log" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFetchDupes(t *testing.T) { - // TODO: mock HTTP request - url := "https://en.wikipedia.org/wiki/Dishwasher_salmon" - dupes, err := FetchStackerNewsDupes(url) - if err != nil { - log.Fatal(err) - } - assert.NotEmpty(t, *dupes, "Expected at least one duplicate") -}