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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package bot
import (
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/pluginapi"
)
type Bot interface {
Ensure(stored *model.Bot, iconPath string) error
MattermostUserID() string
String() string
}
type bot struct {
botService pluginapi.BotService
mattermostUserID string
displayName string
}
func New(botService pluginapi.BotService) Bot {
newBot := &bot{
botService: botService,
}
return newBot
}
func (bot *bot) Ensure(stored *model.Bot, iconPath string) error {
if bot.mattermostUserID != "" {
// Already done
return nil
}
botUserID, err := bot.botService.EnsureBot(stored, pluginapi.ProfileImagePath(iconPath))
if err != nil {
return errors.Wrap(err, "failed to ensure bot account")
}
bot.mattermostUserID = botUserID
bot.displayName = stored.DisplayName
return nil
}
func (bot *bot) MattermostUserID() string {
return bot.mattermostUserID
}
func (bot *bot) String() string {
return bot.displayName
}

View File

@@ -0,0 +1,97 @@
package admincclogger
import (
"fmt"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
)
type adminCCLogger struct {
logger.Logger
dmer poster.DMer
logLevel logger.LogLevel
includeContext bool
userIDs []string
}
/*
New promotes the provided logger into a admin cc logger, sending direct messages to all the admin
ids provided through the dmer provided, about all events below the logLevel. If logVerbose is set,
it will also send the context.
- l Logger: A logger to promote.
- dmer DMer: A DMer to send the messages to the admins.
- logLevel: The highest type of message to be stored in telemetry.
- includeContext: Whether the log context should be messaged to the admins.
- userIDs: The user IDs of the admins.
*/
func New(l logger.Logger, dmer poster.DMer, logLevel logger.LogLevel, includeContext bool, userIDs ...string) logger.Logger {
return &adminCCLogger{
Logger: l,
dmer: dmer,
logLevel: logLevel,
includeContext: includeContext,
userIDs: userIDs,
}
}
// NewFromAPI creates a adminCCLogger directly from a LogAPI instead of passing a logger.
func NewFromAPI(api common.LogAPI, dmer poster.DMer, logLevel logger.LogLevel, includeContext bool, userIDs ...string) logger.Logger {
return New(logger.New(api), dmer, logLevel, includeContext, userIDs...)
}
func (l *adminCCLogger) Debugf(format string, args ...any) {
l.Logger.Debugf(format, args...)
message := fmt.Sprintf(format, args...)
if logger.Level(l.logLevel) >= 4 {
l.logToAdmins("DEBUG", message)
}
}
func (l *adminCCLogger) Errorf(format string, args ...any) {
l.Logger.Errorf(format, args...)
message := fmt.Sprintf(format, args...)
if logger.Level(l.logLevel) >= 1 {
l.logToAdmins("ERROR", message)
}
}
func (l *adminCCLogger) Infof(format string, args ...any) {
l.Logger.Infof(format, args...)
message := fmt.Sprintf(format, args...)
if logger.Level(l.logLevel) >= 3 {
l.logToAdmins("INFO", message)
}
}
func (l *adminCCLogger) Warnf(format string, args ...any) {
l.Logger.Warnf(format, args...)
message := fmt.Sprintf(format, args...)
if logger.Level(l.logLevel) >= 2 {
l.logToAdmins("WARN", message)
}
}
func (l *adminCCLogger) logToAdmins(level, message string) {
context := l.Context()
if l.includeContext && len(context) > 0 {
message += "\n" + common.JSONBlock(context)
}
_ = l.dmAdmins("(log %s) %s", level, message)
}
func (l *adminCCLogger) dmAdmins(format string, args ...any) error {
for _, id := range l.userIDs {
_, err := l.dmer.DM(id, format, args...)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package logger
import (
"fmt"
"maps"
"time"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
)
type defaultLogger struct {
logContext LogContext
logAPI common.LogAPI
}
/*
New creates a new logger.
- api: LogAPI implementation
*/
func New(api common.LogAPI) Logger {
l := &defaultLogger{
logAPI: api,
}
return l
}
func (l *defaultLogger) With(logContext LogContext) Logger {
newLogger := *l
if len(newLogger.logContext) == 0 {
newLogger.logContext = map[string]any{}
}
maps.Copy(newLogger.logContext, logContext)
return &newLogger
}
func (l *defaultLogger) WithError(err error) Logger {
newLogger := *l
if len(newLogger.logContext) == 0 {
newLogger.logContext = map[string]any{}
}
newLogger.logContext[ErrorKey] = err.Error()
return &newLogger
}
func (l *defaultLogger) Context() LogContext {
return l.logContext
}
func (l *defaultLogger) Timed() Logger {
return l.With(LogContext{
timed: time.Now(),
})
}
func (l *defaultLogger) Debugf(format string, args ...any) {
measure(l.logContext)
message := fmt.Sprintf(format, args...)
l.logAPI.LogDebug(message, toKeyValuePairs(l.logContext)...)
}
func (l *defaultLogger) Errorf(format string, args ...any) {
measure(l.logContext)
message := fmt.Sprintf(format, args...)
l.logAPI.LogError(message, toKeyValuePairs(l.logContext)...)
}
func (l *defaultLogger) Infof(format string, args ...any) {
measure(l.logContext)
message := fmt.Sprintf(format, args...)
l.logAPI.LogInfo(message, toKeyValuePairs(l.logContext)...)
}
func (l *defaultLogger) Warnf(format string, args ...any) {
measure(l.logContext)
message := fmt.Sprintf(format, args...)
l.logAPI.LogWarn(message, toKeyValuePairs(l.logContext)...)
}

View File

@@ -0,0 +1,78 @@
package logger
import "time"
const (
timed = "__since"
elapsed = "Elapsed"
ErrorKey = "error"
)
// LogLevel defines the level of log messages
type LogLevel string
const (
// LogLevelDebug denotes debug messages
LogLevelDebug = "debug"
// LogLevelInfo denotes info messages
LogLevelInfo = "info"
// LogLevelWarn denotes warn messages
LogLevelWarn = "warn"
// LogLevelError denotes error messages
LogLevelError = "error"
)
// LogContext defines the context for the logs.
type LogContext map[string]any
// Logger defines an object able to log messages.
type Logger interface {
// With adds a logContext to the logger.
With(LogContext) Logger
// WithError adds an Error to the logger.
WithError(error) Logger
// Context returns the current context
Context() LogContext
// Timed add a timed log context.
Timed() Logger
// Debugf logs a formatted string as a debug message.
Debugf(format string, args ...any)
// Errorf logs a formatted string as an error message.
Errorf(format string, args ...any)
// Infof logs a formatted string as an info message.
Infof(format string, args ...any)
// Warnf logs a formatted string as an warning message.
Warnf(format string, args ...any)
}
func measure(lc LogContext) {
if lc[timed] == nil {
return
}
started := lc[timed].(time.Time)
lc[elapsed] = time.Since(started).String()
delete(lc, timed)
}
// Level assigns an integer to the LogLevel string
func Level(l LogLevel) int {
switch l {
case LogLevelDebug:
return 4
case LogLevelInfo:
return 3
case LogLevelWarn:
return 2
case LogLevelError:
return 1
}
return 0
}
func toKeyValuePairs(in map[string]any) (out []any) {
for k, v := range in {
out = append(out, k, v)
}
return out
}

View File

@@ -0,0 +1,17 @@
package logger
type nilLogger struct{}
// NewNilLogger returns a logger that performs no action.
func NewNilLogger() Logger {
return &nilLogger{}
}
func (l *nilLogger) With(LogContext) Logger { return l }
func (l *nilLogger) WithError(error) Logger { return l }
func (l *nilLogger) Context() LogContext { return nil }
func (l *nilLogger) Timed() Logger { return l }
func (l *nilLogger) Debugf(string, ...any) {}
func (l *nilLogger) Errorf(string, ...any) {}
func (l *nilLogger) Infof(string, ...any) {}
func (l *nilLogger) Warnf(string, ...any) {}

View File

@@ -0,0 +1,60 @@
package logger
import (
"fmt"
"maps"
"testing"
"time"
)
type testLogger struct {
testing.TB
logContext LogContext
}
// NewTestLogger creates a logger for testing purposes.
func NewTestLogger() Logger {
return &testLogger{}
}
func (l *testLogger) With(logContext LogContext) Logger {
newl := *l
if len(newl.logContext) == 0 {
newl.logContext = map[string]any{}
}
maps.Copy(newl.logContext, logContext)
return &newl
}
func (l *testLogger) WithError(err error) Logger {
newl := *l
if len(newl.logContext) == 0 {
newl.logContext = map[string]any{}
}
newl.logContext[ErrorKey] = err.Error()
return &newl
}
func (l *testLogger) Context() LogContext {
return l.logContext
}
func (l *testLogger) Timed() Logger {
return l.With(LogContext{
timed: time.Now(),
})
}
func (l *testLogger) logf(prefix, format string, args ...any) {
out := fmt.Sprintf(prefix+": "+format, args...)
if len(l.logContext) > 0 {
measure(l.logContext)
out += fmt.Sprintf(" -- %+v", l.logContext)
}
l.TB.Log(out)
}
func (l *testLogger) Debugf(format string, args ...any) { l.logf("DEBUG", format, args...) }
func (l *testLogger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) }
func (l *testLogger) Infof(format string, args ...any) { l.logf("INFO", format, args...) }
func (l *testLogger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) }

