From bbd531ef5cef9095267961c2f50e6f21b8df2c7a Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Tue, 8 Aug 2017 18:32:45 +0200 Subject: [PATCH] Initial commit. --- .dockerignore | 49 +++ .gitignore | 31 ++ .gitmodules | 16 + Dockerfile | 13 + main.go | 461 +++++++++++++++++++++ main.tpl | 45 ++ manager/antiflood.go | 86 ++++ manager/manager.go | 23 + manager/topic.go | 22 + sort.go | 16 + templates.go | 121 ++++++ templates_test.go | 36 ++ topic.go | 198 +++++++++ topic_test.go | 245 +++++++++++ util.go | 39 ++ util_test.go | 1 + vendor/github.com/helmbold/richgo | 1 + vendor/github.com/icedream/go-footballdata | 1 + vendor/github.com/patrickmn/go-cache | 1 + vendor/github.com/stretchr/testify | 1 + vendor/github.com/thoj/go-ircevent | 1 + 21 files changed, 1407 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Dockerfile create mode 100644 main.go create mode 100644 main.tpl create mode 100644 manager/antiflood.go create mode 100644 manager/manager.go create mode 100644 manager/topic.go create mode 100644 sort.go create mode 100644 templates.go create mode 100644 templates_test.go create mode 100644 topic.go create mode 100644 topic_test.go create mode 100644 util.go create mode 100644 util_test.go create mode 160000 vendor/github.com/helmbold/richgo create mode 160000 vendor/github.com/icedream/go-footballdata create mode 160000 vendor/github.com/patrickmn/go-cache create mode 160000 vendor/github.com/stretchr/testify create mode 160000 vendor/github.com/thoj/go-ircevent diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c9719a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Binaries +**/*.o +**/*.a +**/*.so +**/*.dll +**/*.exe +**/*.test +**/*.prof +embot + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +**/*.[568vq] +**/[568vq].out + +# cgo +**/*.cgo1.go +**/*.cgo2.c +**/_cgo_defun.c +**/_cgo_gotypes.go +**/_cgo_export.* + +# Unit tests +**/*_test.go + +# Configurations +config.yml +secrets.yml + +# Scripts +**/*.bat + +**/_testmain.go + +# Docker +**/Dockerfile + +# Ignores +**/*.*ignore + +# Documentation +LICENSE +**/*.md + +# Source code metadata +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a455823 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Binaries +*.o +*.a +*.so +*.dll +*.exe +*.test +*.prof +embot + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +# cgo +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +# Configurations +config.yml +secrets.yml + +_testmain.go + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..59e0fb7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "vendor/github.com/icedream/go-footballdata"] + path = vendor/github.com/icedream/go-footballdata + url = https://github.com/icedream/go-footballdata.git + branch = develop +[submodule "vendor/github.com/stretchr/testify"] + path = vendor/github.com/stretchr/testify + url = https://github.com/stretchr/testify.git +[submodule "vendor/github.com/helmbold/richgo"] + path = vendor/github.com/helmbold/richgo + url = https://github.com/helmbold/richgo.git +[submodule "vendor/github.com/thoj/go-ircevent"] + path = vendor/github.com/thoj/go-ircevent + url = https://github.com/thoj/go-ircevent.git +[submodule "vendor/github.com/patrickmn/go-cache"] + path = vendor/github.com/patrickmn/go-cache + url = https://github.com/patrickmn/go-cache.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac708d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.8 + +RUN mkdir -p /go/src/app +WORKDIR /go/src/app + +COPY . /go/src/app +RUN \ + mkdir -p "$GOPATH/src/github.com/icedream" &&\ + ln -sf /go/src/app "$GOPATH/src/github.com/icedream/embot" &&\ + go-wrapper download &&\ + go-wrapper install + +CMD ["go-wrapper", "run"] diff --git a/main.go b/main.go new file mode 100644 index 0000000..53d4930 --- /dev/null +++ b/main.go @@ -0,0 +1,461 @@ +package main + +import ( + "fmt" + "log" + "math" + "sort" + "strings" + "time" + + "net/http" + + "github.com/thoj/go-ircevent" + "gopkg.in/alecthomas/kingpin.v2" + + "github.com/icedream/embot/manager" + "github.com/icedream/go-footballdata" +) + +const ( + SoccerSeason_EuropeanChampionShipsFrance2016 = 444 + + day = 24 * time.Hour + week = 7 * day +) + +type versus struct { + HomeTeam, AwayTeam string +} + +func must(err error) { + if err == nil { + return + } + + log.Fatal(err) +} + +func main() { + var debug bool + var useTLS bool + var server string + var password string + var timeout time.Duration + var pingFreq time.Duration + + var allowInvite bool + + var authToken string + + nickname := "EMBot" + ident := "embot" + var nickservPw string + channels := []string{} + + // IRC config + kingpin.Flag("nick", "The nickname.").Short('n').StringVar(&nickname) + kingpin.Flag("ident", "The ident.").Short('i').StringVar(&ident) + kingpin.Flag("debug", "Enables debug mode.").Short('d').BoolVar(&debug) + kingpin.Flag("tls", "Use TLS.").BoolVar(&useTLS) + kingpin.Flag("server", "The server to connect to.").Short('s').StringVar(&server) + kingpin.Flag("password", "The password to use for logging into the IRC server.").Short('p').StringVar(&password) + kingpin.Flag("timeout", "The timeout on the connection.").Short('t').DurationVar(&timeout) + kingpin.Flag("pingfreq", "The ping frequency.").DurationVar(&pingFreq) + kingpin.Flag("nickserv-pw", "NickServ password.").StringVar(&nickservPw) + kingpin.Flag("channel", "Channel to join. Can be used multiple times.").Short('c').StringsVar(&channels) + + // football-data config + kingpin.Flag("footballdata-key", "The API key to use to access the YouTube API.").StringVar(&authToken) + + // behavior config + kingpin.Flag("allow-invite", "Determines whether the bot can be invited to other IRC channels.").BoolVar(&allowInvite) + + kingpin.Parse() + + if len(nickname) == 0 { + log.Fatal("Nickname must be longer than 0 chars.") + } + if len(ident) == 0 { + log.Fatal("Ident must be longer than 0 chars.") + } + + // Load football data API client + footballData := footballdata.NewClient(http.DefaultClient) + footballData.SetToken(authToken) + + // Manager + m := manager.NewManager() + + // IRC + //conn := m.AntifloodIrcConn(irc.IRC(nickname, ident)) + conn := irc.IRC(nickname, ident) + conn.Debug = debug + conn.VerboseCallbackHandler = conn.Debug + conn.UseTLS = useTLS + conn.Password = password + if timeout > time.Duration(0) { + conn.Timeout = timeout + } + if pingFreq > time.Duration(0) { + conn.PingFreq = pingFreq + } + + updateTopics := func() { + // Get football data + if r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).Do(); err != nil { + log.Print(err) + } else { + currentMatches := []*footballdata.Fixture{} + lastMatches := []*footballdata.Fixture{} + nextMatches := []*footballdata.Fixture{} + for i, fixture := range r.Fixtures { + switch { + case fixture.Status == footballdata.FixtureStatus_InPlay || + (fixture.Status == footballdata.FixtureStatus_Timed && fixture.Date.Sub(time.Now()).Minutes() < 10): + // This is a current match! + currentMatches = append(currentMatches, &r.Fixtures[i]) + case fixture.Status == footballdata.FixtureStatus_Timed: + // This is an upcoming match! + add := true + for _, existingMatch := range nextMatches { + difference := math.Abs(fixture.Date.Sub(existingMatch.Date).Minutes()) + if difference > 45 { + if fixture.Date.Before(existingMatch.Date) { + nextMatches = []*footballdata.Fixture{&r.Fixtures[i]} + } + add = false + break + } + } + if !add { + continue + } + nextMatches = append(nextMatches, &r.Fixtures[i]) + case fixture.Status == footballdata.FixtureStatus_Finished: + // This is a finished match! + add := true + for _, existingMatch := range lastMatches { + difference := math.Abs(fixture.Date.Sub(existingMatch.Date).Minutes()) + if difference > 45 { + if fixture.Date.After(existingMatch.Date) { + lastMatches = []*footballdata.Fixture{&r.Fixtures[i]} + } + add = false + break + } + } + if !add { + continue + } + lastMatches = append(lastMatches, &r.Fixtures[i]) + } + } + + for _, target := range channels { + oldTopic := m.GetTopic(target) + oldTopicParts := strings.Split(oldTopic, "|") + + newtopicParts := []string{} + + additionals := map[versus]string{} + + for _, oldTopicPart := range oldTopicParts { + if r, err := parseTopicPart(oldTopicPart); err == nil { + if !r.HasMatches() { + continue + } + for _, match := range r.Matches { + vs := versus{match.HomeTeam, match.AwayTeam} + additionals[vs] = match.Additional + } + } + } + + for _, oldTopicPart := range oldTopicParts { + newTopicPart := "" + newTopicPartAdd := false + + if topicPartInfo, err := parseTopicPart(oldTopicPart); err == nil { + switch strings.ToUpper(topicPartInfo.Prefix) { + case "LAST", "NOW": + newTopicPartInfo := topicPartInfo + newTopicPartInfo.Prefix = "Last" + newTopicPartInfo.Matches = []topicMatchInfo{} + + fixtures := timeSortedMatches{} + + if len(currentMatches) > 0 { + newTopicPartInfo.Prefix = "Now" + + // Add current matches + fixtures = append(fixtures, currentMatches...) + } else { + // Add last matches only + fixtures = append(fixtures, lastMatches...) + } + + if len(fixtures) <= 0 { + // Use old data to keep this part of the topic + newTopicPartInfo = topicPartInfo + } else { + sort.Sort(fixtures) + for _, fixture := range fixtures { + newTopicMatchInfo := topicMatchInfo{ + HomeTeam: fixture.HomeTeamName, + AwayTeam: fixture.AwayTeamName, + } + + // Re-apply old additional info + if a, ok := additionals[versus{fixture.HomeTeamName, fixture.AwayTeamName}]; ok { + newTopicMatchInfo.Additional = a + } + + if fixture.Status != footballdata.FixtureStatus_Timed { + // Real-time match scores + newTopicMatchInfo.Current = &topicScoreInfo{ + HomeScore: int(fixture.Result.GoalsHomeTeam), + AwayScore: int(fixture.Result.GoalsAwayTeam), + } + + // Half-time additional info + additionalScoreInfo := newTopicMatchInfo.AdditionalScoreInfo() + if additionalScoreInfo == nil { + additionalScoreInfo = new(topicAdditionalScoreInfo) + additionalScoreInfo.Pre = newTopicMatchInfo.Additional + } + if fixture.Result.HalfTime != nil { + additionalScoreInfo.HalfScore = fmt.Sprintf("%d:%d", fixture.Result.HalfTime.GoalsHomeTeam, fixture.Result.HalfTime.GoalsAwayTeam) + } else if fixture.Status == footballdata.FixtureStatus_InPlay && + len(additionalScoreInfo.HalfScore) <= 0 && + time.Now().Sub(fixture.Date).Minutes() >= 50 { + // API doesn't provide us with half-time data yet, just generate that data ourselves + additionalScoreInfo.HalfScore = fmt.Sprintf("%d:%d", fixture.Result.GoalsHomeTeam, fixture.Result.GoalsAwayTeam) + } + if fixture.Result.ExtraTime != nil { + additionalScoreInfo.IsAET = true + newTopicMatchInfo.Current = &topicScoreInfo{ + HomeScore: int(fixture.Result.ExtraTime.GoalsHomeTeam), + AwayScore: int(fixture.Result.ExtraTime.GoalsAwayTeam), + } + } + if fixture.Result.PenaltyShootout != nil { + additionalScoreInfo.PenScore = fmt.Sprintf("%d:%d", fixture.Result.PenaltyShootout.GoalsHomeTeam, fixture.Result.PenaltyShootout.GoalsAwayTeam) + } + newTopicMatchInfo.Additional = additionalScoreInfo.String() + } + + newTopicPartInfo.Matches = append(newTopicPartInfo.Matches, newTopicMatchInfo) + } + } + + newTopicPart = newTopicPartInfo.String() + newTopicPartAdd = true + case "NEXT": + newTopicPartInfo := topicPartInfo + newTopicPartInfo.Matches = []topicMatchInfo{} + + fixtures := append(timeSortedMatches{}, nextMatches...) + + if len(fixtures) <= 0 { + // Use old data to keep this part of the topic + if len(newTopicPartInfo.Text) <= 0 { + newTopicPartInfo.Text = "No match" + } + } else { + sort.Sort(fixtures) + for _, fixture := range fixtures { + newTopicMatchInfo := topicMatchInfo{ + HomeTeam: fixture.HomeTeamName, + AwayTeam: fixture.AwayTeamName, + } + if a, ok := additionals[versus{fixture.HomeTeamName, fixture.AwayTeamName}]; ok { + newTopicMatchInfo.Additional = a + } + newTopicPartInfo.Matches = append(newTopicPartInfo.Matches, newTopicMatchInfo) + } + } + + newTopicPart = newTopicPartInfo.String() + newTopicPartAdd = true + default: + newTopicPart = topicPartInfo.String() + newTopicPartAdd = true + } + } else { + newTopicPart = strings.TrimSpace(oldTopicPart) + newTopicPartAdd = true + } + + if newTopicPartAdd { + newtopicParts = append(newtopicParts, newTopicPart) + } + } + + newTopic := strings.Join(newtopicParts, " | ") + + if oldTopic != newTopic { + conn.SendRawf("TOPIC %s :%s", target, newTopic) + } + } + } + } + + joinChan := make(chan string) + inviteChan := make(chan string) + updateTopicsChan := make(chan interface{}) + + // register callbacks + conn.AddCallback("001", func(e *irc.Event) { // handle RPL_WELCOME + // nickserv login + if len(nickservPw) > 0 { + conn.Privmsg("NickServ", "IDENTIFY "+nickservPw) + log.Print("Sent NickServ login request.") + } + + // I am a bot! (+B user mode) + conn.Mode(conn.GetNick(), "+B-iw") + + // Join configured channels + if len(channels) > 0 { + conn.Join(strings.Join(channels, ",")) + } + }) + conn.AddCallback("JOIN", func(e *irc.Event) { + // Is this JOIN not about us? + if !strings.EqualFold(e.Nick, conn.GetNick()) { + return + } + + m.SaveTopic(e.Arguments[0], "") + + // Asynchronous notification + select { + case joinChan <- e.Arguments[0]: + default: + } + }) + conn.AddCallback("INVITE", func(e *irc.Event) { + // Allow invites? + if !allowInvite { + return + } + + // Is this INVITE not for us? + if !strings.EqualFold(e.Arguments[0], conn.GetNick()) { + return + } + + // Asynchronous notification + select { + case inviteChan <- e.Arguments[1]: + default: + } + + // We have been invited, autojoin! + go func(sourceNick string, targetChannel string) { + joinWaitLoop: + for { + select { + case channel := <-joinChan: + if strings.EqualFold(channel, targetChannel) { + // TODO - Thanks message + time.Sleep(1 * time.Second) + conn.Privmsgf(targetChannel, "Thanks for inviting me, %s! I am %s. I hope I can be of great help for everyone here in %s! :)", sourceNick, conn.GetNick(), targetChannel) + //time.Sleep(2 * time.Second) + //conn.Privmsg(targetChannel, "If you ever run into trouble with me (or find any bugs), please use the channel #MediaLink for contact on this IRC.") + break joinWaitLoop + } + case channel := <-inviteChan: + if strings.EqualFold(channel, targetChannel) { + break joinWaitLoop + } + case <-time.After(time.Minute): + log.Printf("WARNING: Timed out waiting for us to join %s as we got invited", targetChannel) + break joinWaitLoop + } + } + }(e.Nick, e.Arguments[1]) + conn.Join(e.Arguments[1]) + }) + conn.AddCallback("PRIVMSG", func(e *irc.Event) { + go func(event *irc.Event) { + //sender := event.Nick + target := event.Arguments[0] + isChannel := true + if strings.EqualFold(target, conn.GetNick()) { + // Private message to us! + target = event.Nick + isChannel = false + } + if strings.EqualFold(target, conn.GetNick()) { + // Emergency switch to avoid endless loop, + // dropping all messages from the bot to the bot! + log.Printf("BUG - Emergency switch, caught message from bot to bot: %s", event.Arguments) + return + } + msg := stripIrcFormatting(event.Message()) + + log.Printf("<%s @ %s> %s", event.Nick, target, msg) + switch { + case !isChannel && strings.HasPrefix(msg, "updatetopics"): + updateTopicsChan <- nil + case strings.HasPrefix(msg, "!match"): + /*if r, err := footballData.FixturesOfSoccerSeason(SoccerSeason_EuropeanChampionShipsFrance2016).TimeFrame(1 * day).Do(); err != nil { + if s, err := tplString("error", err); err != nil { + log.Print(err) + } else { + conn.Privmsg(target, s) + } + } else { + log.Printf("%+v", r) + for _, fixture := range r.Fixtures { + if s, err := tplString("match", fixture); err != nil { + log.Print(err) + } else { + conn.Privmsg(target, s) + } + } + }*/ + } + }(e) + }) + + conn.AddCallback("TOPIC", func(e *irc.Event) { + channel := e.Arguments[0] + topic := e.Arguments[1] + log.Printf("Topic for %s is: %s", channel, topic) + m.SaveTopic(channel, topic) + }) + conn.AddCallback("332", func(e *irc.Event) { + channel := e.Arguments[1] + topic := e.Arguments[2] + log.Printf("Topic for %s is: %s", channel, topic) + m.SaveTopic(channel, topic) + }) + conn.AddCallback("331", func(e *irc.Event) { + channel := e.Arguments[1] + log.Printf("No topic set for %s.", channel) + m.SaveTopic(channel, "") + }) + + // connect + must(conn.Connect(server)) + + // Fetch realtime data regularly for topic + go func() { + for { + // Wait a minute before refreshing + select { + case <-time.After(30 * time.Second): + case <-updateTopicsChan: + } + + updateTopics() + } + }() + + // listen for errors + log.Print("Now looping.") + conn.Loop() +} diff --git a/main.tpl b/main.tpl new file mode 100644 index 0000000..edec2a0 --- /dev/null +++ b/main.tpl @@ -0,0 +1,45 @@ +{{ define "error" }} + {{ bold -}} + {{ color 4 -}} + ERROR: + {{- reset }} + + {{ . }} +{{ end }} + +{{ define "match" }} + {{/* + Date time.Time + Status FixtureStatus = "IN_PLAY" | "FINISHED" | "TIMED" + Matchday uint16 + HomeTeamName string + AwayTeamName string + Result FixtureResult + { + GoalsHomeTeam uint16 + GoalsAwayTeam uint16 + } + */}} + + {{ if or (eq .Status "IN_PLAY") (eq .Status "FINISHED") }} + {{ bold }}{{ .HomeTeamName }}{{ bold }} + {{ .Result.GoalsHomeTeam -}} + : + {{- .Result.GoalsAwayTeam }} + {{ bold }}{{ .AwayTeamName }}{{ bold }} + {{ else }} + {{ bold }}{{ .HomeTeamName }}{{ bold }} + vs + {{ bold }}{{ .AwayTeamName }}{{ bold }} + {{ end }} + | + {{ if eq .Status "IN_PLAY" }} + {{ playtime .Date }} + {{ else }} + {{ if eq .Status "FINISHED" }} + Match finished + {{ else }} + Match starts {{ ago .Date }} + {{ end }} + {{ end }} +{{ end }} \ No newline at end of file diff --git a/manager/antiflood.go b/manager/antiflood.go new file mode 100644 index 0000000..75547a0 --- /dev/null +++ b/manager/antiflood.go @@ -0,0 +1,86 @@ +package manager + +import ( + "crypto/sha512" + "fmt" + "log" + "strings" + "time" + + "github.com/thoj/go-ircevent" + + cache "github.com/patrickmn/go-cache" +) + +func (m *Manager) initAntiflood() { + m.cache = cache.New(1*time.Minute, 5*time.Second) +} + +func (m *Manager) TrackOutput(target, t string) (shouldNotSend bool) { + key := normalizeTextAntiflood(target, t) + + if _, ok := m.cache.Get(key); ok { + // The URL has been used recently, should ignore + shouldNotSend = true + } else { + m.cache.Add(key, nil, cache.DefaultExpiration) + } + + return +} + +func (m *Manager) AntifloodIrcConn(c *irc.Connection) *ircConnectionProxy { + return &ircConnectionProxy{Connection: c, m: m} +} + +func normalizeTextAntiflood(target, text string) string { + s := sha512.New() + s.Write([]byte(text)) + return fmt.Sprintf("TEXT/%s/%X", strings.ToUpper(target), s.Sum([]byte{})) +} + +// Proxies several methods of the IRC connection in order to drop repeated messages +type ircConnectionProxy struct { + *irc.Connection + + m *Manager +} + +func (proxy *ircConnectionProxy) Action(target, message string) { + if proxy.m.TrackOutput(target, message) { + log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) + return + } + + proxy.Connection.Action(target, message) +} + +func (proxy *ircConnectionProxy) Actionf(target, format string, a ...interface{}) { + proxy.Action(target, fmt.Sprintf(format, a...)) +} + +func (proxy *ircConnectionProxy) Privmsg(target, message string) { + if proxy.m.TrackOutput(target, message) { + log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) + return + } + + proxy.Connection.Privmsg(target, message) +} + +func (proxy *ircConnectionProxy) Privmsgf(target, format string, a ...interface{}) { + proxy.Privmsg(target, fmt.Sprintf(format, a...)) +} + +func (proxy *ircConnectionProxy) Notice(target, message string) { + if proxy.m.TrackOutput(target, message) { + log.Printf("WARNING: Output antiflood triggered, dropping message for %s: %s", target, message) + return + } + + proxy.Connection.Notice(target, message) +} + +func (proxy *ircConnectionProxy) Noticef(target, format string, a ...interface{}) { + proxy.Notice(target, fmt.Sprintf(format, a...)) +} diff --git a/manager/manager.go b/manager/manager.go new file mode 100644 index 0000000..2b9e309 --- /dev/null +++ b/manager/manager.go @@ -0,0 +1,23 @@ +package manager + +import ( + "sync" + + "github.com/patrickmn/go-cache" +) + +type Manager struct { + // antiflood variables + cache *cache.Cache + + // topic variables + topicStateLock sync.RWMutex + topicMap map[string]string +} + +func NewManager() *Manager { + m := new(Manager) + m.initAntiflood() + m.initTopic() + return m +} diff --git a/manager/topic.go b/manager/topic.go new file mode 100644 index 0000000..1851028 --- /dev/null +++ b/manager/topic.go @@ -0,0 +1,22 @@ +package manager + +import "strings" + +func (m *Manager) initTopic() { + m.topicMap = map[string]string{} +} + +func (m *Manager) GetTopic(channel string) (retval string) { + channel = strings.ToLower(channel) + m.topicStateLock.RLock() + defer m.topicStateLock.RUnlock() + retval, _ = m.topicMap[channel] + return +} + +func (m *Manager) SaveTopic(channel string, topic string) { + channel = strings.ToLower(channel) + m.topicStateLock.Lock() + defer m.topicStateLock.Unlock() + m.topicMap[channel] = topic +} diff --git a/sort.go b/sort.go new file mode 100644 index 0000000..f5fa1c8 --- /dev/null +++ b/sort.go @@ -0,0 +1,16 @@ +package main + +import "github.com/icedream/go-footballdata" + +type timeSortedMatches []*footballdata.Fixture + +func (slice timeSortedMatches) Len() int { + return len(slice) +} + +func (slice timeSortedMatches) Less(i, j int) bool { + return slice[i].Date.Before(slice[j].Date) +} +func (slice timeSortedMatches) Swap(i, j int) { + slice[i], slice[j] = slice[j], slice[i] +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..7b4ea11 --- /dev/null +++ b/templates.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "math" + "net/url" + "regexp" + "strconv" + "strings" + "text/template" + "time" + + "github.com/dustin/go-humanize" +) + +var ( + compactNumUnits = []string{"", "k", "M"} + + tplFuncMap = template.FuncMap{ + // The name "title" is what the function will be called in the template text. + "color": func(num int) string { + return string(runeIrcColor) + strconv.Itoa(num) + }, + "bcolor": func(fgNum, bgNum int) string { + return string(runeIrcColor) + strconv.Itoa(fgNum) + "," + strconv.Itoa(bgNum) + }, + "bold": func() string { + return string(runeIrcBold) + }, + "italic": func() string { + return string(runeIrcItalic) + }, + "reset": func() string { + return string(runeIrcReset) + string(runeIrcColor) + }, + "reverse": func() string { + return string(runeIrcReverse) + }, + "underline": func() string { + return string(runeIrcUnderline) + }, + "urlencode": func(s string) string { + return url.QueryEscape(s) + }, + "yesno": func(yes string, no string, value bool) string { + if value { + return yes + } + + return no + }, + "excerpt": func(maxLength uint16, text string) string { + if len(text) > int(maxLength) { + return text[0:maxLength-1] + "\u2026" + } + return text + }, + "comma": func(num uint64) string { + return humanize.Comma(int64(num)) + }, + "compactnum": func(num uint64) string { + // 1 => 0 + // 1000 => 1 + // 1000000 => 2 + log10 := math.Floor(math.Log10(float64(num)) / 3) + + // Cut to available units + cut := int(math.Min(float64(len(compactNumUnits)-1), log10)) + + numf := float64(num) + numf /= math.Pow10(cut * 3) + + // Rounding + numf = math.Floor((numf*10)+.5) / 10 + if numf >= 1000 { + numf /= 1000 + if cut < len(compactNumUnits)-1 { + cut++ + } + } + + unit := compactNumUnits[cut] + f := "%.1f%s" + if numf-math.Floor(numf) < 0.05 { + f = "%.0f%s" + } + + return fmt.Sprintf(f, numf, unit) + }, + "ago": func(t time.Time) string { + return humanize.Time(t) + }, + "size": func(s uint64) string { + return humanize.Bytes(s) + }, + "playtime": func(s time.Duration) string { + return fmt.Sprintf("%d", int(math.Floor(s.Minutes()))) + "'" + }, + } + + ircTpl = template.Must( + template.New(""). + Funcs(tplFuncMap). + ParseGlob("*.tpl")) + + rxInsignificantWhitespace = regexp.MustCompile(`\s+`) +) + +func tplString(name string, data interface{}) (string, error) { + w := new(bytes.Buffer) + if err := ircTpl.ExecuteTemplate(w, name, data); err != nil { + return "", err + } + s := w.String() + s = rxInsignificantWhitespace.ReplaceAllString(s, " ") + s = strings.Trim(s, " ") + log.Printf("tplString(%v): %s", name, s) + return s, nil +} diff --git a/templates_test.go b/templates_test.go new file mode 100644 index 0000000..f0f64eb --- /dev/null +++ b/templates_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_TemplateFuncs_comma(t *testing.T) { + f := tplFuncMap["comma"].(func(num uint64) string) + + assert.Equal(t, "1", f(1)) + assert.Equal(t, "10", f(10)) + assert.Equal(t, "100", f(100)) + assert.Equal(t, "1,000", f(1000)) + assert.Equal(t, "1,000,000", f(1000000)) +} + +func Test_TemplateFuncs_compactnum(t *testing.T) { + f := tplFuncMap["compactnum"].(func(num uint64) string) + + assert.Equal(t, "1", f(1)) + assert.Equal(t, "10", f(10)) + assert.Equal(t, "100", f(100)) + assert.Equal(t, "999", f(999)) + assert.Equal(t, "1k", f(1000)) + assert.Equal(t, "9k", f(9000)) + assert.Equal(t, "9.9k", f(9900)) + assert.Equal(t, "10k", f(9999)) + assert.Equal(t, "10k", f(10000)) + assert.Equal(t, "100k", f(100000)) + assert.Equal(t, "999k", f(999000)) + assert.Equal(t, "999.9k", f(999900)) + assert.Equal(t, "1M", f(999999)) + assert.Equal(t, "1M", f(1000000)) +} diff --git a/topic.go b/topic.go new file mode 100644 index 0000000..2bfd445 --- /dev/null +++ b/topic.go @@ -0,0 +1,198 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/helmbold/richgo/regexp" +) + +var ( + errIncompatibleTopicPart = errors.New("Incompatible topic part") + + rxTopicPart = regexp.MustCompile(`\s*\x02` + + `\s*\x03\s*(?P[0-9]+)` + + `(?P[A-Za-z\s]+):` + + `\s*\x03` + + `\s*(?P.+)` + + `\s*\x02` + + `\s*`) + + rxMatch = regexp.MustCompile(`^` + + `(?P` + + `\w+(?:\s+\w+)*)` + + `\s+(?P\-|\d+):(?P\-|\d+)` + + `\s+(?P\w+(?:\s+\w+)*)` + + `(` + + `\s+(?P.+)` + + `)?` + + `$`) + + rxAdditionalScoreInfo = regexp.MustCompile(`^` + + `(?P
.*?)\s*` +
+		`(\[(?:` +
+		`(?PP)\s*(?P\d+:\d+)` +
+		`|` +
+		`(?PAET)` +
+		`)\]\s*)?` +
+		`\((?P\d+:\d+)\)` +
+		`\s*(?P.*)` +
+		`$`)
+)
+
+type topicPart struct {
+	PrefixColor string
+	Prefix      string
+
+	Text    string
+	Matches []topicMatchInfo
+}
+
+func (t *topicPart) HasMatches() bool {
+	return t.Matches != nil && len(t.Matches) > 0
+}
+
+func (t *topicPart) String() string {
+	retval := fmt.Sprintf("\x02\x03%s%s:\x03 ", t.PrefixColor, t.Prefix)
+	if t.Matches == nil || len(t.Matches) == 0 {
+		retval += t.Text
+	} else {
+		first := true
+		for _, match := range t.Matches {
+			if !first {
+				retval += " + "
+			}
+			retval += match.String()
+			first = false
+		}
+	}
+	retval += "\x02"
+	return retval
+}
+
+type topicMatchInfo struct {
+	HomeTeam   string
+	AwayTeam   string
+	Current    *topicScoreInfo
+	Additional string
+}
+
+type topicAdditionalScoreInfo struct {
+	IsAET     bool
+	PenScore  string
+	HalfScore string
+
+	Pre  string
+	Post string
+}
+
+func (i *topicAdditionalScoreInfo) String() string {
+	retval := ""
+	switch {
+	case len(i.PenScore) > 0:
+		retval += fmt.Sprintf("[P %s]", i.PenScore)
+	case i.IsAET:
+		retval += "[AET]"
+	}
+	if len(retval) > 0 {
+		retval += " "
+	}
+	if len(i.HalfScore) > 0 {
+		retval += fmt.Sprintf("(%s)", i.HalfScore)
+	}
+
+	if len(i.Pre) > 0 {
+		if len(retval) > 0 {
+			retval = " " + retval
+		}
+		retval = i.Pre + retval
+	}
+
+	if len(i.Post) > 0 {
+		if len(retval) > 0 {
+			retval += " "
+		}
+		retval += i.Post
+	}
+	return retval
+}
+
+func (m *topicMatchInfo) AdditionalScoreInfo() *topicAdditionalScoreInfo {
+	a := rxAdditionalScoreInfo.Match(m.Additional)
+	if a == nil {
+		return nil
+	}
+
+	return &topicAdditionalScoreInfo{
+		HalfScore: a.NamedGroups["halfScore"],
+		IsAET:     len(a.NamedGroups["penScore"]) > 0 || len(a.NamedGroups["extraTag"]) > 0,
+		PenScore:  a.NamedGroups["penScore"],
+
+		Pre:  a.NamedGroups["pre"],
+		Post: a.NamedGroups["post"],
+	}
+}
+
+func (m *topicMatchInfo) String() string {
+	retval := fmt.Sprintf("%s %s %s", m.HomeTeam, m.Current, m.AwayTeam)
+	if len(m.Additional) > 0 {
+		retval += " " + m.Additional
+	}
+	return retval
+
+}
+
+type topicScoreInfo struct {
+	HomeScore int
+	AwayScore int
+}
+
+func (s *topicScoreInfo) String() string {
+	if s == nil {
+		return "-:-"
+	}
+	return fmt.Sprintf("%d:%d", s.HomeScore, s.AwayScore)
+}
+
+func parseTopicPart(s string) (r topicPart, err error) {
+	match := rxTopicPart.Match(s)
+	if match == nil {
+		err = errIncompatibleTopicPart
+		return
+	}
+	r.PrefixColor = match.NamedGroups["prefixColor"]
+	r.Prefix = match.NamedGroups["prefix"]
+	r.Text = match.NamedGroups["text"]
+
+	// Try to get match info if compatible
+	matchCompatible := true
+	matchInfos := []topicMatchInfo{}
+	for _, matchPart := range strings.Split(r.Text, " + ") {
+		match = rxMatch.Match(matchPart)
+		if match == nil {
+			matchCompatible = false
+			break
+		}
+		matchInfo := topicMatchInfo{}
+		matchInfo.HomeTeam = match.NamedGroups["homeTeam"]
+		matchInfo.AwayTeam = match.NamedGroups["awayTeam"]
+		homeScore, err := strconv.Atoi(match.NamedGroups["homeScore"])
+		if err == nil {
+			awayScore, err := strconv.Atoi(match.NamedGroups["awayScore"])
+			if err == nil {
+				matchInfo.Current = &topicScoreInfo{
+					HomeScore: homeScore,
+					AwayScore: awayScore,
+				}
+			}
+		}
+		matchInfo.Additional = match.NamedGroups["additional"]
+		matchInfos = append(matchInfos, matchInfo)
+	}
+	if matchCompatible {
+		r.Matches = matchInfos
+	}
+	return
+}
diff --git a/topic_test.go b/topic_test.go
new file mode 100644
index 0000000..7a6adc1
--- /dev/null
+++ b/topic_test.go
@@ -0,0 +1,245 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_topicPart_String(t *testing.T) {
+	assert.Equal(t, "\x02\x031Now:\x03 testing\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+	}).String())
+	assert.Equal(t, "\x02\x031Now:\x03 testing\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches:     []topicMatchInfo{},
+	}).String())
+	assert.Equal(t, "\x02\x031Now:\x03 a -:- b\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches: []topicMatchInfo{
+			topicMatchInfo{
+				HomeTeam: "a",
+				AwayTeam: "b",
+			},
+		},
+	}).String())
+	assert.Equal(t, "\x02\x031Now:\x03 a 0:1 b\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches: []topicMatchInfo{
+			topicMatchInfo{
+				HomeTeam: "a",
+				AwayTeam: "b",
+				Current: &topicScoreInfo{
+					HomeScore: 0,
+					AwayScore: 1,
+				},
+			},
+		},
+	}).String())
+	assert.EqualValues(t, "\x02\x031Now:\x03 a 0:1 b (0:0)\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches: []topicMatchInfo{
+			topicMatchInfo{
+				HomeTeam: "a",
+				AwayTeam: "b",
+				Current: &topicScoreInfo{
+					HomeScore: 0,
+					AwayScore: 1,
+				},
+				Additional: "(0:0)",
+			},
+		},
+	}).String())
+	assert.EqualValues(t, "\x02\x031Now:\x03 a 0:1 b (0:0) + c 0:0 d\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches: []topicMatchInfo{
+			topicMatchInfo{
+				HomeTeam: "a",
+				AwayTeam: "b",
+				Current: &topicScoreInfo{
+					HomeScore: 0,
+					AwayScore: 1,
+				},
+				Additional: "(0:0)",
+			},
+			topicMatchInfo{
+				HomeTeam: "c",
+				AwayTeam: "d",
+				Current: &topicScoreInfo{
+					HomeScore: 0,
+					AwayScore: 0,
+				},
+			},
+		},
+	}).String())
+	assert.EqualValues(t, "\x02\x031Now:\x03 a 0:1 b (0:0) + c 2:2 d (1:1)\x02", (&topicPart{
+		PrefixColor: "1",
+		Prefix:      "Now",
+		Text:        "testing",
+		Matches: []topicMatchInfo{
+			topicMatchInfo{
+				HomeTeam: "a",
+				AwayTeam: "b",
+				Current: &topicScoreInfo{
+					HomeScore: 0,
+					AwayScore: 1,
+				},
+				Additional: "(0:0)",
+			},
+			topicMatchInfo{
+				HomeTeam: "c",
+				AwayTeam: "d",
+				Current: &topicScoreInfo{
+					HomeScore: 2,
+					AwayScore: 2,
+				},
+				Additional: "(1:1)",
+			},
+		},
+	}).String())
+}
+
+func Test_parseTopic(t *testing.T) {
+	var topic topicPart
+	var err error
+
+	topic, err = parseTopicPart("Incompatible text")
+	assert.Equal(t, errIncompatibleTopicPart, err)
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 No match\x02")
+	if assert.Nil(t, err) {
+		assert.EqualValues(t, topicPart{
+			PrefixColor: "3",
+			Prefix:      "Last",
+			Text:        "No match",
+		}, topic)
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a -:- b\x02")
+	if assert.Nil(t, err) {
+		assert.EqualValues(t, topicPart{
+			PrefixColor: "3",
+			Prefix:      "Last",
+			Text:        "a -:- b",
+			Matches: []topicMatchInfo{
+				topicMatchInfo{
+					HomeTeam: "a",
+					AwayTeam: "b",
+				},
+			},
+		}, topic)
+	}
+
+	topic, err = parseTopicPart(" \x02\x0313Last:\x03 needs to be replaced by the bot\x02 ")
+	if assert.Nil(t, err) {
+		assert.EqualValues(t, topicPart{
+			PrefixColor: "13",
+			Prefix:      "Last",
+			Text:        "needs to be replaced by the bot",
+		}, topic)
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a 0:0 b (0:0)\x02")
+	if assert.Nil(t, err) {
+		assert.EqualValues(t, topicPart{
+			PrefixColor: "3",
+			Prefix:      "Last",
+			Text:        "a 0:0 b (0:0)",
+			Matches: []topicMatchInfo{
+				topicMatchInfo{
+					HomeTeam: "a",
+					AwayTeam: "b",
+					Current: &topicScoreInfo{
+						HomeScore: 0,
+						AwayScore: 0,
+					},
+					Additional: "(0:0)",
+				},
+			},
+		}, topic)
+	}
+}
+
+func Test_topicPart_Matches_AdditionalScoreInfo(t *testing.T) {
+	topic, err := parseTopicPart("\x02\x033Last:\x03 a 0:0 b [aBx] (0:0) (abcd)\x02")
+	if assert.Nil(t, err) && assert.Len(t, topic.Matches, 1) {
+		info := topic.Matches[0].AdditionalScoreInfo()
+		if assert.NotNil(t, info) {
+			assert.EqualValues(t, &topicAdditionalScoreInfo{
+				Pre:       "[aBx]",
+				Post:      "(abcd)",
+				PenScore:  "",
+				HalfScore: "0:0",
+				IsAET:     false,
+			}, info)
+		}
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a 0:0 b (0:0)\x02")
+	if assert.Nil(t, err) && assert.Len(t, topic.Matches, 1) {
+		info := topic.Matches[0].AdditionalScoreInfo()
+		if assert.NotNil(t, info) {
+			assert.EqualValues(t, &topicAdditionalScoreInfo{
+				Pre:       "",
+				Post:      "",
+				PenScore:  "",
+				HalfScore: "0:0",
+				IsAET:     false,
+			}, info)
+		}
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a 0:0 b [P5:4] (0:0)\x02")
+	if assert.Nil(t, err) && assert.Len(t, topic.Matches, 1) {
+		info := topic.Matches[0].AdditionalScoreInfo()
+		if assert.NotNil(t, info) {
+			assert.EqualValues(t, &topicAdditionalScoreInfo{
+				Pre:       "",
+				Post:      "",
+				PenScore:  "5:4",
+				HalfScore: "0:0",
+				IsAET:     true,
+			}, info)
+		}
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a 0:0 b [P 5:4] (0:0)\x02")
+	if assert.Nil(t, err) && assert.Len(t, topic.Matches, 1) {
+		info := topic.Matches[0].AdditionalScoreInfo()
+		if assert.NotNil(t, info) {
+			assert.EqualValues(t, &topicAdditionalScoreInfo{
+				Pre:       "",
+				Post:      "",
+				PenScore:  "5:4",
+				HalfScore: "0:0",
+				IsAET:     true,
+			}, info)
+		}
+	}
+
+	topic, err = parseTopicPart("\x02\x033Last:\x03 a 0:0 b [AET] (0:0)\x02")
+	if assert.Nil(t, err) && assert.Len(t, topic.Matches, 1) {
+		info := topic.Matches[0].AdditionalScoreInfo()
+		if assert.NotNil(t, info) {
+			assert.EqualValues(t, &topicAdditionalScoreInfo{
+				Pre:       "",
+				Post:      "",
+				PenScore:  "",
+				HalfScore: "0:0",
+				IsAET:     true,
+			}, info)
+		}
+	}
+}
diff --git a/util.go b/util.go
new file mode 100644
index 0000000..571c460
--- /dev/null
+++ b/util.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/icedream/go-footballdata"
+)
+
+const (
+	runeIrcBold      = '\x02'
+	runeIrcColor     = '\x03'
+	runeIrcReset     = '\x0f'
+	runeIrcReverse   = '\x16'
+	runeIrcItalic    = '\x1d'
+	runeIrcUnderline = '\x1f'
+)
+
+var (
+	rxIrcColor = regexp.MustCompile(string(runeIrcColor) + "([0-9]*(,[0-9]*)?)")
+)
+
+func stripIrcFormatting(text string) string {
+	text = strings.Replace(text, string(runeIrcBold), "", -1)
+	text = strings.Replace(text, string(runeIrcReset), "", -1)
+	text = strings.Replace(text, string(runeIrcReverse), "", -1)
+	text = strings.Replace(text, string(runeIrcItalic), "", -1)
+	text = strings.Replace(text, string(runeIrcUnderline), "", -1)
+	text = rxIrcColor.ReplaceAllLiteralString(text, "")
+	return text
+}
+
+func fixturesMap(fixtures []footballdata.Fixture) map[versus]footballdata.Fixture {
+	retval := make(map[versus]footballdata.Fixture)
+	for _, fixture := range fixtures {
+		retval[versus{fixture.HomeTeamName, fixture.AwayTeamName}] = fixture
+	}
+	return retval
+}
diff --git a/util_test.go b/util_test.go
new file mode 100644
index 0000000..06ab7d0
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1 @@
+package main
diff --git a/vendor/github.com/helmbold/richgo b/vendor/github.com/helmbold/richgo
new file mode 160000
index 0000000..f0f3222
--- /dev/null
+++ b/vendor/github.com/helmbold/richgo
@@ -0,0 +1 @@
+Subproject commit f0f3222ce8996a866feeaa5aabfbde70e8f31109
diff --git a/vendor/github.com/icedream/go-footballdata b/vendor/github.com/icedream/go-footballdata
new file mode 160000
index 0000000..17cd8cd
--- /dev/null
+++ b/vendor/github.com/icedream/go-footballdata
@@ -0,0 +1 @@
+Subproject commit 17cd8cd68efe4599201b997bf9bf945a72077755
diff --git a/vendor/github.com/patrickmn/go-cache b/vendor/github.com/patrickmn/go-cache
new file mode 160000
index 0000000..a3647f8
--- /dev/null
+++ b/vendor/github.com/patrickmn/go-cache
@@ -0,0 +1 @@
+Subproject commit a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0
diff --git a/vendor/github.com/stretchr/testify b/vendor/github.com/stretchr/testify
new file mode 160000
index 0000000..05e8a0e
--- /dev/null
+++ b/vendor/github.com/stretchr/testify
@@ -0,0 +1 @@
+Subproject commit 05e8a0eda380579888eb53c394909df027f06991
diff --git a/vendor/github.com/thoj/go-ircevent b/vendor/github.com/thoj/go-ircevent
new file mode 160000
index 0000000..1b0acb5
--- /dev/null
+++ b/vendor/github.com/thoj/go-ircevent
@@ -0,0 +1 @@
+Subproject commit 1b0acb5f2f1b615cfbd4b9f91abb14cb39a18769