mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 20:01:17 +00:00
Initial version
This commit is contained in:
commit
da62d913c8
16 changed files with 1105 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
config.yaml
|
||||||
|
.env
|
||||||
|
storage.json.gz
|
||||||
|
twitch-bot
|
27
action_ban.go
Normal file
27
action_ban.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerAction(func(c *irc.Client, m *irc.Message, r *ruleAction) error {
|
||||||
|
if r.Ban == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
c.WriteMessage(&irc.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{
|
||||||
|
m.Params[0],
|
||||||
|
fmt.Sprintf("/ban %s %s", m.User, *r.Ban),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"sending timeout",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
24
action_counter.go
Normal file
24
action_counter.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerAction(func(c *irc.Client, m *irc.Message, r *ruleAction) error {
|
||||||
|
if r.Counter == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var counterStep int64 = 1
|
||||||
|
if r.CounterStep != nil {
|
||||||
|
counterStep = *r.CounterStep
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
store.UpdateCounter(*r.Counter, counterStep, false),
|
||||||
|
"update counter",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
30
action_respond.go
Normal file
30
action_respond.go
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerAction(func(c *irc.Client, m *irc.Message, r *ruleAction) error {
|
||||||
|
if r.Respond == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := formatMessage(*r.Respond, m, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "preparing response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
c.WriteMessage(&irc.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{
|
||||||
|
m.Params[0],
|
||||||
|
msg,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"sending response",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
28
action_timeout.go
Normal file
28
action_timeout.go
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerAction(func(c *irc.Client, m *irc.Message, r *ruleAction) error {
|
||||||
|
if r.Timeout == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
c.WriteMessage(&irc.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{
|
||||||
|
m.Params[0],
|
||||||
|
fmt.Sprintf("/timeout %s %d", m.User, *r.Timeout/time.Second),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"sending timeout",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
49
actions.go
Normal file
49
actions.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
availableActions []actionFunc
|
||||||
|
availableActionsLock = new(sync.RWMutex)
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionFunc func(*irc.Client, *irc.Message, *ruleAction) error
|
||||||
|
|
||||||
|
func registerAction(af actionFunc) {
|
||||||
|
availableActionsLock.Lock()
|
||||||
|
defer availableActionsLock.Unlock()
|
||||||
|
|
||||||
|
availableActions = append(availableActions, af)
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerActions(c *irc.Client, m *irc.Message, ra *ruleAction) error {
|
||||||
|
availableActionsLock.RLock()
|
||||||
|
defer availableActionsLock.RUnlock()
|
||||||
|
|
||||||
|
for _, af := range availableActions {
|
||||||
|
if err := af(c, m, ra); err != nil {
|
||||||
|
return errors.Wrap(err, "execute action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMessage(c *irc.Client, m *irc.Message, event *string) {
|
||||||
|
for _, r := range config.GetMatchingRules(m, event) {
|
||||||
|
for _, a := range r.Actions {
|
||||||
|
if err := triggerActions(c, m, a); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to trigger action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock command
|
||||||
|
timerStore.Add(r.MatcherID())
|
||||||
|
}
|
||||||
|
}
|
20
badges.go
Normal file
20
badges.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
type badgeCollection map[string]*int
|
||||||
|
|
||||||
|
func (b badgeCollection) Add(badge string, level int) {
|
||||||
|
b[badge] = &level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b badgeCollection) Get(badge string) int {
|
||||||
|
l, ok := b[badge]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return *l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b badgeCollection) Has(badge string) bool {
|
||||||
|
return b[badge] != nil
|
||||||
|
}
|
197
config.go
Normal file
197
config.go
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configFile struct {
|
||||||
|
Channels []string `yaml:"channels"`
|
||||||
|
PermitAllowModerator bool `yaml:"permit_allow_moderator"`
|
||||||
|
PermitTimeout time.Duration `yaml:"permit_timeout"`
|
||||||
|
Rules []*rule `yaml:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigFile() configFile {
|
||||||
|
return configFile{
|
||||||
|
PermitTimeout: time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rule struct {
|
||||||
|
Actions []*ruleAction `yaml:"actions"`
|
||||||
|
|
||||||
|
Cooldown *time.Duration `yaml:"cooldown"`
|
||||||
|
|
||||||
|
MatchChannel *string `yaml:"match_channel"`
|
||||||
|
MatchEvent *string `yaml:"match_event"`
|
||||||
|
MatchMessage *string `yaml:"match_message"`
|
||||||
|
|
||||||
|
DisableOnPermit bool `yaml:"disable_on_permit"`
|
||||||
|
DisableOn []string `yaml:"disable_on"`
|
||||||
|
EnableOn []string `yaml:"enable_on"`
|
||||||
|
|
||||||
|
matchMessage *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r rule) MatcherID() string {
|
||||||
|
out := sha256.New()
|
||||||
|
|
||||||
|
for _, e := range []*string{
|
||||||
|
r.MatchChannel,
|
||||||
|
r.MatchEvent,
|
||||||
|
r.MatchMessage,
|
||||||
|
} {
|
||||||
|
if e != nil {
|
||||||
|
fmt.Fprintf(out, *e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("sha256:%x", out.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r rule) Matches(m *irc.Message, event *string) bool {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
var (
|
||||||
|
badges = ircHandler{}.ParseBadgeLevels(m)
|
||||||
|
logger = log.WithFields(log.Fields{
|
||||||
|
"msg": m,
|
||||||
|
"rule": r,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check Channel match
|
||||||
|
if r.MatchChannel != nil {
|
||||||
|
if len(m.Params) == 0 || (m.Params[0] != *r.MatchChannel) {
|
||||||
|
logger.Trace("Non-Match: Channel")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Event match
|
||||||
|
if r.MatchEvent != nil {
|
||||||
|
if event == nil || *r.MatchEvent != *event {
|
||||||
|
logger.Trace("Non-Match: Event")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Message match
|
||||||
|
if r.MatchMessage != nil {
|
||||||
|
// If the regexp was not yet compiled, cache it
|
||||||
|
if r.matchMessage == nil {
|
||||||
|
if r.matchMessage, err = regexp.Compile(*r.MatchMessage); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to compile expression")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether the message matches
|
||||||
|
if !r.matchMessage.MatchString(m.Trailing()) {
|
||||||
|
logger.Trace("Non-Match: Message")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether user has one of the disable rules
|
||||||
|
for _, b := range r.DisableOn {
|
||||||
|
if badges.Has(b) {
|
||||||
|
logger.Tracef("Non-Match: Disable-Badge %s", b)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether user has at least one of the enable rules
|
||||||
|
if len(r.EnableOn) > 0 {
|
||||||
|
var userHasEnableBadge bool
|
||||||
|
for _, b := range r.EnableOn {
|
||||||
|
if badges.Has(b) {
|
||||||
|
userHasEnableBadge = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !userHasEnableBadge {
|
||||||
|
logger.Trace("Non-Match: No enable-badges")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check on permit
|
||||||
|
if r.DisableOnPermit && timerStore.HasPermit(m.User) {
|
||||||
|
logger.Trace("Non-Match: Permit")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether rule is in cooldown
|
||||||
|
if r.Cooldown != nil && timerStore.Has(r.MatcherID(), *r.Cooldown) {
|
||||||
|
logger.Trace("Non-Match: On cooldown")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing objected: Matches!
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleAction struct {
|
||||||
|
Ban *string `yaml:"ban"`
|
||||||
|
CounterStep *int64 `yaml:"counter_step"`
|
||||||
|
Counter *string `yaml:"counter"`
|
||||||
|
Respond *string `yaml:"respond"`
|
||||||
|
Timeout *time.Duration `yaml:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(filename string) error {
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "open config file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
decoder = yaml.NewDecoder(f)
|
||||||
|
tmpConfig = newConfigFile()
|
||||||
|
)
|
||||||
|
|
||||||
|
decoder.SetStrict(true)
|
||||||
|
|
||||||
|
if err = decoder.Decode(&tmpConfig); err != nil {
|
||||||
|
return errors.Wrap(err, "decode config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tmpConfig.Channels) == 0 {
|
||||||
|
log.Warn("Loaded config with empty channel list")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tmpConfig.Rules) == 0 {
|
||||||
|
log.Warn("Loaded config with empty ruleset")
|
||||||
|
}
|
||||||
|
|
||||||
|
configLock.Lock()
|
||||||
|
defer configLock.Unlock()
|
||||||
|
|
||||||
|
config = &tmpConfig
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configFile) GetMatchingRules(m *irc.Message, event *string) []*rule {
|
||||||
|
configLock.RLock()
|
||||||
|
defer configLock.RUnlock()
|
||||||
|
|
||||||
|
var out []*rule
|
||||||
|
|
||||||
|
for _, r := range c.Rules {
|
||||||
|
if r.Matches(m, event) {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
10
events.go
Normal file
10
events.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
func ptrStr(s string) *string { return &s }
|
||||||
|
|
||||||
|
var (
|
||||||
|
eventTypeHost = ptrStr("host")
|
||||||
|
eventTypePermit = ptrStr("permit")
|
||||||
|
eventTypeRaid = ptrStr("raid")
|
||||||
|
eventTypeResub = ptrStr("resub")
|
||||||
|
)
|
14
go.mod
Normal file
14
go.mod
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
module github.com/Luzifer/twitch-bot
|
||||||
|
|
||||||
|
go 1.15
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Luzifer/korvike/functions v0.6.1
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1
|
||||||
|
github.com/go-irc/irc v2.1.0+incompatible
|
||||||
|
github.com/gologme/log v1.2.0
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/sirupsen/logrus v1.7.0
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7
|
||||||
|
gopkg.in/yaml.v2 v2.2.2
|
||||||
|
)
|
145
go.sum
Normal file
145
go.sum
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/Luzifer/korvike v0.8.1 h1:JlQarp8DAkS9VZzpONfvM0W1/H9A3YWF7Hx5Cn5gTcM=
|
||||||
|
github.com/Luzifer/korvike/functions v0.6.1 h1:OGDaEciVzQh0NUMUxcEK1/vmHLIn4lmneoU/iuKc8YI=
|
||||||
|
github.com/Luzifer/korvike/functions v0.6.1/go.mod h1:D7C4XN3++eXL3MH87sRPBDEDgL9ylYdEav3Wdp3HCfU=
|
||||||
|
github.com/Luzifer/rconfig v1.2.0 h1:waD1sqasGVSQSrExpLrQ9Q1JmMaltrS391VdOjWXP/I=
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg=
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw=
|
||||||
|
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||||
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
|
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/go-irc/irc v1.3.0 h1:IMD+d/+EzY51ecMLOz73r/NXTZrEp8khrePxRCvX71M=
|
||||||
|
github.com/go-irc/irc v2.1.0+incompatible h1:pg7pMVq5OYQbqTxceByD/EN8VIsba7DtKn49rsCnG8Y=
|
||||||
|
github.com/go-irc/irc v2.1.0+incompatible/go.mod h1:jJILTRy8s/qOvusiKifAEfhQMVwft1ZwQaVJnnzmyX4=
|
||||||
|
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
|
||||||
|
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||||
|
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
|
||||||
|
github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
|
github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.5.4 h1:1BZvpawXoJCWX6pNtow9+rpEj+3itIlutiqnntI6jOE=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.1 h1:DMo4fmknnz0E0evoNYnV48RjWndOsmd6OW+09R3cEP8=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hashicorp/vault/api v1.0.4 h1:j08Or/wryXT4AcHj1oCbMd7IijXcKzYUGw59LGu9onU=
|
||||||
|
github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
|
||||||
|
github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8=
|
||||||
|
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
|
||||||
|
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
|
||||||
|
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
|
||||||
|
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
|
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||||
|
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
|
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
|
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
|
||||||
|
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||||
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I=
|
||||||
|
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
|
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||||
|
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||||
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db h1:6/JqlYfC1CCaLnGceQTI+sDGhC9UBSPAsBqI0Gun6kU=
|
||||||
|
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4=
|
||||||
|
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
254
irc.go
Normal file
254
irc.go
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const twitchRequestTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
badgeBroadcaster = "broadcaster"
|
||||||
|
badgeFounder = "founder"
|
||||||
|
badgeModerator = "moderator"
|
||||||
|
badgeSubscriber = "subscriber"
|
||||||
|
badgeVIP = "vip"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ircHandler struct {
|
||||||
|
conn *tls.Conn
|
||||||
|
c *irc.Client
|
||||||
|
user string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIRCHandler() (*ircHandler, error) {
|
||||||
|
h := new(ircHandler)
|
||||||
|
|
||||||
|
username, err := h.fetchTwitchUsername()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "fetching username")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := tls.Dial("tcp", "irc.chat.twitch.tv:6697", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "connect to IRC server")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.c = irc.NewClient(conn, irc.ClientConfig{
|
||||||
|
Nick: username,
|
||||||
|
Pass: strings.Join([]string{"oauth", cfg.TwitchToken}, ":"),
|
||||||
|
User: username,
|
||||||
|
Name: username,
|
||||||
|
Handler: h,
|
||||||
|
})
|
||||||
|
h.conn = conn
|
||||||
|
h.user = username
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) Close() error { return i.conn.Close() }
|
||||||
|
|
||||||
|
func (i ircHandler) Handle(c *irc.Client, m *irc.Message) {
|
||||||
|
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",
|
||||||
|
}, " "),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for _, ch := range config.Channels {
|
||||||
|
c.Write(fmt.Sprintf("JOIN #%s", strings.TrimLeft(ch, "#")))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "NOTICE":
|
||||||
|
// NOTICE (Twitch Commands)
|
||||||
|
// General notices from the server.
|
||||||
|
i.handleTwitchNotice(m)
|
||||||
|
|
||||||
|
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.
|
||||||
|
log.Warn("We were asked to reconnect, closing connection")
|
||||||
|
i.Close()
|
||||||
|
|
||||||
|
case "USERNOTICE":
|
||||||
|
// USERNOTICE (Twitch Commands)
|
||||||
|
// Announces Twitch-specific events to the channel (for example, a user’s subscription notification).
|
||||||
|
i.handleTwitchUsernotice(m)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"command": m.Command,
|
||||||
|
"tags": m.Tags,
|
||||||
|
"trailing": m.Trailing(),
|
||||||
|
}).Trace("Unhandled message")
|
||||||
|
// Unhandled message type, not yet needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) Run() error { return errors.Wrap(i.c.Run(), "running IRC client") }
|
||||||
|
|
||||||
|
func (ircHandler) fetchTwitchUsername() (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.twitch.tv/helix/users", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "assemble user request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Client-Id", cfg.TwitchClient)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.TwitchToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "requesting user info")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return "", errors.Wrap(err, "parse user info")
|
||||||
|
}
|
||||||
|
|
||||||
|
if l := len(payload.Data); l != 1 {
|
||||||
|
return "", errors.Errorf("unexpected number of users returned: %d", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Data[0].Login, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) handlePermit(m *irc.Message) {
|
||||||
|
badges := i.ParseBadgeLevels(m)
|
||||||
|
if !badges.Has(badgeBroadcaster) && (!config.PermitAllowModerator || !badges.Has(badgeModerator)) {
|
||||||
|
// Neither broadcaster nor moderator or moderator not permitted
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgParts := strings.Split(m.Trailing(), " ")
|
||||||
|
if len(msgParts) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := msgParts[1]
|
||||||
|
|
||||||
|
log.WithField("user", username).Debug("Added permit")
|
||||||
|
timerStore.Add(timerStore.NormalizeUsername(username))
|
||||||
|
|
||||||
|
go handleMessage(i.c, m, eventTypePermit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) handleTwitchNotice(m *irc.Message) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"tags": m.Tags,
|
||||||
|
"trailing": m.Trailing,
|
||||||
|
}).Debug("IRC NOTICE event")
|
||||||
|
|
||||||
|
switch m.Tags["msg-id"] {
|
||||||
|
case "":
|
||||||
|
// Notices SHOULD have msg-id tags...
|
||||||
|
log.WithField("msg", m).Warn("Received notice without msg-id")
|
||||||
|
|
||||||
|
case "host_success", "host_success_viewers":
|
||||||
|
log.WithField("trailing", m.Trailing()).Warn("Incoming host")
|
||||||
|
|
||||||
|
go handleMessage(i.c, m, eventTypeHost)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) handleTwitchPrivmsg(m *irc.Message) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"name": m.Name,
|
||||||
|
"user": m.User,
|
||||||
|
"tags": m.Tags,
|
||||||
|
"trailing": m.Trailing(),
|
||||||
|
}).Trace("Received privmsg")
|
||||||
|
|
||||||
|
if strings.HasPrefix(m.Trailing(), "!permit") {
|
||||||
|
i.handlePermit(m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go handleMessage(i.c, m, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i ircHandler) handleTwitchUsernotice(m *irc.Message) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"tags": m.Tags,
|
||||||
|
"trailing": m.Trailing,
|
||||||
|
}).Debug("IRC USERNOTICE event")
|
||||||
|
|
||||||
|
switch m.Tags["msg-id"] {
|
||||||
|
case "":
|
||||||
|
// Notices SHOULD have msg-id tags...
|
||||||
|
log.WithField("msg", m).Warn("Received usernotice without msg-id")
|
||||||
|
|
||||||
|
case "raid":
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"from": m.Tags["login"],
|
||||||
|
"viewercount": m.Tags["msg-param-viewerCount"],
|
||||||
|
}).Info("Incoming raid")
|
||||||
|
|
||||||
|
go handleMessage(i.c, m, eventTypeRaid)
|
||||||
|
|
||||||
|
case "resub":
|
||||||
|
go handleMessage(i.c, m, eventTypeResub)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ircHandler) ParseBadgeLevels(m *irc.Message) badgeCollection {
|
||||||
|
out := badgeCollection{}
|
||||||
|
|
||||||
|
badgeString, ok := m.GetTag("badges")
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
badges := strings.Split(badgeString, ",")
|
||||||
|
for _, b := range badges {
|
||||||
|
badgeParts := strings.Split(b, "/")
|
||||||
|
if len(badgeParts) != 2 {
|
||||||
|
log.WithField("badge", b).Warn("Malformed badge found")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
level, err := strconv.Atoi(badgeParts[1])
|
||||||
|
if err != nil {
|
||||||
|
log.WithField("badge", b).Warn("Unparsable level in badge")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Add(badgeParts[0], level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
112
main.go
Normal file
112
main.go
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/fsnotify.v1"
|
||||||
|
|
||||||
|
"github.com/Luzifer/rconfig/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg = struct {
|
||||||
|
Config string `flag:"config,c" default:"./config.yaml" description:"Location of configuration file"`
|
||||||
|
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
||||||
|
StorageFile string `flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data"`
|
||||||
|
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as" validate:"nonzero"`
|
||||||
|
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client"`
|
||||||
|
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
config *configFile
|
||||||
|
configLock = new(sync.RWMutex)
|
||||||
|
|
||||||
|
store = newStorageFile()
|
||||||
|
|
||||||
|
version = "dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rconfig.AutoEnv(true)
|
||||||
|
if err := rconfig.ParseAndValidate(&cfg); err != nil {
|
||||||
|
log.Fatalf("Unable to parse commandline options: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VersionAndExit {
|
||||||
|
fmt.Printf("twitch-bot %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to parse log level")
|
||||||
|
} else {
|
||||||
|
log.SetLevel(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if err = store.Load(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to load storage file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = loadConfig(cfg.Config); err != nil {
|
||||||
|
log.WithError(err).Fatal("Initial config load failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fswatch, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to create file watcher")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = fswatch.Add(cfg.Config); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to watch config, auto-reload will not work")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
irc *ircHandler
|
||||||
|
ircDisconnected = make(chan struct{}, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
ircDisconnected <- struct{}{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
|
||||||
|
case <-ircDisconnected:
|
||||||
|
if irc != nil {
|
||||||
|
irc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if irc, err = newIRCHandler(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to create IRC client")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := irc.Run(); err != nil {
|
||||||
|
log.WithError(err).Error("IRC run exited unexpectedly")
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
ircDisconnected <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
case evt := <-fswatch.Events:
|
||||||
|
if evt.Op&fsnotify.Write != fsnotify.Write {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loadConfig(cfg.Config); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to reload config")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Config file reloaded")
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
59
msgformatter.go
Normal file
59
msgformatter.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
korvike "github.com/Luzifer/korvike/functions"
|
||||||
|
|
||||||
|
"github.com/go-irc/irc"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatMessage(tplString string, m *irc.Message, fields map[string]interface{}) (string, error) {
|
||||||
|
fm := korvike.GetFunctionMap()
|
||||||
|
fm["getArg"] = tplGetMessageArg
|
||||||
|
fm["getCounterValue"] = tplGetCounterValue
|
||||||
|
fm["getTag"] = tplGetTagFromMessage
|
||||||
|
|
||||||
|
tpl, err := template.
|
||||||
|
New(tplString).
|
||||||
|
Funcs(fm).
|
||||||
|
Parse(tplString)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "parse template")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields == nil {
|
||||||
|
fields = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields["msg"] = m
|
||||||
|
fields["permitTimeout"] = int64(*&config.PermitTimeout / time.Second)
|
||||||
|
fields["username"] = m.User
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
err = tpl.Execute(buf, fields)
|
||||||
|
|
||||||
|
return buf.String(), errors.Wrap(err, "execute template")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tplGetCounterValue(name string, _ ...string) int64 {
|
||||||
|
return store.GetCounterValue(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tplGetMessageArg(m *irc.Message, params ...int) (string, error) {
|
||||||
|
msgParts := strings.Split(m.Trailing(), " ")
|
||||||
|
if len(msgParts) < params[0]+1 {
|
||||||
|
return "", errors.New("argument not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgParts[params[0]], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tplGetTagFromMessage(m *irc.Message, params ...string) string {
|
||||||
|
s, _ := m.GetTag(params[0])
|
||||||
|
return s
|
||||||
|
}
|
89
store.go
Normal file
89
store.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageFile struct {
|
||||||
|
Counters map[string]int64 `json:"counters"`
|
||||||
|
|
||||||
|
lock *sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStorageFile() *storageFile {
|
||||||
|
return &storageFile{
|
||||||
|
Counters: map[string]int64{},
|
||||||
|
|
||||||
|
lock: new(sync.RWMutex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) GetCounterValue(counter string) int64 {
|
||||||
|
s.lock.RLock()
|
||||||
|
defer s.lock.RUnlock()
|
||||||
|
|
||||||
|
return s.Counters[counter]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) Load() error {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
f, err := os.Open(cfg.StorageFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Store init state
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "open storage file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
zf, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create gzip reader")
|
||||||
|
}
|
||||||
|
defer zf.Close()
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
json.NewDecoder(zf).Decode(s),
|
||||||
|
"decode storage object",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) Save() error {
|
||||||
|
// NOTE(kahlers): DO NOT LOCK THIS, all calling functions are
|
||||||
|
// modifying functions and must have locks in place
|
||||||
|
|
||||||
|
f, err := os.Create(cfg.StorageFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "create storage file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
zf := gzip.NewWriter(f)
|
||||||
|
defer zf.Close()
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
json.NewEncoder(zf).Encode(s),
|
||||||
|
"encode storage object",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) UpdateCounter(counter string, value int64, absolute bool) error {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
if !absolute {
|
||||||
|
value = s.Counters[counter] + value
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Counters[counter] = value
|
||||||
|
|
||||||
|
return errors.Wrap(s.Save(), "saving store")
|
||||||
|
}
|
43
timers.go
Normal file
43
timers.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var timerStore = newTimer()
|
||||||
|
|
||||||
|
type timer struct {
|
||||||
|
timers map[string]time.Time
|
||||||
|
lock *sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTimer() *timer {
|
||||||
|
return &timer{
|
||||||
|
timers: map[string]time.Time{},
|
||||||
|
lock: new(sync.RWMutex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *timer) Add(id string) {
|
||||||
|
t.lock.Lock()
|
||||||
|
defer t.lock.Unlock()
|
||||||
|
|
||||||
|
t.timers[id] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *timer) Has(id string, validity time.Duration) bool {
|
||||||
|
t.lock.RLock()
|
||||||
|
defer t.lock.RUnlock()
|
||||||
|
|
||||||
|
return time.Since(t.timers[id]) < validity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *timer) HasPermit(username string) bool {
|
||||||
|
return t.Has(t.NormalizeUsername(username), config.PermitTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t timer) NormalizeUsername(username string) string {
|
||||||
|
return strings.ToLower(strings.TrimLeft(username, "@"))
|
||||||
|
}
|
Loading…
Reference in a new issue