Add code linting / binary publishing (#3)

This commit is contained in:
Knut Ahlers 2021-04-03 14:11:47 +02:00 committed by GitHub
parent 8b575f7771
commit 0ae34112b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 426 additions and 100 deletions

59
.golangci.yml Normal file
View file

@ -0,0 +1,59 @@
# Derived from https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml
---
run:
skip-dirs:
- config
skip-files:
- assets.go
- bindata.go
output:
format: tab
linters-settings:
funlen:
lines: 100
statements: 60
gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 15
linters:
disable-all: true
enable:
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers [fast: true, auto-fix: false]
- bodyclose # checks whether HTTP response body is closed successfully [fast: true, auto-fix: false]
- deadcode # Finds unused code [fast: true, auto-fix: false]
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) [fast: true, auto-fix: false]
- exportloopref # checks for pointers to enclosing loop variables [fast: true, auto-fix: false]
- funlen # Tool for detection of long functions [fast: true, auto-fix: false]
- gocognit # Computes and checks the cognitive complexity of functions [fast: true, auto-fix: false]
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
- gocritic # The most opinionated Go source code linter [fast: true, auto-fix: false]
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
- godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false]
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
- golint # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes [fast: true, auto-fix: false]
- gomnd # An analyzer to detect magic numbers. [fast: true, auto-fix: false]
- gosec # Inspects source code for security problems [fast: true, auto-fix: false]
- gosimple # Linter for Go source code that specializes in simplifying a code [fast: true, auto-fix: false]
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: true, auto-fix: false]
- ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false]
- misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true]
- nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false]
- noctx # noctx finds sending http request without context.Context [fast: true, auto-fix: false]
- nolintlint # Reports ill-formed or insufficient nolint directives [fast: true, auto-fix: false]
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: true, auto-fix: false]
- structcheck # Finds unused struct fields [fast: true, auto-fix: false]
- stylecheck # Stylecheck is a replacement for golint [fast: true, auto-fix: false]
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false]
- unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false]
- unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false]
- varcheck # Finds unused global variables and constants [fast: true, auto-fix: false]
...

13
.repo-runner.yaml Normal file
View file

@ -0,0 +1,13 @@
---
image: "reporunner/golang-alpine"
checkout_dir: /go/src/github.com/Luzifer/twitch-bot
commands:
- make lint test publish
environment:
DRAFT: "false"
CGO_ENABLED: 0
GO111MODULE: on
MOD_MODE: readonly

View file

@ -1,4 +1,16 @@
default:
default: lint test
lint:
golangci-lint run --timeout=5m
publish:
curl -sSLo golang.sh https://raw.githubusercontent.com/Luzifer/github-publish/master/golang.sh
bash golang.sh
test:
go test -cover -v .
# --- Wiki Updates
pull_wiki:
git subtree pull --prefix=wiki https://github.com/Luzifer/twitch-bot.wiki.git master --squash

View file

@ -45,7 +45,7 @@ func init() {
return errors.Wrap(err, "encoding script input")
}
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
cmd := exec.CommandContext(ctx, command[0], command[1:]...) // #nosec G204 // This is expected to call a command with parameters
cmd.Env = os.Environ()
cmd.Stderr = os.Stderr
cmd.Stdin = stdin

5
irc.go
View file

