irc.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"context"
"crypto/tls"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"gopkg.in/irc.v4"
"github.com/Luzifer/twitch-bot/v3/pkg/event"
"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
type ircHandler struct {
c *irc.Client
conn *tls.Conn
ctx context.Context //nolint:containedctx // just stored internally
ctxCancelFn func()
user string
}
var (
rawMessageHandlers []plugins.RawMessageHandlerFunc
rawMessageHandlersLock sync.Mutex
)
func notifyRawMessageHandlers(m *irc.Message) error {
rawMessageHandlersLock.Lock()
defer rawMessageHandlersLock.Unlock()
for _, fn := range rawMessageHandlers {
if err := fn(m); err != nil {
return fmt.Errorf("executing raw message handlers: %w", err)
}
}
return nil
}
func registerRawMessageHandler(fn plugins.RawMessageHandlerFunc) error {
rawMessageHandlersLock.Lock()
defer rawMessageHandlersLock.Unlock()
rawMessageHandlers = append(rawMessageHandlers, fn)
return nil
}
func newIRCHandler() (*ircHandler, error) {
h := new(ircHandler)
_, username, err := twitchClient.GetAuthorizedUser(context.Background())
if err != nil {
return nil, fmt.Errorf("fetching username: %w", err)
}
ctx, cancel := context.WithCancel(context.Background()) //#nosec:G118 // Cancel is retained in the handler and called on constructor failure or handler shutdown
h.ctx, h.ctxCancelFn = ctx, cancel
handlerReady := false
defer func() {
if !handlerReady {
cancel()
}
}()
conn, err := tls.Dial("tcp", "irc.chat.twitch.tv:6697", nil) //nolint:noctx // Would use background context
if err != nil {
return nil, fmt.Errorf("connect to IRC server: %w", err)
}
token, err := twitchClient.GetToken(context.Background())
if err != nil {
return nil, fmt.Errorf("getting auth token: %w", err)
}
h.c = irc.NewClient(conn, irc.ClientConfig{
Nick: username,
Pass: strings.Join([]string{"oauth", token}, ":"),
User: username,
Name: username,
Handler: h,
SendLimit: cfg.IRCRateLimit,
SendBurst: 0, // Twitch uses a bucket system, we don't have anything to replicate that in this IRC client
})
h.conn = conn
h.user = username
handlerReady = true
return h, nil
}
func (i ircHandler) Client() *irc.Client { return i.c }
func (i ircHandler) Close() error {
i.ctxCancelFn()
return nil
}
func (i ircHandler) ExecuteJoins(channels []string) {
for _, ch := range channels {
_ = i.c.Write(fmt.Sprintf("JOIN #%s", strings.TrimLeft(ch, "#")))
}
}
func (i ircHandler) ExecutePart(channel string) {
_ = i.c.Write(fmt.Sprintf("PART #%s", strings.TrimLeft(channel, "#")))
}
//nolint:gocyclo // this is only a simple distribution-list without much logic
func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
// We've received a message, update status check
statusIRCMessageReceived = time.Now()
go func(m *irc.Message) {
configLock.RLock()
defer configLock.RUnlock()
if err := config.LogRawMessage(m); err != nil {
logrus.WithError(err).Error("Unable to log raw message")
}
}(m)
if m.Tags["source-room-id"] != "" && m.Tags["source-room-id"] != m.Tags["room-id"] {
// Message has its `source-room-id` set, which signals it
// originates from a shared chat. Additionally its `source-room-id`
// does not match the `room-id` which we are listening to. So we
// shouldn't care about handling that message in order to prevent
// false bit alerts or reactions to `!so` or other "shared" commands.
return
}
switch m.Command {
case "001":
// 001 is a welcome event, so we join channels there
_ = c.WriteMessage(&irc.Message{
Command: "CAP",
Params: []string{
"REQ",
strings.Join([]string{
"twitch.tv/commands",
"twitch.tv/membership",
"twitch.tv/tags",
}, " "),
},
})
go i.ExecuteJoins(config.Channels)
case "CLEARCHAT":
// CLEARCHAT (Twitch Commands)
// Purge a user’s messages, typically after a user is banned from
// chat or timed out.
i.handleClearChat(m)
case "CLEARMSG":
// CLEARMSG (Twitch Commands)
// Removes a single message from a channel. This is triggered by
// the/delete <target-msg-id> command on IRC.
i.handleClearMessage(m)
case "JOIN":
// JOIN (Default IRC Command)
// User enters the channel, might be triggered multiple times
// should not be used to greet users
i.handleJoin(m)
case "NOTICE":
// NOTICE (Twitch Commands)
// General notices from the server.
i.handleTwitchNotice(m)
case "PART":
// PART (Default IRC Command)
// User leaves the channel, might be triggered multiple times
i.handlePart(m)
case "PING":
// PING (Default IRC Command)
// Handled by the library, just here to prevent trace-logging every ping
case "PRIVMSG":
i.handleTwitchPrivmsg(m)
case "RECONNECT":
// RECONNECT (Twitch Commands)
// In this case, reconnect and rejoin channels that were on the connection, as you would normally.
logrus.Warn("We were asked to reconnect, closing connection")
if err := i.Close(); err != nil {
logrus.WithError(err).Error("closing IRC connection after reconnect")
}
case "USERNOTICE":
// USERNOTICE (Twitch Commands)
// Announces Twitch-specific events to the channel (for example, a user’s subscription notification).
i.handleTwitchUsernotice(m)
case "WHISPER":
// WHISPER (Twitch Commands)
// Delivers whisper-messages received
i.handleTwitchWhisper(m)
default:
logrus.WithFields(logrus.Fields{
"command": m.Command,
"tags": m.Tags,
"trailing": m.Trailing(),
}).Trace("Unhandled message")
// Unhandled message type, not yet needed
}
if err := notifyRawMessageHandlers(m); err != nil {
logrus.WithError(err).Error("Unable to notify raw message handlers")
}
}
func (i ircHandler) Run() error {
if err := i.c.RunContext(i.ctx); err != nil {
return fmt.Errorf("running IRC client: %w", err)
}
return nil
}
func (i ircHandler) SendMessage(m *irc.Message) (err error) {
if err = i.c.WriteMessage(m); err != nil {
return fmt.Errorf("writing message: %w", err)
}
return nil
}
func (ircHandler) getChannel(m *irc.Message) string {
if len(m.Params) > 0 {
return m.Params[0]
}
return ""
}
func (i ircHandler) handleClearChat(m *irc.Message) {
seconds, secondsErr := strconv.Atoi(m.Tags["ban-duration"])
targetUserID, hasTargetUserID := m.Tags["target-user-id"]
channel := i.getChannel(m) // Compatibility to plugins.DeriveChannel
var evt event.Event
switch {
case secondsErr == nil && hasTargetUserID:
// User & Duration = Timeout
evt = event.Timeout{
Channel: channel,
Duration: time.Duration(seconds) * time.Second,
Seconds: seconds,
TargetID: targetUserID,
TargetName: m.Trailing(),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User was timed out")
case hasTargetUserID:
// User w/o Duration = Ban
evt = event.Ban{
Channel: channel,
TargetID: targetUserID,
TargetName: m.Trailing(),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User was banned")
default:
// No User = /clear
evt = event.ClearChat{
Channel: channel,
}
logrus.WithFields(event.ToLogFields(evt)).Info("Chat was cleared")
}
go handleTypedMessage(i.c, m, evt)
}
func (i ircHandler) handleClearMessage(m *irc.Message) {
evt := event.ClearMessage{
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
MessageID: m.Tags["target-msg-id"],
TargetName: m.Tags["login"],
}
logrus.WithFields(event.ToLogFields(evt)).
WithField("message", m.Trailing()).
Info("Message was deleted")
go handleTypedMessage(i.c, m, evt)
}
func (i ircHandler) handleJoin(m *irc.Message) {
go handleTypedMessage(i.c, m, event.Join{
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
User: m.User, // Compatibility to plugins.DeriveUser
})
}
func (i ircHandler) handlePart(m *irc.Message) {
go handleTypedMessage(i.c, m, event.Part{
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
User: m.User, // Compatibility to plugins.DeriveUser
})
}
func (i ircHandler) handlePermit(m *irc.Message) {
badges := twitch.ParseBadgeLevels(m)
if !badges.Has(twitch.BadgeBroadcaster) && (!config.PermitAllowModerator || !badges.Has(twitch.BadgeModerator)) {
// Neither broadcaster nor moderator or moderator not permitted
return
}
msgParts := strings.Split(m.Trailing(), " ")
if len(msgParts) != 2 { //revive:disable-line:add-constant // This is not a magic number but just an expected count
return
}
username := msgParts[1]
evt := event.Permit{
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
To: username,
User: m.User, // Compatibility to plugins.DeriveUser
UserID: m.Tags["user-id"],
}
logrus.WithFields(event.ToLogFields(evt)).Debug("Added permit")
if err := timerService.AddPermit(m.Params[0], username); err != nil {
logrus.WithError(err).Error("adding permit")
}
go handleTypedMessage(i.c, m, evt)
}
func (i ircHandler) handleTwitchNotice(m *irc.Message) {
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"tags": m.Tags,
"trailing": m.Trailing(),
}).Trace("IRC NOTICE event")
switch m.Tags["msg-id"] {
case "":
// Notices SHOULD have msg-id tags...
logrus.WithField("msg", m).Warn("Received notice without msg-id")
default:
logrus.WithField("id", m.Tags["msg-id"]).Debug("unhandled notice received")
}
}
func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"name": m.Name,
eventFieldUserName: m.User,
eventFieldUserID: m.Tags["user-id"],
"tags": m.Tags,
"trailing": m.Trailing(),
}).Trace("Received privmsg")
if m.User != i.user {
// Count messages from other users than self
configLock.RLock()
for _, am := range config.AutoMessages {
am.CountMessage(m.Params[0])
}
configLock.RUnlock()
}
if strings.HasPrefix(m.Trailing(), "!permit") {
i.handlePermit(m)
return
}
if bits := i.tagToNumeric(m, "bits", 0); bits > 0 {
evt := event.Bits{
Bits: bits,
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
Message: m.Trailing(),
User: m.User, // Compatibility to plugins.DeriveUser
UserID: m.Tags["user-id"],
}
logrus.WithFields(event.ToLogFields(evt)).Info("User spent bits in chat message")
go handleTypedMessage(i.c, m, evt)
}
go handleTypedMessage(i.c, m, event.Message{})
}
//nolint:funlen // just a list of mappings
func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
logrus.WithFields(logrus.Fields{
eventFieldChannel: i.getChannel(m),
"tags": m.Tags,
"trailing": m.Trailing(),
}).Trace("IRC USERNOTICE event")
var (
baseChannel = event.BaseChannel{
Channel: i.getChannel(m), // Compatibility to plugins.DeriveChannel
}
baseUser = event.BaseUser{
User: m.Tags["login"], // Compatibility to plugins.DeriveUser
UserID: m.Tags["user-id"],
}
)
message := m.Trailing()
if message == i.getChannel(m) {
// If no message is given, Trailing yields the channel name
message = ""
}
switch m.Tags["msg-id"] {
case "":
// Notices SHOULD have msg-id tags...
logrus.WithField("msg", m).Warn("Received usernotice without msg-id")
case "announcement":
evt := event.Announcement{
BaseChannel: baseChannel,
BaseUser: baseUser,
Color: m.Tags["msg-param-color"],
Message: m.Trailing(),
}
logrus.WithFields(event.ToLogFields(evt)).Info("Announcement was made")
go handleTypedMessage(i.c, m, evt)
case "giftpaidupgrade":
evt := event.GiftPaidUpgrade{
BaseChannel: baseChannel,
BaseUser: baseUser,
Gifter: m.Tags["msg-param-sender-login"],
}
logrus.WithFields(event.ToLogFields(evt)).Info("User upgraded from gift to paid sub")
go handleTypedMessage(i.c, m, evt)
case "primepaidupgrade":
evt := event.PrimePaidUpgrade{
BaseChannel: baseChannel,
BaseUser: baseUser,
Plan: m.Tags["msg-param-sub-plan"],
}
logrus.WithFields(event.ToLogFields(evt)).Info("User upgraded from prime to paid sub")
go handleTypedMessage(i.c, m, evt)
case "raid":
evt := event.Raid{
BaseChannel: baseChannel,
BaseUser: baseUser,
From: m.Tags["login"],
ViewerCount: i.tagToNumeric(m, "msg-param-viewerCount", 0),
}
logrus.WithFields(event.ToLogFields(evt)).Info("Incoming raid")
go handleTypedMessage(i.c, m, evt)
case "resub":
evt := event.Resub{
BaseChannel: baseChannel,
BaseUser: baseUser,
From: m.Tags["login"],
Message: message,
MultiMonth: i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
Plan: m.Tags["msg-param-sub-plan"],
SubscribedMonths: i.tagToNumeric(m, "msg-param-cumulative-months", 0),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User re-subscribed")
go handleTypedMessage(i.c, m, evt)
case "sub":
evt := event.Sub{
BaseChannel: baseChannel,
BaseUser: baseUser,
From: m.Tags["login"],
MultiMonth: i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
Plan: m.Tags["msg-param-sub-plan"],
}
logrus.WithFields(event.ToLogFields(evt)).Info("User subscribed")
go handleTypedMessage(i.c, m, evt)
case "subgift", "anonsubgift":
evt := event.SubGift{
BaseChannel: baseChannel,
BaseUser: baseUser,
From: m.Tags["login"],
GiftedMonths: i.tagToNumeric(m, "msg-param-gift-months", 1),
MultiMonth: i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
OriginID: m.Tags["msg-param-origin-id"],
Plan: m.Tags["msg-param-sub-plan"],
SubscribedMonths: i.tagToNumeric(m, "msg-param-months", 0),
To: m.Tags["msg-param-recipient-user-name"],
TotalGifted: i.tagToNumeric(m, "msg-param-sender-count", 0),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User gifted a sub")
go handleTypedMessage(i.c, m, evt)
case "submysterygift":
evt := event.SubMysteryGift{
BaseChannel: baseChannel,
BaseUser: baseUser,
From: m.Tags["login"],
MultiMonth: i.tagToNumeric(m, "msg-param-multimonth-duration", 0),
Number: i.tagToNumeric(m, "msg-param-mass-gift-count", 0),
OriginID: m.Tags["msg-param-origin-id"],
Plan: m.Tags["msg-param-sub-plan"],
TotalGifted: i.tagToNumeric(m, "msg-param-sender-count", 0),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User gifted subs to the community")
go handleTypedMessage(i.c, m, evt)
case "viewermilestone":
switch m.Tags["msg-param-category"] {
case "watch-streak":
evt := event.WatchStreak{
BaseChannel: baseChannel,
BaseUser: baseUser,
Message: message,
Streak: i.tagToNumeric(m, "msg-param-value", 0),
}
logrus.WithFields(event.ToLogFields(evt)).Info("User shared a watch-streak")
go handleTypedMessage(i.c, m, evt)
default:
logrus.WithField("category", m.Tags["msg-param-category"]).Debug("found unhandled viewermilestone category")
}
}
}
func (i ircHandler) handleTwitchWhisper(m *irc.Message) {
go handleTypedMessage(i.c, m, event.Whisper{})
}
func (ircHandler) tagToNumeric(m *irc.Message, tag string, fallback int64) int64 {
tv := m.Tags[tag]
if tv == "" {
return fallback
}
v, err := strconv.ParseInt(tv, 10, 64)
if err != nil {
return fallback
}
return v
}