Files
hbot/hbot.go
2026-02-06 09:51:31 -03:00

333 lines
8.6 KiB
Go

// Package hbot is IRCv3 enabled framework for writing IRC bots
package hbot
import (
"bufio"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
)
// Bot implements an irc bot to be connected to a given server
type Bot struct {
config Config
con net.Conn
outgoing chan string
handlers []Handler
mu sync.Mutex
joinOnce sync.Once
closeOnce sync.Once
wg sync.WaitGroup
capHandler ircCaps
prefix Prefix
prefixMu sync.RWMutex
joined chan struct{}
}
type Logger struct {
Verbosef func(format string, args ...interface{})
Errorf func(format string, args ...interface{})
}
func DiscardLogf(format string, args ...interface{}) {}
type TLSKnob int
const (
AutoTLS TLSKnob = iota
YesTLS
NoTLS
)
type Config struct {
Host string
Nick string
Realname string
User string
Password string
Channels []string
SASL bool
NickServ bool
MsgSafetyBuffer bool // Set it if long messages get truncated on the receiving end
Dial func(network, addr string) (net.Conn, error) // An optional custom function for connecting to the server
ThrottleDelay time.Duration // Duration to wait between sending of messages to avoid being kicked by the server for flooding (default 200ms)
PingTimeout time.Duration // Maximum time between incoming data
UseTLS TLSKnob
TLSConfig tls.Config
Logger Logger
}
// CommonBotUserPrefix may be optionally used as a user prefix or realname to identify as a bot.
// e.g. `/mode #CHANNEL +q $~x:*!~botsan-*@*` or `/mode #CHANNEL +q $~r:botsan-*`.
const CommonBotUserPrefix = "botsan-"
// NewBot creates a new instance of Bot
func NewBot(conf *Config) *Bot {
bot := Bot{config: *conf, joined: make(chan struct{})}
if bot.config.ThrottleDelay == 0 {
bot.config.ThrottleDelay = 200 * time.Millisecond
}
if bot.config.PingTimeout == 0 {
bot.config.PingTimeout = 300 * time.Second
}
if bot.config.Logger.Verbosef == nil {
bot.config.Logger.Verbosef = DiscardLogf
}
if bot.config.Logger.Errorf == nil {
bot.config.Logger.Errorf = DiscardLogf
}
if len(bot.config.Realname) == 0 {
bot.config.Realname = bot.config.Nick
}
if len(bot.config.User) == 0 {
bot.config.User = bot.config.Nick
}
if len(bot.config.Nick) > 16 {
bot.config.Nick = bot.config.Nick[:16]
}
bot.AddTrigger(pingPong)
bot.AddTrigger(joinChannels)
bot.AddTrigger(getPrefix)
bot.AddTrigger(setNick)
bot.AddTrigger(nickError)
bot.AddTrigger(&bot.capHandler)
bot.AddTrigger(saslFail)
bot.AddTrigger(saslSuccess)
bot.AddTrigger(errorLogger)
return &bot
}
// Joined returns a channel that closes after channels are joined
func (bot *Bot) Joined() <-chan struct{} {
bot.mu.Lock()
defer bot.mu.Unlock()
return bot.joined
}
// saslAuthenticate performs SASL authentication
// ref: https://github.com/atheme/charybdis/blob/master/doc/sasl.txt
func (bot *Bot) saslAuthenticate(user, pass string) {
bot.capHandler.saslEnable()
bot.capHandler.saslCreds(user, pass)
bot.config.Logger.Verbosef("Beginning sasl authentication")
bot.Send("CAP LS")
bot.sendUserCommand(bot.config.User, bot.config.Realname, "0")
bot.SetNick(bot.config.Nick)
}
// standardRegistration performs a basic set of registration commands
func (bot *Bot) standardRegistration() {
bot.Send("CAP LS")
// Server registration
if bot.config.Password != "" {
bot.Send("PASS " + bot.config.Password)
}
bot.config.Logger.Verbosef("Sending standard registration")
bot.sendUserCommand(bot.config.User, bot.config.Realname, "0")
bot.SetNick(bot.config.Nick)
}
// nickservAuthenticate performs NickServ authentication
func (bot *Bot) nickservAuthenticate(pass string) {
bot.Send("CAP LS")
if bot.config.Password != "" {
bot.Send("PRIVMSG NickServ IDENTIFY " + bot.config.Password)
}
bot.config.Logger.Verbosef("Identifying with NickServ")
bot.sendUserCommand(bot.config.User, bot.config.Realname, "0")
bot.SetNick(bot.config.Nick)
}
// Set username, real name, and mode
func (bot *Bot) sendUserCommand(user, realname, mode string) {
bot.Send(fmt.Sprintf("USER %s %s * :%s", user, mode, realname))
}
func (bot *Bot) connect(host string) error {
bot.config.Logger.Verbosef("Connecting")
dial := bot.config.Dial
if dial == nil {
dial = net.Dial
}
var err error
bot.con, err = dial("tcp", host)
if err != nil {
return err
}
if bot.config.UseTLS == AutoTLS {
_, portStr, _ := net.SplitHostPort(host)
port, _ := strconv.Atoi(portStr)
switch port {
case 194:
// This was the original IRC port
// But nowadays it's not used because it is a privileged port
bot.config.UseTLS = NoTLS
case 6667:
bot.config.UseTLS = NoTLS
case 6697:
bot.config.UseTLS = YesTLS
default:
bot.config.Logger.Errorf("Warning: port is neither the standard TCP port (6667) nor the standard TLS port (6697); falling back to TCP")
bot.config.UseTLS = NoTLS
}
}
if bot.config.UseTLS == YesTLS {
if len(bot.config.TLSConfig.ServerName) == 0 {
hostname, _, err := net.SplitHostPort(host)
if err != nil {
return err
}
bot.config.TLSConfig.ServerName = hostname
}
bot.con = tls.Client(bot.con, &bot.config.TLSConfig)
}
return nil
}
// Incoming message gathering routine
func (bot *Bot) handleIncomingMessages() {
defer bot.wg.Done()
scan := bufio.NewScanner(bot.con)
for scan.Scan() {
// Disconnect if we have seen absolutely nothing for 300 seconds
bot.con.SetDeadline(time.Now().Add(bot.config.PingTimeout))
text := scan.Text()
msg := ParseMessage(text)
go func() {
for _, h := range bot.handlers {
go h.Handle(bot, msg)
}
}()
}
bot.close("incoming", scan.Err())
}
// Handles message speed throtling
func (bot *Bot) handleOutgoingMessages() {
defer bot.wg.Done()
for s := range bot.outgoing {
_, err := fmt.Fprint(bot.con, s+"\r\n")
if err != nil {
bot.close("outgoing", err)
return
}
time.Sleep(bot.config.ThrottleDelay)
}
}
// Run starts the bot and connects to the server. Blocks until we disconnect from the server.
func (bot *Bot) Run() {
bot.mu.Lock()
bot.joinOnce = sync.Once{}
bot.closeOnce = sync.Once{}
bot.wg = sync.WaitGroup{}
bot.capHandler.reset()
bot.outgoing = make(chan string, 128)
bot.prefix = Prefix{
Name: bot.config.Nick,
User: bot.config.Nick,
Host: strings.Repeat("*", 510-353-len(bot.config.Nick)*2),
}
bot.mu.Unlock()
// Attempt reconnection
err := bot.connect(bot.config.Host)
if err != nil {
bot.config.Logger.Errorf("Connection error: %v", err)
return
}
bot.config.Logger.Verbosef("Connected")
bot.wg.Add(2)
go bot.handleIncomingMessages()
go bot.handleOutgoingMessages()
if bot.config.SASL {
bot.saslAuthenticate(bot.config.Nick, bot.config.Password)
} else if bot.config.NickServ {
bot.nickservAuthenticate(bot.config.Password)
} else {
bot.standardRegistration()
}
bot.wg.Wait()
bot.mu.Lock()
bot.joined = make(chan struct{})
bot.mu.Unlock()
bot.config.Logger.Verbosef("Disconnected")
}
// CapStatus returns whether the server capability is enabled and present
func (bot *Bot) CapStatus(cap string) (enabled, present bool) {
bot.capHandler.mu.Lock()
defer bot.capHandler.mu.Unlock()
if v, ok := bot.capHandler.capsEnabled[cap]; ok {
return v, true
}
return false, false
}
func (bot *Bot) close(fault string, err error) {
bot.closeOnce.Do(func() {
if err != nil {
bot.config.Logger.Errorf("Closing from %s: %v", fault, err)
}
bot.con.Close()
select {
case bot.outgoing <- "PING":
default:
}
})
}
// Close closes the bot
func (bot *Bot) Close() {
bot.close("", nil)
}
// Prefix returns the bot's prefix.
func (bot *Bot) Prefix() Prefix {
bot.mu.Lock()
defer bot.mu.Unlock()
return bot.prefix
}
// Nick returns the bot's nick.
func (bot *Bot) Nick() string {
bot.mu.Lock()
defer bot.mu.Unlock()
return bot.config.Nick
}
// Handler is used to subscribe and react to events on the bot Server
type Handler interface {
Handle(*Bot, *Message)
}
// Trigger is a Handler which is guarded by a condition.
// DO NOT alter *Message in your triggers or you'll have strange things happen.
type Trigger struct {
// Returns true if this trigger applies to the passed in message
Condition func(*Bot, *Message) bool
// The action to perform if Condition is true
Action func(*Bot, *Message)
}
// AddTrigger adds a trigger to the bot's handlers
func (bot *Bot) AddTrigger(h Handler) {
bot.handlers = append(bot.handlers, h)
}
// Handle executes the trigger action if the condition is satisfied
func (t Trigger) Handle(bot *Bot, m *Message) {
if t.Condition(bot, m) {
t.Action(bot, m)
}
}