diff --git a/action_core.go b/action_core.go index 2033826..ba2e3ea 100644 --- a/action_core.go +++ b/action_core.go @@ -9,6 +9,7 @@ import ( deleteactor "github.com/Luzifer/twitch-bot/internal/actors/delete" "github.com/Luzifer/twitch-bot/internal/actors/modchannel" "github.com/Luzifer/twitch-bot/internal/actors/punish" + "github.com/Luzifer/twitch-bot/internal/actors/quotedb" "github.com/Luzifer/twitch-bot/internal/actors/raw" "github.com/Luzifer/twitch-bot/internal/actors/respond" "github.com/Luzifer/twitch-bot/internal/actors/timeout" @@ -25,6 +26,7 @@ var coreActorRegistations = []plugins.RegisterFunc{ deleteactor.Register, modchannel.Register, punish.Register, + quotedb.Register, raw.Register, respond.Register, timeout.Register, diff --git a/actorDocs.tpl b/actorDocs.tpl index c5e8d0c..fd37855 100644 --- a/actorDocs.tpl +++ b/actorDocs.tpl @@ -13,23 +13,23 @@ # {{ .Description }} # Optional: {{ .Optional }} {{- if eq .Type "bool" }} - # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }} + # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating){{ end }} {{ .Key }}: {{ eq .Default "true" }} {{- end }} {{- if eq .Type "duration" }} - # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }} + # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating){{ end }} {{ .Key }}: {{ if eq .Default "" }}0s{{ else }}{{ .Default }}{{ end }} {{- end }} {{- if eq .Type "int64" }} - # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }} + # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating){{ end }} {{ .Key }}: {{ if eq .Default "" }}0{{ else }}{{ .Default }}{{ end }} {{- end }} {{- if eq .Type "string" }} - # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating) {{ end }} + # Type: {{ .Type }}{{ if .SupportTemplate }} (Supports Templating){{ end }} {{ .Key }}: "{{ .Default }}" {{- end }} {{- if eq .Type "stringslice" }} - # Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string) {{ end }} + # Type: array of strings{{ if .SupportTemplate }} (Supports Templating in each string){{ end }} {{ .Key }}: [] {{- end }} {{- end }} diff --git a/functions_counter.go b/functions_counter.go index 2e441fc..e1ca64d 100644 --- a/functions_counter.go +++ b/functions_counter.go @@ -9,7 +9,7 @@ import ( ) func init() { - tplFuncs.Register("channelCounter", func(m *irc.Message, r *plugins.Rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("channelCounter", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { return func(name string) (string, error) { channel, ok := fields["channel"].(string) if !ok { diff --git a/functions_irc.go b/functions_irc.go index 0a7d87a..d2056ef 100644 --- a/functions_irc.go +++ b/functions_irc.go @@ -10,7 +10,7 @@ import ( ) func init() { - tplFuncs.Register("arg", func(m *irc.Message, r *plugins.Rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("arg", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { return func(arg int) (string, error) { msgParts := strings.Split(m.Trailing(), " ") if len(msgParts) <= arg { @@ -21,7 +21,7 @@ func init() { } }) - tplFuncs.Register("botHasBadge", func(m *irc.Message, r *plugins.Rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("botHasBadge", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { return func(badge string) bool { channel, ok := fields["channel"].(string) if !ok { @@ -40,7 +40,7 @@ func init() { tplFuncs.Register("fixUsername", plugins.GenericTemplateFunctionGetter(func(username string) string { return strings.TrimLeft(username, "@#") })) - tplFuncs.Register("group", func(m *irc.Message, r *plugins.Rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("group", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { return func(idx int, fallback ...string) (string, error) { fields := r.GetMatchMessage().FindStringSubmatch(m.Trailing()) if len(fields) <= idx { @@ -55,7 +55,7 @@ func init() { } }) - tplFuncs.Register("tag", func(m *irc.Message, r *plugins.Rule, fields map[string]interface{}) interface{} { + tplFuncs.Register("tag", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { return func(tag string) string { s, _ := m.GetTag(tag) return s diff --git a/internal/actors/quotedb/actor.go b/internal/actors/quotedb/actor.go new file mode 100644 index 0000000..1e27218 --- /dev/null +++ b/internal/actors/quotedb/actor.go @@ -0,0 +1,275 @@ +package quotedb + +import ( + "encoding/json" + "math/rand" + "strconv" + "sync" + + "github.com/Luzifer/twitch-bot/plugins" + "github.com/go-irc/irc" + "github.com/pkg/errors" +) + +const ( + actorName = "quotedb" + moduleUUID = "917c83ee-ed40-41e4-a558-1c2e59fdf1f5" +) + +var ( + formatMessage plugins.MsgFormatter + store plugins.StorageManager + storedObject = newStorage() + + ptrStringEmpty = func(v string) *string { return &v }("") + ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}") + ptrStringZero = func(v string) *string { return &v }("0") +) + +func Register(args plugins.RegistrationArguments) error { + formatMessage = args.FormatMessage + store = args.GetStorageManager() + + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) + + args.RegisterActorDocumentation(plugins.ActionDocumentation{ + Description: "Manage a database of quotes in your channel", + Name: "Quote Database", + Type: actorName, + + Fields: []plugins.ActionDocumentationField{ + { + Default: "", + Description: "Action to execute (one of: add, del, get)", + Key: "action", + Name: "Action", + Optional: false, + SupportTemplate: false, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "0", + Description: "Index of the quote to work with, must yield a number (required on 'del', optional on 'get')", + Key: "index", + Name: "Index", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "", + Description: "Quote to add: Format like you like your quote, nothing is added (required on: add)", + Key: "quote", + Name: "Quote", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + { + Default: "Quote #{{ .index }}: {{ .quote }}", + Description: "Format to use when posting a quote (required on: get)", + Key: "format", + Name: "Format", + Optional: true, + SupportTemplate: true, + Type: plugins.ActionDocumentationFieldTypeString, + }, + }, + }) + + args.RegisterTemplateFunction("lastQuoteIndex", func(m *irc.Message, r *plugins.Rule, fields plugins.FieldCollection) interface{} { + return func() int { + return storedObject.GetMaxQuoteIdx(plugins.DeriveChannel(m, nil)) + } + }) + + return errors.Wrap( + store.GetModuleStore(moduleUUID, storedObject), + "loading module storage", + ) +} + +type ( + actor struct{} + + storage struct { + ChannelQuotes map[string][]string `json:"channel_quotes"` + + lock sync.RWMutex + } +) + +func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData plugins.FieldCollection, attrs plugins.FieldCollection) (preventCooldown bool, err error) { + var ( + action = attrs.MustString("action", ptrStringEmpty) + indexStr = attrs.MustString("index", ptrStringZero) + quote = attrs.MustString("quote", ptrStringEmpty) + ) + + if indexStr == "" { + indexStr = "0" + } + + if indexStr, err = formatMessage(indexStr, m, r, eventData); err != nil { + return false, errors.Wrap(err, "formatting index") + } + + index, err := strconv.Atoi(indexStr) + if err != nil { + return false, errors.Wrap(err, "parsing index to number") + } + + switch action { + case "add": + quote, err = formatMessage(quote, m, r, eventData) + if err != nil { + return false, errors.Wrap(err, "formatting quote") + } + + storedObject.AddQuote(plugins.DeriveChannel(m, eventData), quote) + return false, errors.Wrap( + store.SetModuleStore(moduleUUID, storedObject), + "storing quote database", + ) + + case "del": + storedObject.DelQuote(plugins.DeriveChannel(m, eventData), index) + return false, errors.Wrap( + store.SetModuleStore(moduleUUID, storedObject), + "storing quote database", + ) + + case "get": + idx, quote := storedObject.GetQuote(plugins.DeriveChannel(m, eventData), index) + + if idx == 0 { + // No quote was found for the given idx + return false, nil + } + + fields := make(plugins.FieldCollection) + for k, v := range eventData { + fields[k] = v + } + fields["index"] = idx + fields["quote"] = quote + + format := attrs.MustString("format", ptrStringOutFormat) + msg, err := formatMessage(format, m, r, fields) + if err != nil { + return false, errors.Wrap(err, "formatting output message") + } + + return false, errors.Wrap( + c.WriteMessage(&irc.Message{ + Command: "PRIVMSG", + Params: []string{ + plugins.DeriveChannel(m, eventData), + msg, + }, + }), + "sending command", + ) + } + + return false, nil +} + +func (a actor) IsAsync() bool { return false } +func (a actor) Name() string { return actorName } + +func (a actor) Validate(attrs plugins.FieldCollection) (err error) { + action := attrs.MustString("action", ptrStringEmpty) + + switch action { + case "add": + if v, err := attrs.String("quote"); err != nil || v == "" { + return errors.New("quote must be non-empty string for action add") + } + + case "del": + if v, err := attrs.String("index"); err != nil || v == "" { + return errors.New("index must be non-empty string for adction del") + } + + case "get": + // No requirements + + default: + return errors.New("action must be one of add, del or get") + } + + return nil +} + +// Storage + +func newStorage() *storage { + return &storage{ + ChannelQuotes: make(map[string][]string), + } +} + +func (s *storage) AddQuote(channel, quote string) { + s.lock.Lock() + defer s.lock.Unlock() + + s.ChannelQuotes[channel] = append(s.ChannelQuotes[channel], quote) +} + +func (s *storage) DelQuote(channel string, quote int) { + s.lock.Lock() + defer s.lock.Unlock() + + var quotes []string + for i, q := range s.ChannelQuotes[channel] { + if i == quote { + continue + } + quotes = append(quotes, q) + } + + s.ChannelQuotes[channel] = quotes +} + +func (s *storage) GetMaxQuoteIdx(channel string) int { + s.lock.RLock() + defer s.lock.RUnlock() + + return len(s.ChannelQuotes[channel]) +} + +func (s *storage) GetQuote(channel string, quote int) (int, string) { + s.lock.RLock() + defer s.lock.RUnlock() + + if quote == 0 { + quote = rand.Intn(len(s.ChannelQuotes[channel])) + 1 + } + + if quote > len(s.ChannelQuotes[channel]) { + return 0, "" + } + + return quote, s.ChannelQuotes[channel][quote-1] +} + +// Implement marshaller interfaces +func (s *storage) MarshalStoredObject() ([]byte, error) { + s.lock.Lock() + defer s.lock.Unlock() + + return json.Marshal(s) +} + +func (s *storage) UnmarshalStoredObject(data []byte) error { + if data == nil { + // No data set yet, don't try to unmarshal + return nil + } + + s.lock.Lock() + defer s.lock.Unlock() + + return json.Unmarshal(data, s) +} diff --git a/plugins/interface.go b/plugins/interface.go index 6d4cf82..6e6ada1 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -83,10 +83,10 @@ type ( UnmarshalStoredObject([]byte) error } - TemplateFuncGetter func(*irc.Message, *Rule, map[string]interface{}) interface{} + TemplateFuncGetter func(*irc.Message, *Rule, FieldCollection) interface{} TemplateFuncRegister func(name string, fg TemplateFuncGetter) ) func GenericTemplateFunctionGetter(f interface{}) TemplateFuncGetter { - return func(*irc.Message, *Rule, map[string]interface{}) interface{} { return f } + return func(*irc.Message, *Rule, FieldCollection) interface{} { return f } } diff --git a/wiki/Actors.md b/wiki/Actors.md index af2efef..431b866 100644 --- a/wiki/Actors.md +++ b/wiki/Actors.md @@ -49,7 +49,7 @@ Execute external script / command attributes: # Command to execute # Optional: false - # Type: array of strings (Supports Templating in each string) + # Type: array of strings (Supports Templating in each string) command: [] # Do not activate cooldown for route when command exits non-zero # Optional: true @@ -66,7 +66,7 @@ Update counter values attributes: # Name of the counter to update # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) counter: "" # Value to add to the counter # Optional: true @@ -74,7 +74,7 @@ Update counter values counter_step: 1 # Value to set the counter to # Optional: true - # Type: string (Supports Templating) + # Type: string (Supports Templating) counter_set: "" ``` @@ -87,15 +87,15 @@ Update stream information attributes: # Channel to update # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) channel: "" # Category / Game to set # Optional: true - # Type: string (Supports Templating) + # Type: string (Supports Templating) game: "" # Stream title to set # Optional: true - # Type: string (Supports Templating) + # Type: string (Supports Templating) title: "" ``` @@ -108,7 +108,7 @@ Modify variable contents attributes: # Name of the variable to update # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) variable: "" # Clear variable content and unset the variable # Optional: true @@ -116,7 +116,7 @@ Modify variable contents clear: false # Value to set the variable to # Optional: true - # Type: string (Supports Templating) + # Type: string (Supports Templating) set: "" ``` @@ -141,7 +141,7 @@ Apply increasing punishments to user reason: "" # User to apply the action to # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) user: "" # Unique identifier for this punishment to differentiate between punishments in the same channel # Optional: true @@ -149,6 +149,31 @@ Apply increasing punishments to user uuid: "" ``` +## Quote Database + +Manage a database of quotes in your channel + +```yaml +- type: quotedb + attributes: + # Action to execute (one of: add, del, get) + # Optional: false + # Type: string + action: "" + # Index of the quote to work with, must yield a number (required on 'del', optional on 'get') + # Optional: true + # Type: string (Supports Templating) + index: "0" + # Quote to add: Format like you like your quote, nothing is added (required on: add) + # Optional: true + # Type: string (Supports Templating) + quote: "" + # Format to use when posting a quote (required on: get) + # Optional: true + # Type: string (Supports Templating) + format: "Quote #{{ .index }}: {{ .quote }}" +``` + ## Reset User Punishment Reset punishment level for user @@ -158,7 +183,7 @@ Reset punishment level for user attributes: # User to reset the level for # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) user: "" # Unique identifier for this punishment to differentiate between punishments in the same channel # Optional: true @@ -175,11 +200,11 @@ Respond to message with a new message attributes: # Message text to send # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) message: "" # Fallback message text to send if message cannot be generated # Optional: true - # Type: string (Supports Templating) + # Type: string (Supports Templating) fallback: "" # Send message as a native Twitch-reply to the original message # Optional: true @@ -200,7 +225,7 @@ Send raw IRC message attributes: # Raw message to send (must be a valid IRC protocol message) # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) message: "" ``` @@ -213,11 +238,11 @@ Send a whisper (requires a verified bot!) attributes: # Message to whisper to the user # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) message: "" # User to send the message to # Optional: false - # Type: string (Supports Templating) + # Type: string (Supports Templating) to: "" ``` diff --git a/wiki/Home.md b/wiki/Home.md index d9cee4e..83d6eb5 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -140,6 +140,7 @@ Additionally there are some functions available in the templates: - `formatDuration ` - Returns a formated duration. Pass empty strings to leave out the part: `{{ formatDuration .dur "hours" "minutes" "" }}` yields `N hours, M minutes` - `followDate ` - Looks up when `from` followed `to` - `group [fallback]` - Gets matching group specified by index from `match_message` regular expression, when `fallback` is defined, it is used when group has an empty match +- `lastQuoteIndex` - Gets the last quote index in the quote database for the current channel - `recentGame [fallback]` - Returns the last played game name of the specified user (see shoutout example) or the `fallback` if the game could not be fetched. If no fallback was supplied the message will fail and not be sent. - `streamUptime ` - Returns the duration the stream is online (causes an error if no current stream is found) - `tag ` - Takes the message sent to the channel, returns the value of the tag specified