Files
parasocial/internal/irc/client.go
2026-07-18 00:17:39 +00:00

219 lines
5.0 KiB
Go

// Package irc manages Twitch IRC chat connections for watched streamers.
package irc
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
)
const DefaultAddr = "irc.chat.twitch.tv:6667"
// ConnectionState describes the lifecycle state of one IRC connection.
type ConnectionState string
const (
StatePending ConnectionState = "pending"
StateJoined ConnectionState = "joined"
StateDisconnected ConnectionState = "disconnected"
)
// Event carries one IRC lifecycle change or log line for a streamer connection.
type Event struct {
Streamer string
State ConnectionState
Line string
}
// EventSink consumes IRC events emitted by a client or manager.
type EventSink func(Event)
// Client maintains one Twitch IRC connection for a single streamer channel.
type Client struct {
Addr string
Login string
Token string
Streamer string
Once bool
Events EventSink
DialContext func(context.Context, string, string) (net.Conn, error)
}
// Run connects, authenticates, joins the configured streamer channel, and stays connected.
func (c *Client) Run(ctx context.Context) (err error) {
if c.Login == "" {
return errors.New("missing Twitch login")
}
if c.Token == "" {
return errors.New("missing OAuth access token")
}
if c.Streamer == "" {
return errors.New("missing streamer")
}
defer func() {
if ctx.Err() != nil {
err = nil
}
}()
addr := c.Addr
if addr == "" {
addr = DefaultAddr
}
conn, err := c.dial(ctx, addr)
if err != nil {
return fmt.Errorf("connect to Twitch IRC: %w", err)
}
session := &session{conn: conn}
defer session.close()
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
_ = session.send("QUIT :parasocial shutting down")
session.close()
case <-done:
}
}()
if err := session.send("PASS oauth:" + c.Token); err != nil {
return err
}
if err := session.send("NICK " + c.Login); err != nil {
return err
}
if err := session.send("JOIN #" + c.Streamer); err != nil {
return err
}
reader := bufio.NewReader(conn)
joined := false
for {
line, err := reader.ReadString('\n')
if err != nil {
if ctx.Err() != nil {
return nil
}
if joined {
return fmt.Errorf("network interruption after join: %w", err)
}
return fmt.Errorf("IRC connection closed before join confirmation: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if payload, ok := pingPayload(line); ok {
if err := session.send("PONG " + payload); err != nil {
return err
}
continue
}
if isAuthFailure(line) {
return fmt.Errorf("IRC authentication failure: %s", line)
}
if isJoinDenied(line, c.Streamer) {
return fmt.Errorf("IRC join denied: %s", line)
}
if isWelcome(line) {
continue
}
if isJoinConfirmation(line, c.Login, c.Streamer) {
joinedLine := fmt.Sprintf("Joined #%s as %s", c.Streamer, c.Login)
c.emit(StateJoined, joinedLine)
joined = true
if c.Once {
if err := session.send("QUIT :parasocial --once complete"); err != nil {
return err
}
return nil
}
continue
}
if joined {
c.emit("", line)
}
}
}
type session struct {
conn net.Conn
mu sync.Mutex
once sync.Once
}
func (s *session) send(line string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := fmt.Fprintf(s.conn, "%s\r\n", line)
return err
}
func (s *session) close() {
s.once.Do(func() {
_ = s.conn.SetDeadline(time.Now())
_ = s.conn.Close()
})
}
func (c *Client) dial(ctx context.Context, addr string) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, "tcp", addr)
}
dialer := &net.Dialer{Timeout: 30 * time.Second}
return dialer.DialContext(ctx, "tcp", addr)
}
func (c *Client) emit(state ConnectionState, line string) {
if c.Events == nil {
return
}
c.Events(Event{
Streamer: c.Streamer,
State: state,
Line: line,
})
}
func pingPayload(line string) (string, bool) {
if strings.HasPrefix(line, "PING ") {
return strings.TrimSpace(strings.TrimPrefix(line, "PING ")), true
}
return "", false
}
func isWelcome(line string) bool {
return strings.Contains(line, " 001 ")
}
func isAuthFailure(line string) bool {
lower := strings.ToLower(line)
return strings.Contains(lower, "login authentication failed") ||
strings.Contains(lower, "improperly formatted auth") ||
strings.Contains(lower, "error logging in")
}
func isJoinDenied(line, streamer string) bool {
lower := strings.ToLower(line)
channel := "#" + strings.ToLower(streamer)
return (strings.Contains(lower, " 403 ") && strings.Contains(lower, channel)) ||
(strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "banned")) ||
(strings.Contains(lower, "notice "+channel) && strings.Contains(lower, "not permitted"))
}
func isJoinConfirmation(line, login, streamer string) bool {
lower := strings.ToLower(line)
channel := "#" + strings.ToLower(streamer)
return strings.Contains(lower, " join "+channel) &&
(strings.HasPrefix(lower, ":"+strings.ToLower(login)+"!") || strings.Contains(lower, "!"))
}