Compare commits

...

30 Commits

Author SHA1 Message Date
587dedf7a9 Update to snappy v0.8.0 2025-02-03 23:40:46 +01:00
fb9c1d9fca rename to github.com/ekzyis/hnbot 2025-02-03 23:40:23 +01:00
2cfb0eaa31 refactor into individual packages 2025-02-03 23:40:23 +01:00
7641414693 Update to snappy v0.7.0 2024-12-28 00:50:33 +01:00
73b2f6a430 Update to snappy v0.5.1rc1 2024-07-02 23:56:59 +02:00
cc5a05574a Fix unique constraint hit
For HN reposts, the same SN dupe was found but since sn_items.id was the primary key, a unique constraint was hit.

This meant that posting the same item was attempted over and over again since the HN item id was never found in sn_items.

I manually migrated the database.
2024-04-25 18:24:27 +02:00
0a0470ae67 Ignore hnbot.sqlite3 2024-04-09 02:06:18 +02:00
822ddda1da Update to snappy v0.4.2 2024-04-07 05:49:38 +02:00
3d35aa5e19 Update README.md 2024-04-07 05:45:32 +02:00
8e28ee4691 Remove .env.template 2024-04-07 05:45:25 +02:00
35a4112c22 Remove discord code 2024-04-07 05:42:05 +02:00
24909f5d88 Delete code related to charts 2024-04-07 05:38:57 +02:00
3bf5c8baba Update to snappy v0.4.1 2024-04-07 05:37:31 +02:00
ee95aa89bd Remove chart link and cron.sh
I think it was more annoying than interesting to look at.

Also, it didn't seem to be worth the maintenance of the cronjob etc.
2024-04-04 20:24:02 +02:00
ecda7954ef Query formatting
I first thought that ORDER BY time ASC was missing because I didn't see it.
2024-03-31 16:52:59 +02:00
7786a7f6da Only crosspost link if still rank 1 after 1 hour 2024-03-31 03:48:52 +02:00
ekzyis
1416550be9 First wait, then run 2024-03-18 08:26:04 +01:00
ekzyis
7bf9e6c53c Remove unnecessary goroutines 2024-03-18 08:22:51 +01:00
ekzyis
0285c5a53a Sync HN items every minute 2024-03-18 08:19:46 +01:00
ekzyis
a2633b1fdb Update to sn-goapi v0.3.3
This should also fix checksum mismatches for v0.3.1
2024-03-18 07:54:24 +01:00
ekzyis
fc0d9d0690 Fix plots not generated until after item is posted 2024-03-18 06:43:48 +00:00
ekzyis
b8188fbb2e Fix comment 2024-03-18 07:15:22 +01:00
ekzyis
d116ba00bd Fix no error handling for comments 2024-03-18 07:14:43 +01:00
ekzyis
a3933b48c6 Only run every 15 minutes
This should prevent spam fees.
2024-03-18 07:09:19 +01:00
ekzyis
638939edd6 Only post the oldest HN item per run 2024-03-18 07:07:13 +01:00
ekzyis
bf0387ffdb go mod tidy 2024-03-17 22:12:56 +00:00
ekzyis
00d3d759ac Add link to chart
* Added SQL file to export CSV
* Updated plot.py to close figures due to RuntimeWarning shown
* hnbot now includes link to chart in comment
* charts are generated and copied to files.ekzyis.com using a cronjob
2024-03-17 21:29:33 +00:00
ekzyis
c993978384 Fix checksum mismatch 2024-03-17 20:49:17 +00:00
ekzyis
bf95fba2dc Fix post error: 'title: must be at least 5 characters' 2024-03-17 21:26:59 +01:00
ekzyis
45d0376889 Add plot script 2024-03-14 04:09:38 +01:00
12 changed files with 280 additions and 563 deletions

View File

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

2
.gitignore vendored
View File

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

View File

