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 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 := 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 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) 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, "!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...") } 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 (for example !group a) // Not implemented yet: // - !group 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 from two diff fields var teamName string switch v := standing.(type) { case footballdata.TeamLeagueStatisticsInStanding: teamName = v.TeamName case footballdata.TeamLeagueStatisticsInStandings: teamName = v.Team } data = append(data, []string{ fmt.Sprintf("%d", actualStanding.Position), 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(30 * time.Second): case <-updateTopicsChan: } updateTopics() } }() // listen for errors log.Print("Now looping.") conn.Loop() }