diff --git a/action_script.go b/action_script.go new file mode 100644 index 0000000..70fa970 --- /dev/null +++ b/action_script.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + + "github.com/go-irc/irc" + "github.com/pkg/errors" +) + +func init() { + registerAction(func(c *irc.Client, m *irc.Message, ruleDef *rule, r *ruleAction) error { + if len(r.Command) == 0 { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.CommandTimeout) + defer cancel() + + var ( + stdin = new(bytes.Buffer) + stdout = new(bytes.Buffer) + ) + + if err := json.NewEncoder(stdin).Encode(map[string]interface{}{ + "badges": ircHandler{}.ParseBadgeLevels(m), + "channel": m.Params[0], + "message": m.Trailing(), + "tags": m.Tags, + "username": m.User, + }); err != nil { + return errors.Wrap(err, "encoding script input") + } + + cmd := exec.CommandContext(ctx, r.Command[0], r.Command[1:]...) + cmd.Env = os.Environ() + cmd.Stderr = os.Stderr + cmd.Stdin = stdin + cmd.Stdout = stdout + + if err := cmd.Run(); err != nil { + return errors.Wrap(err, "running command") + } + + if stdout.Len() == 0 { + // Script was successful but did not yield actions + return nil + } + + var ( + actions []*ruleAction + decoder = json.NewDecoder(stdout) + ) + + decoder.DisallowUnknownFields() + if err := decoder.Decode(&actions); err != nil { + return errors.Wrap(err, "decoding actions output") + } + + for _, action := range actions { + if err := triggerActions(c, m, ruleDef, action); err != nil { + return errors.Wrap(err, "execute returned action") + } + } + + return nil + }) +} diff --git a/config.go b/config.go index 5469517..cf9be25 100644 --- a/config.go +++ b/config.go @@ -177,12 +177,13 @@ func (r *rule) Matches(m *irc.Message, event *string) bool { } type ruleAction struct { - Ban *string `yaml:"ban"` - CounterStep *int64 `yaml:"counter_step"` - Counter *string `yaml:"counter"` - DeleteMessage *bool `yaml:"delete_message"` - Respond *string `yaml:"respond"` - Timeout *time.Duration `yaml:"timeout"` + Ban *string `json:"ban" yaml:"ban"` + Command []string `json:"command" yaml:"command"` + CounterStep *int64 `json:"counter_step" yaml:"counter_step"` + Counter *string `json:"counter" yaml:"counter"` + DeleteMessage *bool `json:"delete_message" yaml:"delete_message"` + Respond *string `json:"respond" yaml:"respond"` + Timeout *time.Duration `json:"timeout" yaml:"timeout"` } func loadConfig(filename string) error { diff --git a/main.go b/main.go index fcbbc31..1847f1b 100644 --- a/main.go +++ b/main.go @@ -14,12 +14,13 @@ import ( 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"` + CommandTimeout time.Duration `flag:"command-timeout" default:"30s" description:"Timeout for command execution"` + 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 diff --git a/wiki/Home.md b/wiki/Home.md index 237a244..18eb1d7 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -19,6 +19,9 @@ rules: # See below for examples # Issue a ban on the user who wrote the chat-line - ban: "reason of ban" + # Command to execute for the chat message, must return an JSON encoded array of actions + - command: [/bin/bash, -c, "echo '[{\"respond\": \"Text\"}]'"] + # Modify an internal counter value (does NOT send a chat line) - counter: "counterid" # String to identify the counter, applies templating counter_step: 1 # Integer, can be negative or positive, default: +1 @@ -81,6 +84,50 @@ Additionally there are some functions available in the templates: - `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. - `tag ` - Takes the message sent to the channel, returns the value of the tag specified +## Command executions + +Your command will get a JSON object passed through `stdin` you can parse to gain details about the message. It is expected to yield an array of actions on `stdout` and exit with status `0`. If it does not the action will be marked failed. In case you need to output debug output you can use `stderr` which is directly piped to the bots `stderr`. + +This is an example input you might get on `stdin`: + +```json +{ + "badges": { + "glhf-pledge": 1, + "moderator": 1 + }, + "channel": "#tezrian", + "message": "!test", + "tags": { + "badge-info": "", + "badges": "moderator/1,glhf-pledge/1", + "client-nonce": "6801c82a341f728dbbaad87ef30eae49", + "color": "#A72920", + "display-name": "Luziferus", + "emotes": "", + "flags": "", + "id": "dca06466-3741-4b22-8339-4cb5b07a02cc", + "mod": "1", + "room-id": "485884564", + "subscriber": "0", + "tmi-sent-ts": "1610313040489", + "turbo": "0", + "user-id": "69699328", + "user-type": "mod" + }, + "username": "luziferus" +} +``` + +The example was dumped using this action: + +```yaml + - actions: + - command: [/usr/bin/bash, -c, "jq . >&2"] + match_channels: ['#tezrian'] + match_message: '^!test' +``` + ## Rule examples ### Game death counter with dynamic name