View File

@@ -0,0 +1,77 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot (interfaces: Bot)
// Package mock_bot is a generated GoMock package.
package mock_bot
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost/server/public/model"
)
// MockBot is a mock of Bot interface.
type MockBot struct {
ctrl *gomock.Controller
recorder *MockBotMockRecorder
}
// MockBotMockRecorder is the mock recorder for MockBot.
type MockBotMockRecorder struct {
mock *MockBot
}
// NewMockBot creates a new mock instance.
func NewMockBot(ctrl *gomock.Controller) *MockBot {
mock := &MockBot{ctrl: ctrl}
mock.recorder = &MockBotMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockBot) EXPECT() *MockBotMockRecorder {
return m.recorder
}
// Ensure mocks base method.
func (m *MockBot) Ensure(arg0 *model.Bot, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Ensure", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Ensure indicates an expected call of Ensure.
func (mr *MockBotMockRecorder) Ensure(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ensure", reflect.TypeOf((*MockBot)(nil).Ensure), arg0, arg1)
}
// MattermostUserID mocks base method.
func (m *MockBot) MattermostUserID() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MattermostUserID")
ret0, _ := ret[0].(string)
return ret0
}
// MattermostUserID indicates an expected call of MattermostUserID.
func (mr *MockBotMockRecorder) MattermostUserID() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MattermostUserID", reflect.TypeOf((*MockBot)(nil).MattermostUserID))
}
// String mocks base method.
func (m *MockBot) String() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "String")
ret0, _ := ret[0].(string)
return ret0
}
// String indicates an expected call of String.
func (mr *MockBotMockRecorder) String() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockBot)(nil).String))
}

View File

@@ -0,0 +1,159 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger (interfaces: Logger)
// Package mock_bot is a generated GoMock package.
package mock_bot
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
logger "github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Context mocks base method.
func (m *MockLogger) Context() logger.LogContext {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Context")
ret0, _ := ret[0].(logger.LogContext)
return ret0
}
// Context indicates an expected call of Context.
func (mr *MockLoggerMockRecorder) Context() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockLogger)(nil).Context))
}
// Debugf mocks base method.
func (m *MockLogger) Debugf(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Debugf", varargs...)
}
// Debugf indicates an expected call of Debugf.
func (mr *MockLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...)
}
// Errorf mocks base method.
func (m *MockLogger) Errorf(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Errorf", varargs...)
}
// Errorf indicates an expected call of Errorf.
func (mr *MockLoggerMockRecorder) Errorf(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*MockLogger)(nil).Errorf), varargs...)
}
// Infof mocks base method.
func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Infof", varargs...)
}
// Infof indicates an expected call of Infof.
func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...)
}
// Timed mocks base method.
func (m *MockLogger) Timed() logger.Logger {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Timed")
ret0, _ := ret[0].(logger.Logger)
return ret0
}
// Timed indicates an expected call of Timed.
func (mr *MockLoggerMockRecorder) Timed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Timed", reflect.TypeOf((*MockLogger)(nil).Timed))
}
// Warnf mocks base method.
func (m *MockLogger) Warnf(arg0 string, arg1 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Warnf", varargs...)
}
// Warnf indicates an expected call of Warnf.
func (mr *MockLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...)
}
// With mocks base method.
func (m *MockLogger) With(arg0 logger.LogContext) logger.Logger {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "With", arg0)
ret0, _ := ret[0].(logger.Logger)
return ret0
}
// With indicates an expected call of With.
func (mr *MockLoggerMockRecorder) With(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockLogger)(nil).With), arg0)
}
// WithError mocks base method.
func (m *MockLogger) WithError(arg0 error) logger.Logger {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithError", arg0)
ret0, _ := ret[0].(logger.Logger)
return ret0
}
// WithError indicates an expected call of WithError.
func (mr *MockLoggerMockRecorder) WithError(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithError", reflect.TypeOf((*MockLogger)(nil).WithError), arg0)
}

View File

@@ -0,0 +1,151 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster (interfaces: Poster)
// Package mock_bot is a generated GoMock package.
package mock_bot
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost/server/public/model"
)
// MockPoster is a mock of Poster interface.
type MockPoster struct {
ctrl *gomock.Controller
recorder *MockPosterMockRecorder
}
// MockPosterMockRecorder is the mock recorder for MockPoster.
type MockPosterMockRecorder struct {
mock *MockPoster
}
// NewMockPoster creates a new mock instance.
func NewMockPoster(ctrl *gomock.Controller) *MockPoster {
mock := &MockPoster{ctrl: ctrl}
mock.recorder = &MockPosterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPoster) EXPECT() *MockPosterMockRecorder {
return m.recorder
}
// DM mocks base method.
func (m *MockPoster) DM(arg0, arg1 string, arg2 ...interface{}) (string, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "DM", varargs...)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DM indicates an expected call of DM.
func (mr *MockPosterMockRecorder) DM(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DM", reflect.TypeOf((*MockPoster)(nil).DM), varargs...)
}
// DMWithAttachments mocks base method.
func (m *MockPoster) DMWithAttachments(arg0 string, arg1 ...*model.MessageAttachment) (string, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "DMWithAttachments", varargs...)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DMWithAttachments indicates an expected call of DMWithAttachments.
func (mr *MockPosterMockRecorder) DMWithAttachments(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DMWithAttachments", reflect.TypeOf((*MockPoster)(nil).DMWithAttachments), varargs...)
}
// DeletePost mocks base method.
func (m *MockPoster) DeletePost(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeletePost", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// DeletePost indicates an expected call of DeletePost.
func (mr *MockPosterMockRecorder) DeletePost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePost", reflect.TypeOf((*MockPoster)(nil).DeletePost), arg0)
}
// Ephemeral mocks base method.
func (m *MockPoster) Ephemeral(arg0, arg1, arg2 string, arg3 ...interface{}) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1, arg2}
for _, a := range arg3 {
varargs = append(varargs, a)
}
m.ctrl.Call(m, "Ephemeral", varargs...)
}
// Ephemeral indicates an expected call of Ephemeral.
func (mr *MockPosterMockRecorder) Ephemeral(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1, arg2}, arg3...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ephemeral", reflect.TypeOf((*MockPoster)(nil).Ephemeral), varargs...)
}
// UpdatePost mocks base method.
func (m *MockPoster) UpdatePost(arg0 *model.Post) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdatePost", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// UpdatePost indicates an expected call of UpdatePost.
func (mr *MockPosterMockRecorder) UpdatePost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePost", reflect.TypeOf((*MockPoster)(nil).UpdatePost), arg0)
}
// UpdatePostByID mocks base method.
func (m *MockPoster) UpdatePostByID(arg0, arg1 string, arg2 ...interface{}) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "UpdatePostByID", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// UpdatePostByID indicates an expected call of UpdatePostByID.
func (mr *MockPosterMockRecorder) UpdatePostByID(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePostByID", reflect.TypeOf((*MockPoster)(nil).UpdatePostByID), varargs...)
}
// UpdatePosterID mocks base method.
func (m *MockPoster) UpdatePosterID(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "UpdatePosterID", arg0)
}
// UpdatePosterID indicates an expected call of UpdatePosterID.
func (mr *MockPosterMockRecorder) UpdatePosterID(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePosterID", reflect.TypeOf((*MockPoster)(nil).UpdatePosterID), arg0)
}

View File

@@ -0,0 +1,76 @@
package poster
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type defaultPoster struct {
postAPI PostAPI
id string
}
// NewPoster creates a new default poster
func NewPoster(postAPI PostAPI, id string) Poster {
return &defaultPoster{
postAPI: postAPI,
id: id,
}
}
// DM posts a simple Direct Message to the specified user
func (p *defaultPoster) DM(mattermostUserID, format string, args ...any) (string, error) {
post := &model.Post{
Message: fmt.Sprintf(format, args...),
}
err := p.postAPI.DM(p.id, mattermostUserID, post)
if err != nil {
return "", err
}
return post.Id, nil
}
// DMWithAttachments posts a Direct Message that contains Message Attachments.
// Often used to include post actions.
func (p *defaultPoster) DMWithAttachments(mattermostUserID string, attachments ...*model.MessageAttachment) (string, error) {
post := model.Post{}
model.ParseMessageAttachment(&post, attachments)
err := p.postAPI.DM(p.id, mattermostUserID, &post)
if err != nil {
return "", err
}
return post.Id, nil
}
// Ephemeral sends an ephemeral message to a user
func (p *defaultPoster) Ephemeral(userID, channelID, format string, args ...any) {
post := &model.Post{
UserId: p.id,
ChannelId: channelID,
Message: fmt.Sprintf(format, args...),
}
p.postAPI.SendEphemeralPost(userID, post)
}
func (p *defaultPoster) UpdatePostByID(postID, format string, args ...any) error {
post, err := p.postAPI.GetPost(postID)
if err != nil {
return err
}
post.Message = fmt.Sprintf(format, args...)
return p.UpdatePost(post)
}
func (p *defaultPoster) DeletePost(postID string) error {
return p.postAPI.DeletePost(postID)
}
func (p *defaultPoster) UpdatePost(post *model.Post) error {
return p.postAPI.UpdatePost(post)
}
func (p *defaultPoster) UpdatePosterID(id string) {
p.id = id
}

