From da62d913c86cda78bb891c980678d355c9bcd685 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Mon, 21 Dec 2020 01:32:39 +0100 Subject: [PATCH] Initial version --- .gitignore | 4 + action_ban.go | 27 +++++ action_counter.go | 24 +++++ action_respond.go | 30 ++++++ action_timeout.go | 28 +++++ actions.go | 49 +++++++++ badges.go | 20 ++++ config.go | 197 +++++++++++++++++++++++++++++++++++ events.go | 10 ++ go.mod | 14 +++ go.sum | 145 ++++++++++++++++++++++++++ irc.go | 254 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 112 ++++++++++++++++++++ msgformatter.go | 59 +++++++++++ store.go | 89 ++++++++++++++++ timers.go | 43 ++++++++ 16 files changed, 1105 insertions(+) create mode 100644 .gitignore create mode 100644 action_ban.go create mode 100644 action_counter.go create mode 100644 action_respond.go create mode 100644 action_timeout.go create mode 100644 actions.go create mode 100644 badges.go create mode 100644 config.go create mode 100644 events.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 irc.go create mode 100644 main.go create mode 100644 msgformatter.go create mode 100644 store.go create mode 100644 timers.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a22c6f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.yaml +.env +storage.json.gz +twitch-bot diff --git a/action_ban.go b/action_ban.go new file mode 100644 index 0000000..a795ad1 --- /dev/null +++ b/action_ban.go @@ -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", + ) + }) +} diff --git a/action_counter.go b/action_counter.go new file mode 100644 index 0000000..bdd2b54 --- /dev/null +++ b/action_counter.go @@ -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", + ) + }) +} diff --git a/action_respond.go b/action_respond.go new file mode 100644 index 0000000..4b39cab --- /dev/null +++ b/action_respond.go @@ -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", + ) + }) +} diff --git a/action_timeout.go b/action_timeout.go new file mode 100644 index 0000000..3f3b5e6 --- /dev/null +++ b/action_timeout.go @@ -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", + ) + }) +} diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..ecb2dda --- /dev/null +++ b/actions.go @@ -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()) + } +} diff --git a/badges.go b/badges.go new file mode 100644 index 0000000..c3adf81 --- /dev/null +++ b/badges.go @@ -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 +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9a4fd2c --- /dev/null +++ b/config.go @@ -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 +} diff --git a/events.go b/events.go new file mode 100644 index 0000000..bd275f0 --- /dev/null +++ b/events.go @@ -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") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ec07439 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a96231 --- /dev/null +++ b/go.sum @@ -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= diff --git a/irc.go b/irc.go new file mode 100644 index 0000000..c306603 --- /dev/null +++ b/irc.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..da9fd59 --- /dev/null +++ b/main.go @@ -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") + + } + } +} diff --git a/msgformatter.go b/msgformatter.go new file mode 100644 index 0000000..a2b3692 --- /dev/null +++ b/msgformatter.go @@ -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 +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..ba398d6 --- /dev/null +++ b/store.go @@ -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") +} diff --git a/timers.go b/timers.go new file mode 100644 index 0000000..6ce47f3 --- /dev/null +++ b/timers.go @@ -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, "@")) +}