first commit
This commit is contained in:
90
README.md
Normal file
90
README.md
Normal file
@ -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
|
||||||
|
|
41
cmd/main.go
Normal file
41
cmd/main.go
Normal file
@ -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...")
|
||||||
|
}
|
127
internal/clients/bot.go
Normal file
127
internal/clients/bot.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
46
internal/config/config.go
Normal file
46
internal/config/config.go
Normal file
@ -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
|
||||||
|
}
|
Reference in New Issue
Block a user