diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..b533262 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + twitchapi "streambot_twitch/internal/twitchapi" + "streambot_twitch/internal/commands" + "streambot_twitch/internal/config" +) + +func main() { + // env setup + clientID := os.Getenv("TWITCH_CLIENT_ID") + clientSecret := os.Getenv("TWITCH_CLIENT_SECRET") + streamerChan := os.Getenv("CHANNEL") + userToken := os.Getenv("STREAMER_OAUTH") + + if clientID == "" || clientSecret == "" || streamerChan == "" { + log.Fatal("Set TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET or cHANNEL") + } + + // init config, commands, usage + if err := config.Load(); err != nil {log.Fatal(err)} + if err := commands.LoadDefaultCommands(); err != nil {log.Fatal(err)} + if err := commands.LoadCustomCommands(); err != nil {lof.Fatal(err)} + if err := commands.LoadUsage(); err != nil {log.Fatal(err)} + + // init api client + apiClient, err := twitchapi.NewClient(clientID, clientSecret) + if err != nil {log.Fatalf("API client: %v", err)} + if userToken != "" { + apiClient.SetUserAccessToken(userToken) + } + + // CLI flags + watch := flag.Bool("watch", false, "refresh stats periodically") + interval := flag.Duration("interval", 5*time.Minute, "refresh interval for watch mode") + flag.Parse() + args := flag.Args() + + if len(args) == 0 || strings.ToLower(args[0]) == "shell" { + repl(apiClient, streamerChan, *watch, *interval) + return + } + + dispatch(apiClient, streamerChan, args, *watch, *interval) +} + +// repl interactive prompt +func repl(apiClient *twitchapi.Client, streamerChan string, watch bool, interval time.Duration) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("bot:> ") + line, err := reader.ReadString('\n') + if err != nil { + fmt.Println("Error:", err) + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line == "exit" || line == "quit" { + fmt.Println("bye") + return + } + parts := strings.Fields(line) + dispatch(apiClient, streamerChan, parts, watch, interval) + } +} + +// dispatch handles one command invocation +func dispatch(apiClient *twitchapi.Client, streamerChan string, args []string, watch bool, interval time.Duration) { + cmd := strings.ToLower(args[0]) + switch cmd { + case "stats": + if watch { + printUsage() + t := time.NewTicker(interval) + for range t.C { + printUsage() + } + } else { + printUsage() + } + + case "title": + + } +} diff --git a/internal/chat/handler.go b/internal/chat/handler.go index f0a5479..4dbdedc 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -97,7 +97,13 @@ func HandleMessage(client *twitch.Client, apiClient interface{}, message twitch. // check perms for _, cmd := range commands.GetAllCommands() { if messageMatchesCommand(msg, botName, cmd) { + // track usage: use first alias as key + triggerKey := strings.ToLower(cmd.Triggers[0]) + commands.IncrementUsage(triggerKey) + if userHasPermission(user, cmd.Permission) { + reply := ReplacePlaceholders(cmd.Replay, user.DisplayName, channel, botUsername) + client.Say(channel, cmd.Reply) } else { client.Say(channel, fmt.Sprintf("@%s You don't have permission to use this command.", user.DisplayName)) diff --git a/internal/commands/usage.go b/internal/commands/usage.go new file mode 100644 index 0000000..00a8d2b --- /dev/null +++ b/internal/commands/usage.go @@ -0,0 +1,75 @@ +package commands + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + "sync" +) + +const usageFile = "internal/storage/usage.json" + +var ( + usageMu sybc.Mutex + usageCounts map[string]int +) + +func LoadUsage() error { + usageMu.Lock() + defer usageMu.Unlock() + + data, err := ioutil.ReadFile(usageFile) + if os.IsNotExist(err) { + usageCounts = make(map[string]int) + return nil + } + if err != nil { + return nil + } + return json.Unmarshal(dat, &usageCounts) +} + +func SaveUsage() error { + usageMu.Lock() + defer usageMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(usageFile), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(usageCounts, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(usageFile, data, 0644) +} + +func IncrementUsage(trigger string) { + usageMu.Lock() + defer usageMu.Unlock() + if usageCounts == nil { + usageCounts = make(map[string]int) + } + usageCounts[trigger]++ + SaveUsage() +} + +func AllUsage() map[string]int { + usageMu.Lock() + defer usageMu.Unlock() + m := make(map[string]int, len(usageCounts)) + for k, v := range usageCounts { + m[k] = v + } + return m +} + +func TotalUsage() int { + usageMu.Lock() + defer usageMu.Unlock() + sum := 0 + for _, v := range usageCounts { + sum += v + } + return sum +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..10f9304 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" +) + +const ( + ConfigDir = "internal/storage" + ConfigFile = "settings.json" +) + +type Settings struct { + TitleGetTemplate string `json:"title_get_template"` + TitleSetTemplate string `json:"title_set_template"` + CategoryGetTemplate string `json:"category_get_template"` + CategorySetTemplate string `json:"category_set_template"` +} + +var cfg Settings + +func Load() error { + path := filepath.Join(ConfigDir, ConfigFile) + data, err := ioutil.ReadFile(path) + if os.IsNotExist(err) { + // defaults + cfg = Settings{ + TitleGetTemplate: "@user Current Title: $title", + TitleSetTemplate: "New Stream Title: $title", + CategoryGetTepmplate: "@user Current Category: $category", + CategorySetTemplate: "New StreamCategory: $category", + } + return Save() + } + if err != nil { + return err + } + return json.Unmarshal(data, &cgf) +} + +func Save() error { + if err := os.MkdirAll(ConfigDir, 0755); err != nil { + return err + } + path := filepath.Join(ConfigDir, ConfigFile) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(path, data, 0644) +} + +func Get(key string) (string bool) { + switch key { + case "title-get": + return cfg.TitleGetTemplate, true + case "title-set": + return cfg.TitleSetTemplate, true + case "category-get": + return cfg.CategoryGetTemplate, true + caes "categroy-set": + return cfg.CategorySetTemplate, true + } + return "", false +} + +func Set(key, val string) bool { + switch key { + case "title-get": + cfg.TitleGetTemplate = val + case "tiel-set": + cfg.TitleSetTemplate = val + case "category-get": + cfg.CategoryGetTemplate = val + case "category-set": + cfg.CategorySetTemplate = val + default: + return false + } + Save() + return true +} diff --git a/internal/storage/settings.json b/internal/storage/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/internal/storage/usage.json b/internal/storage/usage.json new file mode 100644 index 0000000..e69de29 diff --git a/internal/twitchapi/client.go b/internal/twitchapi/client.go index 1bfa3b2..db4eb40 100644 --- a/internal/twitchapi/client.go +++ b/internal/twitchapi/client.go @@ -118,25 +118,25 @@ func (c *Client) GetStreamCategory(channel string) (string, error) { return resp.Data.Channels[0].GameName, nil } -func setStreamCategory(client *helix.Client, channel, newCategory string) error { - userID, err := GetUserID(client, channel) +func (c *Client) SetStreamCategory(channel, newCategory string) error { + userID, err := c.GetUserID(channel) if err != nil { return err } // get Twitch GameID by Name from API - gameID, err := getGameIDByName(client, newCategory) + gameID, err := c.GetGameIDByName(newCategory) if err != nil { return err } - _, err = client.EditChannelInformation(&helix.EditChannelInformationParams{ + _, err = c.api.EditChannelInformation(&helix.EditChannelInformationParams{ BroadcasterID: userID, GameID: gameID, }) return err } -func getGameIDByName(client *helix.Client, name string) (string, error) { - resp, err := GetGames(&helix.GamesParams{ +func (c *Client) GetGameIDByName(name string) (string, error) { + resp, err := c.api.GetGames(&helix.GamesParams{ Names: []string{name}, }) if err != nil {