soccer-bot/main.go

695 lines
21 KiB
Go

package main
import (
"bytes"
"fmt"
"log"
"math"
"regexp"
"sort"
"strings"
"time"
"net/http"
"github.com/alecthomas/kingpin"
"github.com/dustin/go-humanize"
"github.com/icedream/go-footballdata"
"github.com/olekukonko/tablewriter"
"github.com/thoj/go-ircevent"
"git.icedream.tech/icedream/soccer-bot/antispam"
"git.icedream.tech/icedream/soccer-bot/manager"
)
const (
Competition_Testing_GermanLeague2 = 453
Competition_Testing = 464
Competition_WorldChampionShip2018 = 467
Competition = Competition_WorldChampionShip2018
day = 24 * time.Hour
week = 7 * day
)
var (
rDiff = regexp.MustCompile("[\\+\\-]\\d+")
)
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
topicRefreshDelay := 30 * time.Second
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)
kingpin.Flag("topic-refresh-delay", "The interval between topic updates. (This includes asking football-data for new information!)").DurationVar(&topicRefreshDelay)
// 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 := 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
}
// Antispam
antispamInstance := antispam.New()
updateTopics := func() {
// Get football data
if r, err := footballData.FixturesOfCompetition(Competition).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 m.GetChannels() {
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("PART", func(e *irc.Event) {
// Is this PART not about us?
if !strings.EqualFold(e.Nick, conn.GetNick()) {
return
}
m.DeleteTopic(e.Arguments[0])
})
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)
cmd := parseCommand(msg)
isCommand := !isChannel || strings.HasPrefix(cmd.Name, "!")
if !isCommand {
return
}
log.Printf("Antispam check: %s, %s", event.Source, cmd.Name)
if ok, duration := antispamInstance.Check(event.Source, cmd.Name); !ok {
conn.Noticef(sender, "Sorry, please try again %s.", humanize.Time(time.Now().Add(duration)))
return
}
switch {
case !isChannel && strings.EqualFold(cmd.Name, "updatetopics"):
updateTopicsChan <- nil
case strings.EqualFold(cmd.Name, "!help"):
conn.Noticef(sender, "\x02!group <group name>\x02 - Displays group table, for example \x02!group a\x02 would show the group A table.")
conn.Noticef(sender, "\x02!country <country name>\x02 or \x02!team <country name>\x02 - Displays upcoming matches and results of past matches of a playing country, for example \x02!country germany\x02.")
conn.Noticef(sender, "\x02!next\x02 - Originally meant to tell you what's the next match, this command really just tells you to look at \x02/topic\x02.")
case strings.EqualFold(cmd.Name, "!country"),
strings.EqualFold(cmd.Name, "!team"):
teamList, err := footballData.TeamsOfCompetition(Competition).Do()
if err != nil {
conn.Noticef(sender, "Sorry, can't display team information at this moment.")
log.Print(err)
}
// find correct team
name := strings.Join(cmd.Arguments, " ")
for _, competitionTeam := range teamList.Teams {
if strings.EqualFold(competitionTeam.Name, name) ||
// strings.EqualFold(team.Code, name) ||
strings.EqualFold(competitionTeam.ShortName, name) {
// found matching team
if competitionTeam.Id == 0 {
conn.Noticef(sender, "Sorry, something went horribly wrong here.")
log.Print("Returned team ID of a team in competition was 0...")
return
}
fixtures, err := footballData.FixturesOfTeam(competitionTeam.Id).
// Season(Competition).
Do()
if err != nil {
conn.Noticef(sender, "Sorry, could not fetch fixtures information at this moment.")
log.Print(err)
return
}
outputs := []string{}
for _, fixture := range fixtures.Fixtures {
homeTeam := fixture.HomeTeamName
awayTeam := fixture.AwayTeamName
var vs string
switch fixture.Status {
case footballdata.FixtureStatus_InPlay,
footballdata.FixtureStatus_Canceled,
footballdata.FixtureStatus_Finished:
// finished, display score
vs = fmt.Sprintf("%d:%d",
fixture.Result.GoalsHomeTeam,
fixture.Result.GoalsAwayTeam)
default:
// upcoming, display time
vs = fmt.Sprintf("[%s]",
fixture.Date.Format("Jan 02 @ 15:04 MST"))
}
outputs = append(outputs,
fmt.Sprintf("%s %s %s",
homeTeam, vs, awayTeam))
}
conn.Privmsg(target, strings.Join(outputs, " | "))
return
}
}
conn.Noticef(sender, "Sorry, I don't know this team.")
// Not implemented - football-data api data not reliable enough?
case strings.EqualFold(cmd.Name, "!player"):
// TODO
conn.Noticef(sender, "Sorry, this isn't implemented yet.")
case strings.EqualFold(cmd.Name, "!next"):
conn.Privmsg(target, "The upcoming match is always listed in the channel topic! If you can't see it (usually above the chat), try to type \x02/topic\x02.")
// Currently implemented:
// - !group / !group <Group name> (for example !group a)
// Not implemented yet:
// - !group <Country name>
case strings.EqualFold(cmd.Name, "!group"),
strings.EqualFold(cmd.Name, "!table"):
if leagueTable, err := footballData.LeagueTableOfCompetition(Competition).Do(); err != nil {
var s string
if s, err = tplString("error", err); err != nil {
log.Print(err)
} else {
conn.Privmsg(target, s)
}
} else {
log.Printf("%+v", leagueTable)
header := []string{"Rank", "Team", "Games", "Goals", "Diff", "Points"}
data := [][]string{}
// cast standing items to a new array with a more generic type
var standing []interface{}
if leagueTable.Standing != nil {
standing = make([]interface{}, len(leagueTable.Standing))
for i, v := range leagueTable.Standing {
standing[i] = v
}
}
if standing == nil {
if leagueTable.Standings == nil {
conn.Noticef(sender, "Sorry, can't display league table at this moment.")
return
}
// expect a standing name to be input
if len(cmd.Arguments) < 1 {
conn.Noticef(sender, "You need to type in which standing you want to view the table of.")
return
}
// find matching standing (case-insensitive)
ok := false
for key, val := range leagueTable.Standings {
if strings.EqualFold(key, cmd.Arguments[0]) {
ok = true
standing = make([]interface{}, len(val))
for i, v := range val {
standing[i] = v
}
break
}
}
if !ok {
conn.Noticef(sender, "Can not find requested standing for league table.")
return
}
}
for _, standing := range standing {
// HACK
var actualStanding footballdata.TeamLeagueStatistics
switch v := standing.(type) {
case footballdata.TeamLeagueStatisticsInStanding:
actualStanding = v.TeamLeagueStatistics
case footballdata.TeamLeagueStatisticsInStandings:
actualStanding = v.TeamLeagueStatistics
}
var goalDiffPrefix string
if actualStanding.GoalDifference > 0 {
goalDiffPrefix = "+"
} else {
goalDiffPrefix = ""
}
// HACK - get team name and rank from two diff fields
var teamName string
var rank uint8
switch v := standing.(type) {
case footballdata.TeamLeagueStatisticsInStanding:
teamName = v.TeamName
rank = v.Position
case footballdata.TeamLeagueStatisticsInStandings:
teamName = v.Team
rank = v.Rank
}
data = append(data, []string{
fmt.Sprintf("%d", rank),
teamName,
fmt.Sprintf("%d", actualStanding.PlayedGames),
fmt.Sprintf("%d:%d", actualStanding.Goals, actualStanding.GoalsAgainst),
fmt.Sprintf("%s%d", goalDiffPrefix, actualStanding.GoalDifference),
fmt.Sprintf("%d", actualStanding.Points),
})
}
buf := new(bytes.Buffer)
table := tablewriter.NewWriter(buf)
table.SetHeader(header)
table.SetAlignment(tablewriter.ALIGN_CENTER)
table.SetNewLine("\n")
table.SetBorder(true)
table.AppendBulk(data)
table.Render()
tableStr := rDiff.ReplaceAllStringFunc(buf.String(), func(s string) string {
switch s[0] {
case '+':
return fmt.Sprintf("\x0303%s\x03", s)
}
return fmt.Sprintf("\x0304%s\x03", s)
})
lines := strings.Split(tableStr, "\n")
for _, line := range lines {
conn.Privmsg(target, line)
}
}
case strings.EqualFold(cmd.Name, "!match"):
/*if r, err := footballData.FixturesOfCompetition(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(topicRefreshDelay):
case <-updateTopicsChan:
}
updateTopics()
}
}()
// listen for errors
log.Print("Now looping.")
conn.Loop()
}