View File

@@ -0,0 +1,414 @@
package poster
import (
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
"github.com/mattermost/mattermost/server/public/pluginapi"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster/mock_import"
)
const (
botID = "test-bot-user"
userID = "test-user-1"
dmChannelID = "dm-channel-id"
)
func TestInterface(t *testing.T) {
t.Run("Plugin API satisfy the interface", func(t *testing.T) {
api := &plugintest.API{}
driver := &plugintest.Driver{}
client := pluginapi.NewClient(api, driver)
_ = NewPoster(&client.Post, botID)
})
}
func TestDM(t *testing.T) {
format := "test format, string: %s int: %d value: %v"
args := []any{"some string", 5, 8.423}
expectedMessage := "test format, string: some string int: 5 value: 8.423"
expectedPostID := "expected-post-id"
post := &model.Post{
Message: expectedMessage,
}
postWithID := model.Post{
Id: expectedPostID,
UserId: botID,
ChannelId: dmChannelID,
Message: expectedMessage,
}
mockError := errors.New("mock error")
t.Run("DM Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
//nolint:govet //copy lock, but only used in tests
postAPI.
EXPECT().
DM(botID, userID, post).
SetArg(2, postWithID).
Return(nil).
Times(1)
postID, err := poster.DM(userID, format, args...)
assert.Equal(t, expectedPostID, postID)
assert.NoError(t, err)
})
t.Run("DM error", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
DM(botID, userID, post).
Return(mockError).
Times(1)
_, err := poster.DM(userID, format, args...)
assert.Error(t, err)
})
}
func TestDMWithAttachments(t *testing.T) {
expectedPostID := "expected-post-id"
attachments := []*model.MessageAttachment{
{},
{},
}
post := &model.Post{}
model.ParseMessageAttachment(post, attachments)
postWithID := model.Post{
Id: expectedPostID,
UserId: botID,
ChannelId: dmChannelID,
Type: model.PostTypeMessageAttachment,
Props: model.StringInterface{
model.PostPropsAttachments: attachments,
},
}
mockError := errors.New("mock error")
t.Run("DM Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
//nolint:govet //copy lock, but only used in tests
postAPI.
EXPECT().
DM(botID, userID, post).
SetArg(2, postWithID).
Return(nil).
Times(1)
postID, err := poster.DMWithAttachments(userID, attachments...)
assert.Equal(t, expectedPostID, postID)
assert.NoError(t, err)
})
t.Run("DM error", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
DM(botID, userID, post).
Return(mockError).
Times(1)
_, err := poster.DMWithAttachments(userID, attachments...)
assert.Error(t, err)
})
}
func TestEphemeral(t *testing.T) {
format := "test format, string: %s int: %d value: %v"
args := []any{"some string", 5, 8.423}
expectedMessage := "test format, string: some string int: 5 value: 8.423"
channelID := "some-channel"
post := &model.Post{
UserId: botID,
ChannelId: channelID,
Message: expectedMessage,
}
expectedPostID := "some-post-ID"
postWithID := model.Post{
Id: expectedPostID,
UserId: botID,
ChannelId: channelID,
Message: expectedMessage,
}
t.Run("Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
//nolint:govet //copy lock, but only used in tests
postAPI.
EXPECT().
SendEphemeralPost(userID, post).
SetArg(1, postWithID).
Times(1)
poster.Ephemeral(userID, channelID, format, args...)
})
}
func TestUpdatePostByID(t *testing.T) {
format := "test format, string: %s int: %d value: %v"
args := []any{"some string", 5, 8.423}
expectedMessage := "test format, string: some string int: 5 value: 8.423"
postID := "some-post-id"
originalPost := &model.Post{
Id: postID,
Message: "some message",
}
updatedPost := &model.Post{
Id: postID,
Message: expectedMessage,
}
mockError := errors.New("mock error")
t.Run("Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
GetPost(postID).
Return(originalPost, nil).
Times(1)
postAPI.
EXPECT().
UpdatePost(updatedPost).
Return(nil).
Times(1)
err := poster.UpdatePostByID(postID, format, args...)
assert.NoError(t, err)
})
t.Run("Error fetching", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
GetPost(postID).
Return(nil, mockError).
Times(1)
err := poster.UpdatePostByID(postID, format, args...)
assert.Error(t, err)
})
t.Run("Error updating", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
GetPost(postID).
Return(originalPost, nil).
Times(1)
postAPI.
EXPECT().
UpdatePost(updatedPost).
Return(mockError).
Times(1)
err := poster.UpdatePostByID(postID, format, args...)
assert.Error(t, err)
})
}
func TestDeletePost(t *testing.T) {
postID := "some-post-id"
mockError := errors.New("mock channel error")
t.Run("Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
DeletePost(postID).
Return(nil).
Times(1)
err := poster.DeletePost(postID)
assert.NoError(t, err)
})
t.Run("Error", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
DeletePost(postID).
Return(mockError).
Times(1)
err := poster.DeletePost(postID)
assert.Error(t, err)
})
}
func TestUpdatePost(t *testing.T) {
post := &model.Post{
Id: "some-post-id",
Message: "some message",
}
mockError := errors.New("mock channel error")
t.Run("Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
UpdatePost(post).
Return(nil).
Times(1)
err := poster.UpdatePost(post)
assert.NoError(t, err)
})
t.Run("Error", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
postAPI.
EXPECT().
UpdatePost(post).
Return(mockError).
Times(1)
err := poster.UpdatePost(post)
assert.Error(t, err)
})
}
func TestUpdatePosterID(t *testing.T) {
format := "test format, string: %s int: %d value: %v"
args := []any{"some string", 5, 8.423}
expectedMessage := "test format, string: some string int: 5 value: 8.423"
expectedPostID := "expected-post-id"
post := &model.Post{
Message: expectedMessage,
}
postWithID := model.Post{
Id: expectedPostID,
UserId: botID,
ChannelId: dmChannelID,
Message: expectedMessage,
}
newBotID := "new-bot-id"
t.Run("Success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
postAPI := mock_import.NewMockPostAPI(ctrl)
poster := NewPoster(postAPI, botID)
//nolint:govet //copy lock, but only used in tests
postAPI.
EXPECT().
DM(botID, userID, post).
SetArg(2, postWithID).
Return(nil).
Times(1)
_, _ = poster.DM(userID, format, args...)
poster.UpdatePosterID(newBotID)
//nolint:govet //copy lock, but only used in tests
postAPI.
EXPECT().
DM(newBotID, userID, post).
SetArg(2, postWithID).
Return(nil).
Times(1)
_, _ = poster.DM(userID, format, args...)
})
}

View File

@@ -0,0 +1,14 @@
package poster
import (
"github.com/mattermost/mattermost/server/public/model"
)
// PostAPI defines the portion of the Post Service used by the poster
type PostAPI interface {
DM(senderUserID, receiverUserID string, post *model.Post) error
GetPost(postID string) (*model.Post, error)
UpdatePost(post *model.Post) error
DeletePost(postID string) error
SendEphemeralPost(userID string, post *model.Post)
}

View File