@ -19,7 +19,6 @@ const (
badgeFounder = "founder"
badgeModerator = "moderator"
badgeSubscriber = "subscriber"
badgeVIP = "vip"
)
type ircHandler struct {
@ -135,7 +134,7 @@ func (i ircHandler) handlePermit(m *irc.Message) {
}
msgParts := strings.Split(m.Trailing(), " ")
if len(msgParts) != 2 {
if len(msgParts) != 2 { //nolint:gomnd // This is not a magic number but just an expected count
return
}
@ -230,7 +229,7 @@ func (ircHandler) ParseBadgeLevels(m *irc.Message) badgeCollection {
badges := strings.Split(badgeString, ",")
for _, b := range badges {
badgeParts := strings.Split(b, "/")
if len(badgeParts) != 2 {
if len(badgeParts) != 2 { //nolint:gomnd // This is not a magic number but just an expected count
log.WithField("badge", b).Warn("Malformed badge found")
continue
}

13
main.go
View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"strings"
"sync"
"time"
@ -12,6 +13,8 @@ import (
"github.com/Luzifer/rconfig/v2"
)
const ircReconnectDelay = 100 * time.Millisecond
var (
cfg = struct {
CommandTimeout time.Duration `flag:"command-timeout" default:"30s" description:"Timeout for command execution"`
@ -32,6 +35,13 @@ var (
)
func init() {
for _, a := range os.Args {
if strings.HasPrefix(a, "-test.") {
// Skip initialize for test run
return
}
}
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
log.Fatalf("Unable to parse commandline options: %s", err)
@ -49,6 +59,7 @@ func init() {
}
}
//nolint: gocognit,gocyclo // Complexity is a little too high but makes no sense to split
func main() {
var err error
@ -93,7 +104,7 @@ func main() {
if err := irc.Run(); err != nil {
log.WithError(err).Error("IRC run exited unexpectedly")
}
time.Sleep(100 * time.Millisecond)
time.Sleep(ircReconnectDelay)
ircDisconnected <- struct{}{}
}()

View file

@ -86,7 +86,7 @@ func formatMessage(tplString string, m *irc.Message, r *rule, fields map[string]
}
fields["msg"] = m
fields["permitTimeout"] = int64(*&config.PermitTimeout / time.Second)
fields["permitTimeout"] = int64(config.PermitTimeout / time.Second)
fields["username"] = m.User
if m.Command == "PRIVMSG" && len(m.Params) > 0 {

246
rule.go
View file

@ -51,8 +51,6 @@ func (r rule) MatcherID() string {
}
func (r *rule) Matches(m *irc.Message, event *string) bool {
var err error
var (
badges = ircHandler{}.ParseBadgeLevels(m)
logger = log.WithFields(log.Fields{
@ -61,69 +59,28 @@ func (r *rule) Matches(m *irc.Message, event *string) bool {
})
)
// Check Channel match
if len(r.MatchChannels) > 0 {
if len(m.Params) == 0 || !str.StringInSlice(m.Params[0], r.MatchChannels) {
logger.Trace("Non-Match: Channel")
for _, matcher := range []func(*log.Entry, *irc.Message, *string, badgeCollection) bool{
r.allowExecuteChannelWhitelist,
r.allowExecuteUserWhitelist,
r.allowExecuteEventWhitelist,
r.allowExecuteMessageMatcherWhitelist,
r.allowExecuteMessageMatcherBlacklist,
r.allowExecuteBadgeBlacklist,
r.allowExecuteBadgeWhitelist,
r.allowExecuteDisableOnPermit,
r.allowExecuteCooldown,
r.allowExecuteDisableOnOffline,
} {
if !matcher(logger, m, event, badges) {
return false
}
}
if len(r.MatchUsers) > 0 {
if !str.StringInSlice(strings.ToLower(m.User), r.MatchUsers) {
logger.Trace("Non-Match: Users")
return false
}
}
// Nothing objected: Matches!
return true
}
// 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
}
}
if len(r.DisableOnMatchMessages) > 0 {
// If the regexps were not pre-compiled, do it now
if len(r.disableOnMatchMessages) != len(r.DisableOnMatchMessages) {
r.disableOnMatchMessages = nil
for _, dm := range r.DisableOnMatchMessages {
dmr, err := regexp.Compile(dm)
if err != nil {
logger.WithError(err).Error("Unable to compile expression")
return false
}
r.disableOnMatchMessages = append(r.disableOnMatchMessages, dmr)
}
}
for _, rex := range r.disableOnMatchMessages {
if rex.MatchString(m.Trailing()) {
logger.Trace("Non-Match: Disable-On-Message")
return false
}
}
}
// Check whether user has one of the disable rules
func (r *rule) allowExecuteBadgeBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
for _, b := range r.DisableOn {
if badges.Has(b) {
logger.Tracef("Non-Match: Disable-Badge %s", b)
@ -131,53 +88,164 @@ func (r *rule) Matches(m *irc.Message, event *string) bool {
}
}
// 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
return true
}
func (r *rule) allowExecuteBadgeWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if len(r.EnableOn) == 0 {
// No match criteria set, does not speak against matching
return true
}
for _, b := range r.EnableOn {
if badges.Has(b) {
return true
}
}
// Check on permit
return false
}
func (r *rule) allowExecuteChannelWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if len(r.MatchChannels) == 0 {
// No match criteria set, does not speak against matching
return true
}
if len(m.Params) == 0 || (!str.StringInSlice(m.Params[0], r.MatchChannels) && !str.StringInSlice(strings.TrimPrefix(m.Params[0], "#"), r.MatchChannels)) {
logger.Trace("Non-Match: Channel")
return false
}
return true
}
func (r *rule) allowExecuteCooldown(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if r.Cooldown == nil {
// No match criteria set, does not speak against matching
return true
}
if !timerStore.InCooldown(r.MatcherID(), *r.Cooldown) {
return true
}
for _, b := range r.SkipCooldownFor {
if badges.Has(b) {
return true
}
}
return false
}
func (r *rule) allowExecuteDisableOnOffline(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if !r.DisableOnOffline {
// No match criteria set, does not speak against matching
return true
}
streamLive, err := twitch.HasLiveStream(strings.TrimLeft(m.Params[0], "#"))
if err != nil {
logger.WithError(err).Error("Unable to determine live status")
return false
}
if !streamLive {
logger.Trace("Non-Match: Stream offline")
return false
}
return true
}
func (r *rule) allowExecuteDisableOnPermit(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if r.DisableOnPermit && timerStore.HasPermit(m.Params[0], m.User) {
logger.Trace("Non-Match: Permit")
return false
}
// Check whether rule is in cooldown
if r.Cooldown != nil && timerStore.InCooldown(r.MatcherID(), *r.Cooldown) {
var userHasSkipBadge bool
for _, b := range r.SkipCooldownFor {
if badges.Has(b) {
userHasSkipBadge = true
return true
}
func (r *rule) allowExecuteEventWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if r.MatchEvent == nil {
// No match criteria set, does not speak against matching
return true
}
if event == nil || *r.MatchEvent != *event {
logger.Trace("Non-Match: Event")
return false
}
return true
}
func (r *rule) allowExecuteMessageMatcherBlacklist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if len(r.DisableOnMatchMessages) == 0 {
// No match criteria set, does not speak against matching
return true
}
// If the regexps were not pre-compiled, do it now
if len(r.disableOnMatchMessages) != len(r.DisableOnMatchMessages) {
r.disableOnMatchMessages = nil
for _, dm := range r.DisableOnMatchMessages {
dmr, err := regexp.Compile(dm)
if err != nil {
logger.WithError(err).Error("Unable to compile expression")
return false
}
r.disableOnMatchMessages = append(r.disableOnMatchMessages, dmr)
}
if !userHasSkipBadge {
logger.Trace("Non-Match: On cooldown")
}
for _, rex := range r.disableOnMatchMessages {
if rex.MatchString(m.Trailing()) {
logger.Trace("Non-Match: Disable-On-Message")
return false
}
}
if r.DisableOnOffline {
streamLive, err := twitch.HasLiveStream(strings.TrimLeft(m.Params[0], "#"))
if err != nil {
logger.WithError(err).Error("Unable to determine live status")
return false
}
if !streamLive {
logger.Trace("Non-Match: Stream offline")
return true
}
func (r *rule) allowExecuteMessageMatcherWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if r.MatchMessage == nil {
// No match criteria set, does not speak against matching
return true
}
var err error
// 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
}
}
// Nothing objected: Matches!
// Check whether the message matches
if !r.matchMessage.MatchString(m.Trailing()) {
logger.Trace("Non-Match: Message")
return false
}
return true
}
func (r *rule) allowExecuteUserWhitelist(logger *log.Entry, m *irc.Message, event *string, badges badgeCollection) bool {
if len(r.MatchUsers) == 0 {
// No match criteria set, does not speak against matching
return true
}
if !str.StringInSlice(strings.ToLower(m.User), r.MatchUsers) {
logger.Trace("Non-Match: Users")
return false
}
return true
}

164
rule_test.go Normal file
View file

@ -0,0 +1,164 @@
package main
import (
"fmt"
"testing"
"time"
"github.com/go-irc/irc"
"github.com/sirupsen/logrus"
)
var (
testLogger = logrus.NewEntry(logrus.StandardLogger())
testBadgeLevel0 = func(i int) *int { return &i }(0)
)
func TestAllowExecuteBadgeBlacklist(t *testing.T) {
r := &rule{DisableOn: []string{badgeBroadcaster}}
if r.allowExecuteBadgeBlacklist(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) {
t.Error("Execution allowed on blacklisted badge")
}
if !r.allowExecuteBadgeBlacklist(testLogger, nil, nil, badgeCollection{badgeModerator: testBadgeLevel0}) {
t.Error("Execution denied without blacklisted badge")
}
}
func TestAllowExecuteBadgeWhitelist(t *testing.T) {
r := &rule{EnableOn: []string{badgeBroadcaster}}
if r.allowExecuteBadgeWhitelist(testLogger, nil, nil, badgeCollection{badgeModerator: testBadgeLevel0}) {
t.Error("Execution allowed without whitelisted badge")
}
if !r.allowExecuteBadgeWhitelist(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) {
t.Error("Execution denied with whitelisted badge")
}
}
func TestAllowExecuteChannelWhitelist(t *testing.T) {
r := &rule{MatchChannels: []string{"#mychannel", "otherchannel"}}
for m, exp := range map[string]bool{
":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,
":amy!amy@foo.example.com PRIVMSG #otherchannel :Testing": true,
":amy!amy@foo.example.com PRIVMSG #randomchannel :Testing": false,
":amy!amy@foo.example.com JOIN #mychannel": true,
":tmi.twitch.tv CLEARCHAT #mychannel": true,
":tmi.twitch.tv CLEARCHAT #mychannel :ronni": true,
":tmi.twitch.tv CLEARCHAT #dallas": false,
"@msg-id=slow_off :tmi.twitch.tv NOTICE #mychannel :This room is no longer in slow mode.": true,
} {
if res := r.allowExecuteChannelWhitelist(testLogger, irc.MustParseMessage(m), nil, badgeCollection{}); res != exp {
t.Errorf("Message %q yield unxpected result: exp=%v res=%v", m, exp, res)
}
}
}
func TestAllowExecuteCooldown(t *testing.T) {
r := &rule{Cooldown: func(i time.Duration) *time.Duration { return &i }(time.Minute), SkipCooldownFor: []string{badgeBroadcaster}}
if !r.allowExecuteCooldown(testLogger, nil, nil, badgeCollection{}) {
t.Error("Initial call was not allowed")
}
// Add cooldown
timerStore.AddCooldown(r.MatcherID())
if r.allowExecuteCooldown(testLogger, nil, nil, badgeCollection{}) {
t.Error("Call after cooldown added was allowed")
}
if !r.allowExecuteCooldown(testLogger, nil, nil, badgeCollection{badgeBroadcaster: testBadgeLevel0}) {
t.Error("Call in cooldown with skip badge was not allowed")
}
}
func TestAllowExecuteDisableOnOffline(t *testing.T) {
r := &rule{DisableOnOffline: true}
// Fake cache entries to prevent calling the real Twitch API
twitch.apiCache.Set([]string{"hasLiveStream", "channel1"}, time.Minute, true)
twitch.apiCache.Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)
for ch, exp := range map[string]bool{
"channel1": true,
"channel2": false,
} {
if res := r.allowExecuteDisableOnOffline(testLogger, irc.MustParseMessage(fmt.Sprintf("PRIVMSG #%s :test", ch)), nil, badgeCollection{}); res != exp {
t.Errorf("Channel %q yield an unexpected result: exp=%v res=%v", ch, exp, res)
}
}
}
func TestAllowExecuteDisableOnPermit(t *testing.T) {
r := &rule{DisableOnPermit: true}
// Permit is using global configuration, so we must fake that one
config = &configFile{PermitTimeout: time.Minute}
defer func() { config = nil }()
m := irc.MustParseMessage(":amy!amy@foo.example.com PRIVMSG #mychannel :Testing")
if !r.allowExecuteDisableOnPermit(testLogger, m, nil, badgeCollection{}) {
t.Error("Execution was not allowed without permit")
}
timerStore.AddPermit(m.Params[0], m.User)
if r.allowExecuteDisableOnPermit(testLogger, m, nil, badgeCollection{}) {
t.Error("Execution was allowed with permit")
}
}
func TestAllowExecuteEventWhitelist(t *testing.T) {
r := &rule{MatchEvent: func(s string) *string { return &s }("test")}
for evt, exp := range map[string]bool{
"foobar": false,
"test": true,
} {
if res := r.allowExecuteEventWhitelist(testLogger, nil, &evt, badgeCollection{}); exp != res {
t.Errorf("Event %q yield unexpected result: exp=%v res=%v", evt, exp, res)
}
}
}
func TestAllowExecuteMessageMatcherBlacklist(t *testing.T) {
r := &rule{DisableOnMatchMessages: []string{`^!disable`}}
for msg, exp := range map[string]bool{
"PRIVMSG #test :Random message": true,
"PRIVMSG #test :!disable this one": false,
} {
if res := r.allowExecuteMessageMatcherBlacklist(testLogger, irc.MustParseMessage(msg), nil, badgeCollection{}); exp != res {
t.Errorf("Message %q yield unexpected result: exp=%v res=%v", msg, exp, res)
}
}
}
func TestAllowExecuteMessageMatcherWhitelist(t *testing.T) {
r := &rule{MatchMessage: func(s string) *string { return &s }(`^!test`)}
for msg, exp := range map[string]bool{
"PRIVMSG #test :Random message": false,
"PRIVMSG #test :!test this one": true,
} {
if res := r.allowExecuteMessageMatcherWhitelist(testLogger, irc.MustParseMessage(msg), nil, badgeCollection{}); exp != res {
t.Errorf("Message %q yield unexpected result: exp=%v res=%v", msg, exp, res)
}
}
}
func TestAllowExecuteUserWhitelist(t *testing.T) {
r := &rule{MatchUsers: []string{"amy"}}
for msg, exp := range map[string]bool{
":amy!amy@foo.example.com PRIVMSG #mychannel :Testing": true,
":bob!bob@foo.example.com PRIVMSG #mychannel :Testing": false,
} {
if res := r.allowExecuteUserWhitelist(testLogger, irc.MustParseMessage(msg), nil, badgeCollection{}); exp != res {
t.Errorf("Message %q yield unexpected result: exp=%v res=%v", msg, exp, res)
}
}
}

View file

@ -12,7 +12,6 @@ type timerType uint8
const (
timerTypePermit timerType = iota
timerTypeChatMessage
timerTypeCooldown
)
@ -26,7 +25,6 @@ type timerEntry struct {
type timer struct {
timers map[string]timerEntry
lock *sync.RWMutex
kind timerType
}
func newTimer() *timer {

View file

@ -12,6 +12,8 @@ import (
log "github.com/sirupsen/logrus"
)
const timeDay = 24 * time.Hour
var twitch = newTwitchClient()
type twitchClient struct {
@ -85,7 +87,7 @@ func (t twitchClient) GetFollowDate(from, to string) (time.Time, error) {
}
// Follow date will not change that often, cache for a long time
t.apiCache.Set(cacheKey, 24*time.Hour, payload.Data[0].FollowedAt)
t.apiCache.Set(cacheKey, timeDay, payload.Data[0].FollowedAt)
return payload.Data[0].FollowedAt, nil
}
@ -154,7 +156,7 @@ func (t twitchClient) getIDForUsername(username string) (string, error) {
}
// The ID for an username will not change (often), cache for a long time
t.apiCache.Set(cacheKey, 24*time.Hour, payload.Data[0].ID)
t.apiCache.Set(cacheKey, timeDay, payload.Data[0].ID)
return payload.Data[0].ID, nil
}