Merge branch '6-show-error-if-dupes-exist-and-add-option-to-override' into 'develop'

Resolve "Show error if dupes exist and add option to override"

Closes #6

See merge request ekzyis/hnbot!5
This commit is contained in:
ekzyis 2023-04-25 00:25:22 +00:00
commit 26aa14c9a1
4 changed files with 158 additions and 62 deletions

View File

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

View File

@ -1,68 +1,53 @@
package main package main
import ( import (
"bytes" "errors"
"encoding/json" "fmt"
"log" "log"
"net/http" "strings"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/namsral/flag" "github.com/namsral/flag"
) )
var ( var (
DiscordWebhook string DiscordToken string
DiscordToken string dg *discordgo.Session
DiscordClient *discordgo.Session DiscordChannelId string
) )
type DiscordEmbedFooter struct {
Text string `json:"text"`
IconUrl string `json:"icon_url"`
}
type DiscordEmbed struct {
Title string `json:"title"`
Url string `json:"url"`
Color int `json:"color"`
Footer DiscordEmbedFooter `json:"footer"`
Timestamp string `json:"timestamp"`
}
type DiscordWebhookPayload struct {
Embeds []DiscordEmbed `json:"embeds"`
}
func init() { func init() {
err := godotenv.Load() err := godotenv.Load()
if err != nil { if err != nil {
log.Fatal("Error loading .env file") log.Fatal("Error loading .env file")
} }
flag.StringVar(&DiscordWebhook, "DISCORD_WEBHOOK", "", "Webhook to send logs to discord")
flag.StringVar(&DiscordToken, "DISCORD_TOKEN", "", "Discord bot token") flag.StringVar(&DiscordToken, "DISCORD_TOKEN", "", "Discord bot token")
flag.StringVar(&DiscordChannelId, "DISCORD_CHANNEL_ID", "", "Discord channel id")
flag.Parse() flag.Parse()
if DiscordWebhook == "" {
log.Fatal("DISCORD_WEBHOOK not set")
}
if DiscordToken == "" { if DiscordToken == "" {
log.Fatal("DISCORD_TOKEN not set") log.Fatal("DISCORD_TOKEN not set")
} }
if DiscordChannelId == "" {
log.Fatal("DISCORD_CHANNEL_ID not set")
}
initBot() initBot()
} }
func initBot() { func initBot() {
var err error var err error
DiscordClient, err = discordgo.New(DiscordToken) dg, err = discordgo.New("Bot " + DiscordToken)
if err != nil { if err != nil {
log.Fatal("error creating discord session:", err) log.Fatal("error creating discord session:", err)
} }
DiscordClient.AddHandler(func(s *discordgo.Session, event *discordgo.Ready) { dg.AddHandler(func(s *discordgo.Session, event *discordgo.Ready) {
log.Println("Logged in as", event.User.Username) log.Println("Logged in as", event.User.Username)
}) })
DiscordClient.AddHandler(onMessage) dg.AddHandler(onMessage)
DiscordClient.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent dg.AddHandler(onMessageReact)
err = DiscordClient.Open() dg.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent | discordgo.IntentGuildMessageReactions
err = dg.Open()
if err != nil { if err != nil {
log.Fatal("error opening connection to discord: ", err, " -- Is your token correct?") log.Fatal("error opening connection to discord: ", err, " -- Is your token correct?")
} }
@ -73,29 +58,110 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID { if m.Author.ID == s.State.User.ID {
return return
} }
id, err := ParseHackerNewsLink(m.Content) hackerNewsId, err := ParseHackerNewsLink(m.Content)
if err != nil {
return
}
story := FetchStoryById(hackerNewsId)
_, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
if err != nil {
var dupesErr *DupesError
if errors.As(err, &dupesErr) {
SendDupesErrorToDiscord(hackerNewsId, dupesErr)
} else {
log.Fatal("unexpected error returned")
}
}
}
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 {
log.Println("error:", 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 { if err != nil {
return return
} }
story := FetchStoryById(id) story := FetchStoryById(id)
PostStoryToStackerNews(&story) id, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: true})
if err != nil {
log.Fatal("unexpected error returned")
}
} }
func SendEmbedToDiscord(embed DiscordEmbed) { func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *DupesError) {
bodyJSON, err := json.Marshal( title := fmt.Sprintf("%d dupe(s) found for %s:", len(dupesErr.Dupes), dupesErr.Url)
DiscordWebhookPayload{ color := 0xffc107
Embeds: []DiscordEmbed{embed}, 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 { if err != nil {
log.Fatal("Error during json.Marshal:", err) log.Fatal("Error during json.Marshal:", err)
} }
req, err := http.NewRequest("POST", DiscordWebhook, bytes.NewBuffer(bodyJSON))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Println("Discord webhook error:", err)
}
defer resp.Body.Close()
} }