@@ -0,0 +1,104 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster (interfaces: PostAPI)
// Package mock_import is a generated GoMock package.
package mock_import
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost/server/public/model"
)
// MockPostAPI is a mock of PostAPI interface.
type MockPostAPI struct {
ctrl *gomock.Controller
recorder *MockPostAPIMockRecorder
}
// MockPostAPIMockRecorder is the mock recorder for MockPostAPI.
type MockPostAPIMockRecorder struct {
mock *MockPostAPI
}
// NewMockPostAPI creates a new mock instance.
func NewMockPostAPI(ctrl *gomock.Controller) *MockPostAPI {
mock := &MockPostAPI{ctrl: ctrl}
mock.recorder = &MockPostAPIMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPostAPI) EXPECT() *MockPostAPIMockRecorder {
return m.recorder
}
// DM mocks base method.
func (m *MockPostAPI) DM(arg0, arg1 string, arg2 *model.Post) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DM", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// DM indicates an expected call of DM.
func (mr *MockPostAPIMockRecorder) DM(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DM", reflect.TypeOf((*MockPostAPI)(nil).DM), arg0, arg1, arg2)
}
// DeletePost mocks base method.
func (m *MockPostAPI) DeletePost(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeletePost", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// DeletePost indicates an expected call of DeletePost.
func (mr *MockPostAPIMockRecorder) DeletePost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePost", reflect.TypeOf((*MockPostAPI)(nil).DeletePost), arg0)
}
// GetPost mocks base method.
func (m *MockPostAPI) GetPost(arg0 string) (*model.Post, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPost", arg0)
ret0, _ := ret[0].(*model.Post)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetPost indicates an expected call of GetPost.
func (mr *MockPostAPIMockRecorder) GetPost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPost", reflect.TypeOf((*MockPostAPI)(nil).GetPost), arg0)
}
// SendEphemeralPost mocks base method.
func (m *MockPostAPI) SendEphemeralPost(arg0 string, arg1 *model.Post) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SendEphemeralPost", arg0, arg1)
}
// SendEphemeralPost indicates an expected call of SendEphemeralPost.
func (mr *MockPostAPIMockRecorder) SendEphemeralPost(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendEphemeralPost", reflect.TypeOf((*MockPostAPI)(nil).SendEphemeralPost), arg0, arg1)
}
// UpdatePost mocks base method.
func (m *MockPostAPI) UpdatePost(arg0 *model.Post) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdatePost", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// UpdatePost indicates an expected call of UpdatePost.
func (mr *MockPostAPIMockRecorder) UpdatePost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePost", reflect.TypeOf((*MockPostAPI)(nil).UpdatePost), arg0)
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package poster
import (
"github.com/mattermost/mattermost/server/public/model"
)
// Poster defines an entity that can post DMs and Ephemerals and update and delete those posts
type Poster interface {
DMer
// DMWithAttachments posts a Direct Message that contains Message Attachments.
// Often used to include post actions.
DMWithAttachments(mattermostUserID string, attachments ...*model.MessageAttachment) (string, error)
// Ephemeral sends an ephemeral message to a user
Ephemeral(mattermostUserID, channelID, format string, args ...any)
// UpdatePostByID updates the post with postID with the formatted message
UpdatePostByID(postID, format string, args ...any) error
// DeletePost deletes a single post
DeletePost(postID string) error
// DMUpdatePost substitute one post with another
UpdatePost(post *model.Post) error
// UpdatePosterID updates the Mattermost User ID of the poster
UpdatePosterID(id string)
}
// DMer defines an entity that can send Direct Messages
type DMer interface {
// DM posts a simple Direct Message to the specified user
DM(mattermostUserID, format string, args ...any) (string, error)
}

View File

@@ -0,0 +1,31 @@
package command
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/pkg/errors"
)
// PluginAPI is the plugin API interface required to manage slash commands.
type PluginAPI interface {
GetBundlePath() (string, error)
}
// GetIconData returns the base64 encoding of a icon for a given path.
// The data returned may be used for slash command autocomplete.
func GetIconData(api PluginAPI, iconPath string) (string, error) {
bundlePath, err := api.GetBundlePath()
if err != nil {
return "", errors.Wrap(err, "couldn't get bundle path")
}
icon, err := os.ReadFile(filepath.Join(bundlePath, iconPath))
if err != nil {
return "", errors.Wrap(err, "failed to open icon")
}
return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(icon)), nil
}

View File

@@ -0,0 +1,74 @@
package command
import (
"fmt"
"regexp"
"runtime/debug"
"strings"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
)
var versionRegexp = regexp.MustCompile(`/v\d$`)
func BuildInfoAutocomplete(cmd string) *model.AutocompleteData {
return model.NewAutocompleteData(cmd, "", "Display build info")
}
func BuildInfo(manifest model.Manifest) (string, error) {
info, ok := debug.ReadBuildInfo()
if !ok {
return "", errors.New("failed to read build info")
}
var (
revision string
revisionShort string
buildTime time.Time
dirty bool
)
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
revision = s.Value
revisionShort = revision[0:7]
case "vcs.time":
var err error
buildTime, err = time.Parse(time.RFC3339, s.Value)
if err != nil {
return "", err
}
case "vcs.modified":
if s.Value == "true" {
dirty = true
}
}
}
path := info.Main.Path
matches := versionRegexp.FindAllString(path, -1)
if len(matches) > 0 {
path = strings.TrimSuffix(path, matches[len(matches)-1])
}
dirtyText := ""
if dirty {
dirtyText = " (dirty)"
}
commit := fmt.Sprintf("[%s](https://%s/commit/%s)", revisionShort, path, revision)
return fmt.Sprintf("%s version: %s, %s%s, built %s with %s\n",
manifest.Name,
manifest.Version,
commit,
dirtyText,
buildTime.Format(time.RFC1123),
info.GoVersion),
nil
}

View File

@@ -0,0 +1,17 @@
package common
import (
"errors"
"github.com/mattermost/mattermost/server/public/pluginapi"
)
var ErrNotFound = errors.New("not found")
type KVStore interface {
Set(key string, value any, options ...pluginapi.KVSetOption) (bool, error)
Get(key string, o any) error
Delete(key string) error
DeleteAll() error
ListKeys(page, count int, options ...pluginapi.ListKeysOption) ([]string, error)
}

View File

@@ -0,0 +1,8 @@
package common
type LogAPI interface {
LogError(message string, keyValuePairs ...any)
LogWarn(message string, keyValuePairs ...any)
LogInfo(message string, keyValuePairs ...any)
LogDebug(message string, keyValuePairs ...any)
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package common
import (
"encoding/json"
"fmt"
)
func JSON(ref any) string {
bb, _ := json.MarshalIndent(ref, "", " ")
return string(bb)
}
func CodeBlock(in string) string {
return fmt.Sprintf("\n```\n%s\n```\n", in)
}
func JSONBlock(ref any) string {
return fmt.Sprintf("\n```json\n%s\n```\n", JSON(ref))
}

View File

@@ -0,0 +1,24 @@
package common
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func MessageAttachmentError(w http.ResponseWriter, err error) {
response := model.PostActionIntegrationResponse{
EphemeralText: "Error:" + err.Error(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
func DialogError(w http.ResponseWriter, err error) {
response := model.SubmitDialogResponse{
Error: err.Error(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,28 @@
package common
import (
"net/url"
"strings"
"github.com/mattermost/mattermost/server/public/pluginapi"
)
// GetPluginURL returns a url like siteURL/plugins/pluginID based on the information from the client.
// If any error happens in the process, a empty string is returned.
func GetPluginURL(client *pluginapi.Client) string {
mattermostSiteURL := client.Configuration.GetConfig().ServiceSettings.SiteURL
if mattermostSiteURL == nil {
return ""
}
_, err := url.Parse(*mattermostSiteURL)
if err != nil {
return ""
}
manifest, err := client.System.GetManifest()
if err != nil {
return ""
}
pluginURLPath := "/plugins/" + manifest.Id
return strings.TrimRight(*mattermostSiteURL, "/") + pluginURLPath
}

View 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
}
}

View 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"
}
}

View 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()
}

View 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
}

View File

@@ -0,0 +1,78 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost-plugin-mscalendar/server/utils/oauther (interfaces: OAuther)
// Package mock_oauther is a generated GoMock package.
package mock_oauther
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
oauth2 "golang.org/x/oauth2"
)
// MockOAuther is a mock of OAuther interface
type MockOAuther struct {
ctrl *gomock.Controller
recorder *MockOAutherMockRecorder
}
// MockOAutherMockRecorder is the mock recorder for MockOAuther
type MockOAutherMockRecorder struct {
mock *MockOAuther
}
// NewMockOAuther creates a new mock instance
func NewMockOAuther(ctrl *gomock.Controller) *MockOAuther {
mock := &MockOAuther{ctrl: ctrl}
mock.recorder = &MockOAutherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockOAuther) EXPECT() *MockOAutherMockRecorder {
return m.recorder
}
// Deauth mocks base method
func (m *MockOAuther) Deauth(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Deauth", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Deauth indicates an expected call of Deauth
func (mr *MockOAutherMockRecorder) Deauth(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deauth", reflect.TypeOf((*MockOAuther)(nil).Deauth), arg0)
}
// GetToken mocks base method
func (m *MockOAuther) GetToken(arg0 string) (*oauth2.Token, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetToken", arg0)
ret0, _ := ret[0].(*oauth2.Token)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetToken indicates an expected call of GetToken
func (mr *MockOAutherMockRecorder) GetToken(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetToken", reflect.TypeOf((*MockOAuther)(nil).GetToken), arg0)
}
// GetURL mocks base method
func (m *MockOAuther) GetURL() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetURL")
ret0, _ := ret[0].(string)
return ret0
}
// GetURL indicates an expected call of GetURL
func (mr *MockOAutherMockRecorder) GetURL() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetURL", reflect.TypeOf((*MockOAuther)(nil).GetURL))
}

