From 2f7214e9a08adfef9fd0b5403695dae0011d7e38 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 19:43:48 +0200 Subject: [PATCH 01/11] Move SnApiToken check into sn.go --- main.go | 23 ----------------------- sn.go | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/main.go b/main.go index 22c6460..365ccca 100644 --- a/main.go +++ b/main.go @@ -1,28 +1,5 @@ package main -import ( - "log" - - "github.com/joho/godotenv" - "github.com/namsral/flag" -) - -var ( - SnApiToken string -) - -func init() { - err := godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - flag.StringVar(&SnApiToken, "NEXT_AUTH_CSRF_TOKEN", "", "Token required for authorizing requests to stacker.news/api/graphql") - flag.Parse() - if SnApiToken == "" { - log.Fatal("NEXT_AUTH_CSRF_TOKEN not set") - } -} - func main() { stories := fetchTopStoriesFromHN() filtered := filterByRelevanceForSN(&stories) diff --git a/sn.go b/sn.go index 6498dcc..c392fe5 100644 --- a/sn.go +++ b/sn.go @@ -6,6 +6,9 @@ import ( "fmt" "log" "net/http" + + "github.com/joho/godotenv" + "github.com/namsral/flag" ) type GraphQLPayload struct { @@ -25,6 +28,22 @@ type DupesResponse struct { } `json:"data"` } +var ( + SnApiToken string +) + +func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + flag.StringVar(&SnApiToken, "NEXT_AUTH_CSRF_TOKEN", "", "Token required for authorizing requests to stacker.news/api/graphql") + flag.Parse() + if SnApiToken == "" { + log.Fatal("NEXT_AUTH_CSRF_TOKEN not set") + } +} + func makeGraphQLRequest(body GraphQLPayload) *http.Response { bodyJSON, err := json.Marshal(body) if err != nil { From 02a9e4e32fc93b8afffb83b8f8f99a5276d3974d Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 19:51:17 +0200 Subject: [PATCH 02/11] Use more vars --- hn.go | 12 +++++++++++- sn.go | 7 ++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/hn.go b/hn.go index e2a2f52..db84907 100644 --- a/hn.go +++ b/hn.go @@ -20,10 +20,20 @@ type Story struct { Url string } +var ( + HackerNewsUrl string + HackerNewsFirebaseUrl string +) + +func init() { + HackerNewsUrl = "https://news.ycombinator.com" + HackerNewsFirebaseUrl = "https://hacker-news.firebaseio.com/v0" +} + func fetchTopStoriesFromHN() []Story { // API docs: https://github.com/HackerNews/API - url := "https://hacker-news.firebaseio.com/v0/topstories.json" + url := fmt.Sprintf("%s/topstories.json", HackerNewsFirebaseUrl) resp, err := http.Get(url) if err != nil { log.Fatal("Error fetching top stories:", err) diff --git a/sn.go b/sn.go index c392fe5..48013a6 100644 --- a/sn.go +++ b/sn.go @@ -29,10 +29,12 @@ type DupesResponse struct { } var ( + SnApiUrl string SnApiToken string ) func init() { + SnApiUrl = "https://stacker.news/api/graphql" err := godotenv.Load() if err != nil { log.Fatal("Error loading .env file") @@ -50,8 +52,7 @@ func makeGraphQLRequest(body GraphQLPayload) *http.Response { log.Fatal("Error during json.Marshal:", err) } - url := "https://stacker.news/api/graphql" - req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyJSON)) + req, err := http.NewRequest("POST", SnApiUrl, bytes.NewBuffer(bodyJSON)) if err != nil { panic(err) } @@ -64,7 +65,7 @@ func makeGraphQLRequest(body GraphQLPayload) *http.Response { panic(err) } - log.Printf("POST %s %d\n", url, resp.StatusCode) + log.Printf("POST %s %d\n", SnApiUrl, resp.StatusCode) return resp } From 5d2130b6f57fdf5e52300c857fbd6693fe7a984e Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 19:56:42 +0200 Subject: [PATCH 03/11] Rename functions --- hn.go | 6 +++--- main.go | 6 +++--- sn.go | 14 +++++++------- sn_test.go | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/hn.go b/hn.go index db84907..df02fe7 100644 --- a/hn.go +++ b/hn.go @@ -30,7 +30,7 @@ func init() { HackerNewsFirebaseUrl = "https://hacker-news.firebaseio.com/v0" } -func fetchTopStoriesFromHN() []Story { +func FetchHackerNewsTopStories() []Story { // API docs: https://github.com/HackerNews/API url := fmt.Sprintf("%s/topstories.json", HackerNewsFirebaseUrl) @@ -53,7 +53,7 @@ func fetchTopStoriesFromHN() []Story { var stories [limit]Story for i, id := range ids { - story := fetchStoryByID(id) + story := FetchStoryById(id) stories[i] = story } @@ -61,7 +61,7 @@ func fetchTopStoriesFromHN() []Story { return stories[:] } -func fetchStoryByID(id ItemID) Story { +func FetchStoryById(id ItemID) Story { url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id) resp, err := http.Get(url) if err != nil { diff --git a/main.go b/main.go index 365ccca..27fa5c6 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,9 @@ package main func main() { - stories := fetchTopStoriesFromHN() - filtered := filterByRelevanceForSN(&stories) + stories := FetchHackerNewsTopStories() + filtered := CurateContentForStackerNews(&stories) for _, story := range *filtered { - postToSN(&story) + PostStoryToStackerNews(&story) } } diff --git a/sn.go b/sn.go index 48013a6..91f7eac 100644 --- a/sn.go +++ b/sn.go @@ -46,7 +46,7 @@ func init() { } } -func makeGraphQLRequest(body GraphQLPayload) *http.Response { +func MakeStackerNewsRequest(body GraphQLPayload) *http.Response { bodyJSON, err := json.Marshal(body) if err != nil { log.Fatal("Error during json.Marshal:", err) @@ -70,14 +70,14 @@ func makeGraphQLRequest(body GraphQLPayload) *http.Response { return resp } -func filterByRelevanceForSN(stories *[]Story) *[]Story { +func CurateContentForStackerNews(stories *[]Story) *[]Story { // TODO: filter by relevance slice := (*stories)[0:1] return &slice } -func fetchDupes(url string) *[]Dupe { +func FetchStackerNewsDupes(url string) *[]Dupe { body := GraphQLPayload{ Query: ` query Dupes($url: String!) { @@ -91,7 +91,7 @@ func fetchDupes(url string) *[]Dupe { "url": url, }, } - resp := makeGraphQLRequest(body) + resp := MakeStackerNewsRequest(body) defer resp.Body.Close() var dupesResp DupesResponse @@ -103,8 +103,8 @@ func fetchDupes(url string) *[]Dupe { return &dupesResp.Data.Dupes } -func postToSN(story *Story) { - dupes := fetchDupes(story.Url) +func PostStoryToStackerNews(story *Story) { + dupes := FetchStackerNewsDupes(story.Url) if len(*dupes) > 0 { return } @@ -122,6 +122,6 @@ func postToSN(story *Story) { "title": story.Title, }, } - resp := makeGraphQLRequest(body) + resp := MakeStackerNewsRequest(body) defer resp.Body.Close() } diff --git a/sn_test.go b/sn_test.go index 020e5cc..806b53d 100644 --- a/sn_test.go +++ b/sn_test.go @@ -9,6 +9,6 @@ import ( func TestFetchDupes(t *testing.T) { // TODO: mock HTTP request url := "https://en.wikipedia.org/wiki/Dishwasher_salmon" - dupes := fetchDupes(url) + dupes := FetchStackerNewsDupes(url) assert.NotEmpty(t, *dupes, "Expected at least one duplicate") } From 56a33da94a684d1e10f980302a1d8907f2ebf7cf Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 20:00:13 +0200 Subject: [PATCH 04/11] Use consistent query indentation --- sn.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sn.go b/sn.go index 91f7eac..77b8ac3 100644 --- a/sn.go +++ b/sn.go @@ -111,12 +111,11 @@ func PostStoryToStackerNews(story *Story) { body := GraphQLPayload{ Query: ` - mutation upsertLink($url: String!, $title: String!) { - upsertLink(url: $url, title: $title) { - id - } - } - `, + mutation upsertLink($url: String!, $title: String!) { + upsertLink(url: $url, title: $title) { + id + } + }`, Variables: map[string]interface{}{ "url": story.Url, "title": story.Title, From 8cc4b42094a483bf825266f9d8b4501e25ebc3d0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 21:05:10 +0200 Subject: [PATCH 05/11] Comment SN posts with HN info --- go.mod | 1 + go.sum | 2 ++ hn.go | 8 ++++++++ sn.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/go.mod b/go.mod index 0e1d138..a1c1a30 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/namsral/flag v1.7.4-pre // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 893bfa6..9779f50 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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= diff --git a/hn.go b/hn.go index df02fe7..f200536 100644 --- a/hn.go +++ b/hn.go @@ -78,3 +78,11 @@ func FetchStoryById(id ItemID) Story { return story } + +func HackerNewsUserLink(user string) string { + return fmt.Sprintf("%s/user?id=%s", HackerNewsUrl, user) +} + +func HackerNewsItemLink(id int) string { + return fmt.Sprintf("%s/item?id=%d", HackerNewsUrl, id) +} diff --git a/sn.go b/sn.go index 77b8ac3..036cf7a 100644 --- a/sn.go +++ b/sn.go @@ -6,7 +6,9 @@ import ( "fmt" "log" "net/http" + "time" + "github.com/dustin/go-humanize" "github.com/joho/godotenv" "github.com/namsral/flag" ) @@ -28,6 +30,16 @@ type DupesResponse struct { } `json:"data"` } +type Item struct { + Id int `json:"id,string"` +} + +type UpsertLinkResponse struct { + Data struct { + UpsertLink Item `json:"upsertLink"` + } `json:"data"` +} + var ( SnApiUrl string SnApiToken string @@ -123,4 +135,38 @@ func PostStoryToStackerNews(story *Story) { } resp := MakeStackerNewsRequest(body) defer resp.Body.Close() + + var upsertLinkResp UpsertLinkResponse + err := json.NewDecoder(resp.Body).Decode(&upsertLinkResp) + if err != nil { + log.Fatal("Error decoding dupes JSON:", err) + } + + parentId := upsertLinkResp.Data.UpsertLink.Id + comment := fmt.Sprintf( + "This link was posted by [%s](%s) %s on [HN](%s). It received %d points and %d comments.", + story.By, + HackerNewsUserLink(story.By), + humanize.Time(time.Unix(int64(story.Time), 0)), + HackerNewsItemLink(story.ID), + story.Score, story.Descendants, + ) + CommentStackerNewsPost(comment, parentId) +} + +func CommentStackerNewsPost(text string, parentId int) { + body := GraphQLPayload{ + Query: ` + mutation createComment($text: String!, $parentId: ID!) { + createComment(text: $text, parentId: $parentId) { + id + } + }`, + Variables: map[string]interface{}{ + "text": text, + "parentId": parentId, + }, + } + resp := MakeStackerNewsRequest(body) + defer resp.Body.Close() } From c0e20780391743a2665a82b78eede7a88d44b0d6 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 21:50:57 +0200 Subject: [PATCH 06/11] Add logging --- sn.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sn.go b/sn.go index 036cf7a..a4bffb8 100644 --- a/sn.go +++ b/sn.go @@ -118,6 +118,7 @@ func FetchStackerNewsDupes(url string) *[]Dupe { func PostStoryToStackerNews(story *Story) { dupes := FetchStackerNewsDupes(story.Url) if len(*dupes) > 0 { + log.Printf("%s was already posted. Skipping.\n", story.Url) return } @@ -141,8 +142,10 @@ func PostStoryToStackerNews(story *Story) { if err != nil { log.Fatal("Error decoding dupes JSON:", err) } - parentId := upsertLinkResp.Data.UpsertLink.Id + + log.Printf("Created new post on SN: id=%d url=%s\n", parentId, story.Url) + comment := fmt.Sprintf( "This link was posted by [%s](%s) %s on [HN](%s). It received %d points and %d comments.", story.By, @@ -152,6 +155,9 @@ func PostStoryToStackerNews(story *Story) { story.Score, story.Descendants, ) CommentStackerNewsPost(comment, parentId) + + log.Printf("Commented post on SN: parentId=%d text='%s'\n", parentId, comment) + } func CommentStackerNewsPost(text string, parentId int) { From 7b841abf2fc0ed0522c1985face762ca60c69b83 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 22:03:14 +0200 Subject: [PATCH 07/11] Fix SN API authorization --- .env.template | 2 +- sn.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env.template b/.env.template index 123b52b..959dded 100644 --- a/.env.template +++ b/.env.template @@ -1 +1 @@ -NEXT_AUTH_CSRF_TOKEN= \ No newline at end of file +AUTH_COOKIE= \ No newline at end of file diff --git a/sn.go b/sn.go index a4bffb8..0f42094 100644 --- a/sn.go +++ b/sn.go @@ -41,8 +41,8 @@ type UpsertLinkResponse struct { } var ( - SnApiUrl string - SnApiToken string + SnApiUrl string + SnAuthCookie string ) func init() { @@ -51,10 +51,10 @@ func init() { if err != nil { log.Fatal("Error loading .env file") } - flag.StringVar(&SnApiToken, "NEXT_AUTH_CSRF_TOKEN", "", "Token required for authorizing requests to stacker.news/api/graphql") + flag.StringVar(&SnAuthCookie, "AUTH_COOKIE", "", "Cookie required for authorizing requests to stacker.news/api/graphql") flag.Parse() - if SnApiToken == "" { - log.Fatal("NEXT_AUTH_CSRF_TOKEN not set") + if SnAuthCookie == "" { + log.Fatal("AUTH_COOKIE not set") } } @@ -69,7 +69,7 @@ func MakeStackerNewsRequest(body GraphQLPayload) *http.Response { panic(err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Cookie", fmt.Sprintf("__Host-next-auth.csrf-token=%s", SnApiToken)) + req.Header.Set("Cookie", SnAuthCookie) client := http.DefaultClient resp, err := client.Do(req) From f853cb5050903f5ebeb116baf8bbf43731d21762 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 16 Apr 2023 23:26:31 +0200 Subject: [PATCH 08/11] Add function to comment HN story --- .env.template | 3 ++- hn.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ sn.go | 4 +-- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.env.template b/.env.template index 959dded..8b8c8ec 100644 --- a/.env.template +++ b/.env.template @@ -1 +1,2 @@ -AUTH_COOKIE= \ No newline at end of file +SN_AUTH_COOKIE= +HN_AUTH_COOKIE= diff --git a/hn.go b/hn.go index f200536..c42fb7b 100644 --- a/hn.go +++ b/hn.go @@ -3,8 +3,16 @@ package main import ( "encoding/json" "fmt" + "io/ioutil" "log" "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/joho/godotenv" + "github.com/namsral/flag" ) type ItemID = int @@ -23,11 +31,21 @@ type Story struct { var ( HackerNewsUrl string HackerNewsFirebaseUrl string + HnAuthCookie string ) func init() { HackerNewsUrl = "https://news.ycombinator.com" HackerNewsFirebaseUrl = "https://hacker-news.firebaseio.com/v0" + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + flag.StringVar(&HnAuthCookie, "HN_AUTH_COOKIE", "", "Cookie required for authorizing requests to news.ycombinator.com") + flag.Parse() + if HnAuthCookie == "" { + log.Fatal("HN_AUTH_COOKIE not set") + } } func FetchHackerNewsTopStories() []Story { @@ -79,6 +97,61 @@ func FetchStoryById(id ItemID) Story { return story } +func FetchHackerNewsItemHMAC(id ItemID) string { + hnUrl := fmt.Sprintf("%s/item?id=%d", HackerNewsUrl, id) + req, err := http.NewRequest("GET", hnUrl, nil) + if err != nil { + panic(err) + } + // Cookie header must be set to fetch the correct HMAC for posting comments + req.Header.Set("Cookie", HnAuthCookie) + client := http.DefaultClient + resp, err := client.Do(req) + if err != nil { + panic(err) + } + log.Printf("GET %s %d\n", hnUrl, resp.StatusCode) + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatal("Failed to read response body:", err) + } + + // Find HMAC in body + re := regexp.MustCompile(`name="hmac" value="([a-z0-9]+)"`) + match := re.FindStringSubmatch(string(body)) + if len(match) == 0 { + log.Fatal("No HMAC found") + } + hmac := match[1] + + return hmac +} + +func CommentHackerNewsStory(text string, id ItemID) { + hmac := FetchHackerNewsItemHMAC(id) + + hnUrl := fmt.Sprintf("%s/comment", HackerNewsUrl) + data := url.Values{} + data.Set("parent", strconv.Itoa(id)) + data.Set("goto", fmt.Sprintf("item?id=%d", id)) + data.Set("text", text) + data.Set("hmac", hmac) + req, err := http.NewRequest("POST", hnUrl, strings.NewReader(data.Encode())) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", HnAuthCookie) + client := http.DefaultClient + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + log.Printf("POST %s %d\n", hnUrl, resp.StatusCode) +} + func HackerNewsUserLink(user string) string { return fmt.Sprintf("%s/user?id=%s", HackerNewsUrl, user) } diff --git a/sn.go b/sn.go index 0f42094..730b836 100644 --- a/sn.go +++ b/sn.go @@ -51,10 +51,10 @@ func init() { if err != nil { log.Fatal("Error loading .env file") } - flag.StringVar(&SnAuthCookie, "AUTH_COOKIE", "", "Cookie required for authorizing requests to stacker.news/api/graphql") + flag.StringVar(&SnAuthCookie, "SN_AUTH_COOKIE", "", "Cookie required for authorizing requests to stacker.news/api/graphql") flag.Parse() if SnAuthCookie == "" { - log.Fatal("AUTH_COOKIE not set") + log.Fatal("SN_AUTH_COOKIE not set") } } From 4f6314ab376e918deb28b5dd51c089e9aaf18018 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 17 Apr 2023 00:41:16 +0200 Subject: [PATCH 09/11] Add function to fetch SN user posts --- sn.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/sn.go b/sn.go index 730b836..327a6c0 100644 --- a/sn.go +++ b/sn.go @@ -30,8 +30,21 @@ type DupesResponse struct { } `json:"data"` } +type User struct { + Name string `json:"name"` +} +type Comment struct { + Id int `json:"id,string"` + Text string `json:"text"` + User User `json:"user"` +} type Item struct { - Id int `json:"id,string"` + Id int `json:"id,string"` + Title string `json:"title"` + Url string `json:"url"` + Sats int `json:"sats"` + CreatedAt string `json:"createdAt"` + Comments []Comment `json:"comments"` } type UpsertLinkResponse struct { @@ -40,6 +53,15 @@ type UpsertLinkResponse struct { } `json:"data"` } +type ItemsResponse struct { + Data struct { + Items struct { + Items []Item `json:"items"` + Cursor string `json:"cursor"` + } `json:"items"` + } `json:"data"` +} + var ( SnApiUrl string SnAuthCookie string @@ -176,3 +198,57 @@ func CommentStackerNewsPost(text string, parentId int) { resp := MakeStackerNewsRequest(body) defer resp.Body.Close() } + +func FetchStackerNewsUserItems(user string) *[]Item { + query := ` + query items($name: String!, $cursor: String) { + items(name: $name, sort: "user", cursor: $cursor) { + items { + id + title + url + sats + comments { + text + user { + name + } + } + } + cursor + } + } + ` + var items []Item + var cursor string + for { + body := GraphQLPayload{ + Query: query, + Variables: map[string]interface{}{ + "name": user, + "cursor": cursor, + }, + } + resp := MakeStackerNewsRequest(body) + defer resp.Body.Close() + + var itemsResp ItemsResponse + err := json.NewDecoder(resp.Body).Decode(&itemsResp) + if err != nil { + log.Fatal("Error decoding items JSON:", err) + } + fetchedItems := itemsResp.Data.Items.Items + + for _, item := range fetchedItems { + items = append(items, item) + } + if len(fetchedItems) < 21 { + break + } + cursor = itemsResp.Data.Items.Cursor + } + + log.Printf("Fetched %d items\n", len(items)) + + return &items +} From 0e47ca21fdb6bceb62c776c305401423ea599a2c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 17 Apr 2023 00:41:46 +0200 Subject: [PATCH 10/11] Remove empty line --- sn.go | 1 - 1 file changed, 1 deletion(-) diff --git a/sn.go b/sn.go index 327a6c0..8c20e34 100644 --- a/sn.go +++ b/sn.go @@ -179,7 +179,6 @@ func PostStoryToStackerNews(story *Story) { CommentStackerNewsPost(comment, parentId) log.Printf("Commented post on SN: parentId=%d text='%s'\n", parentId, comment) - } func CommentStackerNewsPost(text string, parentId int) { From 9f3b488d050d32b8bf60b9e71b9343e93df436a7 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 17 Apr 2023 01:58:38 +0200 Subject: [PATCH 11/11] Notify OP on HN about claimable sats --- .env.template | 1 + hn.go | 13 +++++++++ main.go | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ sn.go | 30 +++++++++++++++---- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/.env.template b/.env.template index 8b8c8ec..4ed0ca1 100644 --- a/.env.template +++ b/.env.template @@ -1,2 +1,3 @@ SN_AUTH_COOKIE= +SN_USERNAME=hn HN_AUTH_COOKIE= diff --git a/hn.go b/hn.go index c42fb7b..fda1104 100644 --- a/hn.go +++ b/hn.go @@ -159,3 +159,16 @@ func HackerNewsUserLink(user string) string { func HackerNewsItemLink(id int) string { return fmt.Sprintf("%s/item?id=%d", HackerNewsUrl, id) } + +func FindHackerNewsItemId(text string) int { + re := regexp.MustCompile(fmt.Sprintf(`\[HN\]\(%s/item\?id=([0-9]+)\)`, HackerNewsUrl)) + match := re.FindStringSubmatch(text) + if len(match) == 0 { + log.Fatal("No Hacker News item URL found") + } + id, err := strconv.Atoi(match[1]) + if err != nil { + panic(err) + } + return id +} diff --git a/main.go b/main.go index 27fa5c6..5080f00 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,88 @@ package main +import ( + "fmt" + "log" + "time" + + "github.com/joho/godotenv" + "github.com/namsral/flag" +) + +var ( + SnUserName string +) + +func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + flag.StringVar(&SnUserName, "SN_USERNAME", "", "Username of bot on SN") + flag.Parse() + if SnUserName == "" { + log.Fatal("SN_USERNAME not set") + } +} + +func GenerateHnComment(id int, sats int, nComments int) string { + lnInvoiceDocs := "https://docs.lightning.engineering/the-lightning-network/payment-lifecycle/understanding-lightning-invoices" + return fmt.Sprintf( + ""+ + "Your post received %d sats and %d comments on %s [0].\n\n"+ + "To claim your sats, reply to this comment with a LN address or invoice [1].\n\n"+ + "You can create a SN account to obtain a LN address.\n"+ + "\n\n"+ + "[0] %s/r/%s (referral link)\n\n"+ + "[1] %s", + sats, + nComments, + StackerNewsUrl, + StackerNewsItemLink(id), + SnUserName, + lnInvoiceDocs, + ) +} + +func GenerateSnReply(sats int, nComments int) string { + return fmt.Sprintf("Notified OP on HN that their post received %d sats and %d comments.", sats, nComments) +} + func main() { stories := FetchHackerNewsTopStories() filtered := CurateContentForStackerNews(&stories) for _, story := range *filtered { PostStoryToStackerNews(&story) } + + items := FetchStackerNewsUserItems(SnUserName) + now := time.Now() + for _, item := range *items { + duration := now.Sub(item.CreatedAt) + if duration >= 24*time.Hour && item.Sats > 0 { + log.Printf("Found SN item (id=%d) older than 24 hours with %d sats and %d comments\n", item.Id, item.Sats, item.NComments) + for _, comment := range item.Comments { + if comment.User.Name == SnUserName { + snReply := GenerateSnReply(item.Sats, item.NComments) + // Check if OP on HN was already notified + alreadyNotified := false + for _, comment2 := range comment.Comments { + if comment2.User.Name == SnUserName { + alreadyNotified = true + } + } + if alreadyNotified { + log.Println("OP on HN was already notified") + break + } + text := comment.Text + hnItemId := FindHackerNewsItemId(text) + hnComment := GenerateHnComment(item.Id, item.Sats, item.NComments) + CommentHackerNewsStory(hnComment, hnItemId) + CommentStackerNewsPost(snReply, comment.Id) + break + } + } + } + } } diff --git a/sn.go b/sn.go index 8c20e34..1765e0a 100644 --- a/sn.go +++ b/sn.go @@ -34,17 +34,19 @@ type User struct { Name string `json:"name"` } type Comment struct { - Id int `json:"id,string"` - Text string `json:"text"` - User User `json:"user"` + Id int `json:"id,string"` + Text string `json:"text"` + User User `json:"user"` + Comments []Comment `json:"comments"` } type Item struct { Id int `json:"id,string"` Title string `json:"title"` Url string `json:"url"` Sats int `json:"sats"` - CreatedAt string `json:"createdAt"` + CreatedAt time.Time `json:"createdAt"` Comments []Comment `json:"comments"` + NComments int `json:"ncomments"` } type UpsertLinkResponse struct { @@ -63,11 +65,13 @@ type ItemsResponse struct { } var ( - SnApiUrl string - SnAuthCookie string + StackerNewsUrl string + SnApiUrl string + SnAuthCookie string ) func init() { + StackerNewsUrl = "https://stacker.news" SnApiUrl = "https://stacker.news/api/graphql" err := godotenv.Load() if err != nil { @@ -207,12 +211,22 @@ func FetchStackerNewsUserItems(user string) *[]Item { title url sats + createdAt comments { + id text user { name } + comments { + id + text + user { + name + } + } } + ncomments } cursor } @@ -251,3 +265,7 @@ func FetchStackerNewsUserItems(user string) *[]Item { return &items } + +func StackerNewsItemLink(id int) string { + return fmt.Sprintf("%s/items/%d", StackerNewsUrl, id) +}