diff --git a/.env.template b/.env.template index 123b52b..4ed0ca1 100644 --- a/.env.template +++ b/.env.template @@ -1 +1,3 @@ -NEXT_AUTH_CSRF_TOKEN= \ No newline at end of file +SN_AUTH_COOKIE= +SN_USERNAME=hn +HN_AUTH_COOKIE= 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 e2a2f52..fda1104 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 @@ -20,10 +28,30 @@ type Story struct { Url string } -func fetchTopStoriesFromHN() []Story { +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 { // 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) @@ -43,7 +71,7 @@ func fetchTopStoriesFromHN() []Story { var stories [limit]Story for i, id := range ids { - story := fetchStoryByID(id) + story := FetchStoryById(id) stories[i] = story } @@ -51,7 +79,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 { @@ -68,3 +96,79 @@ 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) +} + +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 22c6460..5080f00 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,16 @@ package main import ( + "fmt" "log" + "time" "github.com/joho/godotenv" "github.com/namsral/flag" ) var ( - SnApiToken string + SnUserName string ) func init() { @@ -16,17 +18,71 @@ 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(&SnUserName, "SN_USERNAME", "", "Username of bot on SN") flag.Parse() - if SnApiToken == "" { - log.Fatal("NEXT_AUTH_CSRF_TOKEN not set") + 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 := fetchTopStoriesFromHN() - filtered := filterByRelevanceForSN(&stories) + stories := FetchHackerNewsTopStories() + filtered := CurateContentForStackerNews(&stories) for _, story := range *filtered { - postToSN(&story) + 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 6498dcc..1765e0a 100644 --- a/sn.go +++ b/sn.go @@ -6,6 +6,11 @@ import ( "fmt" "log" "net/http" + "time" + + "github.com/dustin/go-humanize" + "github.com/joho/godotenv" + "github.com/namsral/flag" ) type GraphQLPayload struct { @@ -25,19 +30,72 @@ type DupesResponse struct { } `json:"data"` } -func makeGraphQLRequest(body GraphQLPayload) *http.Response { +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 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 { + Data struct { + UpsertLink Item `json:"upsertLink"` + } `json:"data"` +} + +type ItemsResponse struct { + Data struct { + Items struct { + Items []Item `json:"items"` + Cursor string `json:"cursor"` + } `json:"items"` + } `json:"data"` +} + +var ( + StackerNewsUrl string + SnApiUrl string + SnAuthCookie string +) + +func init() { + StackerNewsUrl = "https://stacker.news" + SnApiUrl = "https://stacker.news/api/graphql" + 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 { bodyJSON, err := json.Marshal(body) if err != nil { 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) } 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) @@ -45,19 +103,19 @@ 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 } -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!) { @@ -71,7 +129,7 @@ func fetchDupes(url string) *[]Dupe { "url": url, }, } - resp := makeGraphQLRequest(body) + resp := MakeStackerNewsRequest(body) defer resp.Body.Close() var dupesResp DupesResponse @@ -83,25 +141,131 @@ 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 { + log.Printf("%s was already posted. Skipping.\n", story.Url) return } 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, }, } - resp := makeGraphQLRequest(body) + 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 + + 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, + HackerNewsUserLink(story.By), + humanize.Time(time.Unix(int64(story.Time), 0)), + HackerNewsItemLink(story.ID), + 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) { + 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() } + +func FetchStackerNewsUserItems(user string) *[]Item { + query := ` + query items($name: String!, $cursor: String) { + items(name: $name, sort: "user", cursor: $cursor) { + items { + id + title + url + sats + createdAt + comments { + id + text + user { + name + } + comments { + id + text + user { + name + } + } + } + ncomments + } + 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 +} + +func StackerNewsItemLink(id int) string { + return fmt.Sprintf("%s/items/%d", StackerNewsUrl, id) +} 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") }