View File

@@ -0,0 +1,105 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/oauther (interfaces: OAuther)
// Package mock_oauther is a generated GoMock package.
package mock_oauther
import (
http "net/http"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
oauth2 "golang.org/x/oauth2"
)
// MockOAuther is a mock of OAuther interface.
type MockOAuther struct {
ctrl *gomock.Controller
recorder *MockOAutherMockRecorder
}
// MockOAutherMockRecorder is the mock recorder for MockOAuther.
type MockOAutherMockRecorder struct {
mock *MockOAuther
}
// NewMockOAuther creates a new mock instance.
func NewMockOAuther(ctrl *gomock.Controller) *MockOAuther {
mock := &MockOAuther{ctrl: ctrl}
mock.recorder = &MockOAutherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOAuther) EXPECT() *MockOAutherMockRecorder {
return m.recorder
}
// AddPayload mocks base method.
func (m *MockOAuther) AddPayload(arg0 string, arg1 []byte) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddPayload", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// AddPayload indicates an expected call of AddPayload.
func (mr *MockOAutherMockRecorder) AddPayload(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPayload", reflect.TypeOf((*MockOAuther)(nil).AddPayload), arg0, arg1)
}
// Deauthorize mocks base method.
func (m *MockOAuther) Deauthorize(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Deauthorize", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Deauthorize indicates an expected call of Deauthorize.
func (mr *MockOAutherMockRecorder) Deauthorize(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deauthorize", reflect.TypeOf((*MockOAuther)(nil).Deauthorize), arg0)
}
// GetConnectURL mocks base method.
func (m *MockOAuther) GetConnectURL() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetConnectURL")
ret0, _ := ret[0].(string)
return ret0
}
// GetConnectURL indicates an expected call of GetConnectURL.
func (mr *MockOAutherMockRecorder) GetConnectURL() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConnectURL", reflect.TypeOf((*MockOAuther)(nil).GetConnectURL))
}
// GetToken mocks base method.
func (m *MockOAuther) GetToken(arg0 string) (*oauth2.Token, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetToken", arg0)
ret0, _ := ret[0].(*oauth2.Token)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetToken indicates an expected call of GetToken.
func (mr *MockOAutherMockRecorder) GetToken(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetToken", reflect.TypeOf((*MockOAuther)(nil).GetToken), arg0)
}
// ServeHTTP mocks base method.
func (m *MockOAuther) ServeHTTP(arg0 http.ResponseWriter, arg1 *http.Request) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ServeHTTP", arg0, arg1)
}
// ServeHTTP indicates an expected call of ServeHTTP.
func (mr *MockOAutherMockRecorder) ServeHTTP(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ServeHTTP", reflect.TypeOf((*MockOAuther)(nil).ServeHTTP), arg0, arg1)
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package oauther
import (
"net/http"
"time"
"golang.org/x/oauth2"
"github.com/mattermost/mattermost/server/public/pluginapi"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
)
const (
// DefaultStorePrefix is the prefix used when storing information in the KVStore by default.
DefaultStorePrefix = "oauth_"
// DefaultOAuthURL is the URL the OAuther will use to register its endpoints by default.
DefaultOAuthURL = "/oauth2"
// DefaultConnectedString is the string shown to the user when the oauth flow is completed by default.
DefaultConnectedString = "Successfully connected. Please close this window."
// DefaultOAuth2StateTimeToLive is the duration the states from the OAuth flow will live in the KVStore by default.
DefaultOAuth2StateTimeToLive = 5 * time.Minute
// DefaultPayloadTimeToLive is the duration the user payload will live in the KVStore by default.
DefaultPayloadTimeToLive = 10 * time.Minute
)
const (
connectURL = "/connect"
completeURL = "/complete"
)
// OAuther defines an object able to perform the OAuth flow.
type OAuther interface {
// GetToken returns the oauth token for userID, or error if it does not exist or there is any store error.
GetToken(userID string) (*oauth2.Token, error)
// GetConnectURL returns the URL to reach in order to start the OAuth flow.
GetConnectURL() string
// Deauthorize removes the token for userID. Return error if there is any store error.
Deauthorize(userID string) error
// ServeHTTP implements http.Handler
ServeHTTP(w http.ResponseWriter, r *http.Request)
// AddPayload stores some information to be returned after the flow is over
AddPayload(userID string, payload []byte) error
}
type oAuther struct {
pluginURL string
config oauth2.Config
onConnect func(userID string, token oauth2.Token, payload []byte)
store common.KVStore
logger logger.Logger
storePrefix string
oAuthURL string
connectedString string
oAuth2StateTimeToLive time.Duration
payloadTimeToLive time.Duration
}
/*
New creates a new OAuther.
- pluginURL: The base URL for the plugin (e.g. https://www.instance.com/plugins/pluginid).
- oAuthConfig: The configuration of the Authorization flow to perform.
- onConnect: What to do when the Authorization process is complete.
- store: A KVStore to store the data of the OAuther.
- l Logger: A logger to log errors during authorization.
- options: Optional options for the OAuther. Available options are StorePrefix, OAuthURL, ConnectedString and OAuth2StateTimeToLive.
*/
func New(
pluginURL string,
oAuthConfig oauth2.Config,
onConnect func(userID string, token oauth2.Token, payload []byte),
store common.KVStore,
l logger.Logger,
options ...Option,
) OAuther {
o := &oAuther{
pluginURL: pluginURL,
config: oAuthConfig,
onConnect: onConnect,
store: store,
logger: l,
storePrefix: DefaultStorePrefix,
oAuthURL: DefaultOAuthURL,
connectedString: DefaultConnectedString,
oAuth2StateTimeToLive: DefaultOAuth2StateTimeToLive,
payloadTimeToLive: DefaultPayloadTimeToLive,
}
for _, option := range options {
option(o)
}
o.config.RedirectURL = o.pluginURL + o.oAuthURL + "/complete"
return o
}
/*
NewFromClient creates a new OAuther from the plugin api client.
- pluginapi: A plugin api client.
- pluginID: The plugin ID.
- oAuthConfig: The configuration of the Authorization flow to perform.
- onConnect: What to do when the Authorization process is complete.
- l Logger: A logger to log errors during authorization.
- options: Optional options for the OAuther. Available options are StorePrefix, OAuthURL, ConnectedString and OAuth2StateTimeToLive.
*/
func NewFromClient(
client *pluginapi.Client,
oAuthConfig oauth2.Config,
onConnect func(userID string, token oauth2.Token, payload []byte),
l logger.Logger,
options ...Option,
) OAuther {
return New(
common.GetPluginURL(client),
oAuthConfig,
onConnect,
&client.KV,
l,
options...,
)
}
func (o *oAuther) GetConnectURL() string {
return o.pluginURL + o.oAuthURL + "/connect"
}
func (o *oAuther) GetToken(userID string) (*oauth2.Token, error) {
var token *oauth2.Token
err := o.store.Get(o.getTokenKey(userID), &token)
if err != nil {
return nil, err
}
return token, nil
}
func (o *oAuther) getTokenKey(userID string) string {
return o.storePrefix + "token_" + userID
}
func (o *oAuther) getStateKey(userID string) string {
return o.storePrefix + "state_" + userID
}
func (o *oAuther) getPayloadKey(userID string) string {
return o.storePrefix + "payload_" + userID
}
func (o *oAuther) Deauthorize(userID string) error {
err := o.store.Delete(o.getTokenKey(userID))
if err != nil {
return err
}
return nil
}
func (o *oAuther) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case o.oAuthURL + connectURL:
o.oauth2Connect(w, r)
case o.oAuthURL + completeURL:
o.oauth2Complete(w, r)
default:
http.NotFound(w, r)
}
}
func (o *oAuther) AddPayload(userID string, payload []byte) error {
_, err := o.store.Set(o.getPayloadKey(userID), payload, pluginapi.SetExpiry(o.payloadTimeToLive))
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package oauther
import (
"context"
"fmt"
"net/http"
"strings"
)
func (o *oAuther) oauth2Complete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
authedUserID := r.Header.Get("Mattermost-User-ID")
if authedUserID == "" {
o.logger.Debugf("oauth2Complete: reached by non authed user")
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
code := r.URL.Query().Get("code")
if code == "" {
o.logger.Debugf("oauth2Complete: reached with no code")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
state := r.URL.Query().Get("state")
var storedState string
err := o.store.Get(o.getStateKey(authedUserID), &storedState)
if err != nil {
o.logger.Warnf("oauth2Complete: cannot get state, err=%s", err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if storedState != state {
o.logger.Debugf("oauth2Complete: state mismatch")
o.logger.Debugf("received state '%s'; expected state '%s'", state, storedState)
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
userID := strings.Split(state, "_")[1]
if userID != authedUserID {
o.logger.Debugf("oauth2Complete: authed user mismatch")
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
ctx := context.Background()
token, err := o.config.Exchange(ctx, code)
if err != nil {
o.logger.Warnf("oauth2Complete: could not generate token, err=%s", err.Error())
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
var payload []byte
err = o.store.Get(o.getPayloadKey(userID), &payload)
if err != nil {
o.logger.Errorf("oauth2Complete: could not fetch payload, err=%s", err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ok, err := o.store.Set(o.getTokenKey(userID), token)
if err != nil {
o.logger.Errorf("oauth2Complete: cannot store the token, err=%s", err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !ok {
o.logger.Errorf("oauth2Complete: cannot store token without error")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<script>
window.close();
</script>
</head>
<body>
<p>%s</p>
</body>
</html>
`, o.connectedString)
w.Header().Set("Content-Type", "text/html")
_, err = w.Write([]byte(html))
if err != nil {
o.logger.Errorf("oauth2Complete: error writing response, err=%s", err.Error())
}
if o.onConnect != nil {
o.onConnect(userID, *token, payload)
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package oauther
import (
"fmt"
"net/http"
"golang.org/x/oauth2"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/pluginapi"
)
func (o *oAuther) oauth2Connect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("Mattermost-User-ID")
if userID == "" {
o.logger.Debugf("oauth2Connect: reached by non authed user")
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
state := fmt.Sprintf("%v_%v", model.NewId()[0:15], userID)
_, err := o.store.Set(o.getStateKey(userID), state, pluginapi.SetExpiry(o.oAuth2StateTimeToLive))
if err != nil {
o.logger.Errorf("oauth2Connect: failed to store state, err=%s", err.Error())
http.Error(w, "failed to store token state", http.StatusInternalServerError)
return
}
redirectURL := o.config.AuthCodeURL(state, oauth2.AccessTypeOffline)
http.Redirect(w, r, redirectURL, http.StatusFound)
}

View File

@@ -0,0 +1,47 @@
package oauther
import "time"
// Option defines each option that can be passed in the creation of the OAuther.
// Options functions available are OAuthURL, StorePrefix, ConnectedString and OAuth2StateTimeToLive and PayloadTimeToLive.
type Option func(*oAuther)
// OAuthURL defines the URL the OAuther will use to register its endpoints.
// Defaults to "/oauth2".
func OAuthURL(url string) Option {
return func(o *oAuther) {
o.oAuthURL = url
}
}
// StorePrefix defines the prefix the OAuther will use to store information in the KVStore.
// Defaults to "oauth_".
func StorePrefix(prefix string) Option {
return func(o *oAuther) {
o.storePrefix = prefix
}
}
// ConnectedString defines the string shown to the user when the oauth flow is completed.
// Defaults to "Successfully connected. Please close this window.".
func ConnectedString(text string) Option {
return func(o *oAuther) {
o.connectedString = text
}
}
// OAuth2StateTimeToLive is the duration the states from the OAuth flow will live in the KVStore.
// Defaults to 5 minutes.
func OAuth2StateTimeToLive(ttl time.Duration) Option {
return func(o *oAuther) {
o.oAuth2StateTimeToLive = ttl
}
}
// PayloadTimeToLive is the duration the payload from the OAuth flow will live in the KVStore.
// Defaults to 10 minutes.
func PayloadTimeToLive(ttl time.Duration) Option {
return func(o *oAuther) {
o.payloadTimeToLive = ttl
}
}

View File

@@ -0,0 +1,75 @@
package panel
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel/settings"
)
type handler struct {
panel Panel
}
func Init(r *mux.Router, panel Panel) {
sh := &handler{
panel: panel,
}
panelRouter := r.PathPrefix("/").Subrouter()
panelRouter.HandleFunc(panel.URL(), sh.handleAction).Methods(http.MethodPost)
}
func (sh *handler) handleAction(w http.ResponseWriter, r *http.Request) {
mattermostUserID := r.Header.Get("Mattermost-User-ID")
if mattermostUserID == "" {
common.MessageAttachmentError(w, errors.New("Not authorized"))
return
}
var request model.PostActionIntegrationRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
common.MessageAttachmentError(w, errors.New("invalid request"))
return
}
id, ok := request.Context[settings.ContextIDKey]
if !ok {
common.MessageAttachmentError(w, errors.New("missing setting id"))
return
}
value, ok := request.Context[settings.ContextButtonValueKey]
if !ok {
value, ok = request.Context[settings.ContextOptionValueKey]
if !ok {
common.MessageAttachmentError(w, errors.New("valid key not found"))
return
}
}
idString, ok := id.(string)
if !ok {
common.MessageAttachmentError(w, errors.New("setting id is not a string"))
return
}
err := sh.panel.Set(mattermostUserID, idString, value)
if err != nil {
common.MessageAttachmentError(w, errors.Wrap(err, "cannot save setting"))
return
}
response := model.PostActionIntegrationResponse{}
post, err := sh.panel.ToPost(mattermostUserID)
if err == nil {
response.Update = post
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,118 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel (interfaces: Panel)
// Package mock_panel is a generated GoMock package.
package mock_panel
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost/server/public/model"
)
// MockPanel is a mock of Panel interface.
type MockPanel struct {
ctrl *gomock.Controller
recorder *MockPanelMockRecorder
}
// MockPanelMockRecorder is the mock recorder for MockPanel.
type MockPanelMockRecorder struct {
mock *MockPanel
}
// NewMockPanel creates a new mock instance.
func NewMockPanel(ctrl *gomock.Controller) *MockPanel {
mock := &MockPanel{ctrl: ctrl}
mock.recorder = &MockPanelMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPanel) EXPECT() *MockPanelMockRecorder {
return m.recorder
}
// Clear mocks base method.
func (m *MockPanel) Clear(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Clear", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Clear indicates an expected call of Clear.
func (mr *MockPanelMockRecorder) Clear(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Clear", reflect.TypeOf((*MockPanel)(nil).Clear), arg0)
}
// GetSettingIDs mocks base method.
func (m *MockPanel) GetSettingIDs() []string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSettingIDs")
ret0, _ := ret[0].([]string)
return ret0
}
// GetSettingIDs indicates an expected call of GetSettingIDs.
func (mr *MockPanelMockRecorder) GetSettingIDs() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSettingIDs", reflect.TypeOf((*MockPanel)(nil).GetSettingIDs))
}
// Print mocks base method.
func (m *MockPanel) Print(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Print", arg0)
}
// Print indicates an expected call of Print.
func (mr *MockPanelMockRecorder) Print(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Print", reflect.TypeOf((*MockPanel)(nil).Print), arg0)
}
// Set mocks base method.
func (m *MockPanel) Set(arg0, arg1 string, arg2 interface{}) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Set", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// Set indicates an expected call of Set.
func (mr *MockPanelMockRecorder) Set(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockPanel)(nil).Set), arg0, arg1, arg2)
}
// ToPost mocks base method.
func (m *MockPanel) ToPost(arg0 string) (*model.Post, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ToPost", arg0)
ret0, _ := ret[0].(*model.Post)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ToPost indicates an expected call of ToPost.
func (mr *MockPanelMockRecorder) ToPost(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ToPost", reflect.TypeOf((*MockPanel)(nil).ToPost), arg0)
}
// URL mocks base method.
func (m *MockPanel) URL() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "URL")
ret0, _ := ret[0].(string)
return ret0
}
// URL indicates an expected call of URL.
func (mr *MockPanelMockRecorder) URL() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "URL", reflect.TypeOf((*MockPanel)(nil).URL))
}

View File

@@ -0,0 +1,77 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel (interfaces: Store)
// Package mock_panel is a generated GoMock package.
package mock_panel
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockStore is a mock of Store interface.
type MockStore struct {
ctrl *gomock.Controller
recorder *MockStoreMockRecorder
}
// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder struct {
mock *MockStore
}
// NewMockStore creates a new mock instance.
func NewMockStore(ctrl *gomock.Controller) *MockStore {
mock := &MockStore{ctrl: ctrl}
mock.recorder = &MockStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
return m.recorder
}
// DeletePanelPostID mocks base method.
func (m *MockStore) DeletePanelPostID(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeletePanelPostID", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// DeletePanelPostID indicates an expected call of DeletePanelPostID.
func (mr *MockStoreMockRecorder) DeletePanelPostID(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePanelPostID", reflect.TypeOf((*MockStore)(nil).DeletePanelPostID), arg0)
}
// GetPanelPostID mocks base method.
func (m *MockStore) GetPanelPostID(arg0 string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPanelPostID", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetPanelPostID indicates an expected call of GetPanelPostID.
func (mr *MockStoreMockRecorder) GetPanelPostID(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPanelPostID", reflect.TypeOf((*MockStore)(nil).GetPanelPostID), arg0)
}
// SetPanelPostID mocks base method.
func (m *MockStore) SetPanelPostID(arg0, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPanelPostID", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SetPanelPostID indicates an expected call of SetPanelPostID.
func (mr *MockStoreMockRecorder) SetPanelPostID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPanelPostID", reflect.TypeOf((*MockStore)(nil).SetPanelPostID), arg0, arg1)
}

View File

@@ -0,0 +1,149 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel/settings (interfaces: Setting)
// Package mock_panel is a generated GoMock package.
package mock_panel
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
model "github.com/mattermost/mattermost/server/public/model"
)
// MockSetting is a mock of Setting interface.
type MockSetting struct {
ctrl *gomock.Controller
recorder *MockSettingMockRecorder
}
// MockSettingMockRecorder is the mock recorder for MockSetting.
type MockSettingMockRecorder struct {
mock *MockSetting
}
// NewMockSetting creates a new mock instance.
func NewMockSetting(ctrl *gomock.Controller) *MockSetting {
mock := &MockSetting{ctrl: ctrl}
mock.recorder = &MockSettingMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSetting) EXPECT() *MockSettingMockRecorder {
return m.recorder
}
// Get mocks base method.
func (m *MockSetting) Get(arg0 string) (interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", arg0)
ret0, _ := ret[0].(interface{})
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockSettingMockRecorder) Get(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSetting)(nil).Get), arg0)
}
// GetDependency mocks base method.
func (m *MockSetting) GetDependency() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDependency")
ret0, _ := ret[0].(string)
return ret0
}
// GetDependency indicates an expected call of GetDependency.
func (mr *MockSettingMockRecorder) GetDependency() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDependency", reflect.TypeOf((*MockSetting)(nil).GetDependency))
}
// GetDescription mocks base method.
func (m *MockSetting) GetDescription() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDescription")
ret0, _ := ret[0].(string)
return ret0
}
// GetDescription indicates an expected call of GetDescription.
func (mr *MockSettingMockRecorder) GetDescription() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDescription", reflect.TypeOf((*MockSetting)(nil).GetDescription))
}
// GetID mocks base method.
func (m *MockSetting) GetID() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetID")
ret0, _ := ret[0].(string)
return ret0
}
// GetID indicates an expected call of GetID.
func (mr *MockSettingMockRecorder) GetID() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetID", reflect.TypeOf((*MockSetting)(nil).GetID))
}
// GetMessageAttachments mocks base method.
func (m *MockSetting) GetMessageAttachments(arg0, arg1 string, arg2 bool) (*model.MessageAttachment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMessageAttachments", arg0, arg1, arg2)
ret0, _ := ret[0].(*model.MessageAttachment)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMessageAttachments indicates an expected call of GetMessageAttachments.
func (mr *MockSettingMockRecorder) GetMessageAttachments(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMessageAttachments", reflect.TypeOf((*MockSetting)(nil).GetMessageAttachments), arg0, arg1, arg2)
}
// GetTitle mocks base method.
func (m *MockSetting) GetTitle() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTitle")
ret0, _ := ret[0].(string)
return ret0
}
// GetTitle indicates an expected call of GetTitle.
func (mr *MockSettingMockRecorder) GetTitle() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockSetting)(nil).GetTitle))
}
// IsDisabled mocks base method.
func (m *MockSetting) IsDisabled(arg0 interface{}) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsDisabled", arg0)
ret0, _ := ret[0].(bool)
return ret0
}
// IsDisabled indicates an expected call of IsDisabled.
func (mr *MockSettingMockRecorder) IsDisabled(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsDisabled", reflect.TypeOf((*MockSetting)(nil).IsDisabled), arg0)
}
// Set mocks base method.
func (m *MockSetting) Set(arg0 string, arg1 interface{}) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Set", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Set indicates an expected call of Set.
func (mr *MockSettingMockRecorder) Set(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockSetting)(nil).Set), arg0, arg1)
}

View File

@@ -0,0 +1,171 @@
package panel
import (
"errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel/settings"
)
type Panel interface {
Set(userID, settingID string, value any) error
Print(userID string)
ToPost(userID string) (*model.Post, error)
Clear(userID string) error
URL() string
GetSettingIDs() []string
}
type panel struct {
settings map[string]settings.Setting
settingKeys []string
poster poster.Poster
logger logger.Logger
store Store
settingHandler string
pluginURL string
}
func NewSettingsPanel(
settingList []settings.Setting,
p poster.Poster,
l logger.Logger,
store Store,
settingHandler,
pluginURL string,
) Panel {
settingsMap := make(map[string]settings.Setting)
settingKeys := []string{}
for _, s := range settingList {
settingsMap[s.GetID()] = s
settingKeys = append(settingKeys, s.GetID())
}
panel := &panel{
settings: settingsMap,
settingKeys: settingKeys,
poster: p,
logger: l,
store: store,
settingHandler: settingHandler,
pluginURL: pluginURL,
}
return panel
}
func (p *panel) Set(userID, settingID string, value any) error {
s, ok := p.settings[settingID]
if !ok {
return errors.New("cannot find setting " + settingID)
}
err := s.Set(userID, value)
if err != nil {
return err
}
return nil
}
func (p *panel) GetSettingIDs() []string {
return p.settingKeys
}
func (p *panel) URL() string {
return p.settingHandler
}
func (p *panel) Print(userID string) {
err := p.cleanPreviousSettingsPosts(userID)
if err != nil {
p.logger.Errorf("could not clean previous setting post, %s", err.Error())
}
sas := []*model.MessageAttachment{}
for _, key := range p.settingKeys {
s := p.settings[key]
sa, loopErr := s.GetMessageAttachments(userID, p.pluginURL+p.settingHandler, p.isSettingDisabled(userID, s))
if loopErr != nil {
p.logger.Errorf("error creating the message attachment, err=%s", loopErr.Error())
continue
}
sas = append(sas, sa)
}
postID, err := p.poster.DMWithAttachments(userID, sas...)
if err != nil {
p.logger.Errorf("error creating the message, err=%s", err.Error())
return
}
err = p.store.SetPanelPostID(userID, postID)
if err != nil {
p.logger.Errorf("could not set the post IDs, err=%s", err.Error())
}
}
func (p *panel) ToPost(userID string) (*model.Post, error) {
post := &model.Post{}
sas := []*model.MessageAttachment{}
for _, key := range p.settingKeys {
s := p.settings[key]
sa, err := s.GetMessageAttachments(userID, p.pluginURL+p.settingHandler, p.isSettingDisabled(userID, s))
if err != nil {
p.logger.Errorf("error creating the message attachment for setting %s, err=%s", s.GetID(), err.Error())
continue
}
sas = append(sas, sa)
}
model.ParseMessageAttachment(post, sas)
return post, nil
}
func (p *panel) cleanPreviousSettingsPosts(userID string) error {
postID, err := p.store.GetPanelPostID(userID)
if err == common.ErrNotFound {
return nil
}
if err != nil {
return err
}
err = p.poster.DeletePost(postID)
if err != nil {
p.logger.Errorf("could not delete setting post, %s", err)
}
err = p.store.DeletePanelPostID(userID)
if err != nil {
return err
}
return nil
}
func (p *panel) Clear(userID string) error {
return p.cleanPreviousSettingsPosts(userID)
}
func (p *panel) isSettingDisabled(userID string, s settings.Setting) bool {
dependencyID := s.GetDependency()
if dependencyID == "" {
return false
}
dependency, ok := p.settings[dependencyID]
if !ok {
p.logger.Errorf("settings dependency %s not found", dependencyID)
return false
}
value, err := dependency.Get(userID)
if err != nil {
p.logger.Errorf("cannot get dependency %s value", dependencyID)
return false
}
return s.IsDisabled(value)
}

View File

@@ -0,0 +1,28 @@
package settings
type baseSetting struct {
title string
description string
id string
dependsOn string
}
func (s *baseSetting) GetID() string {
return s.id
}
func (s *baseSetting) GetTitle() string {
return s.title
}
func (s *baseSetting) GetDescription() string {
return s.description
}
func (s *baseSetting) GetDependency() string {
return s.dependsOn
}
func (s *baseSetting) IsDisabled(foreignValue any) bool {
return false
}

View File

@@ -0,0 +1,116 @@
package settings
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type boolSetting struct {
baseSetting
store SettingStore
}
// NewBoolSetting creates a new setting input for boolean values
func NewBoolSetting(id, title, description, dependsOn string, store SettingStore) Setting {
return &boolSetting{
baseSetting: baseSetting{
title: title,
description: description,
id: id,
dependsOn: dependsOn,
},
store: store,
}
}
func (s *boolSetting) Set(userID string, value any) error {
boolValue := false
if value == TrueString {
boolValue = true
}
err := s.store.SetSetting(userID, s.id, boolValue)
if err != nil {
return err
}
return nil
}
func (s *boolSetting) Get(userID string) (any, error) {
value, err := s.store.GetSetting(userID, s.id)
if err != nil {
return "", err
}
boolValue, ok := value.(bool)
if !ok {
return "", errors.New("current value is not a bool")
}
stringValue := FalseString
if boolValue {
stringValue = TrueString
}
return stringValue, nil
}
func (s *boolSetting) GetMessageAttachments(userID, settingHandler string, disabled bool) (*model.MessageAttachment, error) {
title := fmt.Sprintf("Setting: %s", s.title)
currentValueMessage := DisabledString
actions := []*model.PostAction{}
if !disabled {
currentValue, err := s.Get(userID)
if err != nil {
return nil, err
}
currentTextValue := "No"
if currentValue == TrueString {
currentTextValue = "Yes"
}
currentValueMessage = fmt.Sprintf("Current value: %s", currentTextValue)
actionTrue := model.PostAction{
Type: model.PostActionTypeButton,
Name: "Yes",
Integration: &model.PostActionIntegration{
URL: settingHandler,
Context: map[string]any{
ContextIDKey: s.id,
ContextButtonValueKey: TrueString,
},
},
}
actionFalse := model.PostAction{
Type: model.PostActionTypeButton,
Name: "No",
Integration: &model.PostActionIntegration{
URL: settingHandler,
Context: map[string]any{
ContextIDKey: s.id,
ContextButtonValueKey: FalseString,
},
},
}
actions = []*model.PostAction{&actionTrue, &actionFalse}
}
text := fmt.Sprintf("%s\n%s", s.description, currentValueMessage)
sa := model.MessageAttachment{
Title: title,
Text: text,
Fallback: fmt.Sprintf("%s: %s", title, text),
Actions: actions,
}
return &sa, nil
}
func (s *boolSetting) IsDisabled(foreignValue any) bool {
return foreignValue == FalseString
}

View File

@@ -0,0 +1,41 @@
package settings
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type emptySetting struct {
baseSetting
}
// NewEmptySetting creates a new panel value with no setting attached
func NewEmptySetting(id, title, description string) Setting {
return &emptySetting{
baseSetting: baseSetting{
id: id,
title: title,
description: description,
},
}
}
func (s *emptySetting) GetMessageAttachments(userID, settingHandler string, disabled bool) (*model.MessageAttachment, error) {
title := fmt.Sprintf("Setting: %s", s.title)
sa := model.MessageAttachment{
Title: title,
Text: s.description,
Fallback: fmt.Sprintf("%s: %s", title, s.description),
}
return &sa, nil
}
func (s *emptySetting) Get(userID string) (any, error) {
return nil, nil
}
func (s *emptySetting) Set(userID string, value any) error {
return nil
}

View File

@@ -0,0 +1,91 @@
package settings
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type optionSetting struct {
baseSetting
options []string
store SettingStore
}
// NewOptionSetting creates a new setting input to select from a dropdown
func NewOptionSetting(id, title, description, dependsOn string, options []string, store SettingStore) Setting {
return &optionSetting{
baseSetting: baseSetting{
title: title,
description: description,
id: id,
dependsOn: dependsOn,
},
options: options,
store: store,
}
}
func (s *optionSetting) Set(userID string, value any) error {
err := s.store.SetSetting(userID, s.id, value)
if err != nil {
return err
}
return nil
}
func (s *optionSetting) Get(userID string) (any, error) {
value, err := s.store.GetSetting(userID, s.id)
if err != nil {
return "", err
}
valueString, ok := value.(string)
if !ok {
return "", errors.New("current value is not a string")
}
return valueString, nil
}
func (s *optionSetting) GetMessageAttachments(userID, settingHandler string, disabled bool) (*model.MessageAttachment, error) {
title := fmt.Sprintf("Setting: %s", s.title)
currentValueMessage := DisabledString
actions := []*model.PostAction{}
if !disabled {
currentTextValue, err := s.Get(userID)
if err != nil {
return nil, err
}
currentValueMessage = fmt.Sprintf("Current value: %s", currentTextValue)
actionOptions := model.PostAction{
Type: model.PostActionTypeSelect,
Name: "Select an option:",
Integration: &model.PostActionIntegration{
URL: settingHandler + "?" + s.id + "=true",
Context: map[string]any{
ContextIDKey: s.id,
},
},
Options: stringsToOptions(s.options),
}
actions = []*model.PostAction{&actionOptions}
}
text := fmt.Sprintf("%s\n%s", s.description, currentValueMessage)
sa := model.MessageAttachment{
Title: title,
Text: text,
Fallback: fmt.Sprintf("%s: %s", title, text),
Actions: actions,
}
return &sa, nil
}
func (s *optionSetting) IsDisabled(foreignValue any) bool {
return foreignValue == FalseString
}

View File

@@ -0,0 +1,69 @@
package settings
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type readOnlySetting struct {
baseSetting
store SettingStore
}
// NewReadOnlySetting creates a new panel value that only read from the setting
func NewReadOnlySetting(id, title, description, dependsOn string, store SettingStore) Setting {
return &readOnlySetting{
baseSetting: baseSetting{
title: title,
description: description,
id: id,
dependsOn: dependsOn,
},
store: store,
}
}
func (s *readOnlySetting) Get(userID string) (any, error) {
value, err := s.store.GetSetting(userID, s.id)
if err != nil {
return "", err
}
stringValue, ok := value.(string)
if !ok {
return "", errors.New("current value is not a string")
}
return stringValue, nil
}
func (s *readOnlySetting) Set(userID string, value any) error {
return nil
}
func (s *readOnlySetting) GetMessageAttachments(userID, settingHandler string, disabled bool) (*model.MessageAttachment, error) {
title := fmt.Sprintf("Setting: %s", s.title)
currentValueMessage := DisabledString
if !disabled {
currentValue, err := s.Get(userID)
if err != nil {
return nil, err
}
currentValueMessage = fmt.Sprintf("Current value: %s", currentValue)
}
text := fmt.Sprintf("%s\n%s", s.description, currentValueMessage)
sa := model.MessageAttachment{
Title: title,
Text: text,
Fallback: fmt.Sprintf("%s: %s", title, text),
}
return &sa, nil
}
func (s *readOnlySetting) IsDisabled(foreignValue any) bool {
return foreignValue == FalseString
}

View File

@@ -0,0 +1,33 @@
package settings
import (
"github.com/mattermost/mattermost/server/public/model"
)
const (
// ContextIDKey defines the key used in the context to store the ID
ContextIDKey = "setting_id"
// ContextButtonValueKey defines the key used in the context to store a button value
ContextButtonValueKey = "button_value"
// ContextOptionValueKey defines the key used in the context to store a selected option value
ContextOptionValueKey = "selected_option"
// DisabledString defines the string used to show that a setting is disabled
DisabledString = "Disabled"
// TrueString codify the boolean true into a string
TrueString = "true"
// FalseString codify the boolean false into a string
FalseString = "false"
)
// Setting defines the behavior of each element a the panel
type Setting interface {
Set(userID string, value any) error
Get(userID string) (any, error)
GetID() string
GetDependency() string
IsDisabled(foreignValue any) bool
GetTitle() string
GetDescription() string
GetMessageAttachments(userID, settingHandler string, disabled bool) (*model.MessageAttachment, error)
}

View File

@@ -0,0 +1,7 @@
package settings
// SettingStore defines the behavior needed to set and get settings
type SettingStore interface {
SetSetting(userID, settingID string, value any) error
GetSetting(userID, settingID string) (any, error)
}

View File

@@ -0,0 +1,16 @@
package settings
import (
"github.com/mattermost/mattermost/server/public/model"
)
func stringsToOptions(in []string) []*model.PostActionOptions {
out := make([]*model.PostActionOptions, len(in))
for i, o := range in {
out[i] = &model.PostActionOptions{
Text: o,
Value: o,
}
}
return out
}

View File

@@ -0,0 +1,53 @@
package panel
import (
"errors"
"github.com/mattermost/mattermost/server/public/pluginapi"
)
type Store interface {
SetPanelPostID(userID string, postID string) error
GetPanelPostID(userID string) (string, error)
DeletePanelPostID(userID string) error
}
type panelStore struct {
kv *pluginapi.KVService
keyPrefix string
}
func NewPanelStore(kv *pluginapi.KVService, keyPrefix string) Store {
return &panelStore{
kv: kv,
keyPrefix: keyPrefix,
}
}
func (ps *panelStore) SetPanelPostID(userID, postID string) error {
ok, err := ps.kv.Set(ps.getKey(userID), postID)
if err != nil {
return err
}
if !ok {
return errors.New("value not set without errors")
}
return nil
}
func (ps *panelStore) GetPanelPostID(userID string) (string, error) {
var postID string
err := ps.kv.Get(ps.getKey(userID), &postID)
if err != nil {
return "", err
}
return postID, nil
}
func (ps *panelStore) DeletePanelPostID(userID string) error {
return ps.kv.Delete(ps.getKey(userID))
}
func (ps *panelStore) getKey(userID string) string {
return ps.keyPrefix + "-" + userID
}