2017-08-08 16:32:45 +00:00
package main
import (
2017-08-11 22:16:47 +00:00
"bytes"
2017-08-08 16:32:45 +00:00
"fmt"
"log"
"math"
2017-08-11 22:16:47 +00:00
"regexp"
2017-08-08 16:32:45 +00:00
"sort"
"strings"
"time"
"net/http"
2018-06-02 23:07:32 +00:00
"github.com/alecthomas/kingpin"
2017-08-14 22:40:47 +00:00
"github.com/dustin/go-humanize"
2017-08-14 22:03:15 +00:00
"github.com/icedream/go-footballdata"
2017-08-11 22:16:47 +00:00
"github.com/olekukonko/tablewriter"
2017-08-08 16:32:45 +00:00
"github.com/thoj/go-ircevent"
2017-08-14 22:03:15 +00:00
"git.icedream.tech/icedream/soccer-bot/antispam"
"git.icedream.tech/icedream/soccer-bot/manager"
2017-08-08 16:32:45 +00:00
)
const (
2018-06-02 22:38:18 +00:00
Competition_Testing_GermanLeague2 = 453
Competition_Testing = 464
Competition_WorldChampionShip2018 = 467
Competition = Competition_WorldChampionShip2018
2017-08-08 16:32:45 +00:00
day = 24 * time . Hour
week = 7 * day
)
2017-08-11 22:16:47 +00:00
var (
rDiff = regexp . MustCompile ( "[\\+\\-]\\d+" )
)
2017-08-08 16:32:45 +00:00
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
}
2017-08-14 22:03:15 +00:00
// Antispam
antispamInstance := antispam . New ( )
2017-08-08 16:32:45 +00:00
updateTopics := func ( ) {
// Get football data
2018-06-02 22:38:18 +00:00
if r , err := footballData . FixturesOfCompetition ( Competition ) . Do ( ) ; err != nil {
2017-08-08 16:32:45 +00:00
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 ) {
2017-08-14 22:03:15 +00:00
sender := event . Nick
2017-08-08 16:32:45 +00:00
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
}
2017-08-14 22:03:15 +00:00
msg := stripIrcFormatting ( event . Message ( ) )
2017-08-08 16:32:45 +00:00
log . Printf ( "<%s @ %s> %s" , event . Nick , target , msg )
2017-08-14 22:03:15 +00:00
cmd := parseCommand ( msg )
2018-06-02 22:47:59 +00:00
isCommand := ! isChannel || strings . HasPrefix ( cmd . Name , "!" )
if ! isCommand {
return
}
2017-08-14 22:03:15 +00:00
log . Printf ( "Antispam check: %s, %s" , event . Source , cmd . Name )
if ok , duration := antispamInstance . Check ( event . Source , cmd . Name ) ; ! ok {
2017-08-14 22:40:47 +00:00
conn . Noticef ( sender , "Sorry, please try again %s." , humanize . Time ( time . Now ( ) . Add ( duration ) ) )
2017-08-14 22:03:15 +00:00
return
}
2017-08-08 16:32:45 +00:00
switch {
2017-08-14 22:03:15 +00:00
case ! isChannel && strings . EqualFold ( cmd . Name , "updatetopics" ) :
2017-08-08 16:32:45 +00:00
updateTopicsChan <- nil
2017-08-14 22:03:15 +00:00
case strings . EqualFold ( cmd . Name , "!table" ) :
2018-06-02 22:38:18 +00:00
if leagueTable , err := footballData . LeagueTableOfCompetition ( Competition ) . Do ( ) ; err != nil {
2017-08-11 22:16:47 +00:00
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 { }
2018-06-02 22:47:46 +00:00
// 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
}
2017-08-11 22:16:47 +00:00
goalDiffPrefix := ""
2018-06-02 22:47:46 +00:00
if actualStanding . GoalDifference > 0 {
2017-08-11 22:16:47 +00:00
goalDiffPrefix = "+"
} else {
goalDiffPrefix = ""
}
2018-06-02 22:47:46 +00:00
// 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
}
2017-08-11 22:16:47 +00:00
data = append ( data , [ ] string {
2018-06-02 22:47:46 +00:00
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 ) ,
2017-08-11 22:16:47 +00:00
} )
}
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 )
}
}
2017-08-14 22:03:15 +00:00
case strings . EqualFold ( cmd . Name , "!match" ) :
2018-06-02 22:38:18 +00:00
/ * if r , err := footballData . FixturesOfCompetition ( SoccerSeason_EuropeanChampionShipsFrance2016 ) . TimeFrame ( 1 * day ) . Do ( ) ; err != nil {
2017-08-08 16:32:45 +00:00
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 ( )
}