View File

@ -7,7 +7,7 @@ func main() {
stories := FetchHackerNewsTopStories() stories := FetchHackerNewsTopStories()
filtered := CurateContentForStackerNews(&stories) filtered := CurateContentForStackerNews(&stories)
for _, story := range *filtered { for _, story := range *filtered {
PostStoryToStackerNews(&story) PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
} }
time.Sleep(time.Hour) time.Sleep(time.Hour)
} }

56
sn.go
View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/namsral/flag" "github.com/namsral/flag"
@ -18,10 +19,17 @@ type GraphQLPayload struct {
Variables map[string]interface{} `json:"variables,omitempty"` Variables map[string]interface{} `json:"variables,omitempty"`
} }
type SnUser struct {
Name string `json:"name"`
}
type Dupe struct { type Dupe struct {
Id int `json:"id,string"` Id int `json:"id,string"`
Url string `json:"url"` Url string `json:"url"`
Title string `json:"title"` Title string `json:"title"`
User SnUser `json:"user"`
CreatedAt time.Time `json:"createdAt"`
Sats int `json:"sats"`
NComments int `json:"ncomments"`
} }
type DupesResponse struct { type DupesResponse struct {
@ -30,6 +38,15 @@ type DupesResponse struct {
} `json:"data"` } `json:"data"`
} }
type DupesError struct {
Url string
Dupes []Dupe
}
func (e *DupesError) Error() string {
return fmt.Sprintf("%s has %d dupes", e.Url, len(e.Dupes))
}
type User struct { type User struct {
Name string `json:"name"` Name string `json:"name"`
} }
@ -123,6 +140,12 @@ func FetchStackerNewsDupes(url string) *[]Dupe {
id id
url url
title title
user {
name
}
createdAt
sats
ncomments
} }
}`, }`,
Variables: map[string]interface{}{ Variables: map[string]interface{}{
@ -141,11 +164,17 @@ func FetchStackerNewsDupes(url string) *[]Dupe {
return &dupesResp.Data.Dupes return &dupesResp.Data.Dupes
} }
func PostStoryToStackerNews(story *Story) { type PostStoryOptions struct {
dupes := FetchStackerNewsDupes(story.Url) SkipDupes bool
if len(*dupes) > 0 { }
log.Printf("%s was already posted. Skipping.\n", story.Url)
return func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) {
if !options.SkipDupes {
dupes := FetchStackerNewsDupes(story.Url)
if len(*dupes) > 0 {
log.Printf("%s was already posted. Skipping.\n", story.Url)
return -1, &DupesError{story.Url, *dupes}
}
} }
body := GraphQLPayload{ body := GraphQLPayload{
@ -183,6 +212,7 @@ func PostStoryToStackerNews(story *Story) {
story.Score, story.Descendants, story.Score, story.Descendants,
) )
CommentStackerNewsPost(comment, parentId) CommentStackerNewsPost(comment, parentId)
return parentId, nil
} }
func StackerNewsItemLink(id int) string { func StackerNewsItemLink(id int) string {
@ -213,15 +243,15 @@ func SendStackerNewsEmbedToDiscord(title string, id int) {
Timestamp := time.Now().Format(time.RFC3339) Timestamp := time.Now().Format(time.RFC3339)
url := StackerNewsItemLink(id) url := StackerNewsItemLink(id)
color := 0xffc107 color := 0xffc107
embed := DiscordEmbed{ embed := discordgo.MessageEmbed{
Title: title, Title: title,
Url: url, URL: url,
Color: color, Color: color,
Footer: DiscordEmbedFooter{ Footer: &discordgo.MessageEmbedFooter{
Text: "Stacker News", Text: "Stacker News",
IconUrl: "https://stacker.news/favicon.png", IconURL: "https://stacker.news/favicon.png",
}, },
Timestamp: Timestamp, Timestamp: Timestamp,
} }
SendEmbedToDiscord(embed) SendEmbedToDiscord(&embed)
} }