Compare commits

..

No commits in common. "develop" and "v0.6.2" have entirely different histories.

13 changed files with 698 additions and 372 deletions

3
.env.template Normal file
View File

@ -0,0 +1,3 @@
SN_AUTH_COOKIE=
DISCORD_TOKEN=
DISCORD_CHANNEL_ID=

1
.gitignore vendored
View File

@ -3,4 +3,3 @@
# go executable
hnbot
hnbot.sqlite3

View File

@ -1,11 +1,7 @@
# hnbot
> Hello, I am a bot posting top stories from HN.
> Hello, I am a bot crossposting top posts from HN.
>
> My original mission was to orange-pill HN by offering the OPs on HN to claim the sats their stories received here.
However, my comments were shadowbanned and ultimately not approved by dang, the site admin.
See this thread: [#164155](https://stacker.news/items/164155)
>
> If you are one of these OPs and want to claim your sats, reply to this bio and we will find a solution!
> I curate content to only post stuff which could be interesting for the SN community on a best-efforts basis.
-- https://stacker.news/hn
-- https://stacker.news/items/161788

View File

@ -1,96 +0,0 @@
package db
import (
"database/sql"
"fmt"
"log"
"github.com/ekzyis/hnbot/hn"
_ "github.com/mattn/go-sqlite3"
)
var (
_db *sql.DB
)
func init() {
var err error
_db, err = sql.Open("sqlite3", "hnbot.sqlite3")
if err != nil {
log.Fatal(err)
}
migrate(_db)
}
func Query(query string, args ...interface{}) (*sql.Rows, error) {
return _db.Query(query, args...)
}
func migrate(db *sql.DB) {
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS hn_items (
id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
time TIMESTAMP WITH TIMEZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL,
url TEXT,
author TEXT NOT NULL,
ndescendants INTEGER NOT NULL,
score INTEGER NOT NULL,
rank INTEGER NOT NULL,
PRIMARY KEY (id, created_at)
);
`); err != nil {
err = fmt.Errorf("error during migration: %w", err)
log.Fatal(err)
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sn_items (
id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
hn_id INTEGER NOT NULL REFERENCES hn_items(id),
PRIMARY KEY (id, hn_id)
);
`); err != nil {
err = fmt.Errorf("error during migration: %w", err)
log.Fatal(err)
}
}
func ItemHasComment(parentId int) bool {
var count int
err := _db.QueryRow(`SELECT COUNT(1) FROM comments WHERE parent_id = ?`, parentId).Scan(&count)
if err != nil {
err = fmt.Errorf("error during item check: %w", err)
log.Fatal(err)
}
return count > 0
}
func SaveHnItems(story *[]hn.Item) error {
for i, s := range *story {
if err := SaveHnItem(&s, i+1); err != nil {
return err
}
}
return nil
}
func SaveHnItem(s *hn.Item, rank int) error {
if _, err := _db.Exec(`
INSERT INTO hn_items(id, time, title, url, author, ndescendants, score, rank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.Time, s.Title, s.Url, s.By, s.Descendants, s.Score, rank); err != nil {
err = fmt.Errorf("error during item insert: %w", err)
return err
}
return nil
}
func SaveSnItem(id int, hnId int) error {
if _, err := _db.Exec(`INSERT INTO sn_items(id, hn_id) VALUES (?, ?)`, id, hnId); err != nil {
err = fmt.Errorf("error during sn item insert: %w", err)
return err
}
return nil
}

187
discord.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"errors"
"fmt"
"log"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/joho/godotenv"
"github.com/namsral/flag"
)
var (
DiscordToken string
dg *discordgo.Session
DiscordChannelId string
)
func init() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
flag.StringVar(&DiscordToken, "DISCORD_TOKEN", "", "Discord bot token")
flag.StringVar(&DiscordChannelId, "DISCORD_CHANNEL_ID", "", "Discord channel id")
flag.Parse()
if DiscordToken == "" {
log.Fatal("DISCORD_TOKEN not set")
}
if DiscordChannelId == "" {
log.Fatal("DISCORD_CHANNEL_ID not set")
}
initBot()
}
func initBot() {
var err error
dg, err = discordgo.New("Bot " + DiscordToken)
if err != nil {
log.Fatal("error creating discord session:", err)
}
dg.AddHandler(func(s *discordgo.Session, event *discordgo.Ready) {
log.Println("Logged in as", event.User.Username)
})
dg.AddHandler(onMessage)
dg.AddHandler(onMessageReact)
dg.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent | discordgo.IntentGuildMessageReactions
err = dg.Open()
if err != nil {
log.Fatal("error opening connection to discord: ", err, " -- Is your token correct?")
}
}
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID {
return
}
hackerNewsId, err := ParseHackerNewsLink(m.Content)
if err != nil {
return
}
story, err := FetchStoryById(hackerNewsId)
_, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
if err != nil {
var dupesErr *DupesError
if errors.As(err, &dupesErr) {
SendDupesErrorToDiscord(hackerNewsId, dupesErr)
return
}
SendErrorToDiscord(err)
}
}
func onMessageReact(s *discordgo.Session, reaction *discordgo.MessageReactionAdd) {
if reaction.UserID == s.State.User.ID {
return
}
if reaction.Emoji.Name != "⏭️" {
return
}
m, err := s.ChannelMessage(reaction.ChannelID, reaction.MessageID)
if err != nil {
SendErrorToDiscord(err)
return
}
if len(m.Embeds) == 0 {
return
}
embed := m.Embeds[0]
if !strings.Contains(embed.Title, "dupe(s) found for") {
return
}
id, err := ParseHackerNewsLink(embed.Footer.Text)
if err != nil {
return
}
story, err := FetchStoryById(id)
if err != nil {
SendErrorToDiscord(err)
return
}
id, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: true})
if err != nil {
SendErrorToDiscord(err)
}
}
func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *DupesError) {
msg := fmt.Sprint(dupesErr)
log.Println(msg)
title := fmt.Sprintf("%d dupe(s) found for %s:", len(dupesErr.Dupes), dupesErr.Url)
color := 0xffc107
var fields []*discordgo.MessageEmbedField
for _, dupe := range dupesErr.Dupes {
fields = append(fields,
&discordgo.MessageEmbedField{
Name: "Title",
Value: dupe.Title,
Inline: false,
},
&discordgo.MessageEmbedField{
Name: "Id",
Value: StackerNewsItemLink(dupe.Id),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Url",
Value: dupe.Url,
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "User",
Value: dupe.User.Name,
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Created",
Value: humanize.Time(dupe.CreatedAt),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Sats",
Value: fmt.Sprint(dupe.Sats),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Comments",
Value: fmt.Sprint(dupe.NComments),
Inline: true,
},
)
}
embed := discordgo.MessageEmbed{
Title: title,
Color: color,
Fields: fields,
Footer: &discordgo.MessageEmbedFooter{
Text: HackerNewsItemLink(hackerNewsId),
IconURL: "https://news.ycombinator.com/y18.gif",
},
}
SendEmbedToDiscord(&embed)
}
func SendEmbedToDiscord(embed *discordgo.MessageEmbed) {
_, err := dg.ChannelMessageSendEmbed(DiscordChannelId, embed)
if err != nil {
err = fmt.Errorf("error during sending embed: %w", err)
log.Println(err)
}
}
func SendErrorToDiscord(err error) {
msg := fmt.Sprint(err)
log.Println(msg)
embed := discordgo.MessageEmbed{
Title: "Error",
Color: 0xff0000,
Description: msg,
}
SendEmbedToDiscord(&embed)
}

16
go.mod
View File

@ -1,12 +1,20 @@
module github.com/ekzyis/hnbot
module gitlab.com/ekzyis/hnbot
go 1.20
require (
github.com/dustin/go-humanize v1.0.1
github.com/ekzyis/snappy v0.8.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22
github.com/namsral/flag v1.7.4-pre
github.com/stretchr/testify v1.8.2
)
require gopkg.in/guregu/null.v4 v4.0.0 // indirect
require (
github.com/bwmarrin/discordgo v0.27.1 // indirect
github.com/davecgh/go-spew v1.1.1 // 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
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

37
go.sum
View File

@ -1,10 +1,35 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
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/ekzyis/snappy v0.8.0 h1:e7dRR384XJgNYa1FWNIZmqITSHOSanteBFXQJPfcQwg=
github.com/ekzyis/snappy v0.8.0/go.mod h1:UksYI0dU0+cnzz0LQjWB1P0QQP/ghx47e4atP99a5Lk=
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=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs=
github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

107
hn.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
)
type Story struct {
ID int
By string // username of author
Time int // UNIX timestamp
Descendants int // number of comments
Kids []int
Score int
Title string
Url string
}
var (
HackerNewsUrl = "https://news.ycombinator.com"
HackerNewsFirebaseUrl = "https://hacker-news.firebaseio.com/v0"
HackerNewsLinkRegexp = regexp.MustCompile(`(?:https?:\/\/)?news\.ycombinator\.com\/item\?id=([0-9]+)`)
)
func FetchHackerNewsTopStories() ([]Story, error) {
log.Println("Fetching HN top stories ...")
// API docs: https://github.com/HackerNews/API
url := fmt.Sprintf("%s/topstories.json", HackerNewsFirebaseUrl)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN top stories %w:", err)
return nil, err
}
defer resp.Body.Close()
var ids []int
err = json.NewDecoder(resp.Body).Decode(&ids)
if err != nil {
err = fmt.Errorf("error decoding HN top stories JSON: %w", err)
return nil, err
}
// we are only interested in the first page of top stories
const limit = 30
ids = ids[:limit]
var stories [limit]Story
for i, id := range ids {
story, err := FetchStoryById(id)
if err != nil {
return nil, err
}
stories[i] = story
}
log.Println("Fetching HN top stories ... OK")
// Can't return [30]Story as []Story so we copy the array
return stories[:], nil
}
func FetchStoryById(id int) (Story, error) {
log.Printf("Fetching HN story (id=%d) ...\n", id)
url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN story (id=%d): %w", id, err)
return Story{}, err
}
defer resp.Body.Close()
var story Story
err = json.NewDecoder(resp.Body).Decode(&story)
if err != nil {
err := fmt.Errorf("error decoding HN story JSON (id=%d): %w", id, err)
return Story{}, err
}
log.Printf("Fetching HN story (id=%d) ... OK\n", id)
return story, nil
}
func ParseHackerNewsLink(link string) (int, error) {
match := HackerNewsLinkRegexp.FindStringSubmatch(link)
if len(match) == 0 {
return -1, errors.New("input is not a hacker news link")
}
id, err := strconv.Atoi(match[1])
if err != nil {
return -1, errors.New("integer conversion to string failed")
}
return id, nil
}
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)
}

105
hn/hn.go
View File

@ -1,105 +0,0 @@
package hn
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
)
type Item struct {
ID int
By string // username of author
Time int // UNIX timestamp
Descendants int // number of comments
Kids []int
Score int
Title string
Url string
}
var (
hnUrl = "https://news.ycombinator.com"
hnFirebaseUrl = "https://hacker-news.firebaseio.com/v0"
hnLinkRegexp = regexp.MustCompile(`(?:https?:\/\/)?news\.ycombinator\.com\/item\?id=([0-9]+)`)
)
func FetchTopItems() ([]Item, error) {
log.Println("[hn] fetch top items ...")
// API docs: https://github.com/HackerNews/API
url := fmt.Sprintf("%s/topstories.json", hnFirebaseUrl)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("error fetching HN top stories %w:", err)
}
defer resp.Body.Close()
var ids []int
err = json.NewDecoder(resp.Body).Decode(&ids)
if err != nil {
return nil, fmt.Errorf("error decoding HN top stories JSON: %w", err)
}
// we are only interested in the first page of top stories
const limit = 30
ids = ids[:limit]
var stories [limit]Item
for i, id := range ids {
var item Item
err := FetchItemById(id, &item)
if err != nil {
return nil, err
}
stories[i] = item
}
log.Println("[hn] fetch top items ... OK")
// Can't return [30]Item as []Item so we copy the array
return stories[:], nil
}
func FetchItemById(id int, hnItem *Item) error {
// log.Printf("[hn] fetch HN item %d ...\n", id)
url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN item %d: %w", id, err)
return err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&hnItem)
if err != nil {
err := fmt.Errorf("error decoding JSON for HN item %d: %w", id, err)
return err
}
// log.Printf("[hn] fetch HN item %d ... OK\n", id)
return nil
}
func ParseLink(link string) (int, error) {
match := hnLinkRegexp.FindStringSubmatch(link)
if len(match) == 0 {
return -1, errors.New("not a hacker news link")
}
id, err := strconv.Atoi(match[1])
if err != nil {
return -1, errors.New("integer conversion to string failed")
}
return id, nil
}
func UserLink(user string) string {
return fmt.Sprintf("%s/user?id=%s", hnUrl, user)
}
func ItemLink(id int) string {
return fmt.Sprintf("%s/item?id=%d", hnUrl, id)
}

68
main.go
View File

@ -4,74 +4,40 @@ import (
"errors"
"log"
"time"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/hn"
sn "github.com/ekzyis/hnbot/sn"
"github.com/joho/godotenv"
)
func SyncHnItemsToDb() {
for {
now := time.Now()
dur := now.Truncate(time.Minute).Add(time.Minute).Sub(now)
log.Println("[hn] sleeping for", dur.Round(time.Second))
time.Sleep(dur)
stories, err := hn.FetchTopItems()
if err != nil {
log.Println(err)
continue
}
if err := db.SaveHnItems(&stories); err != nil {
log.Println(err)
continue
}
}
func WaitUntilNextHour() {
now := time.Now()
dur := now.Truncate(time.Hour).Add(time.Hour).Sub(now)
log.Println("sleeping for", dur.Round(time.Second))
time.Sleep(dur)
}
func main() {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
// fetch HN front page every minute in the background and store state in db
go SyncHnItemsToDb()
// check every 15 minutes if there is now a HN item that is worth posting to SN
for {
var (
filtered *[]hn.Item
err error
)
now := time.Now()
dur := now.Truncate(time.Minute).Add(15 * time.Minute).Sub(now)
log.Println("[sn] sleeping for", dur.Round(time.Second))
time.Sleep(dur)
if filtered, err = sn.CurateContent(); err != nil {
log.Println(err)
stories, err := FetchHackerNewsTopStories()
if err != nil {
SendErrorToDiscord(err)
WaitUntilNextHour()
continue
}
log.Printf("[sn] found %d item(s) to post\n", len(*filtered))
filtered := CurateContentForStackerNews(&stories)
for _, item := range *filtered {
_, err := sn.Post(&item, sn.PostOptions{SkipDupes: false})
for _, story := range *filtered {
_, err := PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
if err != nil {
var dupesErr *sn.DupesError
var dupesErr *DupesError
if errors.As(err, &dupesErr) {
log.Println(dupesErr)
parentId := dupesErr.Dupes[0].Id
if err := db.SaveSnItem(parentId, item.ID); err != nil {
log.Println(err)
}
SendDupesErrorToDiscord(story.ID, dupesErr)
continue
}
log.Println(err)
SendErrorToDiscord(err)
continue
}
log.Println("Posting to SN ... OK")
}
WaitUntilNextHour()
}
}

320
sn.go Normal file
View File

@ -0,0 +1,320 @@
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"
)
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"`
}
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
slice := (*stories)[0:1]
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
}
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)
if err != nil {
return -1, err
}
if len(*dupes) > 0 {
return -1, &DupesError{story.Url, *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)
if err != nil {
return -1, 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)
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)
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)
color := 0xffc107
embed := discordgo.MessageEmbed{
Title: title,
URL: url,
Color: color,
Footer: &discordgo.MessageEmbedFooter{
Text: "Stacker News",
IconURL: "https://stacker.news/favicon.png",
},
Timestamp: Timestamp,
}
SendEmbedToDiscord(&embed)
}

