[MM-53968] Includes mattermost-plugin-api into the mono repo (#24235)

Include https://github.com/mattermost/mattermost-plugin-api into the mono repo

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
Co-authored-by: Michael Kochell <mjkochell@gmail.com>
Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com>
Co-authored-by: Alex Dovenmuehle <alex.dovenmuehle@mattermost.com>
Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
Co-authored-by: Christopher Poile <cpoile@gmail.com>
Co-authored-by: İlker Göktuğ Öztürk <ilkergoktugozturk@gmail.com>
Co-authored-by: Shota Gvinepadze <wineson@gmail.com>
Co-authored-by: Ali Farooq <ali.farooq0@pm.me>
Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com>
Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Christopher Speller <crspeller@gmail.com>
Co-authored-by: Alex Dovenmuehle <adovenmuehle@gmail.com>
Co-authored-by: Szymon Gibała <szymongib@gmail.com>
Co-authored-by: Lev <1187448+levb@users.noreply.github.com>
Co-authored-by: Jason Frerich <jason.frerich@mattermost.com>
Co-authored-by: Agniva De Sarker <agnivade@yahoo.co.in>
Co-authored-by: Artur M. Wolff <artur.m.wolff@gmail.com>
Co-authored-by: Madhav Hugar <16546715+madhavhugar@users.noreply.github.com>
Co-authored-by: Joe <security.joe@pm.me>
Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
Co-authored-by: José Peso <trilopin@users.noreply.github.com>
Этот коммит содержится в:
Ben Schumacher
2023-08-21 09:50:30 +02:00
коммит произвёл GitHub
родитель bc11b29807
Коммит 3ee5432664
117 изменённых файлов: 14912 добавлений и 5 удалений

Просмотреть файл

@@ -0,0 +1,254 @@
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
pluginURL string
botUserID 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, pluginURL, botUserID string) *Flow {
return &Flow{
name: name,
api: api,
pluginURL: pluginURL,
botUserID: botUserID,
steps: map[Name]Step{},
}
}
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]interface{}) (Name, State, map[string]string, error) {
return func(_ *Flow, submitted map[string]interface{}) (Name, State, map[string]string, error) {
stateUpdate := State{}
for k, v := range submitted {
stateUpdate[k] = fmt.Sprintf("%v", v)
}
return toName, stateUpdate, nil, nil
}
}

Просмотреть файл

@@ -0,0 +1,210 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package flow
import (
"encoding/json"
"fmt"
"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.SlackAttachmentError(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.SlackAttachmentError(w, errors.New("invalid request"))
return
}
// selectedButton is 1-based
fromName, selectedButton, err := buttonContext(&request)
if err != nil {
common.SlackAttachmentError(w, err)
return
}
donePost, err := f.handleButton(fromName, selectedButton, request.TriggerId)
if err != nil {
common.SlackAttachmentError(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]interface{},
) (
*model.Post, map[string]string, error,
) {
return f.handle(fromName, selectedButton, submission, "", false)
}
func (f *Flow) handle(
fromName Name, selectedButton int, submission map[string]interface{}, 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
}
state.AppState = state.AppState.MergeWith(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: f.pluginURL + 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("attachments").([]*model.SlackAttachment)
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 = f.pluginURL + namePath(f.name) + "/button"
}
}

Просмотреть файл

@@ -0,0 +1,146 @@
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]interface{}
func (s State) MergeWith(update State) State {
n := State{}
for k, v := range s {
n[k] = v
}
for k, v := range update {
n[k] = v
}
return n
}
// 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()
}

Просмотреть файл

@@ -0,0 +1,269 @@
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.SlackAttachment
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]interface{}) (Name, State, map[string]string, error)
}
func NewStep(name Name) Step {
return Step{
name: name,
template: &model.SlackAttachment{},
}
}
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.SlackAttachmentField{
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.ParseSlackAttachment(&post, []*model.SlackAttachment{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("attachments").([]*model.SlackAttachment)
if !ok || len(attachments) != 1 {
return nil, false, errors.New("expected 1 slack 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.SlackAttachment) *model.SlackAttachment {
if attachment == nil {
return &model.SlackAttachment{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 = f.pluginURL + "/" + 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{
Name: formatState(b.Name, state),
Disabled: b.Disabled,
Style: string(b.Color),
Integration: &model.PostActionIntegration{
Context: map[string]interface{}{
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
}