523 lines
15 KiB
Go
523 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"net/http"
|
|
|
|
"github.com/olekukonko/tablewriter"
|
|
"github.com/thoj/go-ircevent"
|
|
"gopkg.in/alecthomas/kingpin.v2"
|
|
|
|
"github.com/icedream/embot/manager"
|
|
"github.com/icedream/go-footballdata"
|
|
)
|
|
|
|
const (
|
|
Competition_Testing = 453
|
|
Competition_WorldChampionShip2018 = -1
|
|
|
|
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 := 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(Competition_Testing).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, "!table"):
|
|
if leagueTable, err := footballData.LeagueTableOfCompetition(Competition_Testing).Do(); err != nil {
|
|
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{}
|
|
|
|
for _, standing := range leagueTable.Standing {
|
|
goalDiffPrefix := ""
|
|
if standing.GoalDifference > 0 {
|
|
goalDiffPrefix = "+"
|
|
} else {
|
|
goalDiffPrefix = ""
|
|
}
|
|
data = append(data, []string{
|
|
fmt.Sprintf("%d", standing.Position),
|
|
standing.TeamName,
|
|
fmt.Sprintf("%d", standing.PlayedGames),
|
|
fmt.Sprintf("%d:%d", standing.Goals, standing.GoalsAgainst),
|
|
fmt.Sprintf("%s%d", goalDiffPrefix, standing.GoalDifference),
|
|
fmt.Sprintf("%d", standing.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.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()
|
|
}
|