@ -2,10 +2,10 @@
> Hello, I am a bot posting top stories from HN.
>
> My original mission was to orange-pill HN by offering the OPs on HN to withdraw the sats their stories received here.
> 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: https://stacker.news/items/164155
See this thread: [#164155](https://stacker.news/items/164155)
>
> If you are one of these OPs and want to withdraw your sats, reply to this bio and we will find a solution!
> If you are one of these OPs and want to claim your sats, reply to this bio and we will find a solution!
-- https://stacker.news/items/161788
-- https://stacker.news/hn

View File

@ -1,24 +1,29 @@
package main
package db
import (
"database/sql"
"fmt"
"log"
"github.com/ekzyis/hnbot/hn"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
_db *sql.DB
)
func init() {
var err error
db, err = sql.Open("sqlite3", "hnbot.sqlite3")
_db, err = sql.Open("sqlite3", "hnbot.sqlite3")
if err != nil {
log.Fatal(err)
}
migrate(db)
migrate(_db)
}
func Query(query string, args ...interface{}) (*sql.Rows, error) {
return _db.Query(query, args...)
}
func migrate(db *sql.DB) {
@ -41,9 +46,10 @@ func migrate(db *sql.DB) {
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sn_items (
id INTEGER PRIMARY KEY,
id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
hn_id INTEGER NOT NULL REFERENCES hn_items(id)
hn_id INTEGER NOT NULL REFERENCES hn_items(id),
PRIMARY KEY (id, hn_id)
);
`); err != nil {
err = fmt.Errorf("error during migration: %w", err)
@ -53,7 +59,7 @@ func migrate(db *sql.DB) {
func ItemHasComment(parentId int) bool {
var count int
err := db.QueryRow(`SELECT COUNT(1) FROM comments WHERE parent_id = ?`, parentId).Scan(&count)
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)
@ -61,17 +67,17 @@ func ItemHasComment(parentId int) bool {
return count > 0
}
func SaveStories(story *[]Story) error {
func SaveHnItems(story *[]hn.Item) error {
for i, s := range *story {
if err := SaveStory(&s, i+1); err != nil {
if err := SaveHnItem(&s, i+1); err != nil {
return err
}
}
return nil
}
func SaveStory(s *Story, rank int) error {
if _, err := db.Exec(`
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 {
@ -82,7 +88,7 @@ func SaveStory(s *Story, rank int) error {
}
func SaveSnItem(id int, hnId int) error {
if _, err := db.Exec(`INSERT INTO sn_items(id, hn_id) VALUES (?, ?)`, id, hnId); err != nil {
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
}

View File

@ -1,188 +0,0 @@
package main
import (
"errors"
"fmt"
"log"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/ekzyis/sn-goapi"
"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 *sn.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 *sn.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: sn.FormatLink(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)
}

18
go.mod
View File

@ -1,22 +1,12 @@
module gitlab.com/ekzyis/hnbot
module github.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/namsral/flag v1.7.4-pre
github.com/stretchr/testify v1.8.2
github.com/mattn/go-sqlite3 v1.14.22
)
require (
github.com/bwmarrin/discordgo v0.27.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ekzyis/sn-goapi v0.3.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // 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
)
require gopkg.in/guregu/null.v4 v4.0.0 // indirect

45
go.sum
View File

@ -1,47 +1,10 @@
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/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/ekzyis/sn-goapi v0.1.1 h1:FLCbaAIZ1qngtFZTfzkrp4HPFzHUPStdUgLHUPz82Jo=
github.com/ekzyis/sn-goapi v0.1.1/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo=
github.com/ekzyis/sn-goapi v0.1.2-0.20230815195350-cab35b4a3731 h1:BukOC3+ljt0LVIRnsmnWjeqwcCZpVkGzI4W0GdIm1RY=
github.com/ekzyis/sn-goapi v0.1.2-0.20230815195350-cab35b4a3731/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo=
github.com/ekzyis/sn-goapi v0.2.0 h1:k5YJlG+OguQEQRlbS5KghUttbfJaEkfvIfqB8B133FY=
github.com/ekzyis/sn-goapi v0.2.0/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo=
github.com/ekzyis/sn-goapi v0.3.1 h1:IAlbqksX6Vjj25aDFZxml0pAmHgqWDsK3gTYEiIKCcs=
github.com/ekzyis/sn-goapi v0.3.1/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/ekzyis/snappy v0.8.0 h1:e7dRR384XJgNYa1FWNIZmqITSHOSanteBFXQJPfcQwg=
github.com/ekzyis/snappy v0.8.0/go.mod h1:UksYI0dU0+cnzz0LQjWB1P0QQP/ghx47e4atP99a5Lk=
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=
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=
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=

107
hn.go
View File

@ -1,107 +0,0 @@
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 Normal file
View File

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

106
main.go
View File

@ -5,95 +5,73 @@ import (
"log"
"time"
"github.com/ekzyis/sn-goapi"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/hn"
sn "github.com/ekzyis/hnbot/sn"
"github.com/joho/godotenv"
)
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 WaitUntilNextMinute() {
now := time.Now()
dur := now.Truncate(time.Minute).Add(time.Minute).Sub(now)
log.Println("sleeping for", dur.Round(time.Second))
time.Sleep(dur)
}
func CheckNotifications() {
var prevHasNewNotes bool
func SyncHnItemsToDb() {
for {
log.Println("Checking notifications ...")
hasNewNotes, err := sn.CheckNotifications()
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 {
SendErrorToDiscord(err)
} else {
if !prevHasNewNotes && hasNewNotes {
// only send embed on "rising edge"
SendNotificationsEmbedToDiscord()
log.Println("Forwarded notifications to monitoring")
} else if hasNewNotes {
log.Println("Notifications already forwarded")
}
log.Println(err)
continue
}
if err := db.SaveHnItems(&stories); err != nil {
log.Println(err)
continue
}
prevHasNewNotes = hasNewNotes
WaitUntilNextMinute()
}
}
func SessionKeepAlive() {
for {
log.Println("Refresh session using GET /api/auth/session ...")
sn.RefreshSession()
WaitUntilNextHour()
}
}
func main() {
go CheckNotifications()
go SessionKeepAlive()
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
)
stories, err := FetchHackerNewsTopStories()
if err != nil {
SendErrorToDiscord(err)
WaitUntilNextMinute()
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)
continue
}
if err := SaveStories(&stories); err != nil {
SendErrorToDiscord(err)
WaitUntilNextMinute()
continue
}
log.Printf("[sn] found %d item(s) to post\n", len(*filtered))
var filtered *[]Story
if filtered, err = CurateContentForStackerNews(); err != nil {
SendErrorToDiscord(err)
WaitUntilNextMinute()
continue
}
for _, story := range *filtered {
_, err := PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
for _, item := range *filtered {
_, err := sn.Post(&item, sn.PostOptions{SkipDupes: false})
if err != nil {
var dupesErr *sn.DupesError
if errors.As(err, &dupesErr) {
// SendDupesErrorToDiscord(story.ID, dupesErr)
log.Println(dupesErr)
// save dupe in db to prevent retries
parentId := dupesErr.Dupes[0].Id
if err := SaveSnItem(parentId, story.ID); err != nil {
SendErrorToDiscord(err)
if err := db.SaveSnItem(parentId, item.ID); err != nil {
log.Println(err)
}
continue
}
SendErrorToDiscord(err)
log.Println(err)
continue
}
}
WaitUntilNextMinute()
}
}

129
sn.go
View File

@ -1,129 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/ekzyis/sn-goapi"
)
func CurateContentForStackerNews() (*[]Story, error) {
var (
rows *sql.Rows
err error
)
if rows, err = db.Query(`
SELECT t.id, time, title, url, author, score, ndescendants
FROM (
SELECT id, MAX(created_at) AS created_at FROM hn_items
WHERE rank = 1 AND id NOT IN (SELECT hn_id FROM sn_items)
GROUP BY id
) t JOIN hn_items ON t.id = hn_items.id AND t.created_at = hn_items.created_at;
`); err != nil {
err = fmt.Errorf("error querying hn_items: %w", err)
return nil, err
}
defer rows.Close()
var stories []Story
for rows.Next() {
var story Story
if err = rows.Scan(&story.ID, &story.Time, &story.Title, &story.Url, &story.By, &story.Score, &story.Descendants); err != nil {
err = fmt.Errorf("error scanning hn_items: %w", err)
return nil, err
}
stories = append(stories, story)
}
if err = rows.Err(); err != nil {
err = fmt.Errorf("error iterating hn_items: %w", err)
return nil, err
}
return &stories, nil
}
type PostStoryOptions struct {
SkipDupes bool
}
func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) {
url := story.Url
if url == "" {
url = HackerNewsItemLink(story.ID)
}
log.Printf("Posting to SN (url=%s) ...\n", url)
if !options.SkipDupes {
dupes, err := sn.Dupes(url)
if err != nil {
return -1, err
}
if len(*dupes) > 0 {
return -1, &sn.DupesError{Url: url, Dupes: *dupes}
}
}
title := story.Title
if len(title) > 80 {
title = title[0:80]
}
parentId, err := sn.PostLink(url, title, "tech")
if err != nil {
return -1, fmt.Errorf("error posting link: %w", err)
}
log.Printf("Posting to SN (url=%s) ... OK \n", url)
if err := SaveSnItem(parentId, story.ID); err != nil {
return -1, err
}
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,
)
sn.CreateComment(parentId, comment)
return parentId, nil
}
func SendStackerNewsEmbedToDiscord(title string, id int) {
Timestamp := time.Now().Format(time.RFC3339)
url := sn.FormatLink(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)
}
func SendNotificationsEmbedToDiscord() {
Timestamp := time.Now().Format(time.RFC3339)
color := 0xffc107
embed := discordgo.MessageEmbed{
Title: "new notifications",
URL: "https://stacker.news/hn/posts",
Color: color,
Footer: &discordgo.MessageEmbedFooter{
Text: "Stacker News",
IconURL: "https://stacker.news/favicon-notify.png",
},
Timestamp: Timestamp,
}
SendEmbedToDiscord(&embed)
}

102
sn/sn.go Normal file
View File

@ -0,0 +1,102 @@
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
}