package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/gempir/go-twitch-irc/v4"
)
var COMMAND_RE = regexp . MustCompile ( ` ^(\$|!)([a-z_]+) ?(.*)?$ ` )
type Secret struct {
ClientID string
ClientSecret string
AccessToken string
RefreshToken string
}
type Periodic struct {
Seconds int
Message string
}
type Config struct {
ClientNick string
Channel string
Commands map [ string ] string
Actions map [ string ] string
Periodic [ ] Periodic
}
type Poll struct {
Active bool
Text string
Yes int
No int
}
type Bot struct {
Secrets Secret
Config Config
Client * twitch . Client
CurrentPoll Poll
}
type Command struct {
Type string
Command string
Text string
}
type TwitchTokenResponse struct {
AccessToken string ` json:"access_token" `
RefreshToken string ` json:"refresh_token" `
ExpiresIn int ` json:"expires_in" `
Scope [ ] string ` json:"scope" `
TokenType string ` json:"token_type" `
}
func LoadJSON [ T any ] ( file string ) T {
data , err := os . ReadFile ( file )
if err != nil { log . Fatal ( err ) }
var config T
err = json . Unmarshal ( data , & config )
if err != nil { log . Fatal ( err ) }
return config
}
func RunPeriodic ( message Periodic , channel string , client * twitch . Client ) {
for {
time . Sleep ( time . Duration ( message . Seconds ) * time . Second )
client . Say ( channel , message . Message )
}
}
func ( bot * Bot ) RefreshToken ( ) {
api_url := "https://id.twitch.tv/oauth2/token"
data := url . Values { }
data . Set ( "client_id" , bot . Secrets . ClientID )
data . Set ( "client_secret" , bot . Secrets . ClientSecret )
data . Set ( "grant_type" , "refresh_token" )
data . Set ( "refresh_token" , bot . Secrets . RefreshToken )
encoded := strings . NewReader ( data . Encode ( ) )
resp , err := http . Post ( api_url , "application/x-www-form-urlencoded" , encoded )
if err != nil { log . Fatal ( err ) }
defer resp . Body . Close ( )
if resp . StatusCode != http . StatusOK {
log . Fatal ( fmt . Sprintf ( "Invalid status code: %v." , resp . StatusCode ) )
}
var token_resp TwitchTokenResponse
err = json . NewDecoder ( resp . Body ) . Decode ( & token_resp )
if err != nil { log . Fatal ( err ) }
bot . Secrets . AccessToken = fmt . Sprintf ( "oauth:%s" , token_resp . AccessToken ) ;
}
func ( bot * Bot ) LoadConfigs ( ) {
var secrets_file string
var config_file string
var action_file string
flag . StringVar ( & secrets_file , "secrets" , "secret.json" , "Your secrets file. KEEP PRIVATE!" )
flag . StringVar ( & config_file , "config" , "config.json" , "Your config.json. Can be public." )
flag . StringVar ( & action_file , "actions" , "actions.json" , "Your URL actions. KEEP PRIVATE!" )
flag . Parse ( )
log . Println ( "Loading secrets from" , secrets_file )
log . Println ( "Loading config from" , config_file )
log . Println ( "Loading actions from" , action_file )
bot . Secrets = LoadJSON [ Secret ] ( secrets_file )
bot . Config = LoadJSON [ Config ] ( config_file )
bot . Config . Actions = LoadJSON [ map [ string ] string ] ( action_file )
}
func ParseCommand ( message string ) ( Command , error ) {
matches := COMMAND_RE . FindStringSubmatch ( message )
if matches == nil {
return Command { } , errors . New ( "Invalid command format." )
}
result := Command {
matches [ 1 ] ,
matches [ 2 ] ,
matches [ 3 ] ,
}
return result , nil
}
func ( bot * Bot ) StreamerAction ( message twitch . PrivateMessage ) {
if ! message . User . IsBroadcaster {
log . Println ( "! A StreamerAction message was sent by someone not marked as Broadcaster" )
return
}
cmd , err := ParseCommand ( message . Message )
if err != nil {
fmt . Println ( err )
return
}
switch cmd . Command {
case "poll_start" :
if cmd . Text == "" { return }
bot . CurrentPoll . Active = true
bot . CurrentPoll . Text = cmd . Text
bot . CurrentPoll . Yes = 0
bot . CurrentPoll . No = 0
announce := fmt . Sprintf ( "New Poll: %s. Use 1, yes or true; 0, no or false to reply." , bot . CurrentPoll . Text )
bot . Send ( message , announce )
case "poll_end" :
bot . CurrentPoll . Active = false
bot . CurrentPoll . Text = fmt . Sprintf (
"Poll Has Ended: \"%s\" Yes=%d, No=%d" ,
bot . CurrentPoll . Text , bot . CurrentPoll . Yes , bot . CurrentPoll . No )
bot . Send ( message , bot . CurrentPoll . Text )
}
}
func ( bot * Bot ) TryPoll ( message twitch . PrivateMessage ) bool {
switch message . Message {
case "yes" , "1" , "true" :
bot . CurrentPoll . Yes += 1
return true
case "no" , "0" , "false" :
bot . CurrentPoll . No += 1
return true
}
return false
}
func ( bot * Bot ) Send ( message twitch . PrivateMessage , text string ) {
bot . Client . Say ( message . Channel , text )
}
func ( bot * Bot ) Reply ( message twitch . PrivateMessage , text string ) {
bot . Client . Reply ( message . Channel , message . ID , text )
}
func ( bot * Bot ) SendHelp ( message twitch . PrivateMessage ) {
bot . Reply ( message , "Invalid command. Use !help to see what's available." )
}
func ( bot * Bot ) ViewerAction ( message twitch . PrivateMessage ) {
cmd , err := ParseCommand ( message . Message )
if err != nil || cmd . Type != "!" {
bot . SendHelp ( message )
return
}
switch cmd . Command {
case "poll" :
if bot . CurrentPoll . Active {
bot . Reply ( message ,
fmt . Sprintf ( "Current Poll: %s, Yes=%d, No=%d" ,
bot . CurrentPoll . Text , bot . CurrentPoll . Yes , bot . CurrentPoll . No ) )
} else {
bot . Reply ( message , "No poll currently active." )
}
default :
// see if it's a normal command
reply , valid_cmd := bot . Config . Commands [ strings . ToLower ( cmd . Command ) ]
if valid_cmd {
bot . Reply ( message , reply )
} else if ! valid_cmd {
bot . SendHelp ( message )
}
}
}
func ( bot * Bot ) HandleMessage ( message twitch . PrivateMessage ) {
// not sure if this is good enough for auth
if strings . HasPrefix ( message . Message , "$" ) && message . User . IsBroadcaster {
bot . StreamerAction ( message )
} else if bot . CurrentPoll . Active && bot . TryPoll ( message ) {
return
} else if strings . HasPrefix ( message . Message , "!" ) {
bot . ViewerAction ( message )
}
}
func ( bot * Bot ) CreateClient ( ) {
// or client := twitch.NewAnonymousClient() for an anonymous user (no write capabilities)
bot . Client = twitch . NewClient ( bot . Config . ClientNick , bot . Secrets . AccessToken )
bot . Client . OnPrivateMessage ( func ( message twitch . PrivateMessage ) {
bot . HandleMessage ( message )
} )
bot . Client . OnSelfJoinMessage ( func ( message twitch . UserJoinMessage ) {
log . Println ( "Join" , message . Channel , "as" , message . User , "successful" )
} )
bot . Client . OnConnect ( func ( ) {
log . Println ( "Connected. Joining" , bot . Config . Channel )
bot . Client . Join ( bot . Config . Channel )
} )
bot . Client . OnNoticeMessage ( func ( message twitch . NoticeMessage ) {
log . Println ( "NOTICE" , message . Channel , message . Message )
} )
}
func ( bot * Bot ) RunPeriodic ( ) {
for _ , perodic := range ( bot . Config . Periodic ) {
go RunPeriodic ( perodic , bot . Config . Channel , bot . Client )
}
}
func ( bot * Bot ) Connect ( ) {
log . Println ( "Connecting to Twitch..." )
err := bot . Client . Connect ( )
if err != nil { log . Fatal ( err ) }
}
func CreateBot ( ) * Bot {
var bot Bot
bot . CurrentPoll . Active = false
bot . LoadConfigs ( )
bot . RefreshToken ( )
bot . CreateClient ( )
return & bot
}
func main ( ) {
bot := CreateBot ( )
bot . RunPeriodic ( )
bot . Connect ( )
}