From bd8d2e404c8c2d0c5432d31216213f97666c3b43 Mon Sep 17 00:00:00 2001 From: bryce Date: Tue, 8 Jul 2025 16:12:36 +1200 Subject: [PATCH] Added Default Dynamic Command Handlers & api calls !countdown !title !game || !category !uptime --- internal/chat/countdown.go | 111 +++++++++++++++++++++++++ internal/chat/handler.go | 58 ++++++++++--- internal/chat/title_category.go | 71 ++++++++++++++++ internal/chat/uptime.go | 24 ++++++ internal/commands/commands.go | 2 +- internal/storage/default_commands.json | 26 ++++++ 6 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 internal/chat/countdown.go create mode 100644 internal/chat/title_category.go create mode 100644 internal/chat/uptime.go create mode 100644 internal/storage/default_commands.json diff --git a/internal/chat/countdown.go b/internal/chat/countdown.go new file mode 100644 index 0000000..d26618b --- /dev/null +++ b/internal/chat/countdown.go @@ -0,0 +1,111 @@ +package chat + +import ( + "context" + "fmt" + "rangexp" + "strings" + "time" + + twitch "github.com/gempir/go-twitch-irc/v4" +) + +var countdownCancel contect.CancelFunc + +func HandleCountdownCommand(client *twitch.Client, message twitch.PrivateMessage, user twitch.User, channel, msg string) { + if !userHasPermission(user, PermissionStreamer) { + client.Say(channel, fmt.Sprintf("@%s Only the Streamer can start a countdown.", user.DisplayName)) + return + } + + parts := strings.Fields(msg) + if len(parts) < 2 { + client Say(channel, "Usage: !countdown (e.g., !countdown 50m)") + return + } + durationStr := parts[1] + duration, err := parseDuration(durationStr) + if err != nil || duration <= 0 { + client.Say(channel, "Invalid duration format. Please use formats like 50m, 2h30m, 90s") + return + } + + // cancel existing - if running + if countdownCancel != nil { + countdownCancel() + } + + ctx, cancel := context.WithCancel(context.Background()) + countdownCancel = cancel + + go runCountdown(ctx, client, channel, duration) + client.Say(channel, fmt.Sprintf("Countdown Started for %s!", duration)) +} + +func parseDuration(input string) (time.Duration, error) { + replacements := map[string]string{ + "mins": "m", + "min": "m", + "hours": "h", + "hour": "h", + "seconds": "s", + "secs": "s", + "sec": "s", + } + for k, v := range replacements { + input = strings.ReplaceAll(strings.ToLower(input), k, v) + } + + validDuration := regxp.MustCompile(`^(\d+h)?(\d+m)?(\d+s)?$`) + if !validDuration.MatchString(input) { + return 0, fmt.Errorf("Invalid duration format") + } + + return time.ParseDuration(input) +} + +func runCountdown(ctx, context.Context, client *twitch.Client, channel string, duration time.Duration) { + ticker := time.NewTicker(5 * time.Minute) // starts with 5 min interval if > 10 min duration + defer ticker.Stop() + + endTime := time.Now().Add(duration) + + for { + select { + case <-ctx.Done(): + client.Say(channel, "Countdown Cancelled.") + return + case now := <-ticker.C: + remaining := endTime.Sub(now) + if remaining <= 0 { + client.Say(channel, "\x0313Countdown Finished! Time is up! PewPewPew") + return + } + + // Adjust ticker based on time remaining + switch { + case remaining <= 3*time.Minute && ticker.Interval() != 30*time.Second: + ticker.Stop() + ticker = time.NewTicker(30 * time.Second) + case remaining <= 10*time.Minute && remaining > 3*time.Minute && ticker.Interval() != time.Minute: + ticker.Stop() + ticker = time.NewTicker(time.Minute) + case remaining > 10*time.Minute && ticker.Interval() != 5*time.Minute: + ticker.Stop() + ticker = time.NewTimer(5 * time.Minute) + } + + client.Say(channel, fmt.Sprintf("Countdown: %s remaining", formatDuration(remaining))) + } + } +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + if m > 0 { + return fmt.Sprintf("%dm %ds, m, s") + } + return fmt.Sprintf("%ds", s) +} diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 4b434b7..c9ac426 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -14,21 +14,45 @@ const ( MatchContains = "contains" ) +func ReplacePlaceholders(reply, user, channel, bot string) string { + now := time.Now() + replacements := map[string]string{ + "$user": user, + "$channel": channel, + "$bot": bot, + "$date": now.Format("02-01-2006"), + "$time": now.Format("15:04:05"), + } + + for placeholder, value := range replacements { + reply = strings.ReplaceAll(reply, placeholder, value) + } + return reply +} + func messageMatchesCommand(message, botUserName string, cmd commands.CustomCommand) bool { msgLower := strings.ToLower(message) - triggerLower := strings.ToLower(cmd.Trigger) + triggerLower := "" botMention := "@" + strings.ToLower(botUserName) + for _, trigger := range cmd.Triggers{ + triggerLower :=strings.ToLower(trigger) switch cmd.Match { - case MatchPrefix: - return strings.HasPrefix(msgLower, triggerLower) - case MatchMention: - return strings.Contains(msgLower, botMention) && strings.Contains(msgLower, triggerLower) - case MatchContains: - return strings.Contains(msgLower, triggerLower) - default: - return false + case MatchPrefix: + if strings.HasPrefix(msgLower, triggerLower) { + return true + } + case MatchMention: + if strings.Contains(msgLower, botMention) { + return true + } + case MatchContains: + if strings.Contains(msgLower, triggerLower) { + return true + } + } } + return false } func userHasPermisson(user twitch.User, required commands.PermissionLevel) bool { @@ -55,8 +79,22 @@ func HandleMessage(client *twitch.Client, apiClient interface{}, message twitch. msg := message.Message channel := message.Channel - // uptime title game handlers etc. . . + // Dispatch Countdown command + if strings.HasPrefix(msg, "!countdown") { + HandleCountdownCommand(client, message, user, channel, msg) + return + } + // Dispatch title/category commands + if strings.HasPrefix(msg, "!title") || strings.HasPrefix(msg, "!category") || strings.HasPrefix(msg, "!game") { + HandleTitleCategoryCommand(client, apiClient, message, user, channel, msg) + return + } + + // Dispatch custom/default commands + HandleCustomCommands(client, message, user, channel, botUsername) +} + // check perms for _, cmd := range commands.GetAllCommands() { if messageMatchesCommand(msg, botName, cmd) { if userHasPermission(user, cmd.Permission) { diff --git a/internal/chat/title_category.go b/internal/chat/title_category.go new file mode 100644 index 0000000..abe5e58 --- /dev/null +++ b/internal/chat/title_category.go @@ -0,0 +1,71 @@ +package chat + +import ( + "fmt" + "strings" + + twitch "github.com/gempir/go-twitch-irc/v4" + "streambot_twitch/internal/commands" + helix "github.com/nicklaw5/helix" +) + +func HandleTitleCategoryCommand(client *twitch.Client, apiClient inteface{}, message twitch.PrivateMessage, user twitch.User, msg string) { + api, ok := apiClient.(*helix.Client) + if !ok { + client.Say(channel, "Internal ERROR: Twitch API client not available.") + return + } + + parts := strings.SplitN(msg, " ", 2) + cmd := strings.ToLower(parts[0]) + + switch cmd { + case "!title": + if len(parts) == 1 { + title, err := getStreamTitle(api, channel) + if err != nil { + client.Say(channel, fmt.Sprintf("@%s Error fetching title", user.DisplayName)) + return + } + client.Say(channel, fmt.Sprintf("@%s current title: %s", user.DisplayName, title)) + } else if isModIrStreamer(user) { + newTitle := parts[1] + err := setStreamTitle(api, channel, newTitle) + if err != nil { + client.Say(channel, fmt.Sprintf("@%s Failed to update title") user.DisplayName) + return + } + client.Say(channel, fmt.Sprintf("New Stream Title: %s", newTitle)) + } else { + client.Say(channel, fmt.Sprintf("@%s You don't have permission to change the title", user.DisplayName)) + } + case "!category", "!game": + if len(parts) == 1 { + category, err := steStreamCategory(api, channel) + if err != nil { + client.Say(channel, fmt.Sprintf("@%s Error fetching category", user.DisplayName)) + return + } + client.Say(channel, fmt.Sprintf("@%s Current Category: %s", user.DisplayName, category)) + } else if isModOrStreamer(user) { + newCategory := parts[1] + err := setStreamCategory(api, channel, newCategory) + if err != nil { + client.Say(channel, fmt.Sprintf("@%s Failed to update category", user.DisplayName)) + return + } + client.Say(channel, fmt.Sprintf("New Stream Category: %s", newCategory)) + }else { + client.Say(channel, fmt.Sprintf("@%s Tou don't have permission to change the category.", user.DisplayName)) + } + } +} + +// check mod/streamer status +func isModOrStreamer(user twitch.User) bool { + _, isBroadcaster := user.Badges["broadcaster"] + _, isMod := user.Badges["moderator"] + return isBroadcaster || isMod +} + +// Twitch API helper functions in the twitchapi dir diff --git a/internal/chat/uptime.go b/internal/chat/uptime.go new file mode 100644 index 0000000..5dd4209 --- /dev/null +++ b/internal/chat/uptime.go @@ -0,0 +1,24 @@ +package chat + +import ( + "fmt" + "strings" + + twitch "github.com/gempir/go-twitch-irc/v4" + twitchapi "streambot_twitch/internal/twitchapi" +) + +func HandleUptimeCommand(client *twitch.Client, apiClient *twitchapi.Client, message twitch.PrivateMessage, user twitch.User, channel string) { + msg := strings.ToLower(message.Message) + if !strings.HasPrefix(msg, "!uptime") { + return + } + + uptime, err := apiClient.GetStreamUptime(channel) + if err != nil { + client.Say(channel, fmt.Sprintf("@%s Stream is currently offline or error occoured.", user.DisplayName)) + return + } + + client.Say(channel, fmt.Sprintf("@%s Stream has been live for %s", user.DisplayName, uptime)) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index d22a459..8adab94 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -37,7 +37,7 @@ const ( ) type CustomCommand struct { - Trigger string `json:"trigger"` + Triggers string `json:"triggers"` Reply string `json:"reply"` Permission PermissionLevel `json:"permission"` Match MatchType `json:"match"` diff --git a/internal/storage/default_commands.json b/internal/storage/default_commands.json new file mode 100644 index 0000000..5721ab9 --- /dev/null +++ b/internal/storage/default_commands.json @@ -0,0 +1,26 @@ +[ + { + "triggers": "!hola, !hi, !hello, !aloha, !gday, !kiaora, !howdy, !sup", + "reply": "Hello $user It's good to see you, Grab your Coke-a-Cola and Popcorn and Enjoy!", + "permission": "everyone", + "match": "prefix" + }, + { + "triggers": "!rules", + "reply": "Be Kind & respectful. Have Fun & Enjoy the stream!", + "permission": "everyone", + "match": "prefix" + }, + { + "triggers": "!nextSong", + "reply": "$user would like the current song skipped.", + "permission": "everyone", + "match": "prefix" + }, + { + "triggers": "AFOL, lego", + "reply": "We have another fan of lego in the chat! Please by all means build me something on your next stream or video.", + "permission": "everyone", + "match": "contains" + } +]