diff --git a/.env.template b/.env.template index b07ca8a..6954647 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,3 @@ SN_AUTH_COOKIE= -DISCORD_WEBHOOK= DISCORD_TOKEN= +DISCORD_CHANNEL_ID= diff --git a/discord.go b/discord.go index 1ac7599..b4ed143 100644 --- a/discord.go +++ b/discord.go @@ -1,68 +1,53 @@ package main import ( - "bytes" - "encoding/json" + "errors" + "fmt" "log" - "net/http" + "strings" "github.com/bwmarrin/discordgo" + "github.com/dustin/go-humanize" "github.com/joho/godotenv" "github.com/namsral/flag" ) var ( - DiscordWebhook string - DiscordToken string - DiscordClient *discordgo.Session + DiscordToken string + dg *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() { err := godotenv.Load() if err != nil { 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(&DiscordChannelId, "DISCORD_CHANNEL_ID", "", "Discord channel id") flag.Parse() - if DiscordWebhook == "" { - log.Fatal("DISCORD_WEBHOOK not set") - } if DiscordToken == "" { log.Fatal("DISCORD_TOKEN not set") } + if DiscordChannelId == "" { + log.Fatal("DISCORD_CHANNEL_ID not set") + } initBot() } func initBot() { var err error - DiscordClient, err = discordgo.New(DiscordToken) + dg, err = discordgo.New("Bot " + DiscordToken) if err != nil { 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) }) - DiscordClient.AddHandler(onMessage) - DiscordClient.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent - err = DiscordClient.Open() + 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?") } @@ -73,29 +58,110 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.ID == s.State.User.ID { 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 { return } story := FetchStoryById(id) - PostStoryToStackerNews(&story) + id, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: true}) + if err != nil { + log.Fatal("unexpected error returned") + } } -func SendEmbedToDiscord(embed DiscordEmbed) { - bodyJSON, err := json.Marshal( - DiscordWebhookPayload{ - Embeds: []DiscordEmbed{embed}, +func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *DupesError) { + 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 { 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() } diff --git a/main.go b/main.go index 4fa8ef7..ef4d0e9 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,7 @@ func main() { stories := FetchHackerNewsTopStories() filtered := CurateContentForStackerNews(&stories) for _, story := range *filtered { - PostStoryToStackerNews(&story) + PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false}) } time.Sleep(time.Hour) } diff --git a/sn.go b/sn.go index f3b624e..b49a2e2 100644 --- a/sn.go +++ b/sn.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/bwmarrin/discordgo" "github.com/dustin/go-humanize" "github.com/joho/godotenv" "github.com/namsral/flag" @@ -18,10 +19,17 @@ type GraphQLPayload struct { Variables map[string]interface{} `json:"variables,omitempty"` } +type SnUser struct { + Name string `json:"name"` +} type Dupe struct { - Id int `json:"id,string"` - Url string `json:"url"` - Title string `json:"title"` + Id int `json:"id,string"` + Url string `json:"url"` + Title string `json:"title"` + User SnUser `json:"user"` + CreatedAt time.Time `json:"createdAt"` + Sats int `json:"sats"` + NComments int `json:"ncomments"` } type DupesResponse struct { @@ -30,6 +38,15 @@ type DupesResponse struct { } `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 { Name string `json:"name"` } @@ -123,6 +140,12 @@ func FetchStackerNewsDupes(url string) *[]Dupe { id url title + user { + name + } + createdAt + sats + ncomments } }`, Variables: map[string]interface{}{ @@ -141,11 +164,17 @@ func FetchStackerNewsDupes(url string) *[]Dupe { return &dupesResp.Data.Dupes } -func PostStoryToStackerNews(story *Story) { - dupes := FetchStackerNewsDupes(story.Url) - if len(*dupes) > 0 { - log.Printf("%s was already posted. Skipping.\n", story.Url) - return +type PostStoryOptions struct { + SkipDupes bool +} + +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{ @@ -183,6 +212,7 @@ func PostStoryToStackerNews(story *Story) { story.Score, story.Descendants, ) CommentStackerNewsPost(comment, parentId) + return parentId, nil } func StackerNewsItemLink(id int) string { @@ -213,15 +243,15 @@ func SendStackerNewsEmbedToDiscord(title string, id int) { Timestamp := time.Now().Format(time.RFC3339) url := StackerNewsItemLink(id) color := 0xffc107 - embed := DiscordEmbed{ + embed := discordgo.MessageEmbed{ Title: title, - Url: url, + URL: url, Color: color, - Footer: DiscordEmbedFooter{ + Footer: &discordgo.MessageEmbedFooter{ Text: "Stacker News", - IconUrl: "https://stacker.news/favicon.png", + IconURL: "https://stacker.news/favicon.png", }, Timestamp: Timestamp, } - SendEmbedToDiscord(embed) + SendEmbedToDiscord(&embed) }