From c7b9e661f5557347faa949c99d92a30edab90d62 Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Fri, 13 Jun 2025 19:43:16 -0500 Subject: [PATCH] first commit --- README.md | 90 +++++++++++++++++++++++++++ cmd/main.go | 41 ++++++++++++ go.mod | 3 + internal/clients/bot.go | 127 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 46 ++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 internal/clients/bot.go create mode 100644 internal/config/config.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb833bf --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Twitch Fish Bot + +A Go bot that automatically sends `!fish` to a Twitch channel every 5 minutes. + +## Project Structure + +``` +twitch-fish-bot/ +├── cmd/ +│ └── main.go +├── internal/ +│ ├── clients/ +│ │ └── bot.go +│ └── config/ +│ └── config.go +├── go.mod +└── README.md +``` + +## Setup Instructions + +### Create the project directory: + +```bash +mkdir twitch-fish-bot +cd twitch-fish-bot +``` + +### Create the directory structure: + +```bash +mkdir -p cmd internal/clients internal/config +``` + +### Create each file with the content provided in the artifacts + +### Initialize Go module: + +```bash +go mod init twitch-fish-bot +``` + +### Update configuration: + +Edit `internal/config/config.go` + +Replace the placeholder values with your actual: +- Bot username +- OAuth token (from [twitchtokengenerator.com](https://twitchtokengenerator.com/)) +- Client ID (from [twitchtokengenerator.com](https://twitchtokengenerator.com/) - required as of May 1st) +- Target channel name + +### Run the bot: + +```bash +go run cmd/main.go +``` + +## Getting Credentials + +1. Go to [twitchtokengenerator.com](https://twitchtokengenerator.com/) +2. Select "Bot Chat Token" +3. Get both the OAuth Token and Client ID from the generated output +4. Update `internal/config/config.go` with these values + +## Features + +- Clean architecture with separated concerns +- Graceful shutdown with Ctrl+C handling +- Proper logging with timestamps +- Connection management with automatic PING/PONG handling +- Error handling throughout the application +- Client ID support for Helix API compliance +- Configurable through the config package + +## Usage + +The bot will: +- Connect to Twitch IRC +- Join the specified channel +- Send `!fish` immediately after joining +- Continue sending `!fish` every 5 minutes +- Run until stopped with Ctrl+C + +## Notes + +- Make sure your bot account isn't banned from the target channel +- Consider asking the streamer for permission before running automated bots +- The bot logs all chat messages - remove this if you prefer privacy + diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..8c48620 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "twitch-fish-bot/internal/clients" + "twitch-fish-bot/internal/config" +) + +func main() { + // Load configuration + cfg := config.Load() + + // Create and start the bot + bot := clients.NewTwitchBot(cfg.Username, cfg.OAuth, cfg.ClientID, cfg.Channel) + + // Connect to Twitch + if err := bot.Connect(); err != nil { + log.Fatalf("Failed to connect: %v", err) + } + defer bot.Close() + + // Start message handler in goroutine + go bot.HandleMessages() + + // Start the fish timer in goroutine + go bot.StartFishTimer() + + log.Printf("Bot started successfully! Sending !fish every 5 minutes to #%s", cfg.Channel) + log.Println("Press Ctrl+C to stop the bot") + + // Wait for interrupt signal to gracefully shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + + log.Println("Shutting down bot...") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..17e31fb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module twitch-fish-bot + +go 1.24.4 diff --git a/internal/clients/bot.go b/internal/clients/bot.go new file mode 100644 index 0000000..c83d48c --- /dev/null +++ b/internal/clients/bot.go @@ -0,0 +1,127 @@ +package clients + +import ( + "bufio" + "fmt" + "log" + "net" + "net/textproto" + "strings" + "time" +) + +type TwitchBot struct { + conn net.Conn + reader *textproto.Reader + username string + oauth string + clientID string + channel string +} + +func NewTwitchBot(username, oauth, clientID, channel string) *TwitchBot { + return &TwitchBot{ + username: username, + oauth: oauth, + clientID: clientID, + channel: channel, + } +} + +func (bot *TwitchBot) Connect() error { + var err error + bot.conn, err = net.Dial("tcp", "irc.chat.twitch.tv:6667") + if err != nil { + return fmt.Errorf("failed to connect to Twitch IRC: %v", err) + } + + bot.reader = textproto.NewReader(bufio.NewReader(bot.conn)) + + // Authenticate with IRC (Client ID not needed for IRC connection) + fmt.Fprintf(bot.conn, "PASS %s\r\n", bot.oauth) + fmt.Fprintf(bot.conn, "NICK %s\r\n", bot.username) + fmt.Fprintf(bot.conn, "CAP REQ :twitch.tv/membership\r\n") + fmt.Fprintf(bot.conn, "CAP REQ :twitch.tv/tags\r\n") + fmt.Fprintf(bot.conn, "CAP REQ :twitch.tv/commands\r\n") + + // Join channel + fmt.Fprintf(bot.conn, "JOIN #%s\r\n", bot.channel) + + log.Printf("Connected to Twitch IRC and joined #%s", bot.channel) + log.Printf("Client ID ready for Helix API calls: %s", bot.clientID[:8]+"...") + return nil +} + +func (bot *TwitchBot) SendMessage(message string) error { + _, err := fmt.Fprintf(bot.conn, "PRIVMSG #%s :%s\r\n", bot.channel, message) + if err != nil { + return fmt.Errorf("failed to send message: %v", err) + } + log.Printf("Sent message: %s", message) + return nil +} + +// GetClientID returns the client ID for potential Helix API usage +func (bot *TwitchBot) GetClientID() string { + return bot.clientID +} + +// GetOAuthToken returns the OAuth token (without oauth: prefix) for Helix API usage +func (bot *TwitchBot) GetOAuthToken() string { + // Remove oauth: prefix for API calls + return strings.TrimPrefix(bot.oauth, "oauth:") +} + +func (bot *TwitchBot) HandleMessages() { + for { + line, err := bot.reader.ReadLine() + if err != nil { + log.Printf("Error reading from connection: %v", err) + break + } + + // Handle PING messages to keep connection alive + if strings.HasPrefix(line, "PING") { + pong := strings.Replace(line, "PING", "PONG", 1) + fmt.Fprintf(bot.conn, "%s\r\n", pong) + continue + } + + // Log incoming messages (optional - remove if you don't want to see chat) + if strings.Contains(line, "PRIVMSG") { + // Parse username from message for cleaner logging + if idx := strings.Index(line, "!"); idx != -1 { + username := line[1:idx] + if msgIdx := strings.Index(line, " :"); msgIdx != -1 { + message := line[msgIdx+2:] + log.Printf("[%s]: %s", username, message) + } + } + } + } +} + +func (bot *TwitchBot) StartFishTimer() { + // Send initial !fish message after a short delay + time.Sleep(2 * time.Second) + if err := bot.SendMessage("!fish"); err != nil { + log.Printf("Error sending initial !fish: %v", err) + } + + // Set up timer for every 5 minutes + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + if err := bot.SendMessage("!fish"); err != nil { + log.Printf("Error sending !fish: %v", err) + } + } +} + +func (bot *TwitchBot) Close() { + if bot.conn != nil { + log.Println("Closing connection to Twitch IRC") + bot.conn.Close() + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d92d2cd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "log" + "strings" +) + +type Config struct { + Username string + OAuth string + ClientID string + Channel string +} + +func Load() *Config { + // TODO: Replace these with your actual values + cfg := &Config{ + Username: "", // Your Twitch bot username + OAuth: "", // Get from https://twitchtokengenerator.com/ + ClientID: "", // Get from https://twitchtokengenerator.com/ (new field) + Channel: "", // Channel to join (without #) + } + + // Validate configuration + if cfg.Username == "" { + log.Fatal("Username cannot be empty") + } + if cfg.OAuth == "" { + log.Fatal("OAuth token cannot be empty") + } + if cfg.ClientID == "" { + log.Fatal("Client ID cannot be empty - required for Helix API calls as of May 1st") + } + if cfg.Channel == "" { + log.Fatal("Channel cannot be empty") + } + + // Ensure OAuth token has proper format for IRC + if !strings.HasPrefix(cfg.OAuth, "oauth:") { + cfg.OAuth = "oauth:" + cfg.OAuth + } + + log.Printf("Loaded config for user: %s, channel: #%s", cfg.Username, cfg.Channel) + log.Printf("Client ID configured: %s", cfg.ClientID[:8]+"...") // Only show first 8 chars for security + return cfg +}