Initial commit.

master
Icedream 2017-08-08 18:32:45 +02:00
commit bbd531ef5c
Signed by: icedream
GPG Key ID: 1573F6D8EFE4D0CF
21 changed files with 1407 additions and 0 deletions

49
.dockerignore Normal file
View File

@ -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

31
.gitignore vendored Normal file
View File

@ -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

16
.gitmodules vendored Normal file
View File

@ -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

13
Dockerfile Normal file
View File

@ -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"]

461
main.go Normal file
View File

@ -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()
}

45
main.tpl Normal file
View File

@ -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 }}

86
manager/antiflood.go Normal file
View File

@ -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...))
}

23
manager/manager.go Normal file
View File

@ -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
}

22
manager/topic.go Normal file
View File

@ -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
}

16
sort.go Normal file
View File

@ -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]
}

121
templates.go Normal file
View File

@ -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
}

36
templates_test.go Normal file
View File

@ -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))
}

198
topic.go Normal file
View File

@ -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<prefixColor>[0-9]+)` +
`(?P<prefix>[A-Za-z\s]+):` +
`\s*\x03` +
`\s*(?P<text>.+)` +
`\s*\x02` +
`\s*`)
rxMatch = regexp.MustCompile(`^` +
`(?P<homeTeam>` +
`\w+(?:\s+\w+)*)` +
`\s+(?P<homeScore>\-|\d+):(?P<awayScore>\-|\d+)` +
`\s+(?P<awayTeam>\w+(?:\s+\w+)*)` +
`(` +
`\s+(?P<additional>.+)` +
`)?` +
`$`)
rxAdditionalScoreInfo = regexp.MustCompile(`^` +
`(?P<pre>.*?)\s*` +
`(\[(?:` +
`(?P<penTag>P)\s*(?P<penScore>\d+:\d+)` +
`|` +
`(?P<extraTag>AET)` +
`)\]\s*)?` +
`\((?P<halfScore>\d+:\d+)\)` +
`\s*(?P<post>.*)` +
`$`)
)
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
}

245
topic_test.go Normal file
View File

@ -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)
}
}
}

39
util.go Normal file
View File

@ -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
}

1
util_test.go Normal file
View File

@ -0,0 +1 @@
package main

1
vendor/github.com/helmbold/richgo generated vendored Submodule

@ -0,0 +1 @@
Subproject commit f0f3222ce8996a866feeaa5aabfbde70e8f31109

1
vendor/github.com/icedream/go-footballdata generated vendored Submodule

@ -0,0 +1 @@
Subproject commit 17cd8cd68efe4599201b997bf9bf945a72077755

1
vendor/github.com/patrickmn/go-cache generated vendored Submodule

@ -0,0 +1 @@
Subproject commit a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0

1
vendor/github.com/stretchr/testify generated vendored Submodule

@ -0,0 +1 @@
Subproject commit 05e8a0eda380579888eb53c394909df027f06991

1
vendor/github.com/thoj/go-ircevent generated vendored Submodule

@ -0,0 +1 @@
Subproject commit 1b0acb5f2f1b615cfbd4b9f91abb14cb39a18769