Started REPL env for custom interactive shell

ONLY active when bot is running
- Arguments [--watch] displays how many usses of each trigger and how
many uses over the lifetime ofthe bot
Commands
- title [get/set] <newTitle>
- category [get/set] <newCategory> - MUST be a legit Twitch.tv one
- yet to come [cmd add|del|edit] [trigger] [response] (NOTE if you want
Emotes you will have to type the 'name' in for twitch chat to recognise
it)
This commit is contained in:
bryce
2025-07-15 01:08:12 +12:00
parent b82d5dd0d4
commit c704380ee3
7 changed files with 267 additions and 6 deletions

96
cmd/cli/main.go Normal file
View File

@@ -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":
}
}

View File

@@ -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))

View File

@@ -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
}

84
internal/config/config.go Normal file
View File

@@ -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
}

View File

View File

View File

@@ -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 {