snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
270
server/public/pluginapi/experimental/flow/flow.go
Normal file
270
server/public/pluginapi/experimental/flow/flow.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||
)
|
||||
|
||||
type Name string
|
||||
|
||||
const (
|
||||
contextStepKey = "step"
|
||||
contextButtonKey = "button"
|
||||
)
|
||||
|
||||
type Flow struct {
|
||||
UserID string
|
||||
state *flowState
|
||||
|
||||
name Name
|
||||
api *pluginapi.Client
|
||||
pluginID string
|
||||
botUserID string
|
||||
|
||||
siteURL string
|
||||
|
||||
steps map[Name]Step
|
||||
index []Name
|
||||
done func(userID string, state State) error
|
||||
|
||||
debugLogState bool
|
||||
}
|
||||
|
||||
// NewFlow creates a new flow using direct messages with the user.
|
||||
//
|
||||
// name must be a unique identifier for the flow within the plugin.
|
||||
func NewFlow(name Name, api *pluginapi.Client, pluginID, botUserID string) (*Flow, error) {
|
||||
if api == nil {
|
||||
return nil, errors.New("API client must not be nil")
|
||||
}
|
||||
|
||||
config := api.Configuration.GetConfig()
|
||||
if config == nil {
|
||||
return nil, errors.New("failed to fetch configuration")
|
||||
}
|
||||
|
||||
if config.ServiceSettings.SiteURL == nil {
|
||||
return nil, errors.New("please configure the Mattermost Server's SiteURL, then restart the plugin.")
|
||||
}
|
||||
|
||||
return &Flow{
|
||||
name: name,
|
||||
api: api,
|
||||
pluginID: pluginID,
|
||||
botUserID: botUserID,
|
||||
siteURL: strings.TrimRight(*config.ServiceSettings.SiteURL, "/"),
|
||||
steps: map[Name]Step{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Flow) WithSteps(orderedSteps ...Step) *Flow {
|
||||
if f.steps == nil {
|
||||
f.steps = map[Name]Step{}
|
||||
}
|
||||
for _, step := range orderedSteps {
|
||||
stepName := step.name
|
||||
if _, ok := f.steps[stepName]; ok {
|
||||
f.api.Log.Warn("ignored duplicate step name", "name", stepName, "flow", f.name)
|
||||
continue
|
||||
}
|
||||
f.steps[stepName] = step
|
||||
f.index = append(f.index, stepName)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Flow) OnDone(done func(string, State) error) *Flow {
|
||||
f.done = done
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Flow) InitHTTP(r *mux.Router) *Flow {
|
||||
flowRouter := r.PathPrefix("/").Subrouter()
|
||||
flowRouter.HandleFunc(namePath(f.name)+"/button", f.handleButtonHTTP).Methods(http.MethodPost)
|
||||
flowRouter.HandleFunc(namePath(f.name)+"/dialog", f.handleDialogHTTP).Methods(http.MethodPost)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Flow) WithDebugLog() *Flow {
|
||||
f.debugLogState = true
|
||||
return f
|
||||
}
|
||||
|
||||
// ForUser creates a new flow using direct messages with the user.
|
||||
func (f *Flow) ForUser(userID string) *Flow {
|
||||
clone := *f
|
||||
clone.UserID = userID
|
||||
clone.state = nil
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (f *Flow) GetCurrentStep() (Name, error) {
|
||||
state, err := f.getState()
|
||||
if err != nil {
|
||||
// Don't return an error if no flow is running
|
||||
if errors.Is(err, errStateNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return state.StepName, err
|
||||
}
|
||||
|
||||
func (f *Flow) GetState() State {
|
||||
state, _ := f.getState()
|
||||
return state.AppState
|
||||
}
|
||||
|
||||
func (f *Flow) Start(appState State) error {
|
||||
if len(f.index) == 0 {
|
||||
return errors.New("no steps")
|
||||
}
|
||||
|
||||
err := f.storeState(flowState{
|
||||
AppState: appState,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.Go(f.index[0])
|
||||
}
|
||||
|
||||
func (f *Flow) Finish() error {
|
||||
state, err := f.getState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = f.removeState()
|
||||
|
||||
if f.done != nil {
|
||||
err = f.done(f.UserID, state.AppState)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *Flow) Go(toName Name) error {
|
||||
state, err := f.getState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if toName == state.StepName {
|
||||
// Stay at the current step, nothing to do
|
||||
return nil
|
||||
}
|
||||
// Moving onto a different step, mark the current step as "Done"
|
||||
if state.StepName != "" && !state.Done {
|
||||
from, ok := f.steps[state.StepName]
|
||||
if !ok {
|
||||
return errors.Errorf("%s: step not found", toName)
|
||||
}
|
||||
|
||||
var donePost *model.Post
|
||||
donePost, err = from.done(f, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if donePost != nil {
|
||||
donePost.Id = state.PostID
|
||||
err = f.api.Post.UpdatePost(donePost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if toName == "" {
|
||||
return f.Finish()
|
||||
}
|
||||
to, ok := f.steps[toName]
|
||||
if !ok {
|
||||
return errors.Errorf("%s: step not found", toName)
|
||||
}
|
||||
|
||||
post, terminal, err := to.do(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.processButtonPostActions(post)
|
||||
|
||||
if f.debugLogState {
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
post.Message = fmt.Sprintf("State:\n```\n%s\n```\n", string(data))
|
||||
}
|
||||
|
||||
err = f.api.Post.DM(f.botUserID, f.UserID, post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if terminal {
|
||||
return f.Finish()
|
||||
}
|
||||
|
||||
state.StepName = toName
|
||||
state.Done = false
|
||||
state.PostID = post.Id
|
||||
err = f.storeState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if to.autoForward {
|
||||
var nextName Name
|
||||
|
||||
if to.forwardTo != "" {
|
||||
nextName = to.forwardTo
|
||||
} else {
|
||||
nextName = f.next(toName)
|
||||
}
|
||||
|
||||
if nextName != "" {
|
||||
return f.Go(nextName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Flow) next(fromName Name) Name {
|
||||
for i, n := range f.index {
|
||||
if fromName == n {
|
||||
if i+1 < len(f.index) {
|
||||
return f.index[i+1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func namePath(name Name) string {
|
||||
return "/" + url.PathEscape(strings.Trim(string(name), "/"))
|
||||
}
|
||||
|
||||
func Goto(toName Name) func(*Flow) (Name, State, error) {
|
||||
return func(_ *Flow) (Name, State, error) {
|
||||
return toName, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func DialogGoto(toName Name) func(*Flow, map[string]any) (Name, State, map[string]string, error) {
|
||||
return func(_ *Flow, submitted map[string]any) (Name, State, map[string]string, error) {
|
||||
stateUpdate := State{}
|
||||
for k, v := range submitted {
|
||||
stateUpdate[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
return toName, stateUpdate, nil, nil
|
||||
}
|
||||
}
|
||||
211
server/public/pluginapi/experimental/flow/handler.go
Normal file
211
server/public/pluginapi/experimental/flow/handler.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License for license information.
|
||||
|
||||
package flow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
|
||||
)
|
||||
|
||||
func (f *Flow) handleButtonHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("Mattermost-User-ID")
|
||||
if userID == "" {
|
||||
common.MessageAttachmentError(w, errors.New("Not authorized"))
|
||||
return
|
||||
}
|
||||
f = f.ForUser(userID)
|
||||
|
||||
var request model.PostActionIntegrationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
common.MessageAttachmentError(w, errors.New("invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
// selectedButton is 1-based
|
||||
fromName, selectedButton, err := buttonContext(&request)
|
||||
if err != nil {
|
||||
common.MessageAttachmentError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
donePost, err := f.handleButton(fromName, selectedButton, request.TriggerId)
|
||||
if err != nil {
|
||||
common.MessageAttachmentError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(model.PostActionIntegrationResponse{
|
||||
Update: donePost,
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Flow) handleDialogHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("Mattermost-User-ID")
|
||||
if userID == "" {
|
||||
common.DialogError(w, errors.New("not authorized"))
|
||||
return
|
||||
}
|
||||
f = f.ForUser(userID)
|
||||
|
||||
var request model.SubmitDialogRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
common.DialogError(w, errors.New("invalid request"))
|
||||
return
|
||||
}
|
||||
fromName, selectedButton, err := dialogContext(&request)
|
||||
if err != nil {
|
||||
common.DialogError(w, errors.Wrap(err, "invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
// handleDialog updates the post
|
||||
donePost, fieldErrors, err := f.handleDialog(fromName, selectedButton, request.Submission)
|
||||
if err != nil || len(fieldErrors) != 0 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
resp := model.SubmitDialogResponse{
|
||||
Errors: fieldErrors,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
err = f.api.Post.UpdatePost(donePost)
|
||||
if err != nil {
|
||||
common.DialogError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(model.SubmitDialogResponse{})
|
||||
}
|
||||
|
||||
func (f *Flow) handleButton(fromName Name, selectedButton int, triggerID string) (*model.Post, error) {
|
||||
post, _, err := f.handle(fromName, selectedButton, nil, triggerID, true)
|
||||
return post, err
|
||||
}
|
||||
|
||||
func (f *Flow) handleDialog(
|
||||
fromName Name, selectedButton int, submission map[string]any,
|
||||
) (
|
||||
*model.Post, map[string]string, error,
|
||||
) {
|
||||
return f.handle(fromName, selectedButton, submission, "", false)
|
||||
}
|
||||
|
||||
func (f *Flow) handle(
|
||||
fromName Name, selectedButton int, submission map[string]any, triggerID string, asButton bool,
|
||||
) (
|
||||
*model.Post, map[string]string, error,
|
||||
) {
|
||||
state, err := f.getState()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if state.StepName != fromName {
|
||||
return nil, nil, errors.Errorf("click from an inactive step: %v", fromName)
|
||||
}
|
||||
from, ok := f.steps[fromName]
|
||||
if !ok {
|
||||
return nil, nil, errors.Errorf("step %q not found", fromName)
|
||||
}
|
||||
|
||||
if selectedButton == 0 || selectedButton > len(from.buttons) {
|
||||
return nil, nil, errors.Errorf("button number %v to high or too low, only %v buttons", selectedButton, len(from.buttons))
|
||||
}
|
||||
b := from.buttons[selectedButton-1]
|
||||
|
||||
var updated State
|
||||
toName := fromName
|
||||
var fieldErrors map[string]string
|
||||
if asButton {
|
||||
if b.OnClick != nil {
|
||||
toName, updated, err = b.OnClick(f)
|
||||
}
|
||||
} else {
|
||||
if b.OnDialogSubmit != nil {
|
||||
toName, updated, fieldErrors, err = b.OnDialogSubmit(f, submission)
|
||||
}
|
||||
}
|
||||
if err != nil || len(fieldErrors) > 0 {
|
||||
return nil, fieldErrors, err
|
||||
}
|
||||
maps.Copy(state.AppState, updated)
|
||||
state.Done = true
|
||||
err = f.storeState(state)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Empty next step name in the response indicates advancing to the next step
|
||||
// in the flow. To stay on the same step the handlers should return the step
|
||||
// name.
|
||||
if toName == "" {
|
||||
toName = f.next(fromName)
|
||||
}
|
||||
|
||||
if asButton && b.Dialog != nil {
|
||||
if b.OnDialogSubmit == nil {
|
||||
return nil, nil, errors.Errorf("no submit function for dialog, step: %s", fromName)
|
||||
}
|
||||
|
||||
dialogRequest := model.OpenDialogRequest{
|
||||
TriggerId: triggerID,
|
||||
URL: "/plugins/" + f.pluginID + namePath(f.name) + "/dialog",
|
||||
Dialog: processDialog(b.Dialog, state.AppState),
|
||||
}
|
||||
dialogRequest.Dialog.State = fmt.Sprintf("%v,%v", fromName, selectedButton)
|
||||
|
||||
err = f.api.Frontend.OpenInteractiveDialog(dialogRequest)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if toName == fromName {
|
||||
// Nothing else to do
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
donePost, err := from.done(f, selectedButton)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
donePost.Id = state.PostID
|
||||
f.processButtonPostActions(donePost)
|
||||
|
||||
err = f.Go(toName)
|
||||
if err != nil {
|
||||
f.api.Log.Warn("failed to advance flow to next step", "flow_name", f.name, "from", fromName, "to", toName, "error", err.Error())
|
||||
}
|
||||
|
||||
// return the "done" post for the from step - leave updating up to the
|
||||
// API-specific caller.
|
||||
return donePost, nil, nil
|
||||
}
|
||||
|
||||
func (f *Flow) processButtonPostActions(post *model.Post) {
|
||||
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
|
||||
if !ok || len(attachments) == 0 {
|
||||
return
|
||||
}
|
||||
sa := attachments[0]
|
||||
for _, a := range sa.Actions {
|
||||
if a.Integration == nil {
|
||||
a.Integration = &model.PostActionIntegration{}
|
||||
}
|
||||
a.Integration.URL = "/plugins/" + f.pluginID + namePath(f.name) + "/button"
|
||||
}
|
||||
}
|
||||
135
server/public/pluginapi/experimental/flow/state.go
Normal file
135
server/public/pluginapi/experimental/flow/state.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var errStateNotFound = errors.New("flow state not found")
|
||||
|
||||
// State is the "app"'s state
|
||||
type State map[string]any
|
||||
|
||||
// GetString return the value to a given key as a string.
|
||||
// If the key is not found or isn't a string, an empty string is returned.
|
||||
func (s State) GetString(key string) string {
|
||||
vRaw, ok := s[key]
|
||||
if ok {
|
||||
v, ok := vRaw.(string)
|
||||
if ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetInt return the value to a given key as a int.
|
||||
// If the key is not found or isn't an int, zero is returned.
|
||||
func (s State) GetInt(key string) int {
|
||||
vRaw, ok := s[key]
|
||||
if ok {
|
||||
v, ok := vRaw.(int)
|
||||
if ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetBool return the value to a given key as a bool.
|
||||
// If the key is not found or isn't a bool, false is returned.
|
||||
func (s State) GetBool(key string) bool {
|
||||
vRaw, ok := s[key]
|
||||
if ok {
|
||||
v, ok := vRaw.(bool)
|
||||
if ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// JSON-serializable flow state.
|
||||
type flowState struct {
|
||||
// The name of the step.
|
||||
StepName Name
|
||||
|
||||
Done bool
|
||||
|
||||
// ID of the post produced by the step.
|
||||
PostID string
|
||||
|
||||
// Application-level state.
|
||||
AppState State
|
||||
}
|
||||
|
||||
func (f *Flow) storeState(state flowState) error {
|
||||
if f.UserID == "" {
|
||||
return errors.New("no user specified")
|
||||
}
|
||||
|
||||
// Set AppState to differentiate an existing flow
|
||||
if state.AppState == nil {
|
||||
state.AppState = State{}
|
||||
}
|
||||
|
||||
ok, err := f.api.KV.Set(kvKey(f.UserID, f.name), state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("value not set without errors")
|
||||
}
|
||||
|
||||
f.state = &state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) getState() (flowState, error) {
|
||||
if f.UserID == "" {
|
||||
return flowState{}, errors.New("no user specified")
|
||||
}
|
||||
if f.state != nil {
|
||||
return *f.state, nil
|
||||
}
|
||||
state := flowState{}
|
||||
err := f.api.KV.Get(kvKey(f.UserID, f.name), &state)
|
||||
if err != nil {
|
||||
return flowState{}, err
|
||||
}
|
||||
if state.AppState == nil {
|
||||
return flowState{}, errStateNotFound
|
||||
}
|
||||
|
||||
f.state = &state
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (f *Flow) removeState() error {
|
||||
if f.UserID == "" {
|
||||
return errors.New("no user specified")
|
||||
}
|
||||
f.state = nil
|
||||
return f.api.KV.Delete(kvKey(f.UserID, f.name))
|
||||
}
|
||||
|
||||
func kvKey(userID string, flowName Name) string {
|
||||
return "_flow-" + userID + "-" + string(flowName)
|
||||
}
|
||||
|
||||
func formatState(source string, state State) string {
|
||||
t, err := template.New("message").Parse(source)
|
||||
if err != nil {
|
||||
return source + " ###ERROR: " + err.Error()
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
err = t.Execute(buf, state)
|
||||
if err != nil {
|
||||
return source + " ###ERROR: " + err.Error()
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
270
server/public/pluginapi/experimental/flow/step.go
Normal file
270
server/public/pluginapi/experimental/flow/step.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package flow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
type Color string
|
||||
|
||||
const (
|
||||
ColorDefault Color = "default"
|
||||
ColorPrimary Color = "primary"
|
||||
ColorSuccess Color = "success"
|
||||
ColorGood Color = "good"
|
||||
ColorWarning Color = "warning"
|
||||
ColorDanger Color = "danger"
|
||||
)
|
||||
|
||||
type Step struct {
|
||||
name Name
|
||||
template *model.MessageAttachment
|
||||
forwardTo Name
|
||||
autoForward bool
|
||||
terminal bool
|
||||
onRender func(f *Flow)
|
||||
buttons []Button
|
||||
}
|
||||
|
||||
type Button struct {
|
||||
Name string
|
||||
Disabled bool
|
||||
Color Color
|
||||
|
||||
// OnClick is called when the button is clicked. It returns the next step's
|
||||
// name and the state updates to apply.
|
||||
//
|
||||
// If Dialog is also specified, OnClick is executed first.
|
||||
OnClick func(f *Flow) (Name, State, error)
|
||||
|
||||
// Dialog is the interactive dialog to display if the button is clicked
|
||||
// (OnClick is executed first). OnDialogSubmit must be provided.
|
||||
Dialog *model.Dialog
|
||||
|
||||
// Function that is called when the dialog box is submitted. It can return a
|
||||
// general error, or field-specific errors. On success it returns the name
|
||||
// of the next step, and the state updates to apply.
|
||||
OnDialogSubmit func(f *Flow, submitted map[string]any) (Name, State, map[string]string, error)
|
||||
}
|
||||
|
||||
func NewStep(name Name) Step {
|
||||
return Step{
|
||||
name: name,
|
||||
template: &model.MessageAttachment{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s Step) WithButton(buttons ...Button) Step {
|
||||
s.buttons = append(s.buttons, buttons...)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) Terminal() Step {
|
||||
s.terminal = true
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) OnRender(f func(*Flow)) Step {
|
||||
s.onRender = f
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) Next(name Name) Step {
|
||||
s.forwardTo = name
|
||||
s.autoForward = true
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithImage(imageURL string) Step {
|
||||
if u, err := url.Parse(imageURL); err == nil {
|
||||
if u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") {
|
||||
s.template.ImageURL = imageURL
|
||||
} else {
|
||||
s.template.ImageURL = u.Path
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithColor(color Color) Step {
|
||||
s.template.Color = string(color)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithPretext(text string) Step {
|
||||
s.template.Pretext = text
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithField(title, value string) Step {
|
||||
s.template.Fields = append(s.template.Fields, &model.MessageAttachmentField{
|
||||
Title: title,
|
||||
Value: value,
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithTitle(text string) Step {
|
||||
s.template.Title = text
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) WithText(text string) Step {
|
||||
s.template.Text = text
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Step) do(f *Flow) (*model.Post, bool, error) {
|
||||
if s.onRender != nil {
|
||||
s.onRender(f)
|
||||
}
|
||||
|
||||
return s.render(f, false, 0)
|
||||
}
|
||||
|
||||
func (s Step) done(f *Flow, selectedButton int) (*model.Post, error) {
|
||||
post, _, err := s.render(f, true, selectedButton)
|
||||
return post, err
|
||||
}
|
||||
|
||||
func (s Step) render(f *Flow, done bool, selectedButton int) (*model.Post, bool, error) {
|
||||
sa := f.processAttachment(s.template)
|
||||
post := model.Post{}
|
||||
model.ParseMessageAttachment(&post, []*model.MessageAttachment{sa})
|
||||
|
||||
if s.terminal {
|
||||
// Nothing else to do, do not display buttons on terminal posts.
|
||||
return &post, true, nil
|
||||
}
|
||||
|
||||
buttons := processButtons(s.buttons, f.state.AppState)
|
||||
|
||||
attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
|
||||
if !ok || len(attachments) != 1 {
|
||||
return nil, false, errors.New("expected 1 message attachment")
|
||||
}
|
||||
var actions []*model.PostAction
|
||||
if done {
|
||||
if selectedButton > 0 {
|
||||
action := renderButton(buttons[selectedButton-1], s.name, selectedButton, f.state.AppState)
|
||||
action.Disabled = true
|
||||
actions = append(actions, action)
|
||||
}
|
||||
} else {
|
||||
for i, b := range buttons {
|
||||
actions = append(actions, renderButton(b, s.name, i+1, f.state.AppState))
|
||||
}
|
||||
}
|
||||
attachments[0].Actions = actions
|
||||
return &post, false, nil
|
||||
}
|
||||
|
||||
func (f *Flow) processAttachment(attachment *model.MessageAttachment) *model.MessageAttachment {
|
||||
if attachment == nil {
|
||||
return &model.MessageAttachment{Text: "ERROR"}
|
||||
}
|
||||
a := *attachment
|
||||
a.Pretext = formatState(attachment.Pretext, f.state.AppState)
|
||||
a.Title = formatState(attachment.Title, f.state.AppState)
|
||||
a.Text = formatState(attachment.Text, f.state.AppState)
|
||||
|
||||
for _, field := range a.Fields {
|
||||
field.Title = formatState(field.Title, f.state.AppState)
|
||||
v := field.Value.(string)
|
||||
if v != "" {
|
||||
field.Value = formatState(v, f.state.AppState)
|
||||
}
|
||||
}
|
||||
|
||||
a.Fallback = fmt.Sprintf("%s: %s", a.Title, a.Text)
|
||||
|
||||
if attachment.ImageURL != "" {
|
||||
if u, err := url.Parse(attachment.ImageURL); err == nil {
|
||||
if u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") {
|
||||
a.ImageURL = attachment.ImageURL
|
||||
} else {
|
||||
a.ImageURL = fmt.Sprintf("%s/plugins/%s/%s", f.siteURL, f.pluginID, strings.TrimPrefix(attachment.ImageURL, "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &a
|
||||
}
|
||||
|
||||
func processButtons(in []Button, state State) []Button {
|
||||
var out []Button
|
||||
for _, b := range in {
|
||||
button := b
|
||||
button.Name = formatState(b.Name, state)
|
||||
out = append(out, button)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func processDialog(in *model.Dialog, state State) model.Dialog {
|
||||
d := *in
|
||||
d.Title = formatState(d.Title, state)
|
||||
d.IntroductionText = formatState(d.IntroductionText, state)
|
||||
d.SubmitLabel = formatState(d.SubmitLabel, state)
|
||||
for i := range d.Elements {
|
||||
d.Elements[i].DisplayName = formatState(d.Elements[i].DisplayName, state)
|
||||
d.Elements[i].Name = formatState(d.Elements[i].Name, state)
|
||||
d.Elements[i].Default = formatState(d.Elements[i].Default, state)
|
||||
d.Elements[i].Placeholder = formatState(d.Elements[i].Placeholder, state)
|
||||
d.Elements[i].HelpText = formatState(d.Elements[i].HelpText, state)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func renderButton(b Button, stepName Name, i int, state State) *model.PostAction {
|
||||
return &model.PostAction{
|
||||
Type: model.PostActionTypeButton,
|
||||
Name: formatState(b.Name, state),
|
||||
Disabled: b.Disabled,
|
||||
Style: string(b.Color),
|
||||
Integration: &model.PostActionIntegration{
|
||||
Context: map[string]any{
|
||||
contextStepKey: string(stepName),
|
||||
contextButtonKey: strconv.Itoa(i),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buttonContext(request *model.PostActionIntegrationRequest) (Name, int, error) {
|
||||
fromString, ok := request.Context[contextStepKey].(string)
|
||||
if !ok {
|
||||
return "", 0, errors.New("missing step name")
|
||||
}
|
||||
fromName := Name(fromString)
|
||||
|
||||
buttonStr, ok := request.Context[contextButtonKey].(string)
|
||||
if !ok {
|
||||
return "", 0, errors.New("missing button id")
|
||||
}
|
||||
buttonIndex, err := strconv.Atoi(buttonStr)
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(err, "invalid button number")
|
||||
}
|
||||
|
||||
return fromName, buttonIndex, nil
|
||||
}
|
||||
|
||||
func dialogContext(request *model.SubmitDialogRequest) (Name, int, error) {
|
||||
data := strings.Split(request.State, ",")
|
||||
if len(data) != 2 {
|
||||
return "", 0, errors.New("invalid request")
|
||||
}
|
||||
fromName := Name(data[0])
|
||||
buttonIndex, err := strconv.Atoi(data[1])
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(err, "malformed button number")
|
||||
}
|
||||
return fromName, buttonIndex, nil
|
||||
}
|
||||
Reference in New Issue
Block a user