From 94e45c27ce2fce1fef6e7d51519be1f42fd2bdb8 Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Tue, 30 Jan 2018 10:39:08 +0100 Subject: [PATCH 1/6] [go] add webhook verification implementation --- implementations/go/main.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 implementations/go/main.go diff --git a/implementations/go/main.go b/implementations/go/main.go new file mode 100644 index 0000000..d7a95ad --- /dev/null +++ b/implementations/go/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +// VerificationToken is the random string entered in the verification prompt when setting up the app on Facbook +// It can be any string provide it matches what you will enter in the setup prompt +const VerificationToken = "bots are awesome" + +func verifyWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + mode := r.URL.Query().Get("hub.mode") + token := r.URL.Query().Get("hub.verify_token") + challenge := r.URL.Query().Get("hub.challenge") + + if mode == "subscribe" && token == VerificationToken { + fmt.Fprint(w, challenge) + } else { + w.WriteHeader(http.StatusForbidden) + } +} + +func setupRouter() *httprouter.Router { + r := httprouter.New() + r.GET("/webhook", verifyWebhook) + return r +} + +func main() { + err := http.ListenAndServe(":3000", setupRouter()) + if err != nil { + panic(err) + } +} From 77ea6e34360331a1943ccf4d200c2281d59c247b Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Mon, 5 Feb 2018 17:30:10 +0100 Subject: [PATCH 2/6] [go] add dummy message response --- implementations/go/main.go | 65 +++++++++++++++++++++++++++++++++++++ implementations/go/types.go | 34 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 implementations/go/types.go diff --git a/implementations/go/main.go b/implementations/go/main.go index d7a95ad..af914db 100644 --- a/implementations/go/main.go +++ b/implementations/go/main.go @@ -1,8 +1,14 @@ package main import ( + "bytes" + "encoding/json" "fmt" + "io" + "log" "net/http" + "os" + "reflect" "github.com/julienschmidt/httprouter" ) @@ -11,6 +17,8 @@ import ( // It can be any string provide it matches what you will enter in the setup prompt const VerificationToken = "bots are awesome" +var AccessToken = os.Getenv("ACCESS_TOKEN") + func verifyWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { mode := r.URL.Query().Get("hub.mode") token := r.URL.Query().Get("hub.verify_token") @@ -23,9 +31,66 @@ func verifyWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) } } +func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + io.Copy(os.Stdout, r.Body) + // Parse the request payload + payload := webhookPayload{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + log.Println("Unmarshalling webhook payload resulted in an error: ", err) + return + } + + // Make sure this is a page subscription + if payload.Object == "page" { + // Iterate over each entry + // There may be multiple if batched + for _, entry := range payload.Entry { + // Iterate over each messaging event + for _, messaging := range entry.Messaging { + switch { + case !reflect.DeepEqual(messaging.Message, messageEvent{}): + handleMessageEvent(messaging.Message, messaging.Sender.ID) + default: + log.Printf("No handler found for: %+v\n", messaging.Message) + } + } + } + } +} + +func handleMessageEvent(msgEvnt messageEvent, senderID string) { + reply := textResponse{} + reply.Recipient.ID = senderID + reply.Message.Text = fmt.Sprintf("I received your message: '%s', and I've sent it to my Oga at the top Oscar", msgEvnt.Text) + sendResponse(reply) +} + +func sendResponse(payload interface{}) { + // Parse the response payload + pkg, err := json.Marshal(payload) + if err != nil { + log.Println("Sending response parsing in an error: ", err) + return + } + body := bytes.NewBuffer(pkg) + + fbURL := "https://graph.facebook.com/v2.6/me/messages?" + url := fmt.Sprintf("%saccess_token=%s", fbURL, AccessToken) + + req, err := http.NewRequest("POST", url, body) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + + _, err = client.Do(req) + if err != nil { + log.Println("Sending response resulted in an error: ", err) + } +} + func setupRouter() *httprouter.Router { r := httprouter.New() r.GET("/webhook", verifyWebhook) + r.POST("/webhook", handleWebhookEvents) return r } diff --git a/implementations/go/types.go b/implementations/go/types.go new file mode 100644 index 0000000..425ac82 --- /dev/null +++ b/implementations/go/types.go @@ -0,0 +1,34 @@ +package main + +type webhookPayload struct { + Object string `json:"object"` + Entry []struct { + ID string `json:"id"` + Messaging []struct { + Message messageEvent `json:"message"` + Recipient struct { + ID string `json:"id"` + } `json:"recipient"` + Sender struct { + ID string `json:"id"` + } `json:"sender"` + Timestamp int `json:"timestamp"` + } `json:"messaging"` + Time int `json:"time"` + } `json:"entry"` +} + +type messageEvent struct { + Mid string + Seq int + Text string +} + +type textResponse struct { + Recipient struct { + ID string `json:"id"` + } `json:"recipient"` + Message struct { + Text string `json:"text"` + } `json:"message"` +} From 384b977ce3581657638f9880f7659542a5d68bf1 Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Tue, 6 Feb 2018 16:08:59 +0100 Subject: [PATCH 3/6] added bot menu using button template --- implementations/go/main.go | 40 ++++++++++++++++++++++--- implementations/go/types.go | 58 +++++++++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/implementations/go/main.go b/implementations/go/main.go index af914db..def872d 100644 --- a/implementations/go/main.go +++ b/implementations/go/main.go @@ -32,7 +32,6 @@ func verifyWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) } func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - io.Copy(os.Stdout, r.Body) // Parse the request payload payload := webhookPayload{} if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { @@ -50,6 +49,8 @@ func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Pa switch { case !reflect.DeepEqual(messaging.Message, messageEvent{}): handleMessageEvent(messaging.Message, messaging.Sender.ID) + case !reflect.DeepEqual(messaging.Postback, postbackEvent{}): + handlePostbackEvent(messaging.Postback, messaging.Sender.ID) default: log.Printf("No handler found for: %+v\n", messaging.Message) } @@ -58,10 +59,37 @@ func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Pa } } -func handleMessageEvent(msgEvnt messageEvent, senderID string) { +func handlePostbackEvent(msgEvnt postbackEvent, senderID string) { reply := textResponse{} reply.Recipient.ID = senderID - reply.Message.Text = fmt.Sprintf("I received your message: '%s', and I've sent it to my Oga at the top Oscar", msgEvnt.Text) + reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) + sendResponse(reply) +} + +func handleMessageEvent(msgEvnt messageEvent, senderID string) { + reply := templateResponse{} + reply.Recipient.ID = senderID + reply.Message.Attachment.Type = "template" + reply.Message.Attachment.Payload.TemplateType = "button" + reply.Message.Attachment.Payload.Text = "What do you want to do?" + matchSchedulesPostbackBtn := button{ + Type: "postback", + Title: "View match schedules", + Payload: "match-schedules-postback", + } + + leagueTablePostbackBtn := button{ + Type: "postback", + Title: "View league table", + Payload: "league-table-postback", + } + + leagueHighlightsBtn := button{ + Type: "postback", + Title: "View Highlights", + Payload: "league-highlights-postback", + } + reply.Message.Attachment.Payload.Buttons = []button{matchSchedulesPostbackBtn, leagueHighlightsBtn, leagueTablePostbackBtn} sendResponse(reply) } @@ -78,13 +106,17 @@ func sendResponse(payload interface{}) { url := fmt.Sprintf("%saccess_token=%s", fbURL, AccessToken) req, err := http.NewRequest("POST", url, body) + if err != nil { + + } req.Header.Set("Content-Type", "application/json") client := &http.Client{} - _, err = client.Do(req) + res, err := client.Do(req) if err != nil { log.Println("Sending response resulted in an error: ", err) } + io.Copy(os.Stdout, res.Body) } func setupRouter() *httprouter.Router { diff --git a/implementations/go/types.go b/implementations/go/types.go index 425ac82..3726af1 100644 --- a/implementations/go/types.go +++ b/implementations/go/types.go @@ -1,21 +1,22 @@ package main type webhookPayload struct { - Object string `json:"object"` + Object string `json:"object,omitempty"` Entry []struct { - ID string `json:"id"` + ID string `json:"id,omitempty"` Messaging []struct { - Message messageEvent `json:"message"` + Message messageEvent `json:"message,omitempty"` Recipient struct { - ID string `json:"id"` - } `json:"recipient"` + ID string `json:"id,omitempty"` + } `json:"recipient,omitempty"` Sender struct { - ID string `json:"id"` - } `json:"sender"` - Timestamp int `json:"timestamp"` - } `json:"messaging"` - Time int `json:"time"` - } `json:"entry"` + ID string `json:"id,omitempty"` + } `json:"sender,omitempty"` + Timestamp int `json:"timestamp,omitempty"` + Postback postbackEvent `json:"postback,omitempty"` + } `json:"messaging,omitempty"` + Time int `json:"time,omitempty"` + } `json:"entry,omitempty"` } type messageEvent struct { @@ -24,11 +25,38 @@ type messageEvent struct { Text string } +type postbackEvent struct { + Title string `json:"title,omitempty"` + Payload string `json:"payload,omitempty"` +} + type textResponse struct { Recipient struct { - ID string `json:"id"` - } `json:"recipient"` + ID string `json:"id,omitempty"` + } `json:"recipient,omitempty"` + Message struct { + Text string `json:"text,omitempty"` + } `json:"message,omitempty"` +} + +type templateResponse struct { + Recipient struct { + ID string `json:"id,omitempty"` + } `json:"recipient,omitempty"` Message struct { - Text string `json:"text"` - } `json:"message"` + Attachment struct { + Type string `json:"type,omitempty"` + Payload struct { + TemplateType string `json:"template_type,omitempty"` + Text string `json:"text,omitempty"` + Buttons []button `json:"buttons,omitempty"` + } `json:"payload,omitempty"` + } `json:"attachment,omitempty"` + } `json:"message,omitempty"` +} + +type button struct { + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Payload string `json:"payload,omitempty"` } From ba715e2aff2f06732773289c4efb30f03dd69feb Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Fri, 16 Feb 2018 23:25:12 +0100 Subject: [PATCH 4/6] implement league table using list template --- implementations/go/data/data.go | 50 ++++++++++++ implementations/go/data/football_data/api.go | 59 ++++++++++++++ .../go/data/football_data/types.go | 77 +++++++++++++++++++ implementations/go/main.go | 68 ++++++---------- implementations/go/postbacks.go | 38 +++++++++ implementations/go/templates.go | 17 ++++ implementations/go/types.go | 21 ++++- implementations/go/utils/utils.go | 36 +++++++++ 8 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 implementations/go/data/data.go create mode 100644 implementations/go/data/football_data/api.go create mode 100644 implementations/go/data/football_data/types.go create mode 100644 implementations/go/postbacks.go create mode 100644 implementations/go/templates.go create mode 100644 implementations/go/utils/utils.go diff --git a/implementations/go/data/data.go b/implementations/go/data/data.go new file mode 100644 index 0000000..0426e83 --- /dev/null +++ b/implementations/go/data/data.go @@ -0,0 +1,50 @@ +package data + +import ( + "github.com/FBDevCLagos/soccergist/implementations/go/data/football_data" +) + +type League interface { + Table() *football_data.LeagueTable +} + +type LeagueTableTeamInfo struct { + Name, Crest string + Position, Points, MatchPlayed int +} + +func PremierLeagueInfo() League { + competition := football_data.PremierLeague() + l := League(competition) + return l +} + +func FirstFour(table *football_data.LeagueTable) []LeagueTableTeamInfo { + info := []LeagueTableTeamInfo{} + for i, team := range table.Standing { + info = append(info, LeagueTableTeamInfo{ + Position: team.Position, + Name: team.TeamName, + Crest: substitueTeamLogo(team.TeamName), + Points: team.Points, + MatchPlayed: team.PlayedGames, + }) + + if i == 3 { + break + } + } + + return info +} + +var teamsLogo = map[string]string{ + "Manchester City FC": "https://logoeps.com/wp-content/uploads/2011/08/manchester-city-logo-vector.png", + "Manchester United FC": "https://logoeps.com/wp-content/uploads/2011/08/manchester-united-logo-vector.png", + "Liverpool FC": "https://logoeps.com/wp-content/uploads/2011/08/liverpool-logo-vector.png", + "Chelsea FC": "https://logoeps.com/wp-content/uploads/2011/08/chelsea-logo-vector.png", +} + +func substitueTeamLogo(teamName string) string { + return teamsLogo[teamName] +} diff --git a/implementations/go/data/football_data/api.go b/implementations/go/data/football_data/api.go new file mode 100644 index 0000000..d12c522 --- /dev/null +++ b/implementations/go/data/football_data/api.go @@ -0,0 +1,59 @@ +package football_data + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/FBDevCLagos/soccergist/implementations/go/utils" +) + +const ( + URL = "https://api.football-data.org/v1/competitions?season=2017" + PremierLeagueSymbol = "PL" +) + +func PremierLeague() *Competition { + req, err := utils.APIRequest(URL, "GET", nil) + if err != nil || req.StatusCode != http.StatusOK { + log.Println("Error occurred in PremierLeague while making request to: ", URL, err) + } + + return filterPremierLeague(req) +} + +func filterPremierLeague(req *http.Response) (premierLeague *Competition) { + var competitions []Competition + err := json.NewDecoder(req.Body).Decode(&competitions) + if err != nil { + log.Println("Error occurred parsing json: ", err) + return + } + + for _, competition := range competitions { + if competition.League == PremierLeagueSymbol { + premierLeague = &competition + break + } + } + return +} + +func fetchLeagueTable(url string) *LeagueTable { + table := &LeagueTable{} + url = strings.Replace(url, "http://", "https://", 1) + + req, err := utils.APIRequest(url, "GET", nil) + if err != nil || req.StatusCode != http.StatusOK { + log.Println("Error occurred in fetchLeagueTable while making request to: ", url, err) + return nil + } + + err = json.NewDecoder(req.Body).Decode(table) + if err != nil { + log.Println("Error occurred parsing leagueTable json: ", err) + return nil + } + return table +} diff --git a/implementations/go/data/football_data/types.go b/implementations/go/data/football_data/types.go new file mode 100644 index 0000000..c89edeb --- /dev/null +++ b/implementations/go/data/football_data/types.go @@ -0,0 +1,77 @@ +package football_data + +type Competition struct { + Links struct { + Fixtures struct { + Href string `json:"href"` + } `json:"fixtures"` + LeagueTable struct { + Href string `json:"href"` + } `json:"leagueTable"` + Self struct { + Href string `json:"href"` + } `json:"self"` + Teams struct { + Href string `json:"href"` + } `json:"teams"` + } `json:"_links"` + Caption string `json:"caption"` + CurrentMatchday int `json:"currentMatchday"` + ID int `json:"id"` + LastUpdated string `json:"lastUpdated"` + League string `json:"league"` + NumberOfGames int `json:"numberOfGames"` + NumberOfMatchdays int `json:"numberOfMatchdays"` + NumberOfTeams int `json:"numberOfTeams"` + Year string `json:"year"` +} + +type LeagueTable struct { + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + Competition struct { + Href string `json:"href"` + } `json:"competition"` + } `json:"_links"` + LeagueCaption string `json:"leagueCaption"` + Matchday int `json:"matchday"` + Standing []struct { + Links struct { + Team struct { + Href string `json:"href"` + } `json:"team"` + } `json:"_links"` + Position int `json:"position"` + TeamName string `json:"teamName"` + CrestURI string `json:"crestURI"` + PlayedGames int `json:"playedGames"` + Points int `json:"points"` + Goals int `json:"goals"` + GoalsAgainst int `json:"goalsAgainst"` + GoalDifference int `json:"goalDifference"` + Wins int `json:"wins"` + Draws int `json:"draws"` + Losses int `json:"losses"` + Home struct { + Goals int `json:"goals"` + GoalsAgainst int `json:"goalsAgainst"` + Wins int `json:"wins"` + Draws int `json:"draws"` + Losses int `json:"losses"` + } `json:"home"` + Away struct { + Goals int `json:"goals"` + GoalsAgainst int `json:"goalsAgainst"` + Wins int `json:"wins"` + Draws int `json:"draws"` + Losses int `json:"losses"` + } `json:"away"` + } `json:"standing"` +} + +func (c *Competition) Table() *LeagueTable { + url := c.Links.LeagueTable.Href + return fetchLeagueTable(url) +} diff --git a/implementations/go/main.go b/implementations/go/main.go index def872d..7bfa421 100644 --- a/implementations/go/main.go +++ b/implementations/go/main.go @@ -1,15 +1,15 @@ package main import ( - "bytes" "encoding/json" "fmt" - "io" "log" "net/http" "os" "reflect" + "github.com/FBDevCLagos/soccergist/implementations/go/utils" + "github.com/julienschmidt/httprouter" ) @@ -17,7 +17,10 @@ import ( // It can be any string provide it matches what you will enter in the setup prompt const VerificationToken = "bots are awesome" -var AccessToken = os.Getenv("ACCESS_TOKEN") +var ( + AccessToken = os.Getenv("ACCESS_TOKEN") + fbURL = fmt.Sprintf("https://graph.facebook.com/v2.6/me/messages?access_token=%s", AccessToken) +) func verifyWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { mode := r.URL.Query().Get("hub.mode") @@ -59,11 +62,20 @@ func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Pa } } +var postbackHandlers = map[string]func(postbackEvent, string){ + "league-table-postback": handleLeagueTablePostbackEvent, +} + func handlePostbackEvent(msgEvnt postbackEvent, senderID string) { - reply := textResponse{} - reply.Recipient.ID = senderID - reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) - sendResponse(reply) + postbackHandler, ok := postbackHandlers[msgEvnt.Payload] + if !ok { + reply := textResponse{} + reply.Recipient.ID = senderID + reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) + sendResponse(reply) + } else { + postbackHandler(msgEvnt, senderID) + } } func handleMessageEvent(msgEvnt messageEvent, senderID string) { @@ -72,51 +84,17 @@ func handleMessageEvent(msgEvnt messageEvent, senderID string) { reply.Message.Attachment.Type = "template" reply.Message.Attachment.Payload.TemplateType = "button" reply.Message.Attachment.Payload.Text = "What do you want to do?" - matchSchedulesPostbackBtn := button{ - Type: "postback", - Title: "View match schedules", - Payload: "match-schedules-postback", - } - leagueTablePostbackBtn := button{ - Type: "postback", - Title: "View league table", - Payload: "league-table-postback", - } + matchSchedulesPostbackBtn := buildPostbackBtn("View match schedules", "match-schedules-postback") + leagueTablePostbackBtn := buildPostbackBtn("View league table", "league-table-postback") + leagueHighlightsBtn := buildPostbackBtn("View Highlights", "league-highlights-postback") - leagueHighlightsBtn := button{ - Type: "postback", - Title: "View Highlights", - Payload: "league-highlights-postback", - } reply.Message.Attachment.Payload.Buttons = []button{matchSchedulesPostbackBtn, leagueHighlightsBtn, leagueTablePostbackBtn} sendResponse(reply) } func sendResponse(payload interface{}) { - // Parse the response payload - pkg, err := json.Marshal(payload) - if err != nil { - log.Println("Sending response parsing in an error: ", err) - return - } - body := bytes.NewBuffer(pkg) - - fbURL := "https://graph.facebook.com/v2.6/me/messages?" - url := fmt.Sprintf("%saccess_token=%s", fbURL, AccessToken) - - req, err := http.NewRequest("POST", url, body) - if err != nil { - - } - req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - - res, err := client.Do(req) - if err != nil { - log.Println("Sending response resulted in an error: ", err) - } - io.Copy(os.Stdout, res.Body) + utils.APIRequest(fbURL, "POST", payload) } func setupRouter() *httprouter.Router { diff --git a/implementations/go/postbacks.go b/implementations/go/postbacks.go new file mode 100644 index 0000000..40ca77f --- /dev/null +++ b/implementations/go/postbacks.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/FBDevCLagos/soccergist/implementations/go/data" +) + +func handleLeagueTablePostbackEvent(msgEvnt postbackEvent, senderID string) { + league := data.PremierLeagueInfo() + leagueTable := league.Table() + firstFour := data.FirstFour(leagueTable) + elements := []element{} + moreDetailsBtn := buildPostbackBtn("more details", "") + viewMoreBtn := buildPostbackBtn("view more", "league-table-view-more-postback") + + for _, team := range firstFour { + moreDetailsBtn.Payload = fmt.Sprintf("league-table-position-%d-more-details-postback", team.Position) + element := buildBasicElement( + fmt.Sprintf("Position %d: %s", team.Position, team.Name), + fmt.Sprintf("Matches played: %d \n Points: %d", team.MatchPlayed, team.Points), + team.Crest, + ) + + element.Buttons = append(element.Buttons, moreDetailsBtn) + elements = append(elements, element) + } + + reply := templateResponse{} + reply.Recipient.ID = senderID + reply.Message.Attachment.Type = "template" + reply.Message.Attachment.Payload.TemplateType = "list" + reply.Message.Attachment.Payload.TopElementStyle = "large" + reply.Message.Attachment.Payload.Elements = elements + reply.Message.Attachment.Payload.Buttons = []button{viewMoreBtn} + + sendResponse(reply) +} diff --git a/implementations/go/templates.go b/implementations/go/templates.go new file mode 100644 index 0000000..d8a6286 --- /dev/null +++ b/implementations/go/templates.go @@ -0,0 +1,17 @@ +package main + +func buildPostbackBtn(title, payload string) button { + return button{ + Type: "postback", + Title: title, + Payload: payload, + } +} + +func buildBasicElement(title, subtitle, imageURL string) element { + return element{ + Title: title, + ImageURL: imageURL, + Subtitle: subtitle, + } +} diff --git a/implementations/go/types.go b/implementations/go/types.go index 3726af1..2db00bf 100644 --- a/implementations/go/types.go +++ b/implementations/go/types.go @@ -47,9 +47,11 @@ type templateResponse struct { Attachment struct { Type string `json:"type,omitempty"` Payload struct { - TemplateType string `json:"template_type,omitempty"` - Text string `json:"text,omitempty"` - Buttons []button `json:"buttons,omitempty"` + TemplateType string `json:"template_type,omitempty"` + TopElementStyle string `json:"top_element_style,omitempty"` + Text string `json:"text,omitempty"` + Buttons []button `json:"buttons,omitempty"` + Elements []element `json:"elements,omitempty"` } `json:"payload,omitempty"` } `json:"attachment,omitempty"` } `json:"message,omitempty"` @@ -60,3 +62,16 @@ type button struct { Title string `json:"title,omitempty"` Payload string `json:"payload,omitempty"` } + +type element struct { + Buttons []button `json:"buttons,omitempty"` + Title string `json:"title,omitempty"` + Subtitle string `json:"subtitle,omitempty"` + ImageURL string `json:"image_url,omitempty"` + DefaultAction *struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + MessengerExtensions bool `json:"messenger_extensions,omitempty"` + WebviewHeightRatio string `json:"webview_height_ratio,omitempty"` + } `json:"default_action,omitempty"` +} diff --git a/implementations/go/utils/utils.go b/implementations/go/utils/utils.go new file mode 100644 index 0000000..2039405 --- /dev/null +++ b/implementations/go/utils/utils.go @@ -0,0 +1,36 @@ +package utils + +import ( + "bytes" + "encoding/json" + "log" + "net/http" +) + +func APIRequest(url string, requestMethod string, payload interface{}) (*http.Response, error) { + var req *http.Request + var err error + + if payload != nil { + // Parse the response payload + pkg, err := json.Marshal(payload) + if err != nil { + log.Println("Sending response parsing in an error: ", err) + return nil, err + } + body := bytes.NewBuffer(pkg) + req, err = http.NewRequest(requestMethod, url, body) + } else { + req, err = http.NewRequest(requestMethod, url, nil) + } + + if err != nil { + log.Println("Error creating request: ", err) + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + + return client.Do(req) +} From 575bc7137fc44045c9b33afec394b533a706cab3 Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Mon, 12 Mar 2018 15:26:43 +0100 Subject: [PATCH 5/6] implement match schedules --- implementations/go/data/data.go | 3 + .../go/data/football_data/types.go | 80 +++++++++++++++++ implementations/go/main.go | 19 +++- implementations/go/postbacks.go | 87 ++++++++++++++++++- implementations/go/templates.go | 13 +++ implementations/go/types.go | 32 ++++--- 6 files changed, 219 insertions(+), 15 deletions(-) diff --git a/implementations/go/data/data.go b/implementations/go/data/data.go index 0426e83..3864727 100644 --- a/implementations/go/data/data.go +++ b/implementations/go/data/data.go @@ -6,6 +6,9 @@ import ( type League interface { Table() *football_data.LeagueTable + PresentMatchday() int + TotalMatchdays() int + GetMatchdayFixtures(int) *football_data.MatchDayFixtures } type LeagueTableTeamInfo struct { diff --git a/implementations/go/data/football_data/types.go b/implementations/go/data/football_data/types.go index c89edeb..07cca11 100644 --- a/implementations/go/data/football_data/types.go +++ b/implementations/go/data/football_data/types.go @@ -1,5 +1,15 @@ package football_data +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/FBDevCLagos/soccergist/implementations/go/utils" +) + type Competition struct { Links struct { Fixtures struct { @@ -71,7 +81,77 @@ type LeagueTable struct { } `json:"standing"` } +type MatchDayFixtures struct { + Links struct { + Competition struct { + Href string `json:"href"` + } `json:"competition"` + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + Count int `json:"count"` + Fixtures []struct { + Links struct { + AwayTeam struct { + Href string `json:"href"` + } `json:"awayTeam"` + Competition struct { + Href string `json:"href"` + } `json:"competition"` + HomeTeam struct { + Href string `json:"href"` + } `json:"homeTeam"` + Self struct { + Href string `json:"href"` + } `json:"self"` + } `json:"_links"` + AwayTeamName string `json:"awayTeamName"` + Date string `json:"date"` + HomeTeamName string `json:"homeTeamName"` + Matchday int `json:"matchday"` + Odds interface{} `json:"odds"` + Result struct { + GoalsAwayTeam int `json:"goalsAwayTeam"` + GoalsHomeTeam int `json:"goalsHomeTeam"` + HalfTime struct { + GoalsAwayTeam int `json:"goalsAwayTeam"` + GoalsHomeTeam int `json:"goalsHomeTeam"` + } `json:"halfTime"` + } `json:"result"` + Status string `json:"status"` + } `json:"fixtures"` +} + func (c *Competition) Table() *LeagueTable { url := c.Links.LeagueTable.Href return fetchLeagueTable(url) } + +func (c *Competition) PresentMatchday() int { + return c.CurrentMatchday +} + +func (c *Competition) TotalMatchdays() int { + return c.NumberOfMatchdays +} + +func (c *Competition) GetMatchdayFixtures(matchday int) *MatchDayFixtures { + url := fmt.Sprintf("%s?matchday=%d", c.Links.Fixtures.Href, matchday) + matchDayFixtures := &MatchDayFixtures{} + url = strings.Replace(url, "http://", "https://", 1) + + req, err := utils.APIRequest(url, "GET", nil) + if err != nil || req.StatusCode != http.StatusOK { + log.Println("Error occurred in GetMatchdayFixtures while making request to: ", url, err) + return nil + } + + err = json.NewDecoder(req.Body).Decode(matchDayFixtures) + if err != nil { + log.Println("Error occurred parsing json: ", err) + return nil + } + + return matchDayFixtures +} diff --git a/implementations/go/main.go b/implementations/go/main.go index 7bfa421..d8d92dd 100644 --- a/implementations/go/main.go +++ b/implementations/go/main.go @@ -3,10 +3,12 @@ package main import ( "encoding/json" "fmt" + "io" "log" "net/http" "os" "reflect" + "strings" "github.com/FBDevCLagos/soccergist/implementations/go/utils" @@ -62,23 +64,32 @@ func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Pa } } -var postbackHandlers = map[string]func(postbackEvent, string){ - "league-table-postback": handleLeagueTablePostbackEvent, +var postbackHandlers = map[string]func(string, string){ + "league-table-postback": handleLeagueTablePostbackEvent, + "match-schedules-postback": handleMatchSchedulesPostbackEvent, } func handlePostbackEvent(msgEvnt postbackEvent, senderID string) { postbackHandler, ok := postbackHandlers[msgEvnt.Payload] if !ok { - reply := textResponse{} + reply := templateResponse{} reply.Recipient.ID = senderID reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) sendResponse(reply) } else { - postbackHandler(msgEvnt, senderID) + postbackHandler(msgEvnt.Payload, senderID) } } func handleMessageEvent(msgEvnt messageEvent, senderID string) { + if msgEvnt.QuickReply.Payload != "" { + handleQuickReplyEvent(msgEvnt, senderID) + return + } + handleTextMessageEvent(msgEvnt, senderID) +} + +func handleTextMessageEvent(msgEvnt messageEvent, senderID string) { reply := templateResponse{} reply.Recipient.ID = senderID reply.Message.Attachment.Type = "template" diff --git a/implementations/go/postbacks.go b/implementations/go/postbacks.go index 40ca77f..32116a5 100644 --- a/implementations/go/postbacks.go +++ b/implementations/go/postbacks.go @@ -2,11 +2,14 @@ package main import ( "fmt" + "regexp" + "strconv" + "time" "github.com/FBDevCLagos/soccergist/implementations/go/data" ) -func handleLeagueTablePostbackEvent(msgEvnt postbackEvent, senderID string) { +func handleLeagueTablePostbackEvent(msgEvnt, senderID string) { league := data.PremierLeagueInfo() leagueTable := league.Table() firstFour := data.FirstFour(leagueTable) @@ -36,3 +39,85 @@ func handleLeagueTablePostbackEvent(msgEvnt postbackEvent, senderID string) { sendResponse(reply) } + +func handleMatchSchedulesPostbackEvent(payload, senderID string) { + premierLeague := data.PremierLeagueInfo() + rr := regexp.MustCompile("(\\d+)") + matchday := premierLeague.PresentMatchday() + if mday := rr.FindStringSubmatch(payload); len(mday) > 0 { + matchday, _ = strconv.Atoi(mday[0]) + } + + fixtures := premierLeague.GetMatchdayFixtures(matchday) + msg := fmt.Sprintf("Fixtures for Matchday: %d", matchday) + + if fixtures == nil { + return + } + + for _, fixture := range fixtures.Fixtures { + if fixture.Status == "FINISHED" { + msg = fmt.Sprintf("%s\n-----\n%s VS %s => (%d : %d)", msg, fixture.HomeTeamName, fixture.AwayTeamName, fixture.Result.GoalsHomeTeam, fixture.Result.GoalsAwayTeam) + } else if t, err := time.Parse(time.RFC3339, fixture.Date); err == nil { + msg = fmt.Sprintf("%s\n-----\n%s VS %s - %s", msg, fixture.HomeTeamName, fixture.AwayTeamName, t.Format("Mon, Jan 2, 3:04PM")) + } else { + msg = fmt.Sprintf("%s\n-----\n%s VS %s - %s", msg, fixture.HomeTeamName, fixture.AwayTeamName, fixture.Status) + } + } + reply := buildTextMsg(senderID, msg) + + sendResponse(reply) + sendMatchFixuresPagination(senderID, matchday, premierLeague.PresentMatchday(), premierLeague.TotalMatchdays()) +} + +func sendMatchFixuresPagination(senderID string, matchday, currentMatchday, totalMatchdays int) { + contents := []quickReply{} + + if day := matchday - 2; day > 0 { + content := quickReply{ + ContentType: "text", + Payload: fmt.Sprintf("match-schedules-postback-%d", day), + Title: fmt.Sprintf("<< matchday %d", day), + } + contents = append(contents, content) + } + + if day := matchday - 1; day > 0 { + content := quickReply{ + ContentType: "text", + Payload: fmt.Sprintf("match-schedules-postback-%d", day), + Title: fmt.Sprintf("< matchday %d", day), + } + contents = append(contents, content) + } + + if matchday != currentMatchday { + content := quickReply{ + ContentType: "text", + Payload: fmt.Sprintf("match-schedules-postback-%d", currentMatchday), + Title: "current matchday", + } + contents = append(contents, content) + } + + if day := matchday + 1; day <= totalMatchdays { + content := quickReply{ + ContentType: "text", + Payload: fmt.Sprintf("match-schedules-postback-%d", day), + Title: fmt.Sprintf("matchday %d >", day), + } + contents = append(contents, content) + } + + if day := matchday + 2; day <= totalMatchdays { + content := quickReply{ + ContentType: "text", + Payload: fmt.Sprintf("match-schedules-postback-%d", day), + Title: fmt.Sprintf("matchday %d >>", day), + } + contents = append(contents, content) + } + + reply := buildQuickReply("navigation", senderID, contents) + sendResponse(reply) +} diff --git a/implementations/go/templates.go b/implementations/go/templates.go index d8a6286..4824091 100644 --- a/implementations/go/templates.go +++ b/implementations/go/templates.go @@ -15,3 +15,16 @@ func buildBasicElement(title, subtitle, imageURL string) element { Subtitle: subtitle, } } + +func buildTextMsg(senderID, text string) (msg basicResponseTemplate) { + msg.Message.Text = text + msg.Recipient.ID = senderID + return msg +} + +func buildQuickReply(text, senderID string, quickReplies []quickReply) (msg basicResponseTemplate) { + msg.Recipient.ID = senderID + msg.Message.Text = text + msg.Message.QuickReplies = quickReplies + return +} diff --git a/implementations/go/types.go b/implementations/go/types.go index 2db00bf..49c2164 100644 --- a/implementations/go/types.go +++ b/implementations/go/types.go @@ -20,9 +20,12 @@ type webhookPayload struct { } type messageEvent struct { - Mid string - Seq int - Text string + Mid string + Seq int + Text string + QuickReply struct { + Payload string `json:"payload,omitempty"` + } `json:"quick_reply,omitempty"` } type postbackEvent struct { @@ -30,13 +33,11 @@ type postbackEvent struct { Payload string `json:"payload,omitempty"` } -type textResponse struct { - Recipient struct { - ID string `json:"id,omitempty"` - } `json:"recipient,omitempty"` - Message struct { - Text string `json:"text,omitempty"` - } `json:"message,omitempty"` +type quickReply struct { + ContentType string `json:"content_type,omitempty"` + ImageURL string `json:"image_url,omitempty"` + Payload string `json:"payload,omitempty"` + Title string `json:"title,omitempty"` } type templateResponse struct { @@ -44,6 +45,7 @@ type templateResponse struct { ID string `json:"id,omitempty"` } `json:"recipient,omitempty"` Message struct { + Text string `json:"text,omitempty"` Attachment struct { Type string `json:"type,omitempty"` Payload struct { @@ -57,6 +59,16 @@ type templateResponse struct { } `json:"message,omitempty"` } +type basicResponseTemplate struct { + Recipient struct { + ID string `json:"id,omitempty"` + } `json:"recipient,omitempty"` + Message struct { + Text string `json:"text,omitempty"` + QuickReplies []quickReply `json:"quick_replies,omitempty"` + } `json:"message,omitempty"` +} + type button struct { Type string `json:"type,omitempty"` Title string `json:"title,omitempty"` From 80933efa8ecfe7780772181f1e22efb50a16d65b Mon Sep 17 00:00:00 2001 From: Osmond Oscar Date: Mon, 2 Apr 2018 11:25:20 +0100 Subject: [PATCH 6/6] implement match highlights --- implementations/go/data/data.go | 24 +++++++++ implementations/go/data/reddit/reddit.go | 67 ++++++++++++++++++++++++ implementations/go/main.go | 34 ++++++++---- implementations/go/postbacks.go | 41 +++++++++++++++ implementations/go/reddit.agent.sample | 5 ++ implementations/go/types.go | 3 ++ 6 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 implementations/go/data/reddit/reddit.go create mode 100644 implementations/go/reddit.agent.sample diff --git a/implementations/go/data/data.go b/implementations/go/data/data.go index 3864727..120dd8c 100644 --- a/implementations/go/data/data.go +++ b/implementations/go/data/data.go @@ -2,6 +2,7 @@ package data import ( "github.com/FBDevCLagos/soccergist/implementations/go/data/football_data" + "github.com/FBDevCLagos/soccergist/implementations/go/data/reddit" ) type League interface { @@ -11,6 +12,12 @@ type League interface { GetMatchdayFixtures(int) *football_data.MatchDayFixtures } +type Highlight struct { + Title string + Name string + URLs []string +} + type LeagueTableTeamInfo struct { Name, Crest string Position, Points, MatchPlayed int @@ -41,6 +48,23 @@ func FirstFour(table *football_data.LeagueTable) []LeagueTableTeamInfo { return info } +func Highlights(after string) (highlights []Highlight) { + posts := reddit.GetHighlightPosts(after) + + for _, post := range posts { + if len(post.URLs) == 0 { + continue + } + + highlights = append(highlights, Highlight{ + Title: post.Title, + Name: post.Name, + URLs: post.URLs, + }) + } + return +} + var teamsLogo = map[string]string{ "Manchester City FC": "https://logoeps.com/wp-content/uploads/2011/08/manchester-city-logo-vector.png", "Manchester United FC": "https://logoeps.com/wp-content/uploads/2011/08/manchester-united-logo-vector.png", diff --git a/implementations/go/data/reddit/reddit.go b/implementations/go/data/reddit/reddit.go new file mode 100644 index 0000000..a468055 --- /dev/null +++ b/implementations/go/data/reddit/reddit.go @@ -0,0 +1,67 @@ +package reddit + +import ( + "fmt" + "log" + "regexp" + "strings" + + "github.com/turnage/graw/reddit" +) + +const ( + subreddit = "/r/footballhighlights" +) + +var ( + redditBot reddit.Bot + videoURLRegex = regexp.MustCompile(`href="([\w\/:?\.-=]+|[\w\/:\.-]+)"`) +) + +func init() { + var err error + redditBot, err = reddit.NewBotFromAgentFile("reddit.agent", 0) + if err != nil { + log.Fatal("Failed to create bot handle: ", err) + } +} + +type Highlight struct { + *reddit.Post + URLs []string +} + +// GetHighlightPosts returns posts in the highlights subreddit +// Filtering only posts that have 'Premier' league in their title +func GetHighlightPosts(after string) (highlights []*Highlight) { + params := map[string]string{"after": after} + harvest, err := redditBot.ListingWithParams(subreddit, params) + if err != nil { + log.Printf("Failed to fetch: %s %s\n", subreddit, err) + return + } + + for _, post := range harvest.Posts { + if !strings.Contains(post.Title, "Premier") { + continue + } + + highlight := &Highlight{Post: post, URLs: getHighlightVideoURLs(post)} + if len(highlight.URLs) == 0 { + fmt.Println(highlight.Title) + fmt.Println(post.SelfTextHTML) + } + highlights = append(highlights, highlight) + } + + return +} + +func getHighlightVideoURLs(post *reddit.Post) (urls []string) { + videoURLs := videoURLRegex.FindAllStringSubmatch(post.SelfTextHTML, -1) + for _, url := range videoURLs { + // TODO: handle unique videos + urls = append(urls, url[1]) + } + return +} diff --git a/implementations/go/main.go b/implementations/go/main.go index d8d92dd..59ca565 100644 --- a/implementations/go/main.go +++ b/implementations/go/main.go @@ -65,20 +65,27 @@ func handleWebhookEvents(w http.ResponseWriter, r *http.Request, _ httprouter.Pa } var postbackHandlers = map[string]func(string, string){ - "league-table-postback": handleLeagueTablePostbackEvent, - "match-schedules-postback": handleMatchSchedulesPostbackEvent, + "league-table-postback": handleLeagueTablePostbackEvent, + "match-schedules-postback": handleMatchSchedulesPostbackEvent, + "league-highlights-postback": handleLeagueHighlightsPostbackEvent, + + "More Highlights": handleLeagueMoreHighlightsPostbackEvent, } func handlePostbackEvent(msgEvnt postbackEvent, senderID string) { - postbackHandler, ok := postbackHandlers[msgEvnt.Payload] - if !ok { - reply := templateResponse{} - reply.Recipient.ID = senderID - reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) - sendResponse(reply) - } else { + + if postbackHandler, ok := postbackHandlers[msgEvnt.Payload]; ok { postbackHandler(msgEvnt.Payload, senderID) + return + } else if postbackHandler, ok := postbackHandlers[msgEvnt.Title]; ok { + postbackHandler(msgEvnt.Payload, senderID) + return } + + reply := templateResponse{} + reply.Recipient.ID = senderID + reply.Message.Text = fmt.Sprintf("%s - coming soon 🤠", msgEvnt.Title) + sendResponse(reply) } func handleMessageEvent(msgEvnt messageEvent, senderID string) { @@ -104,6 +111,15 @@ func handleTextMessageEvent(msgEvnt messageEvent, senderID string) { sendResponse(reply) } +func handleQuickReplyEvent(msgEvnt messageEvent, senderID string) { + payload := msgEvnt.QuickReply.Payload + if strings.Contains(payload, "match-schedules-postback-") { + handleMatchSchedulesPostbackEvent(payload, senderID) + return + } + log.Println("Unrecognized payload") +} + func sendResponse(payload interface{}) { utils.APIRequest(fbURL, "POST", payload) } diff --git a/implementations/go/postbacks.go b/implementations/go/postbacks.go index 32116a5..e26cd0c 100644 --- a/implementations/go/postbacks.go +++ b/implementations/go/postbacks.go @@ -121,3 +121,44 @@ func sendMatchFixuresPagination(senderID string, matchday, currentMatchday, tota reply := buildQuickReply("navigation", senderID, contents) sendResponse(reply) } + +func handleLeagueMoreHighlightsPostbackEvent(payload, senderID string) { + handleLeagueHighlights(payload, senderID) +} + +func handleLeagueHighlightsPostbackEvent(payload, senderID string) { + handleLeagueHighlights("", senderID) +} + +func handleLeagueHighlights(payload, senderID string) { + elements := []element{} + posts := data.Highlights(payload) + + for _, post := range posts { + // TODO: handle more than one highlight url + btn := button{Type: "web_url", Title: "Watch highlight", URL: post.URLs[0]} + element := buildBasicElement(post.Title, "", "https://i.vimeocdn.com/portrait/6640852_640x640") + element.Buttons = []button{btn} + elements = append(elements, element) + + if len(elements) == 9 { + break + } + } + + if len(posts) > 9 { + viewMore := buildBasicElement("View More Highlights", "", "http://icons-for-free.com/free-icons/png/512/1814113.png") + btn := buildPostbackBtn("More Highlights", posts[9].Name) + viewMore.Buttons = []button{btn} + + elements = append(elements, viewMore) + } + + reply := templateResponse{} + reply.Recipient.ID = senderID + reply.Message.Attachment.Type = "template" + reply.Message.Attachment.Payload.TemplateType = "generic" + reply.Message.Attachment.Payload.Elements = elements + + sendResponse(reply) +} diff --git a/implementations/go/reddit.agent.sample b/implementations/go/reddit.agent.sample new file mode 100644 index 0000000..c2e6496 --- /dev/null +++ b/implementations/go/reddit.agent.sample @@ -0,0 +1,5 @@ +user_agent: ":: (by /u/)" +client_id: "client id (looks kind of like: sdkfbwi48rhijwsdn)" +client_secret: "client secret (looks kind of like: ldkvblwiu34y8hsldjivn)" +username: "reddit username" +password: "reddit password" \ No newline at end of file diff --git a/implementations/go/types.go b/implementations/go/types.go index 49c2164..e0aa9de 100644 --- a/implementations/go/types.go +++ b/implementations/go/types.go @@ -73,6 +73,7 @@ type button struct { Type string `json:"type,omitempty"` Title string `json:"title,omitempty"` Payload string `json:"payload,omitempty"` + URL string `json:"url,omitempty"` } type element struct { @@ -80,6 +81,8 @@ type element struct { Title string `json:"title,omitempty"` Subtitle string `json:"subtitle,omitempty"` ImageURL string `json:"image_url,omitempty"` + MediaType string `json:"media_type,omitempty"` + AttachmentID string `json:"attachment_id,omitempty"` DefaultAction *struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"`