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() }