Skip to content

Instantly share code, notes, and snippets.

@1lann
Last active February 4, 2017 03:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save 1lann/147edc662da628539f3f09d02c1a8ec6 to your computer and use it in GitHub Desktop.
Save 1lann/147edc662da628539f3f09d02c1a8ec6 to your computer and use it in GitHub Desktop.
A bot that posts notifications about new comments and posts on the Riot API Developer Forums.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/bwmarrin/discordgo"
)
// Forum notifications
const channelID = "276779706941177856"
const commentColor = 0x2196F3
const discussionColor = 0x00E676
const developerSite = "https://developer.riotgames.com"
const apolloPath = "https://apollo.developer.leagueoflegends.com/apollo/applications/"
const maxBodyLength = 2000
var thresholdTime = time.Minute * 10
var utcLocation *time.Location
var currentVersion string
type threadState struct {
lastComment time.Time
numComments int
}
var threadStates = make(map[string]threadState)
var threadStatesMutex = new(sync.Mutex)
var session *discordgo.Session
var pages = []string{
"https://developer.riotgames.com/discussion/index",
"https://developer.riotgames.com/discussion/announcements",
"https://developer.riotgames.com/discussion/bugs-feedback",
"https://developer.riotgames.com/discussion/technical-help",
"https://developer.riotgames.com/discussion/tutorials-libraries",
}
// Comment represents a comment posted in a discussion.
type Comment struct {
Message string `json:"message"`
User struct {
Name string `json:"name"`
Realm string `json:"realm"`
ProfileIcon string `json:"lolProfileIcon"`
} `json:"user"`
Replies struct {
Comments []Comment `json:"comments"`
} `json:"replies"`
CreatedAt ApolloTime `json:"createdAt"`
ModifiedAt ApolloTime `json:"modifiedAt"`
}
// Thread represents the information of a discussion (thread).
type Thread struct {
Discussion struct {
ID string `json:"id"`
Title string `json:"title"`
User struct {
Name string `json:"name"`
Realm string `json:"realm"`
ProfileIcon string `json:"lolProfileIcon"`
} `json:"user"`
SoftComments int `json:"softComments"`
Comments struct {
Comments []Comment `json:"comments"`
} `json:"comments"`
CreatedAt ApolloTime `json:"createdAt"`
ModifiedAt ApolloTime `json:"modifiedAt"`
LastCommentedAt ApolloTime `json:"lastCommentedAt"`
Content struct {
Body string `json:"body"`
} `json:"content"`
Application struct {
Name string `json:"name"`
} `json:"application"`
} `json:"discussion"`
}
// ApolloTime represents the time inside Apollo responses
type ApolloTime struct {
time.Time
}
// UnmarshalJSON is the JSON unmarshaller for ApolloTime
func (t *ApolloTime) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
return nil
}
str, err := strconv.Unquote(string(b))
if err != nil {
return err
}
t.Time, err = time.Parse("2006-01-02T15:04:05.999999999-0700", str)
return err
}
func janitor() {
t := time.Tick(time.Hour * 24)
for _ = range t {
threadStatesMutex.Lock()
for key, state := range threadStates {
if time.Since(state.lastComment) > time.Hour*48 {
delete(threadStates, key)
}
}
threadStatesMutex.Unlock()
updateVersion()
}
}
func init() {
utcLocation, _ = time.LoadLocation("UTC")
updateVersion()
}
func updateVersion() {
resp, err := http.Get("https://ddragon.leagueoflegends.com/realms/oce.json")
if err != nil {
log.Println("version checker: failed to get version:", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Println("version checker: version not OK:", resp.Status)
return
}
dec := json.NewDecoder(resp.Body)
var version struct {
Dd string `json:"dd"`
}
if err := dec.Decode(&version); err != nil {
log.Println("version checker: version decode error:", err)
return
}
if currentVersion == version.Dd {
return
}
currentVersion = version.Dd
log.Println("version checker: using version " + currentVersion)
return
}
func main() {
var err error
session, err = discordgo.New(loginEmail, loginPassword)
if err != nil {
panic(err)
}
err = session.Open()
if err != nil {
panic(err)
}
fmt.Println("Running...")
go janitor()
t := time.Tick(time.Second * 10)
for _ = range t {
log.Println("checking...")
for _, page := range pages {
checkNewUpdates(page)
}
log.Println("done checking")
}
}
func checkNewUpdates(page string) {
doc, err := goquery.NewDocument(page)
if err != nil {
log.Println("goquery get:", err)
return
}
doc.Find(".discussion-list .discussion-list-item").Each(func(num int,
thread *goquery.Selection) {
applicationID, _ := thread.Find(".riot-apollo").Attr("data-apollo-application-id")
discussionID, _ := thread.Find(".riot-apollo").Attr("data-apollo-discussion-id")
page, _ := thread.Find(".footer .total-comments-count").Attr("href")
numComments, err := strconv.Atoi(strings.Split(
thread.Find(".footer .total-comments-count").Text(), " ")[0])
if err != nil {
panic(err)
}
threadStatesMutex.Lock()
state, found := threadStates[applicationID+":"+discussionID]
if !found {
threadStatesMutex.Unlock()
checkThread(applicationID, discussionID, page,
time.Now().Add(-thresholdTime))
return
}
if numComments != state.numComments {
threadStatesMutex.Unlock()
checkThread(applicationID, discussionID, page,
state.lastComment)
return
}
threadStatesMutex.Unlock()
})
}
// truncates messages nicely so I don't go above Discord's limit.
func truncateMessage(message string) string {
message = strings.Replace(message, "\r", "", -1)
newMessage := ""
messageParts := strings.Split(message, " ")
for i, part := range messageParts {
if len(newMessage+" "+part) > maxBodyLength {
return newMessage + "..."
}
if i == 0 {
newMessage = part
} else {
newMessage = newMessage + " " + part
}
}
return newMessage
}
func checkThread(applicationID, discussionID, page string, laterThan time.Time) {
log.Println("checking thread:", developerSite+page)
resp, err := http.Get(apolloPath + applicationID + "/discussions/" +
discussionID + "?sort_type=recent&page_size=100")
if err != nil {
log.Println("failed to check thread:", page, "error:", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Println("not OK status code:", resp.Status, "for:", page)
return
}
dec := json.NewDecoder(resp.Body)
var thread Thread
err = dec.Decode(&thread)
if err != nil {
log.Println("failed to decode thread:", apolloPath+applicationID+"/discussions/"+
discussionID+"?sort_type=recent&page_size=100", "error:", err)
return
}
if thread.Discussion.CreatedAt.After(laterThan) {
// This thread is new
title, err := url.QueryUnescape(thread.Discussion.Title)
if err != nil {
log.Println("failed to unescape title:"+
thread.Discussion.Title, "error:", err)
title = "Error getting title"
}
body, err := url.QueryUnescape(thread.Discussion.Content.Body)
if err != nil {
log.Println("failed to unescape body:"+
thread.Discussion.Content.Body, "error:", err)
body = "Error getting body"
}
body = truncateMessage(body)
message := &discordgo.MessageEmbed{
Title: title,
Description: body,
URL: developerSite + page,
Color: discussionColor,
Author: &discordgo.MessageEmbedAuthor{
Name: thread.Discussion.User.Name +
" (" + thread.Discussion.User.Realm +
") posted a new discussion",
IconURL: "https://ddragon.leagueoflegends.com/cdn/" +
currentVersion + "/img/profileicon/" +
thread.Discussion.User.ProfileIcon + ".png",
},
Timestamp: thread.Discussion.CreatedAt.
In(utcLocation).Format(time.RFC3339),
}
_, err = session.ChannelMessageSendEmbed(channelID, message)
if err != nil {
log.Println("error sending message:", err)
}
}
checkComments(page, thread, thread.Discussion.Comments.Comments, laterThan)
threadStatesMutex.Lock()
lastComment := thread.Discussion.LastCommentedAt.Time
if lastComment.IsZero() {
lastComment = thread.Discussion.CreatedAt.Time
}
threadStates[applicationID+":"+discussionID] = threadState{
numComments: thread.Discussion.SoftComments,
lastComment: lastComment,
}
threadStatesMutex.Unlock()
}
func checkComments(page string, thread Thread, comments []Comment, laterThan time.Time) {
for _, comment := range comments {
if comment.CreatedAt.After(laterThan) {
title, err := url.QueryUnescape(thread.Discussion.Title)
if err != nil {
log.Println("failed to unescape title:"+
thread.Discussion.Title, "error:", err)
title = "Error getting title"
}
message := &discordgo.MessageEmbed{
Title: title,
Description: comment.Message,
URL: developerSite + page,
Color: commentColor,
Author: &discordgo.MessageEmbedAuthor{
Name: comment.User.Name +
" (" + comment.User.Realm +
") posted a comment",
IconURL: "https://ddragon.leagueoflegends.com/cdn/" +
currentVersion + "/img/profileicon/" +
comment.User.ProfileIcon + ".png",
},
Timestamp: comment.CreatedAt.
In(utcLocation).Format(time.RFC3339),
}
_, err = session.ChannelMessageSendEmbed(channelID, message)
if err != nil {
log.Println("error sending message:", err)
}
}
checkComments(page, thread, comment.Replies.Comments, laterThan)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment