feat: add midori skeleton, bot scaffolding, CD seeds

This commit is contained in:
41666 2020-09-27 00:32:15 -04:00
parent 7c861b37ca
commit 49d44df231
30 changed files with 854 additions and 123 deletions

21
src/common/BUILD.bazel Normal file
View file

@ -0,0 +1,21 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "common",
srcs = [
"await-exit.go",
"envconfig.go",
"finders.go",
],
importpath = "github.com/roleypoly/roleypoly/src/common",
visibility = ["//visibility:public"],
)
go_test(
name = "common_test",
srcs = [
"envconfig_test.go",
"finders_test.go",
],
embed = [":common"],
)

19
src/common/await-exit.go Normal file
View file

@ -0,0 +1,19 @@
package common
import (
"os"
"os/signal"
"syscall"
)
func AwaitExit() {
syscallExit := make(chan os.Signal, 1)
signal.Notify(
syscallExit,
syscall.SIGINT,
syscall.SIGTERM,
os.Interrupt,
os.Kill,
)
<-syscallExit
}

View file

@ -0,0 +1,18 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "bot",
srcs = [
"commandmux.go",
"scaffolding.go",
],
importpath = "github.com/roleypoly/roleypoly/src/common/bot",
visibility = ["//visibility:public"],
deps = [
"//src/common",
"@com_github_bwmarrin_discordgo//:discordgo",
"@com_github_dghubble_trie//:trie",
"@com_github_lampjaw_discordclient//:discordclient",
"@io_k8s_klog//:klog",
],
)

View file

@ -0,0 +1,87 @@
package bot
import (
"context"
"regexp"
"strings"
"github.com/dghubble/trie"
"github.com/lampjaw/discordclient"
"k8s.io/klog"
)
type CommandMux struct {
commandTrie *trie.PathTrie
matcher *regexp.Regexp
}
func NewCommandMux(matcher *regexp.Regexp) CommandMux {
return CommandMux{
commandTrie: trie.NewPathTrie(),
matcher: matcher,
}
}
type CommandGroup []Command
func (cg CommandGroup) RegisterCommands(commandTrie *trie.PathTrie, prefix string) {
for _, command := range cg {
commandTrie.Put(prefix+"/"+command.CommandName, command.Handler)
}
}
type Command struct {
CommandName string
Handler HandlerFunc
}
func (c CommandMux) RegisterCommandGroup(prefix string, group CommandGroup) {
group.RegisterCommands(c.commandTrie, prefix)
}
func (c CommandMux) Handler(ctx context.Context, message discordclient.Message) {
if !c.matches(message) {
return
}
key := c.commandKeyFromMessage(message)
command := c.commandTrie.Get(key)
if command == nil {
return
}
handlerFn, ok := command.(HandlerFunc)
if !ok {
klog.Warning("CommandMux.Handler: " + key + " handler was not HandlerFunc")
return
}
c.logCommandRun(key, message)
handlerFn(ctx, message)
}
func (c CommandMux) commandKeyFromMessage(message discordclient.Message) string {
commandParts := strings.Split(message.RawMessage(), " ")[1:]
return commandParts[0] + "/" + commandParts[1]
}
func (c CommandMux) matches(message discordclient.Message) bool {
return c.matcher.MatchString(message.RawMessage())
}
func (c CommandMux) logCommandRun(key string, message discordclient.Message) {
klog.Info("CommandMux: " + key + " by " + message.UserName() + " <@" + message.UserID() + ">")
}
func PrefixMatcher(prefix string) *regexp.Regexp {
return regexp.MustCompile(`^` + prefix)
}
func MentionMatcher(userID string) *regexp.Regexp {
return regexp.MustCompile(`<@!?` + userID + ">")
}
func Tokenize(message discordclient.Message) []string {
return strings.SplitAfterN(message.RawMessage(), " ", 3)
}

View file

@ -0,0 +1,101 @@
package bot
import (
"context"
"github.com/bwmarrin/discordgo"
"github.com/lampjaw/discordclient"
"github.com/roleypoly/roleypoly/src/common"
)
type HandlerFunc func(context.Context, discordclient.Message)
type BotScaffolding struct {
BotToken string
BotClientID string
RootUsers []string
AllowBots bool
AllowEdits bool
AllowDeletes bool
GatewayIntents discordgo.Intent
Handler HandlerFunc
}
type Listener struct {
DiscordClient *discordclient.DiscordClient
config *BotScaffolding
handler HandlerFunc
}
type ctxKey string
var ListenerCtxKey ctxKey = "listener"
var MessageCtxKey ctxKey = "message"
func ScaffoldBot(config BotScaffolding) error {
discordClient := discordclient.NewDiscordClient(config.BotToken, config.RootUsers[0], config.BotClientID)
discordClient.GatewayIntents = config.GatewayIntents
discordClient.AllowBots = config.AllowBots
messageChannel, err := discordClient.Listen(-1)
if err != nil {
return err
}
defer common.AwaitExit()
listener := &Listener{
config: &config,
DiscordClient: discordClient,
handler: config.Handler,
}
go listener.processMessages(messageChannel)
return nil
}
func (l *Listener) processMessages(messageChannel <-chan discordclient.Message) {
listenerCtx := context.WithValue(context.Background(), ListenerCtxKey, l)
for {
message := <-messageChannel
if !l.config.AllowEdits && message.Type() == discordclient.MessageTypeUpdate {
continue
}
if !l.config.AllowDeletes && message.Type() == discordclient.MessageTypeDelete {
continue
}
localCtx := context.WithValue(listenerCtx, MessageCtxKey, message)
go l.handler(localCtx, message)
}
}
// ReplyToMessage will use the message context to reply to a message.
// Message may be one of two types:
// - *discordgo.MessageSend
// - string
func ReplyToMessage(listenerCtx context.Context, message interface{}) error {
l := listenerCtx.Value(ListenerCtxKey).(*Listener)
m := listenerCtx.Value(MessageCtxKey).(discordclient.Message)
channelID := m.Channel()
switch message.(type) {
case *discordgo.MessageSend:
_, err := l.DiscordClient.Session.ChannelMessageSendComplex(channelID, message.(*discordgo.MessageSend))
if err != nil {
return err
}
case string:
return l.DiscordClient.SendMessage(channelID, message.(string))
}
return nil
}
// NoOpHandlerFunc does no operations againdst a message. This can be an equivalent to nil.
func NoOpHandlerFunc(context.Context, discordclient.Message) {
}

65
src/common/envconfig.go Normal file
View file

@ -0,0 +1,65 @@
package common
import (
"encoding/json"
"os"
"strconv"
"strings"
)
// GetenvValue is a holder type for Getenv to translate any Getenv strings to real types
type GetenvValue struct {
value string
}
func Getenv(key string, defaultValue ...string) GetenvValue {
value := ""
if len(defaultValue) > 0 {
value = defaultValue[0]
}
envValue := os.Getenv(key)
if envValue != "" {
value = envValue
}
return GetenvValue{
value: strings.TrimSpace(value),
}
}
func (g GetenvValue) String() string {
return g.value
}
func (g GetenvValue) StringSlice(optionalDelimiter ...string) []string {
delimiter := ","
if len(optionalDelimiter) > 0 {
delimiter = optionalDelimiter[0]
}
return strings.Split(g.value, delimiter)
}
func (g GetenvValue) Bool() bool {
lowercaseValue := strings.ToLower(g.value)
if g.value == "1" || lowercaseValue == "true" || lowercaseValue == "yes" {
return true
} else {
return false
}
}
func (g GetenvValue) Number() int {
result, err := strconv.Atoi(g.value)
if err != nil {
return -999999
}
return result
}
func (g GetenvValue) JSON(target interface{}) error {
return json.Unmarshal([]byte(g.value), target)
}

View file

@ -0,0 +1,123 @@
package common_test
import (
"os"
"testing"
"github.com/roleypoly/roleypoly/src/common"
)
var (
testEnv = map[string]string{
"string": "hello world",
"slice": "hello,world",
"slice_no_delim": "hello world",
"slice_set_delim": "hello|world",
"number": "10005",
"number_bad": "abc123",
"bool": "true",
"bool_bad": "truth",
"bool_no": "false",
"bool_number": "1",
"json": `{"hello":"world","arr":[1,2,3]}`,
}
)
func TestMain(m *testing.M) {
for key, value := range testEnv {
os.Setenv("test__"+key, value)
}
m.Run()
}
func TestEnvconfigString(t *testing.T) {
testStr := common.Getenv("test__string").String()
if testStr != testEnv["string"] {
t.FailNow()
}
}
func TestEnvconfigStringSlice(t *testing.T) {
testSl := common.Getenv("test__slice").StringSlice()
if testSl[0] != "hello" || testSl[1] != "world" {
t.FailNow()
}
}
func TestEnvconfigStringSliceNoDelimeter(t *testing.T) {
testSl := common.Getenv("test__slice_no_delim").StringSlice()
if testSl[0] != testEnv["slice_no_delim"] {
t.FailNow()
}
}
func TestEnvconfigStringSliceSetDelimeter(t *testing.T) {
testSl := common.Getenv("test__slice_set_delim").StringSlice("|")
if testSl[0] != "hello" || testSl[1] != "world" {
t.FailNow()
}
}
func TestEnvconfigNumber(t *testing.T) {
testNum := common.Getenv("test__number").Number()
if testNum != 10005 {
t.FailNow()
}
}
func TestEnvconfigNumberBad(t *testing.T) {
testNum := common.Getenv("test__number_bad").Number()
if testNum != -999999 {
t.FailNow()
}
}
func TestEnvconfigBool(t *testing.T) {
testBool := common.Getenv("test__bool").Bool()
if !testBool {
t.FailNow()
}
}
func TestEnvconfigBoolBad(t *testing.T) {
testBool := common.Getenv("test__bool_bad").Bool()
if testBool {
t.FailNow()
}
}
func TestEnvconfigBoolFalse(t *testing.T) {
testBool := common.Getenv("test__bool_no").Bool()
if testBool {
t.FailNow()
}
}
func TestEnvconfigBoolNumber(t *testing.T) {
testBool := common.Getenv("test__bool_number").Bool()
if !testBool {
t.FailNow()
}
}
func TestEnvconfigDefault(t *testing.T) {
testBool := common.Getenv("test__thing_that_doesnt_exist", "yes").Bool()
if !testBool {
t.FailNow()
}
}
type testJSONData struct {
Hello string `json:"hello,omitempty"`
Arr []int `json:"arr,omitempty"`
}
func TestEnvconfigJSON(t *testing.T) {
data := testJSONData{}
err := common.Getenv("test__json").JSON(&data)
if err != nil || data.Hello != "world" || len(data.Arr) != 3 {
t.FailNow()
}
}

12
src/common/finders.go Normal file
View file

@ -0,0 +1,12 @@
package common
// FindString returns true if needle is in haystack
func FindString(needle string, haystack []string) bool {
for _, str := range haystack {
if str == needle {
return true
}
}
return false
}

View file

@ -0,0 +1,17 @@
package common_test
import (
"testing"
"github.com/roleypoly/roleypoly/src/common"
)
func TestFindString(t *testing.T) {
if !common.FindString("hello", []string{"hello", "world"}) {
t.FailNow()
}
if common.FindString("foo", []string{"a", "b", "c"}) {
t.FailNow()
}
}