Compare commits

..

7 Commits

Author SHA1 Message Date
bryce
963e829b7f working on botTokens 2025-07-22 11:33:24 +12:00
bryce
b0be1f99c8 Added !poker and auth entry point 2025-07-15 20:24:16 +12:00
bryce
049051a7e1 reformat Readme.md
split the "commands it comes with" sections into their own group /
permissions
2025-07-15 14:35:45 +12:00
bryce
d67c30d8f4 Readme.md update
added more commands to the suite that it comes with
- streamer / Broadcaster cmds
- VIP cmds
- Moderator cmds
- everyone cmds

included several mini games
- dice
- roulette
- ispy
- poker
- 8ball

added a points system
2025-07-15 14:31:10 +12:00
bryce
0ef35de85b Readme.md Reformatting
this only affects the CMDli section
2025-07-15 11:38:18 +12:00
bryce
e6e6de0381 Update Readme.md and work on the CLI
Readme.md update - added cli usage section
CLI work continues (not finished)
2025-07-15 11:35:58 +12:00
bryce
c704380ee3 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)
2025-07-15 01:08:12 +12:00
15 changed files with 711 additions and 27 deletions

View File

@@ -6,33 +6,106 @@ This bot is a CMDli or Terminal application that should run on most systems.
The Theory is this should be a Light Weight application and to that point, it can only handle Text based (Chat) commands.
>[!NOTE]
>While there is a command response for greetings, the bot should greet every user upon FirstWords (of the stream).
>The bot sohuld also NOT greet userNames that are removed from chat via WizeBot.
>Key for entire document:
>[required] - you must use one of these
>
><optional> - this only needed with certain [required]
## The Commands it comes with:
>### CMDli interface / usage
> $twitchbot will start the bot and show bot:>
>
> the options you can use are:
>
> bot:>title [get|set] <newTitle> - view the Current Title or set a new one
>
> bot:>category [get|set] <category> - view the Current Category or set a new one
>
> bot:>list [categories|cmd] <permLevel> - catergories = view a list of categories from twitch | cmd <permLevel> = shows url for the commands list for that permission level or all commands if <premLevel> isn't specified
>
> bot:>stats [--watch | --interval=2m] - watch command usage & how many times the commands have been used in the lifetime of the bot
>
> bot:>uptime - shows your current stream uptime
>
> bot:>cmd [add|del|edit] [trigger] <response> (response only required if adding or editing a command)
>
> bot:>notif [get|set] <newMessage> - view or set the notification message
>[!NOTE]
>< > = optional Input | [ ] = Required Options
>While there is a command response for greetings, the bot should greet every user upon FirstWords (of the stream).
>
>The bot sohuld also NOT greet userNames that are removed from chat via WizeBot.
> ### MOD COMMANDS ONLY
## The Commands it comes with:
> ### Broadcaster Commands (chat)
> @bot [mute|unmute]
>
> !fav [cmd|user] <trigger|username> - Username HAS to be in chat during the current stream
>
> ^rec [name - start|end] - posts an Announcement (ORANGE) to Chat reading <"Recording Started (Ended) at [timestamp] [name]>" and sets a stream marker with the title
>
> !upcoming [event details] - set or view a message with details of the next event happening on the channel
>
> !countdown or !timer [duration (HH:mm)] - set a timer to countdown for the set duration (updates every 5mins if time is over 30mins; then 3mins if time is 30mins or less; then 1min if time is 15mins or less; then 30sec from 5mins until 00:00)
> ### VIP Commands incl. Artist
> !quoteme [quote] - saves a quote and can recall a random quote or a specific quote using [!quoteme #00000]
>
> !lfg - posts a message letting chat know you are pumped and ready to rock 'n roll
>
> !ispy - starts a random VIP only game of the classic I-spy (rounds last 3mins)
> [VIPs use !guess [thing|emote|word] to guess (1st correct answer wins 100 points)]
> ### MOD COMMANDS
> !title <New Stream Title> - if no Input then it will post the current title to the chat
>
> !category or !game <Twitch Category>
>
> !so [@ChatName] - shoutouts given to the channel (this has a 1hr cooldown per @chatName - the file is "so_cooldown.json")
>
> !tso [@ChatName] - uses the twitch "/shoutout" to shout out a user - Cooldowns are managed by Twitch.tv (Can also be triggered by using "/shoutout in chat")
>
> !cmd [add | del | edit | pause | resume] [!commandName] <command response message> - Manage or Make a command (new commands will be marked with * in the list and have the Mod's Name (for streamer's reference))
>
> !note [mods|caster] [message] - send Broadcaster (caster) or the other Mods a quick note in the respective private discord|whatsapp|element(matrix)|whisper or other msg channels
>
> !clip - creates a 30sec clip of what you just saw and posts it to #twitch-clips in discord
>
> !flag [username] - flag a user as suspicious (self deletes) reply = @user your wish has been carried out with precision
>
> !unflag [username] - remove flag from username (self deletes) reply = same as !flag
>
> !to or !timeout [user] [duration] - timeout a user in chat
>
> !cancel [to|timeout] [user] - cancel a timeout for user
>
> !warn [user] [reason] - give a warning to a user (logs # of warnings user recieved)
>
> !ban [user] [reason] - ban a user from chat - PLS leave a reason damnit
>
> !unban [user] [reason] - unban a user - reason is mandatory here
> ### ALL USERS
> !hi, !hello, !gday, !hola, !kiaora <@ChatName> - if no chatName is given the bot will greet the user who used the command
>
> !uptime - How Long has {$streamerName} been Live?
>
> !nextSong - request for the song to be skipped (if BG music is playing) this one plays a sound to alert the streamer as the bot doesn't have spotify integration (maybe in the future)
> !nextSong - request for the song to be skipped (if BG music is playing) - sends chat Message - the bot doesn't have spotify integration (maybe in the future)
>
> !AFOL - ID yourself as an AFOL (Adult Fan Of Lego) - this list will be saved to "AFOL.txt" for OBS / other reasons
>
> !countdown [MM:ss] - starts a countdown timer in chat (updated every 30 seconds via New Message) for a max of 60 Minutes (1H:00M) - Channel Announcement in Orange once timer is up.
>
> !cough [1-10/10]- you heard {$streamerName} cough on open mic ... Rate it 1 - 10 / 10 (anything over 10 is a coughing fit)
>
> !hug [user] - give another viewer a hug
>
> !dice or !roll - rolls a 6-sided dice gives random number between 1 & 6
>
> !8ball [question] - ask 8ball a question and recieve a totally random answer
>
> !roulette or !spin [points] [color] [number] - spin the roulette wheel & 2x your bet if your color and number are correct
>
> !slots or !pull [points] - spin the slot machine - 3 of a kind wins up to 5x your bet
>
> !poker or !table [points] - be the closest to 21 and win 2x your bet
>
> !points - check how many points you have [useful for !dice, !roll, !roulette, !spin, !slots, !pull, !poker or !table]

View File

@@ -1,12 +1,13 @@
package main
package main
import (
"log"
"os"
"streambot_twitch/internal/auth"
auth "streambot_twitch/internal/auth"
"streambot_twitch/internal/chat"
"streambot_twitch/internal/commands"
"streambot_twitch/internal/points"
twitchapi "streambot_twitch/internal/twitchapi"
tests "streambot_twitch/internal/tests"
twitch "github.com/gempir/go-twitch-irc/v4"
@@ -14,21 +15,26 @@ import (
func main() {
go func() {
if err := oauth.StartOAuthServer(":8080"); err != nil {
if err := auth.StartOAuthServer(":8080"); err != nil {
log.Fatalf("OAuth server error: %v", err)
}
}()
_ = godotenv.Load()
log.Println("OAuth server started on port 8080")
botUsername := os.Getenv("nz_chatterbot")
botOAuth :+ os.Getenv("BOT_OAUTH")
channel := os Getenv("brycefromnz101")
clientID := os.Getenv("TWITCH_CLIENTID")
clientSecret := os.Getenv("TWITCH_CLIENT_SECRET")
botUsername := os.Getenv("BOT_USERNAME")
botAccess := os.Getenv("BOT_ACCESS_TOKEN")
botRefresh := os.Getenv("BOT_REFRESH_TOKEN")
channel := os Getenv("CHANNEL")
clientID := os.Getenv("TWITCH_CLIENT_ID")
clientSecret := os.Getenv("TWITCH_CLIENT_SECRET")
channelAccess := os.Getenv("STREAMER_ACCESS_TOKEN")
channelRefresh := os.Getenv("STREAMER_REFRESH_TOKEN")
botChannel := botUsername
if botUsername == "" || botOAuth == "" || channel == "" || clientID == "" || clientSecret == "" {
if botUsername == "" || botAccess == "" || channel == "" || clientID == "" || clientSecret == "" {
log.Fatal("Please set Bot_Username, Bot_OAuth, Channel, Twitch_Client_ID, and Twitch_Client_Secret env vars")
}
@@ -52,6 +58,14 @@ log.Println("OAuth server started on port 8080")
log.Println("All startup tests passed. Starting bot. . .")
// END of TESTS
// load chat points
if err := points.LoadPoints(); err != nil {
log.Fatalf("Failed to load points: %v", err)
}
// make sure points are saved on exit
defer points.SavePoints()
// load custom commands
if err := commands.LoadCommands(); err != nil {
log.Fatalf("Failed to Load Custom Commands: %v", err)

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

@@ -0,0 +1,100 @@
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":
if len(args)<2 { fmt.Println("usage: title get|set <value>"); return }
sub := strings.ToLower(args[1])
switch sub {
case "get":
}
}
}

13
cmd/oauth/main.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"log"
auth "streambot_twitch/internal/auth"
)
func main() {
if err := auth.StartOAuthServer(":8080"); err != nil {
log.Fatalf("OAuth server error: %v", err)
}
}

5
go.mod
View File

@@ -7,4 +7,7 @@ require (
github.com/nicklaw5/helix v1.25.0
)
require github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/joho/godotenv v1.5.1 // indirect
)

2
go.sum
View File

@@ -3,5 +3,7 @@ github.com/gempir/go-twitch-irc/v4 v4.2.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/j
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=

View File

@@ -1,4 +1,4 @@
package oauth
package auth
import (
"encoding/json"
@@ -39,6 +39,7 @@ func StartOAuthServer(addr string) error {
http.HandleFunc("/", handleIndex)
http.HandleFunc("/callback", handleCallback)
http.HandleFunc("/refresh", handleRefresh)
log.Printf("OAuth server listening on %s", addr)
return http.ListenAndServe(addr, nil)
@@ -46,7 +47,7 @@ func StartOAuthServer(addr string) error {
func handleIndex(w http.ResponseWriter, r *http.Request) {
authURL := fmt.Sprintf(
"https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
"https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&force_verify=true",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
url.QueryEscape(strings.Join(scopes, " ")),
@@ -77,10 +78,26 @@ func handleCallback(w http.ResponseWriter, r *http.Request) {
}
jsonData, _ := json.MarshalIndent(token, "", " ")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "application/ijson")
w.Write(jsonData)
}
func handleRefresh(w http.ResponseWriter, r *http.Request) {
refresh := r.URL.Query().Get("refresh_token")
if refresh == "" {
http.Error(w, "Missing refresh_token parameter", http.StatusBadRequest)
return
}
tr, err := ExchangeRefreshToken(refresh)
if err != nil {
http.Error(w, "Refresh failed: "+err.Error(), http.StatusInternalServerError)
return
}
data, _ := json.MarshalIndent(tr, "", " ")
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func exchangeCodeForToken(code string) (*TokenResponse, error) {
data := url.Values{}
data.Set("client_id", clientID)
@@ -110,3 +127,31 @@ func exchangeCodeForToken(code string) (*TokenResponse, error) {
return &tokenResp, nil
}
// refresh tokens programtically
func ExchangeRefreshToken(refreshToken string) (*TokenResponse, error) {
form := url.Values{}
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refreshToken)
resp, err := http.Post(
"https://id.twitch.tv/oauth2/token",
"application/x-www-form-urlencoded",
strings.NewReader(form.Encode()),
)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh failed: %s", resp.Status)
}
var tr TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
return &tr, nil
}

View File

@@ -91,13 +91,25 @@ func HandleMessage(client *twitch.Client, apiClient interface{}, message twitch.
return
}
// dispatch poker/table commands
if strings.HasPrefix(strings.ToLower(msg), "!poker") || strings.HasPrefix(strings.ToLower(msg), "!table") {
HandlePokerCommand(client, 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) {
// 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))

154
internal/chat/poker.go Normal file
View File

@@ -0,0 +1,154 @@
package chat
import (
"fmt"
"math/rand"
"strconv"
"strings"
"time"
twitch "github.com/gempir/go-twitch-irc/v4"
"streambot_twitch/internal/points"
)
// card
type Card struct {
Rank, Suit string
}
var (
suits = []string{" of Hearts", " of Diamnods", " of Clubs", " of Spades"}
ranks = []string{"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"}
baseDeck []Card
)
func init() {
// build 2 decks (no Jokers) 104 cards
for d := 0; d < 2; d++ {
for _, s := range suits {
for _, r := range ranks {
baseDeck = append(baseDeck, Card{Rank: r, Suit: s})
}
}
}
rand.Seed(time.Now().UnixNano())
}
// impliment !poker or !table <bet>
func HandlePokerCommand(
client *twitch.Client,
message twitch.PrivateMessage,
user twitch.User,
channel, msg string,
) {
parts := strings.Fields(msg)
if len(parts) < 2 {
client.Say(channel, fmt.Sprintf("@%s Usage: !poker <bet>", user.DisplayName))
return
}
bet, err := strconv.Atoi(parts[1])
if err != nil || bet <= 0 {
client.Say(channel, fmt.Sprintf("@%s Invalid bet amount", user.DisplayName))
return
}
login := strings.ToLower(user.Name)
// load or init user points
balance := points.GetPoints(login)
if bet > ballance {
client.Say(channel, fmt.Sprintf("@%s You have %d points, bet lower", user.DisplayName, balance))
return
}
// Deduct the bet
balance, err = points.AddPoints(login, -bet)
if err != nil {
client.Say(channel, fmt.Sprintf("@%s Error updating points: %v", user.DisplayName, err))
return
}
// shuffle deck
deck := make([]Card, len(baseDeck))
copy(deck, baseDeck)
rand.Shuffle(len(deck), func(i, j int) {deck[i], deck[j] = deck[j], deck[i]})
// deal 2 cards each
player := []Card{deck[0], deck[1]}
dealer := []Card{deck[2], deck[3]}
// compute hand values
pv := handValue(player)
dv := handValue(dealer)
// Determine outcome and payout multiplier
var net int
var outcome string
switch {
case pv > 21:
outcome = "FBPenalty Bust! You lose"
net = 0
case pv == 21:
outcome = "BLACKJACK! GoatEmotey"
net = bet * 2
case dv > 21:
outcome = "GoldPLZ Dealer bust! You win"
net = int(float64(bet) * 1.5)
case pv > dv:
outcome = "OhMyDog You Win"
net int(float64(bet) * 1.3)
case pv < dv:
outcome = "cmonBruh You lose"
net = 0
default:
outcome = "PunchTrees Push"
net = bet
}
// Add winnings
balance, err = points.AddPoints(login, net)
if err != nil {
client.Say(channel, fmt.Sprintf("@%s Error updating Points: %v", user.DisplayName, err))
return
}
// format a hand for display
fmtHand := func(h []Card) string {
var parts []string
for _, c := range h {
parts = append(parts, c.Rank+c.Suit)
}
return strings.Join(parts, ",")
}
// post result
client.Say(channel, fmt.Sprintf("@%s You: [%s]=%d Dealer: [%s]=%d => %s | Bet: %d Net: %d Ballance: %d", user.DisplayName,
fmtHand(player), pv,
fmtHand(dealer), dv,
outcome,
bet, net, balance,
))
}
// compute BlackJack style value (A = 11 or 1)
func handValue(hand []Card) int {
total := 0
aces := 0
for _, c := range hand {
switch c.Rank {
case "J", "Q", "K":
total += 10
case "A":
total += 11
aces++
default:
v, _ := strconv.Atoi(c.Rank)
total += v
}
}
// convert A from 11 to 1 while busting
for total > 21 && aces > 0 {
total -= 10
aces--
}
return total
}

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
}

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

@@ -0,0 +1,84 @@
package points
import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
)
const (
dataDir = "internal/storage"
pointsFile = "points.json"
)
var (
mu sync.Mutex
pts map[string]int
)
const DefaultPoints = 100000
// init or load points from disk
func LoadPoints() error {
mu.Lock()
defer mu.Unlock()
pts = make(map[string]int)
path := filepath.Join(dataDir, pointsFile)
data, err := ioutil.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, &pts)
}
// save balances to disk
func SavePoints() error {
mu.Lock()
data, err := json.MarshalIndent(pts, "", " ")
mu.Unlock()
if err != nil {
return err
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
return ioutil.WriteFile(filepath.Join(dataDir, pointsFile), data, 0644)
}
// return point balance for user, init if needed
func GetPoints(user string) int {
mu.Lock()
defer mu.Unlock()
key := strings.ToLower(user)
bal, ok := pts[key]
if !ok {
pts[key] = DefaultPoints
bal = DefaultPoints
}
return bal
}
// add points to user's bal, save & return new bal
func AddPoints(user string, delta int) (int error) {
mu.Lock()
key := strings.ToLower(user)
bal, ok := pts[key]
if !ok {
bal = DefaultPoints
}
bal += delta
pts[key] = bal
mu.Unlock()
if err := SavePoints(); err != nil {
return bal, err
}
return bal, nil
}

View File

View File

View File

@@ -5,10 +5,12 @@ import (
"time"
helix "github.com/nicklaw5/helix"
oauthsrv "streambot_twitch/internal/auth"
)
type Client struct {
api *helix.Client
refreshToken string
}
func NewClient(clientID, clientSecret string) (*Client, error) {
@@ -29,6 +31,29 @@ func NewClient(clientID, clientSecret string) (*Client, error) {
return &Client{api: apiClient}, nil
}
// set User Tokens - Oauth and Refresh
func (c *Client) SetUserTokens(accessToken, refreshToken string, expiresIn int) {
at := strings.TrimPrefix(accessToken, "oauth:")
c.api.SetUserAccessToken(at)
c.accessToken = at
c.refreshToken = refreshToken
if expiresIn > 0 {
c.expiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
}
// use /refresh endpoint to renew tokens
func (c *Client) RefreshIfNeeded() error {
if time.Until(c.expiry) < 5*time.Minute {
tr, err := oauthsrv.ExchangeRefreshToken(c.refreshToken)
if err 1= nil {
return err
}
c.SetUserTokens(tr.AccessToken, tr.RefreshToken, tr.ExpiresIn)
}
return nil
}
// Twitch API helpers
func (c *Client) GetUserID(login string) (string, error) {
@@ -118,25 +143,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 {