102
sn/sn.go
View File

@ -1,102 +0,0 @@
package sn
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/dustin/go-humanize"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/hn"
sn "github.com/ekzyis/snappy"
)
type DupesError = sn.DupesError
func CurateContent() (*[]hn.Item, error) {
var (
rows *sql.Rows
err error
)
if rows, err = db.Query(`
SELECT t.id, time, title, url, author, score, ndescendants
FROM (
SELECT id, MIN(created_at) AS start, MAX(created_at) AS end
FROM hn_items
WHERE rank = 1 AND id NOT IN (SELECT hn_id FROM sn_items) AND length(title) >= 5
GROUP BY id
HAVING unixepoch(end) - unixepoch(start) >= 3600
ORDER BY time ASC
LIMIT 1
) t JOIN hn_items ON t.id = hn_items.id AND t.end = hn_items.created_at;
`); err != nil {
err = fmt.Errorf("error querying hn_items: %w", err)
return nil, err
}
defer rows.Close()
var items []hn.Item
for rows.Next() {
var item hn.Item
if err = rows.Scan(&item.ID, &item.Time, &item.Title, &item.Url, &item.By, &item.Score, &item.Descendants); err != nil {
err = fmt.Errorf("error scanning hn_items: %w", err)
return nil, err
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
err = fmt.Errorf("error iterating hn_items: %w", err)
return nil, err
}
return &items, nil
}
type PostOptions struct {
SkipDupes bool
}
func Post(item *hn.Item, options PostOptions) (int, error) {
c := sn.NewClient()
url := item.Url
if url == "" {
url = hn.ItemLink(item.ID)
}
log.Printf("post to SN: %s ...\n", url)
if !options.SkipDupes {
dupes, err := c.Dupes(url)
if err != nil {
return -1, err
}
if len(*dupes) > 0 {
return -1, &sn.DupesError{Url: url, Dupes: *dupes}
}
}
title := item.Title
if len(title) > 80 {
title = title[0:80]
}
comment := fmt.Sprintf(
"This link was posted by [%s](%s) %s on [HN](%s). It received %d points and %d comments.",
item.By,
hn.UserLink(item.By),
humanize.Time(time.Unix(int64(item.Time), 0)),
hn.ItemLink(item.ID),
item.Score, item.Descendants,
)
parentId, err := c.PostLink(url, title, comment, "tech")
if err != nil {
return -1, fmt.Errorf("error posting link: %w", err)
}
log.Printf("post to SN: %s ... OK \n", url)
if err := db.SaveSnItem(parentId, item.ID); err != nil {
return -1, err
}
return parentId, nil
}

18
sn_test.go Normal file
View File

@ -0,0 +1,18 @@
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")
}