From 8882882d4a7829341fbc9d5ee9894b063e14df1a Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Mon, 10 Jul 2023 20:20:00 +0200 Subject: [PATCH] Add tutorial message template for x plugin (#1127) --- cmd/executor/x/main.go | 62 +++++---- cmd/executor/x/templates/argo.yaml | 19 +-- cmd/executor/x/templates/flux.yaml | 94 ++++++++++++- cmd/executor/x/templates/helm.yaml | 55 +++++++- internal/executor/x/getter/load.go | 9 +- internal/executor/x/mathx/int.go | 8 ++ .../executor/x/output/message_tutorial.go | 64 +++++++++ internal/executor/x/run.go | 74 ++++++---- internal/executor/x/run_test.go | 130 ++++++++++++++++++ internal/executor/x/template/config.go | 65 +++++++-- .../TestRunnerNoExecuteTemplate/helm.yaml | 34 +++++ 11 files changed, 528 insertions(+), 86 deletions(-) create mode 100644 internal/executor/x/output/message_tutorial.go create mode 100644 internal/executor/x/run_test.go create mode 100644 internal/executor/x/testdata/TestRunnerNoExecuteTemplate/helm.yaml diff --git a/cmd/executor/x/main.go b/cmd/executor/x/main.go index 803ca0fba..57d41a6f9 100644 --- a/cmd/executor/x/main.go +++ b/cmd/executor/x/main.go @@ -105,7 +105,7 @@ func (i *XExecutor) Execute(ctx context.Context, in executor.ExecuteInput) (exec }, } if err := pluginx.MergeExecutorConfigs(in.Configs, &cfg); err != nil { - return executor.ExecuteOutput{}, err + return executor.ExecuteOutput{}, fmt.Errorf("while merging configs: %v", err) } log := loggerx.New(cfg.Logger) @@ -114,9 +114,10 @@ func (i *XExecutor) Execute(ctx context.Context, in executor.ExecuteInput) (exec err = renderer.RegisterAll(map[string]x.Render{ "parser:table:.*": output.NewTableCommandParser(log), "wrapper": output.NewCommandWrapper(), + "tutorial": output.NewTutorialWrapper(), }) if err != nil { - return executor.ExecuteOutput{}, err + return executor.ExecuteOutput{}, fmt.Errorf("while registering message renderers: %v", err) } runner := x.NewRunner(log, renderer) @@ -128,22 +129,25 @@ func (i *XExecutor) Execute(ctx context.Context, in executor.ExecuteInput) (exec tool := Normalize(strings.Join(cmd.Run.Tool, " ")) log.WithField("tool", tool).Info("Running command...") - kubeConfigPath, deleteFn, err := i.getKubeconfig(ctx, log, in) - defer deleteFn() - if err != nil { - return executor.ExecuteOutput{}, err - } - command := x.Parse(tool) - out, err := x.RunInstalledCommand(ctx, cfg.TmpDir, command.ToExecute, map[string]string{ - "KUBECONFIG": kubeConfigPath, - }) - if err != nil { - log.WithError(err).WithField("command", command.ToExecute).Error("failed to run command") - return executor.ExecuteOutput{}, err + run := func() (string, error) { + kubeConfigPath, deleteFn, err := i.getKubeconfig(ctx, log, in) + defer deleteFn() + if err != nil { + return "", fmt.Errorf("while creating kubeconfig: %v", err) + } + + out, err := x.RunInstalledCommand(ctx, cfg.TmpDir, command.ToExecute, map[string]string{ + "KUBECONFIG": kubeConfigPath, + }) + if err != nil { + log.WithError(err).WithField("command", command.ToExecute).Error("failed to run command") + return "", fmt.Errorf("while running command: %v", err) + } + return out, nil } - return runner.Run(ctx, cfg, state, command, out) + return runner.Run(ctx, cfg, state, command, run) case cmd.Install != nil: var ( tool = Normalize(strings.Join(cmd.Install.Tool, " ")) @@ -152,20 +156,24 @@ func (i *XExecutor) Execute(ctx context.Context, in executor.ExecuteInput) (exec downloadCmd = fmt.Sprintf("eget %s", tool) ) - log.WithFields(logrus.Fields{ - "dir": dir, - "isCustom": isCustom, - "userCommand": command.ToExecute, - "runCommand": downloadCmd, - }).Info("Installing binary...") - - if _, err := pluginx.ExecuteCommand(ctx, downloadCmd, pluginx.ExecuteCommandEnvs(map[string]string{ - "EGET_BIN": dir, - })); err != nil { - return executor.ExecuteOutput{}, err + run := func() (string, error) { + log.WithFields(logrus.Fields{ + "dir": dir, + "isCustom": isCustom, + "userCommand": command.ToExecute, + "runCommand": downloadCmd, + }).Info("Installing binary...") + + if _, err := pluginx.ExecuteCommand(ctx, downloadCmd, pluginx.ExecuteCommandEnvs(map[string]string{ + "EGET_BIN": dir, + })); err != nil { + return "", err + } + + return "Binary was installed successfully 🎉", nil } - return runner.Run(ctx, cfg, state, command, "Binary was installed successfully 🎉") + return runner.Run(ctx, cfg, state, command, run) } return executor.ExecuteOutput{ Message: api.NewPlaintextMessage("Command not supported", false), diff --git a/cmd/executor/x/templates/argo.yaml b/cmd/executor/x/templates/argo.yaml index 7d42befc3..cc4a68d06 100644 --- a/cmd/executor/x/templates/argo.yaml +++ b/cmd/executor/x/templates/argo.yaml @@ -1,12 +1,13 @@ templates: - - command: - prefix: "argo list" - parser: "table" + - trigger: + command: + prefix: "argo list" + type: "parser:table:space" message: - select: - name: "Workflows" - itemKey: "{{ .Namespace }}/{{ .Name }}" + selects: + - name: "Workflows" + keyTpl: "{{ .Namespace }}/{{ .Name }}" actions: - logs: "argo logs {{ .Name }} -n {{ .Namespace }}" - describe: "argo get {{ .Name }} -n {{ .Namespace }}" - delete: "argo delete {{ .Name }} -n {{ .Namespace }}" + logs: "argo logs {{ .Name }} -n {{ .Namespace }}" + describe: "argo get {{ .Name }} -n {{ .Namespace }}" + delete: "argo delete {{ .Name }} -n {{ .Namespace }}" diff --git a/cmd/executor/x/templates/flux.yaml b/cmd/executor/x/templates/flux.yaml index 56cddb5d9..28604d48d 100644 --- a/cmd/executor/x/templates/flux.yaml +++ b/cmd/executor/x/templates/flux.yaml @@ -1,6 +1,7 @@ templates: - trigger: - command: "flux get sources git" + command: + prefix: "flux get sources git" type: "parser:table:space" message: selects: @@ -16,12 +17,93 @@ templates: Message: {{ .Message}} - trigger: - command: "x install github.com/fluxcd/flux2" + command: + prefix: "x install github.com/fluxcd/flux2" type: "wrapper" message: buttons: - - name: "Get Help" - command: "{{BotName}} x run flux --help" - - name: "Initialize" - command: "{{BotName}} x run flux install" + - name: "Quickstart" + command: "{{BotName}} x run quickstart flux" style: "primary" + + - trigger: + command: + prefix: "quickstart flux" + type: "tutorial" + message: + paginate: + page: 5 + header: "Flux Quick Start tutorial" + buttons: + - name: "Check prerequisites" + command: "{{BotName}} x run flux check --pre" + description: "{{BotName}} flux check --pre" + - name: "Install Flux" + command: "{{BotName}} x run flux install" + description: "{{BotName}} flux install" + - name: "Create Git source" + command: | + {{BotName}} x run flux create source git webapp-latest + --url=https://github.com/stefanprodan/podinfo + --branch=master + --interval=3m + description: | + {{BotName}} flux create source git webapp-latest + --url=https://github.com/stefanprodan/podinfo + --branch=master + --interval=3m + - name: "List Git sources" + command: "{{BotName}} x run flux get sources git" + description: "{{BotName}} flux get sources git" + - name: "Reconcile Git source" + command: "{{BotName}} x run flux reconcile source git flux-system" + description: "{{BotName}} flux reconcile source git flux-system" + - name: "Export Git sources" + command: "{{BotName}} x run flux export source git --all" + description: "{{BotName}} flux export source git --all" + - name: "Create Kustomization" + command: | + {{BotName}} x run flux create kustomization webapp-dev + --source=webapp-latest + --path='./deploy/webapp/' + --prune=true + --interval=5m + --health-check='Deployment/backend.webapp' + --health-check='Deployment/frontend.webapp' + --health-check-timeout=2m + description: | + {{BotName}} x run flux create kustomization webapp-dev + --source=webapp-latest + --path='./deploy/webapp/' + --prune=true + --interval=5m + --health-check='Deployment/backend.webapp' + --health-check='Deployment/frontend.webapp' + --health-check-timeout=2m + - name: "Reconcile Kustomization" + command: "{{BotName}} x run flux reconcile kustomization webapp-dev --with-source" + description: "{{BotName}} flux reconcile kustomization webapp-dev --with-source" + - name: "Suspend Kustomization" + command: "{{BotName}} x run flux suspend kustomization webapp-dev" + description: "{{BotName}} flux suspend kustomization webapp-dev" + - name: "Export Kustomizations" + command: "{{BotName}} x run flux export kustomization --all" + description: "{{BotName}} flux export kustomization --all" + - name: "Resume Kustomization" + command: "{{BotName}} x run flux resume kustomization webapp-dev" + description: "{{BotName}} flux resume kustomization webapp-dev" + - name: "Delete Kustomization" + command: "{{BotName}} x run flux delete kustomization webapp-dev" + description: "{{BotName}} flux delete kustomization webapp-dev" + - name: "Delete Git source" + command: "{{BotName}} x run flux delete source git webapp-latest" + description: "{{BotName}} flux delete source" + - name: "Delete Kustomization" + command: "{{BotName}} x run flux delete kustomization webapp-dev" + description: "{{BotName}} flux delete kustomization webapp-dev" + - name: "Delete Git source" + command: "{{BotName}} x run flux delete source git webapp-latest --silent" + description: "{{BotName}} flux delete source git webapp-latest --silent" + - name: "Uninstall Flux" + command: "{{BotName}} x run flux uninstall" + description: "{{BotName}} flux uninstall" diff --git a/cmd/executor/x/templates/helm.yaml b/cmd/executor/x/templates/helm.yaml index b2ecfae41..6c603b1f9 100644 --- a/cmd/executor/x/templates/helm.yaml +++ b/cmd/executor/x/templates/helm.yaml @@ -1,13 +1,14 @@ templates: - trigger: - command: "helm list" + command: + regex: '^helm list(?:\s+(-A|-a))*\s?$' type: "parser:table:space" message: selects: - name: "Release" keyTpl: "{{ .Namespace }}/{{ .Name }}" actions: - notes: "helm get notes {{ .Name }} -n {{ .Namespace }}" + notes: "helm get notes {{ .Name }} -n {{ .Namespace }}" values: "helm get values {{ .Name }} -n {{ .Namespace }}" delete: "helm delete {{ .Name }} -n {{ .Namespace }}" preview: | @@ -15,3 +16,53 @@ templates: Namespace: {{ .Namespace }} Status: {{ .Status }} Chart: {{ .Chart }} + + - trigger: + command: + prefix: "x install https://get.helm.sh/helm-v" + type: "wrapper" + message: + buttons: + - name: "Quickstart" + command: "{{BotName}} x run quickstart helm" + style: "primary" + + - trigger: + command: + prefix: "quickstart helm" + type: "tutorial" + message: + paginate: + page: 5 + header: "Helm Quick Start tutorial" + buttons: + - name: "Global Help" + description: "{{BotName}} helm help" + command: "{{BotName}} x run helm help" + - name: "Version" + description: "{{BotName}} helm version" + command: "{{BotName}} x run helm version" + - name: "Install help" + description: "{{BotName}} helm install -h" + command: "{{BotName}} x run helm install -h" + - name: "Install by absolute URL" + description: "{{BotName}} helm install\n--repo https://charts.bitnami.com/bitnami psql postgresql\n--set clusterDomain='testing.local'" + command: "{{BotName}} x run helm install\n--repo https://charts.bitnami.com/bitnami psql postgresql\n--set clusterDomain='testing.local'" + - name: "Install by chart reference:" + description: "{{BotName}} helm install https://charts.bitnami.com/bitnami/postgresql-12.1.0.tgz --create-namespace -n test --generate-name" + command: "{{BotName}} x run helm install https://charts.bitnami.com/bitnami/postgresql-12.1.0.tgz --create-namespace -n test --generate-name" + - name: "List" + description: "{{BotName}} helm list -A" + command: "{{BotName}} x run helm list -A" + - name: "List with filter" + description: "{{BotName}} helm list -f 'p' -A" + command: "{{BotName}} x run helm list -f 'p' -A" + - name: "Status" + description: "{{BotName}} helm status psql" + command: "{{BotName}} x run helm status psql" + - name: "Upgrade" + description: "{{BotName}} helm upgrade --repo https://charts.bitnami.com/bitnami psql postgresql --set clusterDomain='cluster.local'" + command: "{{BotName}} x run helm upgrade --repo https://charts.bitnami.com/bitnami psql postgresql --set clusterDomain='cluster.local'" + - name: "History" + description: "{{BotName}} helm history psql" + command: "{{BotName}} x run helm history psql" diff --git a/internal/executor/x/getter/load.go b/internal/executor/x/getter/load.go index eca3c43a1..67baa8c8b 100644 --- a/internal/executor/x/getter/load.go +++ b/internal/executor/x/getter/load.go @@ -2,6 +2,7 @@ package getter import ( "context" + "fmt" "io/fs" "os" "path/filepath" @@ -16,6 +17,10 @@ type Source struct { // Load downloads defined sources and read them from the FS. func Load[T any](ctx context.Context, tmpDir string, templateSources []Source) ([]T, error) { + if len(templateSources) == 0 { + return nil, nil + } + err := EnsureDownloaded(ctx, templateSources, tmpDir) if err != nil { return nil, err @@ -36,7 +41,7 @@ func Load[T any](ctx context.Context, tmpDir string, templateSources []Source) ( file, err := os.ReadFile(filepath.Clean(path)) if err != nil { - return err + return fmt.Errorf("while reading file %q: %v", path, err) } var cfg struct { @@ -44,7 +49,7 @@ func Load[T any](ctx context.Context, tmpDir string, templateSources []Source) ( } err = yaml.Unmarshal(file, &cfg) if err != nil { - return err + return fmt.Errorf("while unmarshaling file %q: %v", path, err) } out = append(out, cfg.Templates...) return nil diff --git a/internal/executor/x/mathx/int.go b/internal/executor/x/mathx/int.go index 1170912bc..a44a848a5 100644 --- a/internal/executor/x/mathx/int.go +++ b/internal/executor/x/mathx/int.go @@ -17,3 +17,11 @@ func DecreaseWithMin(in, min int) int { } return in } + +// Max returns the largest of a or b. +func Max(a, b int) int { + if a > b { + return b + } + return a +} diff --git a/internal/executor/x/output/message_tutorial.go b/internal/executor/x/output/message_tutorial.go new file mode 100644 index 000000000..97295fdd1 --- /dev/null +++ b/internal/executor/x/output/message_tutorial.go @@ -0,0 +1,64 @@ +package output + +import ( + "fmt" + + "github.com/kubeshop/botkube/internal/executor/x/mathx" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/executor/x/template" + "github.com/kubeshop/botkube/pkg/api" +) + +// TutorialWrapper allows constructing interactive message with predefined steps. +type TutorialWrapper struct{} + +// NewTutorialWrapper returns a new TutorialWrapper instance. +func NewTutorialWrapper() *TutorialWrapper { + return &TutorialWrapper{} +} + +// RenderMessage returns interactive message with predefined steps. +func (p *TutorialWrapper) RenderMessage(cmd, _ string, _ *state.Container, msgCtx *template.Template) (api.Message, error) { + var ( + msg = msgCtx.TutorialMessage + start = mathx.Max(msg.Paginate.CurrentPage*msg.Paginate.Page, len(msg.Buttons)-2) + stop = mathx.Max(start+msg.Paginate.Page, len(msg.Buttons)) + ) + + return api.Message{ + OnlyVisibleForYou: true, + ReplaceOriginal: msg.Paginate.CurrentPage > 0, + Sections: []api.Section{ + { + Base: api.Base{ + Header: msg.Header, + }, + }, + { + Buttons: msg.Buttons[start:stop], + }, + { + Buttons: p.getPaginationButtons(msg, msg.Paginate.CurrentPage, cmd), + }, + }, + }, nil +} + +func (p *TutorialWrapper) getPaginationButtons(msg template.TutorialMessage, pageIndex int, cmd string) []api.Button { + allItems := len(msg.Buttons) + if allItems <= msg.Paginate.Page { + return nil + } + + btnsBuilder := api.NewMessageButtonBuilder() + + var out []api.Button + if pageIndex > 0 { + out = append(out, btnsBuilder.ForCommandWithoutDesc("Prev", fmt.Sprintf("x run %s @page:%d", cmd, mathx.DecreaseWithMin(pageIndex, 0)))) + } + + if pageIndex*msg.Paginate.Page < allItems-1 { + out = append(out, btnsBuilder.ForCommandWithoutDesc("Next", fmt.Sprintf("x run %s @page:%d", cmd, mathx.IncreaseWithMax(pageIndex, allItems-1)), api.ButtonStylePrimary)) + } + return out +} diff --git a/internal/executor/x/run.go b/internal/executor/x/run.go index 1b405aa9e..1d2c0f874 100644 --- a/internal/executor/x/run.go +++ b/internal/executor/x/run.go @@ -31,48 +31,70 @@ func NewRunner(log logrus.FieldLogger, renderer *Renderer) *Runner { } // Run runs a given command and parse its output if needed. -func (i *Runner) Run(ctx context.Context, cfg Config, state *state.Container, cmd Command, out string) (executor.ExecuteOutput, error) { - if cmd.IsRawRequired { - i.log.Info("Raw output was explicitly requested") - return executor.ExecuteOutput{ - Message: api.NewCodeBlockMessage(out, true), - }, nil - } - - templates, err := getter.Load[template.Template](ctx, cfg.TmpDir.GetDirectory(), cfg.Templates) +func (i *Runner) Run(ctx context.Context, cfg Config, state *state.Container, cmd Command, runFn func() (string, error)) (executor.ExecuteOutput, error) { + templates, err := i.getTemplates(ctx, cfg) if err != nil { return executor.ExecuteOutput{}, err } + cmdTemplate, tplFound := template.FindTemplate(templates, cmd.ToExecute) - for _, tpl := range templates { - i.log.WithFields(logrus.Fields{ - "trigger": tpl.Trigger.Command, - "type": tpl.Type, - }).Debug("Command template") + log := i.log.WithFields(logrus.Fields{ + "isRawRequired": cmd.IsRawRequired, + "skipExecution": cmdTemplate.SkipCommandExecution, + "foundTemplate": tplFound, + }) + + var cmdOutput string + if !cmdTemplate.SkipCommandExecution { + log.WithField("command", cmd.ToExecute).Error("Running command") + cmdOutput, err = runFn() + if err != nil { + return executor.ExecuteOutput{}, err + } } - cmdTemplate, found := template.FindWithPrefix(templates, cmd.ToExecute) - if !found { - i.log.Info("Templates config not found for command") + if !cmd.IsRawRequired && tplFound { + log.Info("Rendering message based on template") + render, err := i.renderer.Get(cmdTemplate.Type) + if err != nil { + return executor.ExecuteOutput{}, err + } + + cmdTemplate.TutorialMessage.Paginate.CurrentPage = cmd.PageIndex + message, err := render.RenderMessage(cmd.ToExecute, cmdOutput, state, &cmdTemplate) + if err != nil { + return executor.ExecuteOutput{}, err + } return executor.ExecuteOutput{ - Message: api.NewCodeBlockMessage(color.ClearCode(out), true), + Message: message, }, nil } - render, err := i.renderer.Get(cmdTemplate.Type) - if err != nil { - return executor.ExecuteOutput{}, err + log.Infof("Return directly got command output") + if cmdOutput == "" { + return executor.ExecuteOutput{}, nil // return empty message, so Botkube can convert it into "cricket sound" message } - message, err := render.RenderMessage(cmd.ToExecute, out, state, &cmdTemplate) - if err != nil { - return executor.ExecuteOutput{}, err - } return executor.ExecuteOutput{ - Message: message, + Message: api.NewCodeBlockMessage(color.ClearCode(cmdOutput), true), }, nil } +func (i *Runner) getTemplates(ctx context.Context, cfg Config) ([]template.Template, error) { + templates, err := getter.Load[template.Template](ctx, cfg.TmpDir.GetDirectory(), cfg.Templates) + if err != nil { + return nil, err + } + + for _, tpl := range templates { + i.log.WithFields(logrus.Fields{ + "trigger": tpl.Trigger.Command, + "type": tpl.Type, + }).Debug("Command template") + } + return templates, nil +} + // RunInstalledCommand runs a given user command for already installed CLIs. func RunInstalledCommand(ctx context.Context, tmp plugin.TmpDir, in string, envs map[string]string) (string, error) { opts := []pluginx.ExecuteCommandMutation{ diff --git a/internal/executor/x/run_test.go b/internal/executor/x/run_test.go new file mode 100644 index 000000000..89bbd3f18 --- /dev/null +++ b/internal/executor/x/run_test.go @@ -0,0 +1,130 @@ +package x + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kubeshop/botkube/internal/executor/x/getter" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/executor/x/template" + "github.com/kubeshop/botkube/internal/loggerx" + "github.com/kubeshop/botkube/internal/plugin" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/config" +) + +func TestRunnerRawOutput(t *testing.T) { + // given + cmd := Command{ + ToExecute: "test command", + IsRawRequired: true, + } + runExecuted := false + runFn := func() (string, error) { + runExecuted = true + return "command output", nil + } + expMsg := api.NewCodeBlockMessage("command output", true) + + runner := NewRunner(loggerx.NewNoop(), nil) + + // when + output, err := runner.Run(context.Background(), Config{}, nil, cmd, runFn) + + // then + assert.NoError(t, err) + assert.True(t, runExecuted) + assert.Equal(t, expMsg, output.Message) +} + +func TestRunnerNoTemplates(t *testing.T) { + // given + cmd := Command{ + ToExecute: "test command", + } + runExecuted := false + runFn := func() (string, error) { + runExecuted = true + return "command output", nil + } + expMsg := api.NewCodeBlockMessage("command output", true) + + runner := NewRunner(loggerx.NewNoop(), nil) + + // when + output, err := runner.Run(context.Background(), Config{}, nil, cmd, runFn) + + // then + assert.NoError(t, err) + assert.True(t, runExecuted) + assert.Equal(t, expMsg, output.Message) +} + +func TestRunnerNoExecuteTemplate(t *testing.T) { + // given + cmd := Command{ + ToExecute: "quickstart helm", + } + cfg := Config{ + Templates: []getter.Source{ + { + Ref: filepath.Join("./testdata/", t.Name()), + }, + }, + TmpDir: plugin.TmpDir(t.TempDir()), + Logger: config.Logger{}, + } + + runExecuted := false + runFn := func() (string, error) { + runExecuted = true + return "command output", nil + } + + expMsg := api.NewCodeBlockMessage(cmd.ToExecute, false) + + renderer := NewRenderer() + err := renderer.Register("tutorial", &MockRenderer{}) + require.NoError(t, err) + + runner := NewRunner(loggerx.NewNoop(), renderer) + + // when + output, err := runner.Run(context.Background(), cfg, nil, cmd, runFn) + + // then + assert.NoError(t, err) + assert.False(t, runExecuted) + assert.Equal(t, expMsg, output.Message) +} + +func TestRunnerExecuteError(t *testing.T) { + // given + cmd := Command{ + ToExecute: "test command", + } + fixErr := errors.New("fix error") + runFn := func() (string, error) { + return "", fixErr + } + + runner := NewRunner(loggerx.NewNoop(), nil) + + // when + output, err := runner.Run(context.Background(), Config{}, nil, cmd, runFn) + + // then + assert.EqualError(t, err, fixErr.Error()) + assert.Empty(t, output) +} + +type MockRenderer struct{} + +func (r *MockRenderer) RenderMessage(cmd, output string, state *state.Container, msgCtx *template.Template) (api.Message, error) { + return api.NewCodeBlockMessage(cmd, false), nil +} diff --git a/internal/executor/x/template/config.go b/internal/executor/x/template/config.go index a59322953..133e77ac0 100644 --- a/internal/executor/x/template/config.go +++ b/internal/executor/x/template/config.go @@ -1,6 +1,7 @@ package template import ( + "regexp" "strings" "gopkg.in/yaml.v3" @@ -11,15 +12,23 @@ import ( type ( // Template represents a template for message parsing. Template struct { - Type string `yaml:"type"` - Trigger Trigger `yaml:"trigger"` - ParseMessage ParseMessage `yaml:"-"` - WrapMessage WrapMessage `yaml:"-"` + Type string `yaml:"type"` + Trigger Trigger `yaml:"trigger"` + SkipCommandExecution bool `yaml:"-"` + ParseMessage ParseMessage `yaml:"-"` + WrapMessage WrapMessage `yaml:"-"` + TutorialMessage TutorialMessage `yaml:"-"` } // Trigger represents the trigger configuration for a template. Trigger struct { - Command string `yaml:"command"` + Command CommandMatchers `yaml:"command"` + } + + // CommandMatchers represents different command matching strategies. + CommandMatchers struct { + Prefix string `yaml:"prefix"` + Regexp string `yaml:"regex"` } // ParseMessage holds template for message that will be parsed by defined parser. @@ -39,6 +48,19 @@ type ( Name string `yaml:"name"` KeyTpl string `yaml:"keyTpl"` } + + // TutorialMessage holds template interactive tutorial message. + TutorialMessage struct { + Buttons api.Buttons `yaml:"buttons"` + Header string `yaml:"header"` + Paginate Paginate `yaml:"paginate"` + } + + // Paginate holds data required to do the pagination. + Paginate struct { + Page int `yaml:"page"` + CurrentPage int `yaml:"-"` + } ) // UnmarshalYAML is a custom unmarshaler for Template allowing to unmarshal into a proper struct @@ -72,6 +94,17 @@ func (su *Template) UnmarshalYAML(node *yaml.Node) error { return err } su.WrapMessage = data.Message + case data.Type == "tutorial": + var data struct { + Message TutorialMessage `yaml:"message"` + } + err = node.Decode(&data) + if err != nil { + return err + } + + su.SkipCommandExecution = true + su.TutorialMessage = data.Message } su.Type = data.Type @@ -79,15 +112,19 @@ func (su *Template) UnmarshalYAML(node *yaml.Node) error { return nil } -// FindWithPrefix finds a template with a matching command prefix. -func FindWithPrefix(tpls []Template, cmd string) (Template, bool) { - for idx := range tpls { - item := tpls[idx] - if item.Trigger.Command == "" { - continue - } - if strings.HasPrefix(cmd, item.Trigger.Command) { - return item, true +// FindTemplate finds a template with a matching command prefix. +func FindTemplate(tpls []Template, cmd string) (Template, bool) { + for _, item := range tpls { + switch { + case item.Trigger.Command.Prefix != "": + if strings.HasPrefix(cmd, item.Trigger.Command.Prefix) { + return item, true + } + case item.Trigger.Command.Regexp != "": + matched, _ := regexp.MatchString(item.Trigger.Command.Regexp, cmd) + if matched { + return item, true + } } } diff --git a/internal/executor/x/testdata/TestRunnerNoExecuteTemplate/helm.yaml b/internal/executor/x/testdata/TestRunnerNoExecuteTemplate/helm.yaml new file mode 100644 index 000000000..32139ca46 --- /dev/null +++ b/internal/executor/x/testdata/TestRunnerNoExecuteTemplate/helm.yaml @@ -0,0 +1,34 @@ +templates: + - trigger: + command: + prefix: "helm list" + type: "parser:table:space" + message: + selects: + - name: "Release" + keyTpl: "{{ .Namespace }}/{{ .Name }}" + actions: + notes: "helm get notes {{ .Name }} -n {{ .Namespace }}" + values: "helm get values {{ .Name }} -n {{ .Namespace }}" + delete: "helm delete {{ .Name }} -n {{ .Namespace }}" + preview: | + Name: {{ .Name }} + Namespace: {{ .Namespace }} + Status: {{ .Status }} + Chart: {{ .Chart }} + + - trigger: + command: + prefix: "quickstart helm" + type: "tutorial" + message: + paginate: + page: 5 + header: "Helm Quick Start tutorial" + buttons: + - name: "Global Help" + description: "{{Botkube}} helm help" + command: "{{Botkube}} helm help" + - name: "Version" + description: "{{Botkube}} helm version -h" + command: "{{Botkube}} helm version -h"