[MM-53968] Includes mattermost-plugin-api into the mono repo (#24235)
Include https://github.com/mattermost/mattermost-plugin-api into the mono repo Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com> Co-authored-by: Michael Kochell <mjkochell@gmail.com> Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com> Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com> Co-authored-by: Alex Dovenmuehle <alex.dovenmuehle@mattermost.com> Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com> Co-authored-by: Christopher Poile <cpoile@gmail.com> Co-authored-by: İlker Göktuğ Öztürk <ilkergoktugozturk@gmail.com> Co-authored-by: Shota Gvinepadze <wineson@gmail.com> Co-authored-by: Ali Farooq <ali.farooq0@pm.me> Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com> Co-authored-by: Daniel Espino García <larkox@gmail.com> Co-authored-by: Christopher Speller <crspeller@gmail.com> Co-authored-by: Alex Dovenmuehle <adovenmuehle@gmail.com> Co-authored-by: Szymon Gibała <szymongib@gmail.com> Co-authored-by: Lev <1187448+levb@users.noreply.github.com> Co-authored-by: Jason Frerich <jason.frerich@mattermost.com> Co-authored-by: Agniva De Sarker <agnivade@yahoo.co.in> Co-authored-by: Artur M. Wolff <artur.m.wolff@gmail.com> Co-authored-by: Madhav Hugar <16546715+madhavhugar@users.noreply.github.com> Co-authored-by: Joe <security.joe@pm.me> Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com> Co-authored-by: José Peso <trilopin@users.noreply.github.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
bc11b29807
Коммит
3ee5432664
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract
|
.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public
|
||||||
|
|
||||||
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||||
|
|
||||||
@@ -210,6 +210,7 @@ endif
|
|||||||
|
|
||||||
include config.mk
|
include config.mk
|
||||||
include build/*.mk
|
include build/*.mk
|
||||||
|
include public/Makefile
|
||||||
|
|
||||||
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.MockCWS=$(MM_ENABLE_CWS_MOCK)"
|
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.MockCWS=$(MM_ENABLE_CWS_MOCK)"
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ mmctl-mocks: ## Creates mocks for mmctl
|
|||||||
pluginapi: ## Generates api and hooks glue code for plugins
|
pluginapi: ## Generates api and hooks glue code for plugins
|
||||||
cd ./public && $(GO) generate $(GOFLAGS) ./plugin
|
cd ./public && $(GO) generate $(GOFLAGS) ./plugin
|
||||||
|
|
||||||
mocks: store-mocks telemetry-mocks filestore-mocks ldap-mocks plugin-mocks einterfaces-mocks searchengine-mocks sharedchannel-mocks misc-mocks email-mocks platform-mocks mmctl-mocks
|
mocks: store-mocks telemetry-mocks filestore-mocks ldap-mocks plugin-mocks einterfaces-mocks searchengine-mocks sharedchannel-mocks misc-mocks email-mocks platform-mocks mmctl-mocks mocks-public
|
||||||
|
|
||||||
layers: app-layers store-layers pluginapi
|
layers: app-layers store-layers pluginapi
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
"github.com/mattermost/mattermost/server/public/plugin"
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
"github.com/mattermost/mattermost/server/public/shared/driver"
|
||||||
"github.com/mattermost/mattermost/server/v8/channels/app/plugin_api_tests"
|
"github.com/mattermost/mattermost/server/v8/channels/app/plugin_api_tests"
|
||||||
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
|
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
|
||||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||||
"github.com/mattermost/mattermost/server/v8/platform/shared/driver"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MyPlugin struct {
|
type MyPlugin struct {
|
||||||
|
|||||||
14
server/public/Makefile
Обычный файл
14
server/public/Makefile
Обычный файл
@@ -0,0 +1,14 @@
|
|||||||
|
test-public: gotestsum
|
||||||
|
$(GOBIN)/gotestsum ./public/... -- $(GOFLAGS)
|
||||||
|
|
||||||
|
## Generates mock golang interfaces for testing
|
||||||
|
mocks-public:
|
||||||
|
$(GO) install github.com/golang/mock/mockgen@v1.6.0
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/panel/mocks/mock_panel.go -package mock_panel github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel Panel
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/panel/mocks/mock_panelStore.go -package mock_panel github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel Store
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/panel/mocks/mock_setting.go -package mock_panel github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel/settings Setting
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/bot/mocks/mock_bot.go -package mock_bot github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot Bot
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/bot/mocks/mock_logger.go -package mock_bot github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger Logger
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/bot/mocks/mock_poster.go -package mock_bot github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster Poster
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/oauther/mocks/mock_oauther.go -package mock_oauther github.com/mattermost/mattermost/server/public/pluginapi/experimental/oauther OAuther
|
||||||
|
$(GOBIN)/mockgen -destination public/pluginapi/experimental/bot/poster/mock_import/mock_postapi.go -package mock_import github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster PostAPI
|
||||||
@@ -4,9 +4,12 @@ go 1.19
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
|
github.com/blang/semver/v4 v4.0.0
|
||||||
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a
|
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a
|
||||||
github.com/francoispqt/gojay v1.2.13
|
github.com/francoispqt/gojay v1.2.13
|
||||||
github.com/go-sql-driver/mysql v1.7.1
|
github.com/go-sql-driver/mysql v1.7.1
|
||||||
|
github.com/golang/mock v1.6.0
|
||||||
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a
|
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a
|
||||||
github.com/hashicorp/go-hclog v1.5.0
|
github.com/hashicorp/go-hclog v1.5.0
|
||||||
@@ -15,18 +18,23 @@ require (
|
|||||||
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404
|
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404
|
||||||
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d
|
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d
|
||||||
github.com/mattermost/logr/v2 v2.0.16
|
github.com/mattermost/logr/v2 v2.0.16
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.0.3
|
||||||
github.com/pborman/uuid v1.2.1
|
github.com/pborman/uuid v1.2.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/rudderlabs/analytics-go v3.3.3+incompatible
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.8.4
|
||||||
github.com/tinylib/msgp v1.1.8
|
github.com/tinylib/msgp v1.1.8
|
||||||
github.com/vmihailenco/msgpack/v5 v5.3.5
|
github.com/vmihailenco/msgpack/v5 v5.3.5
|
||||||
golang.org/x/crypto v0.10.0
|
golang.org/x/crypto v0.10.0
|
||||||
|
golang.org/x/oauth2 v0.7.0
|
||||||
golang.org/x/text v0.10.0
|
golang.org/x/text v0.10.0
|
||||||
golang.org/x/tools v0.10.0
|
golang.org/x/tools v0.10.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fatih/color v1.15.0 // indirect
|
github.com/fatih/color v1.15.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
||||||
@@ -41,13 +49,20 @@ require (
|
|||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
github.com/philhofer/fwd v1.1.2 // indirect
|
github.com/philhofer/fwd v1.1.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
||||||
|
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.14.3 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/wiggin77/merror v1.0.5 // indirect
|
github.com/wiggin77/merror v1.0.5 // indirect
|
||||||
github.com/wiggin77/srslog v1.0.1 // indirect
|
github.com/wiggin77/srslog v1.0.1 // indirect
|
||||||
|
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||||
golang.org/x/mod v0.11.0 // indirect
|
golang.org/x/mod v0.11.0 // indirect
|
||||||
golang.org/x/net v0.11.0 // indirect
|
golang.org/x/net v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.9.0 // indirect
|
golang.org/x/sys v0.9.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
|
||||||
google.golang.org/grpc v1.56.1 // indirect
|
google.golang.org/grpc v1.56.1 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBr
|
|||||||
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
||||||
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
||||||
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
||||||
|
github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
|
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||||
|
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||||
|
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||||
|
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||||
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
||||||
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
@@ -46,6 +52,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
|||||||
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -66,6 +74,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||||||
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
|
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
|
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a h1:i0+Se9S+2zL5CBxJouqn2Ej6UQMwH1c57ZB6DVnqck4=
|
github.com/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a h1:i0+Se9S+2zL5CBxJouqn2Ej6UQMwH1c57ZB6DVnqck4=
|
||||||
@@ -119,6 +129,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
|||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.0.3 h1:ks/JkQiOEhhuF6jpNvx+Wih1NIiXzUnZeZVnJuI8R8M=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo=
|
||||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||||
@@ -130,6 +142,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
|
||||||
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
@@ -139,9 +152,14 @@ github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
|
|||||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||||
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY=
|
||||||
|
github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30=
|
||||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||||
|
github.com/segmentio/backo-go v1.0.1 h1:68RQccglxZeyURy93ASB/2kc9QudzgIDexJ927N++y4=
|
||||||
|
github.com/segmentio/backo-go v1.0.1/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
|
github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
|
||||||
github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
|
github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
|
||||||
@@ -165,6 +183,8 @@ github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1l
|
|||||||
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
|
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
|
||||||
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
|
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -174,12 +194,19 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||||
|
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
|
||||||
|
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
|
||||||
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||||
github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
|
github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
|
||||||
@@ -192,6 +219,9 @@ github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME
|
|||||||
github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0=
|
github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0=
|
||||||
github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8=
|
github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8=
|
||||||
github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls=
|
github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls=
|
||||||
|
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g=
|
||||||
|
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
|
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
|
||||||
go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
|
go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
|
||||||
@@ -201,6 +231,8 @@ golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+
|
|||||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
|
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
|
||||||
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
|
||||||
@@ -208,6 +240,7 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
|||||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||||
@@ -219,9 +252,14 @@ golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73r
|
|||||||
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
@@ -231,12 +269,15 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG
|
|||||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
|
||||||
|
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||||
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
|
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||||
@@ -245,15 +286,20 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -265,6 +311,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
|||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
@@ -277,13 +324,17 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
||||||
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
|
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
|
||||||
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
|
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||||
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||||
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
|
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
|
||||||
@@ -291,6 +342,8 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
|
|||||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ type Manifest struct {
|
|||||||
|
|
||||||
// RequiredConfig defines any required server configuration fields for the plugin to function properly.
|
// RequiredConfig defines any required server configuration fields for the plugin to function properly.
|
||||||
//
|
//
|
||||||
// Use the pluginapi.Configuration.CheckRequiredServerConfiguration method to enforce this.
|
// Deprecated: The required server configuration fields should be checked using custom code.
|
||||||
|
// This field will get removed in the next major release.
|
||||||
RequiredConfig *Config `json:"required_configuration,omitempty" yaml:"required_configuration,omitempty"`
|
RequiredConfig *Config `json:"required_configuration,omitempty" yaml:"required_configuration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
202
server/public/pluginapi/bot.go
Обычный файл
202
server/public/pluginapi/bot.go
Обычный файл
@@ -0,0 +1,202 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
internalKeyPrefix = "mmi_"
|
||||||
|
botUserKey = internalKeyPrefix + "botid"
|
||||||
|
botEnsureMutexKey = internalKeyPrefix + "bot_ensure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotService exposes methods to manipulate bots.
|
||||||
|
type BotService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a bot by botUserID.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) Get(botUserID string, includeDeleted bool) (*model.Bot, error) {
|
||||||
|
bot, appErr := b.api.GetBot(botUserID, includeDeleted)
|
||||||
|
|
||||||
|
return bot, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotListOption is an option to configure a bot List() request.
|
||||||
|
type BotListOption func(*model.BotGetOptions)
|
||||||
|
|
||||||
|
// BotOwner option configures bot list request to only retrieve the bots that matches with
|
||||||
|
// owner's id.
|
||||||
|
func BotOwner(id string) BotListOption {
|
||||||
|
return func(o *model.BotGetOptions) {
|
||||||
|
o.OwnerId = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotIncludeDeleted option configures bot list request to also retrieve the deleted bots.
|
||||||
|
func BotIncludeDeleted() BotListOption {
|
||||||
|
return func(o *model.BotGetOptions) {
|
||||||
|
o.IncludeDeleted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotOnlyOrphans option configures bot list request to only retrieve orphan bots.
|
||||||
|
func BotOnlyOrphans() BotListOption {
|
||||||
|
return func(o *model.BotGetOptions) {
|
||||||
|
o.OnlyOrphaned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a list of bots by page, count and options.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) List(page, perPage int, options ...BotListOption) ([]*model.Bot, error) {
|
||||||
|
opts := &model.BotGetOptions{
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
}
|
||||||
|
for _, o := range options {
|
||||||
|
o(opts)
|
||||||
|
}
|
||||||
|
bots, appErr := b.api.GetBots(opts)
|
||||||
|
|
||||||
|
return bots, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates the bot and corresponding user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) Create(bot *model.Bot) error {
|
||||||
|
createdBot, appErr := b.api.CreateBot(bot)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
*bot = *createdBot
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch applies the given patch to the bot and corresponding user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) Patch(botUserID string, botPatch *model.BotPatch) (*model.Bot, error) {
|
||||||
|
bot, appErr := b.api.PatchBot(botUserID, botPatch)
|
||||||
|
|
||||||
|
return bot, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActive marks a bot as active or inactive, along with its corresponding user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) UpdateActive(botUserID string, isActive bool) (*model.Bot, error) {
|
||||||
|
bot, appErr := b.api.UpdateBotActive(botUserID, isActive)
|
||||||
|
|
||||||
|
return bot, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePermanently permanently deletes a bot and its corresponding user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) DeletePermanently(botUserID string) error {
|
||||||
|
return normalizeAppErr(b.api.PermanentDeleteBot(botUserID))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ensureBotOptions struct {
|
||||||
|
ProfileImagePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnsureBotOption func(*ensureBotOptions)
|
||||||
|
|
||||||
|
func ProfileImagePath(path string) EnsureBotOption {
|
||||||
|
return func(args *ensureBotOptions) {
|
||||||
|
args.ProfileImagePath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBot either returns an existing bot user matching the given bot, or creates a bot user from the given bot.
|
||||||
|
// A profile image or icon image may be optionally passed in to be set for the existing or newly created bot.
|
||||||
|
// Returns the id of the resulting bot.
|
||||||
|
// EnsureBot can safely be called multiple instances of a plugin concurrently.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (b *BotService) EnsureBot(bot *model.Bot, options ...EnsureBotOption) (string, error) {
|
||||||
|
m, err := cluster.NewMutex(b.api, botEnsureMutexKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "failed to create mutex")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.ensureBot(m, bot, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mutex interface {
|
||||||
|
Lock()
|
||||||
|
Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: this utility function is also used by the product framework. We should move this to mattermost-server and share
|
||||||
|
// the code to maintain consistent behavior. Ticket: MM-44953
|
||||||
|
func (b *BotService) ensureBot(m mutex, bot *model.Bot, options ...EnsureBotOption) (string, error) {
|
||||||
|
err := ensureServerVersion(b.api, "5.10.0")
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "failed to ensure bot")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
o := &ensureBotOptions{
|
||||||
|
ProfileImagePath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, setter := range options {
|
||||||
|
setter(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
botID, err := b.ensureBotUser(m, bot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.ProfileImagePath != "" {
|
||||||
|
imageBytes, err := b.readFile(o.ProfileImagePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "failed to read profile image")
|
||||||
|
}
|
||||||
|
appErr := b.api.SetProfileImage(botID, imageBytes)
|
||||||
|
if appErr != nil {
|
||||||
|
return "", errors.Wrap(appErr, "failed to set profile image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return botID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BotService) ensureBotUser(m mutex, bot *model.Bot) (retBotID string, retErr error) {
|
||||||
|
// Lock to prevent two plugins from racing to create the bot account
|
||||||
|
m.Lock()
|
||||||
|
defer m.Unlock()
|
||||||
|
|
||||||
|
return b.api.EnsureBotUser(bot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BotService) readFile(path string) ([]byte, error) {
|
||||||
|
bundlePath, err := b.api.GetBundlePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get bundle path")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBytes, err := os.ReadFile(filepath.Join(bundlePath, path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to read image")
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageBytes, nil
|
||||||
|
}
|
||||||
368
server/public/pluginapi/bot_test.go
Обычный файл
368
server/public/pluginapi/bot_test.go
Обычный файл
@@ -0,0 +1,368 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateBot(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("CreateBot", &model.Bot{Username: "1"}).Return(&model.Bot{Username: "1", UserId: "2"}, nil)
|
||||||
|
|
||||||
|
bot := &model.Bot{Username: "1"}
|
||||||
|
err := client.Bot.Create(bot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.Bot{Username: "1", UserId: "2"}, bot)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("CreateBot", &model.Bot{Username: "1"}).Return(nil, appErr)
|
||||||
|
|
||||||
|
bot := &model.Bot{Username: "1"}
|
||||||
|
err := client.Bot.Create(&model.Bot{Username: "1"})
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Equal(t, &model.Bot{Username: "1"}, bot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateBotStatus(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("UpdateBotActive", "1", true).Return(&model.Bot{UserId: "2"}, nil)
|
||||||
|
|
||||||
|
bot, err := client.Bot.UpdateActive("1", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.Bot{UserId: "2"}, bot)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("UpdateBotActive", "1", true).Return(nil, appErr)
|
||||||
|
|
||||||
|
bot, err := client.Bot.UpdateActive("1", true)
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, bot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBot(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetBot", "1", true).Return(&model.Bot{UserId: "2"}, nil)
|
||||||
|
|
||||||
|
bot, err := client.Bot.Get("1", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.Bot{UserId: "2"}, bot)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetBot", "1", true).Return(nil, appErr)
|
||||||
|
|
||||||
|
bot, err := client.Bot.Get("1", true)
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, bot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
page, count int
|
||||||
|
options []BotListOption
|
||||||
|
expectedOptions *model.BotGetOptions
|
||||||
|
bots []*model.Bot
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"owner filter",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
[]BotListOption{
|
||||||
|
BotOwner("3"),
|
||||||
|
},
|
||||||
|
&model.BotGetOptions{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 2,
|
||||||
|
OwnerId: "3",
|
||||||
|
},
|
||||||
|
[]*model.Bot{
|
||||||
|
{UserId: "4"},
|
||||||
|
{UserId: "5"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"all filter",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
[]BotListOption{
|
||||||
|
BotOwner("3"),
|
||||||
|
BotIncludeDeleted(),
|
||||||
|
BotOnlyOrphans(),
|
||||||
|
},
|
||||||
|
&model.BotGetOptions{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 2,
|
||||||
|
OwnerId: "3",
|
||||||
|
IncludeDeleted: true,
|
||||||
|
OnlyOrphaned: true,
|
||||||
|
},
|
||||||
|
[]*model.Bot{
|
||||||
|
{UserId: "4"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no filter",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
[]BotListOption{},
|
||||||
|
&model.BotGetOptions{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 2,
|
||||||
|
},
|
||||||
|
[]*model.Bot{
|
||||||
|
{UserId: "4"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"app error",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
[]BotListOption{
|
||||||
|
BotOwner("3"),
|
||||||
|
},
|
||||||
|
&model.BotGetOptions{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: 2,
|
||||||
|
OwnerId: "3",
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
newAppError(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetBots", test.expectedOptions).Return(test.bots, test.err)
|
||||||
|
|
||||||
|
bots, err := client.Bot.List(test.page, test.count, test.options...)
|
||||||
|
if test.err != nil {
|
||||||
|
require.Equal(t, test.err.Error(), err.Error(), test.name)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err, test.name)
|
||||||
|
}
|
||||||
|
require.Equal(t, test.bots, bots, test.name)
|
||||||
|
|
||||||
|
api.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteBotPermanently(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("PermanentDeleteBot", "1").Return(nil)
|
||||||
|
|
||||||
|
err := client.Bot.DeletePermanently("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("PermanentDeleteBot", "1").Return(appErr)
|
||||||
|
|
||||||
|
err := client.Bot.DeletePermanently("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureBot(t *testing.T) {
|
||||||
|
testbot := &model.Bot{
|
||||||
|
Username: "testbot",
|
||||||
|
DisplayName: "Test Bot",
|
||||||
|
Description: "testbotdescription",
|
||||||
|
}
|
||||||
|
|
||||||
|
m := testMutex{}
|
||||||
|
|
||||||
|
t.Run("server version incompatible", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetServerVersion").Return("5.9.0")
|
||||||
|
|
||||||
|
_, err := client.Bot.ensureBot(m, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t,
|
||||||
|
"failed to ensure bot: incompatible server version for plugin, minimum required version: 5.10.0, current version: 5.9.0",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("if bot already exists", func(t *testing.T) {
|
||||||
|
t.Run("should find and return the existing bot ID", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
expectedBotID := model.NewId()
|
||||||
|
|
||||||
|
api.On("GetServerVersion").Return("5.10.0")
|
||||||
|
api.On("EnsureBotUser", testbot).Return(expectedBotID, nil)
|
||||||
|
botID, err := client.Bot.ensureBot(m, testbot)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedBotID, botID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should set the bot profile image when specified", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
expectedBotID := model.NewId()
|
||||||
|
|
||||||
|
profileImageFile, err := os.CreateTemp("", "profile_image")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
profileImageBytes := []byte("profile image")
|
||||||
|
err = os.WriteFile(profileImageFile.Name(), profileImageBytes, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
api.On("GetBundlePath").Return("", nil)
|
||||||
|
api.On("EnsureBotUser", testbot).Return(expectedBotID, nil)
|
||||||
|
api.On("SetProfileImage", expectedBotID, profileImageBytes).Return(nil)
|
||||||
|
api.On("GetServerVersion").Return("5.10.0")
|
||||||
|
|
||||||
|
botID, err := client.Bot.ensureBot(m, testbot, ProfileImagePath(profileImageFile.Name()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedBotID, botID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should find and update the bot with new bot details", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
expectedBotID := model.NewId()
|
||||||
|
expectedBotUsername := "updated_testbot"
|
||||||
|
expectedBotDisplayName := "Updated Test Bot"
|
||||||
|
expectedBotDescription := "updated testbotdescription"
|
||||||
|
|
||||||
|
profileImageFile, err := os.CreateTemp("", "profile_image")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
profileImageBytes := []byte("profile image")
|
||||||
|
err = os.WriteFile(profileImageFile.Name(), profileImageBytes, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
iconImageFile, err := os.CreateTemp("", "profile_image")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
iconImageBytes := []byte("icon image")
|
||||||
|
err = os.WriteFile(iconImageFile.Name(), iconImageBytes, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedTestBot := &model.Bot{
|
||||||
|
Username: expectedBotUsername,
|
||||||
|
DisplayName: expectedBotDisplayName,
|
||||||
|
Description: expectedBotDescription,
|
||||||
|
}
|
||||||
|
api.On("GetServerVersion").Return("5.10.0")
|
||||||
|
api.On("EnsureBotUser", updatedTestBot).Return(expectedBotID, nil)
|
||||||
|
api.On("GetBundlePath").Return("", nil)
|
||||||
|
api.On("SetProfileImage", expectedBotID, profileImageBytes).Return(nil)
|
||||||
|
|
||||||
|
botID, err := client.Bot.ensureBot(m,
|
||||||
|
updatedTestBot,
|
||||||
|
ProfileImagePath(profileImageFile.Name()),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedBotID, botID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("if bot doesn't exist", func(t *testing.T) {
|
||||||
|
t.Run("should create bot and set the bot profile image when specified", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
expectedBotID := model.NewId()
|
||||||
|
|
||||||
|
profileImageFile, err := os.CreateTemp("", "profile_image")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
profileImageBytes := []byte("profile image")
|
||||||
|
err = os.WriteFile(profileImageFile.Name(), profileImageBytes, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
api.On("EnsureBotUser", testbot).Return(expectedBotID, nil)
|
||||||
|
api.On("GetBundlePath").Return("", nil)
|
||||||
|
api.On("SetProfileImage", expectedBotID, profileImageBytes).Return(nil)
|
||||||
|
api.On("GetServerVersion").Return("5.10.0")
|
||||||
|
|
||||||
|
botID, err := client.Bot.ensureBot(m, testbot, ProfileImagePath(profileImageFile.Name()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedBotID, botID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAppError() *model.AppError {
|
||||||
|
return model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMutex struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m testMutex) Lock() {}
|
||||||
|
func (m testMutex) Unlock() {}
|
||||||
286
server/public/pluginapi/channel.go
Обычный файл
286
server/public/pluginapi/channel.go
Обычный файл
@@ -0,0 +1,286 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelService exposes methods to manipulate channels.
|
||||||
|
type ChannelService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) Get(channelID string) (*model.Channel, error) {
|
||||||
|
channel, appErr := c.api.GetChannel(channelID)
|
||||||
|
|
||||||
|
return channel, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName gets a channel by its name, given a team id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) GetByName(teamID, channelName string, includeDeleted bool) (*model.Channel, error) {
|
||||||
|
channel, appErr := c.api.GetChannelByName(teamID, channelName, includeDeleted)
|
||||||
|
|
||||||
|
return channel, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirect gets a direct message channel.
|
||||||
|
//
|
||||||
|
// Note that if the channel does not exist it will create it.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) GetDirect(userID1, userID2 string) (*model.Channel, error) {
|
||||||
|
channel, appErr := c.api.GetDirectChannel(userID1, userID2)
|
||||||
|
|
||||||
|
return channel, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroup gets a group message channel.
|
||||||
|
//
|
||||||
|
// Note that if the channel does not exist it will create it.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) GetGroup(userIDs []string) (*model.Channel, error) {
|
||||||
|
channel, appErr := c.api.GetGroupChannel(userIDs)
|
||||||
|
|
||||||
|
return channel, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByNameForTeamName gets a channel by its name, given a team name.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) GetByNameForTeamName(teamName, channelName string, includeDeleted bool) (*model.Channel, error) {
|
||||||
|
channel, appErr := c.api.GetChannelByNameForTeamName(teamName, channelName, includeDeleted)
|
||||||
|
|
||||||
|
return channel, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForTeamForUser gets a list of channels for given user ID in given team ID.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ChannelService) ListForTeamForUser(teamID, userID string, includeDeleted bool) ([]*model.Channel, error) {
|
||||||
|
channels, appErr := c.api.GetChannelsForTeamForUser(teamID, userID, includeDeleted)
|
||||||
|
|
||||||
|
return channels, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicChannelsForTeam gets a list of all channels.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) ListPublicChannelsForTeam(teamID string, page, perPage int) ([]*model.Channel, error) {
|
||||||
|
channels, appErr := c.api.GetPublicChannelsForTeam(teamID, page, perPage)
|
||||||
|
|
||||||
|
return channels, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns the channels on a team matching the provided search term.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ChannelService) Search(teamID, term string) ([]*model.Channel, error) {
|
||||||
|
channels, appErr := c.api.SearchChannels(teamID, term)
|
||||||
|
|
||||||
|
return channels, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) Create(channel *model.Channel) error {
|
||||||
|
createdChannel, appErr := c.api.CreateChannel(channel)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
*channel = *createdChannel
|
||||||
|
|
||||||
|
return c.waitForChannelCreation(channel.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) Update(channel *model.Channel) error {
|
||||||
|
updatedChannel, appErr := c.api.UpdateChannel(channel)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
*channel = *updatedChannel
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) Delete(channelID string) error {
|
||||||
|
return normalizeAppErr(c.api.DeleteChannel(channelID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelStats gets statistics for a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ChannelService) GetChannelStats(channelID string) (*model.ChannelStats, error) {
|
||||||
|
channelStats, appErr := c.api.GetChannelStats(channelID)
|
||||||
|
|
||||||
|
return channelStats, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMember gets a channel membership for a user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) GetMember(channelID, userID string) (*model.ChannelMember, error) {
|
||||||
|
channelMember, appErr := c.api.GetChannelMember(channelID, userID)
|
||||||
|
|
||||||
|
return channelMember, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers gets a channel membership for all users.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ChannelService) ListMembers(channelID string, page, perPage int) ([]*model.ChannelMember, error) {
|
||||||
|
channelMembers, appErr := c.api.GetChannelMembers(channelID, page, perPage)
|
||||||
|
|
||||||
|
return channelMembersToChannelMemberSlice(channelMembers), normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembersByIDs gets a channel membership for a particular User
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ChannelService) ListMembersByIDs(channelID string, userIDs []string) ([]*model.ChannelMember, error) {
|
||||||
|
channelMembers, appErr := c.api.GetChannelMembersByIds(channelID, userIDs)
|
||||||
|
|
||||||
|
return channelMembersToChannelMemberSlice(channelMembers), normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembersForUser returns all channel memberships on a team for a user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.10
|
||||||
|
func (c *ChannelService) ListMembersForUser(teamID, userID string, page, perPage int) ([]*model.ChannelMember, error) {
|
||||||
|
channelMembers, appErr := c.api.GetChannelMembersForUser(teamID, userID, page, perPage)
|
||||||
|
|
||||||
|
return channelMembers, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember joins a user to a channel (as if they joined themselves).
|
||||||
|
// This means the user will not receive notifications for joining the channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) AddMember(channelID, userID string) (*model.ChannelMember, error) {
|
||||||
|
channelMember, appErr := c.api.AddChannelMember(channelID, userID)
|
||||||
|
|
||||||
|
return channelMember, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUser adds a user to a channel as if the specified user had invited them.
|
||||||
|
// This means the user will receive the regular notifications for being added to the channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (c *ChannelService) AddUser(channelID, userID, asUserID string) (*model.ChannelMember, error) {
|
||||||
|
channelMember, appErr := c.api.AddUserToChannel(channelID, userID, asUserID)
|
||||||
|
|
||||||
|
return channelMember, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMember deletes a channel membership for a user.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) DeleteMember(channelID, userID string) error {
|
||||||
|
appErr := c.api.DeleteChannelMember(channelID, userID)
|
||||||
|
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannelMemberRoles updates a user's roles for a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) UpdateChannelMemberRoles(channelID, userID, newRoles string) (*model.ChannelMember, error) {
|
||||||
|
channelMember, appErr := c.api.UpdateChannelMemberRoles(channelID, userID, newRoles)
|
||||||
|
|
||||||
|
return channelMember, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannelMemberNotifications updates a user's notification properties for a channel.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ChannelService) UpdateChannelMemberNotifications(channelID, userID string, notifications map[string]string) (*model.ChannelMember, error) {
|
||||||
|
channelMember, appErr := c.api.UpdateChannelMemberNotifications(channelID, userID, notifications)
|
||||||
|
|
||||||
|
return channelMember, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSidebarCategory creates a new sidebar category for a set of channels.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (c *ChannelService) CreateSidebarCategory(
|
||||||
|
userID, teamID string, newCategory *model.SidebarCategoryWithChannels) error {
|
||||||
|
category, appErr := c.api.CreateChannelSidebarCategory(userID, teamID, newCategory)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
*newCategory = *category
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSidebarCategories returns sidebar categories.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (c *ChannelService) GetSidebarCategories(userID, teamID string) (*model.OrderedSidebarCategories, error) {
|
||||||
|
categories, appErr := c.api.GetChannelSidebarCategories(userID, teamID)
|
||||||
|
|
||||||
|
return categories, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSidebarCategories updates the channel sidebar categories.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (c *ChannelService) UpdateSidebarCategories(
|
||||||
|
userID, teamID string, categories []*model.SidebarCategoryWithChannels) error {
|
||||||
|
updatedCategories, appErr := c.api.UpdateChannelSidebarCategories(userID, teamID, categories)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
copy(categories, updatedCategories)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChannelService) waitForChannelCreation(channelID string) error {
|
||||||
|
if len(c.api.GetConfig().SqlSettings.DataSourceReplicas) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for time.Since(now) < 1500*time.Millisecond {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := c.api.GetChannel(channelID); err == nil {
|
||||||
|
// Channel found
|
||||||
|
return nil
|
||||||
|
} else if err.StatusCode != http.StatusNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Errorf("giving up waiting for channel creation, channelID=%s", channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelMembersToChannelMemberSlice(cm model.ChannelMembers) []*model.ChannelMember {
|
||||||
|
cmp := make([]*model.ChannelMember, len(cm))
|
||||||
|
for i := 0; i < len(cm); i++ {
|
||||||
|
cmp[i] = &(cm)[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
373
server/public/pluginapi/channel_test.go
Обычный файл
373
server/public/pluginapi/channel_test.go
Обычный файл
@@ -0,0 +1,373 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetMembers(t *testing.T) {
|
||||||
|
t.Run("empty list", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetChannelMembers", "channelID", 1, 10).Return(nil, nil)
|
||||||
|
|
||||||
|
cm, err := client.Channel.ListMembers("channelID", 1, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cm)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTeamChannelByName(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetChannelByNameForTeamName", "1", "2", true).Return(&model.Channel{TeamId: "3"}, nil)
|
||||||
|
|
||||||
|
channel, err := client.Channel.GetByNameForTeamName("1", "2", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &model.Channel{TeamId: "3"}, channel)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetChannelByNameForTeamName", "1", "2", true).Return(nil, newAppError())
|
||||||
|
|
||||||
|
channel, err := client.Channel.GetByNameForTeamName("1", "2", true)
|
||||||
|
require.EqualError(t, err, "here: id, an error occurred")
|
||||||
|
require.Zero(t, channel)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTeamUserChannels(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetChannelsForTeamForUser", "1", "2", true).Return([]*model.Channel{{TeamId: "3"}, {TeamId: "4"}}, nil)
|
||||||
|
|
||||||
|
channels, err := client.Channel.ListForTeamForUser("1", "2", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []*model.Channel{{TeamId: "3"}, {TeamId: "4"}}, channels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
api.On("GetChannelsForTeamForUser", "1", "2", true).Return(nil, appErr)
|
||||||
|
|
||||||
|
channels, err := client.Channel.ListForTeamForUser("1", "2", true)
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Len(t, channels, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPublicTeamChannels(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetPublicChannelsForTeam", "1", 2, 3).Return([]*model.Channel{{TeamId: "3"}, {TeamId: "4"}}, nil)
|
||||||
|
|
||||||
|
channels, err := client.Channel.ListPublicChannelsForTeam("1", 2, 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []*model.Channel{{TeamId: "3"}, {TeamId: "4"}}, channels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
api.On("GetPublicChannelsForTeam", "1", 2, 3).Return(nil, appErr)
|
||||||
|
|
||||||
|
channels, err := client.Channel.ListPublicChannelsForTeam("1", 2, 3)
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Len(t, channels, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateChannel(t *testing.T) {
|
||||||
|
t.Run("create channel with no replicas", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
config := &model.Config{
|
||||||
|
SqlSettings: model.SqlSettings{
|
||||||
|
DataSourceReplicas: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.On("GetConfig").Return(config).Once()
|
||||||
|
|
||||||
|
c := &model.Channel{
|
||||||
|
Id: model.NewId(),
|
||||||
|
Name: "name",
|
||||||
|
DisplayName: "displayname",
|
||||||
|
}
|
||||||
|
api.On("CreateChannel", c).Return(c, nil).Once()
|
||||||
|
|
||||||
|
err := client.Channel.Create(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create channel and wait once", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
config := &model.Config{
|
||||||
|
SqlSettings: model.SqlSettings{
|
||||||
|
DataSourceReplicas: []string{"replica1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.On("GetConfig").Return(config).Once()
|
||||||
|
|
||||||
|
c := &model.Channel{
|
||||||
|
Id: model.NewId(),
|
||||||
|
Name: "name",
|
||||||
|
DisplayName: "displayname",
|
||||||
|
}
|
||||||
|
api.On("CreateChannel", c).Return(c, nil).Once()
|
||||||
|
api.On("GetChannel", c.Id).Return(c, nil).Once()
|
||||||
|
|
||||||
|
err := client.Channel.Create(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create channel and wait multiple times", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
config := &model.Config{
|
||||||
|
SqlSettings: model.SqlSettings{
|
||||||
|
DataSourceReplicas: []string{"replica1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.On("GetConfig").Return(config).Once()
|
||||||
|
|
||||||
|
c := &model.Channel{
|
||||||
|
Id: model.NewId(),
|
||||||
|
Name: "name",
|
||||||
|
DisplayName: "displayname",
|
||||||
|
}
|
||||||
|
api.On("CreateChannel", c).Return(c, nil).Once()
|
||||||
|
|
||||||
|
notFoundErr := model.NewAppError("", "", nil, "", http.StatusNotFound)
|
||||||
|
api.On("GetChannel", c.Id).Return(c, notFoundErr).Times(3)
|
||||||
|
api.On("GetChannel", c.Id).Return(c, nil).Times(1)
|
||||||
|
|
||||||
|
err := client.Channel.Create(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create channel, wait multiple times and return error", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
config := &model.Config{
|
||||||
|
SqlSettings: model.SqlSettings{
|
||||||
|
DataSourceReplicas: []string{"replica1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.On("GetConfig").Return(config).Once()
|
||||||
|
|
||||||
|
c := &model.Channel{
|
||||||
|
Id: model.NewId(),
|
||||||
|
Name: "name",
|
||||||
|
DisplayName: "displayname",
|
||||||
|
}
|
||||||
|
api.On("CreateChannel", c).Return(c, nil).Once()
|
||||||
|
|
||||||
|
notFoundErr := model.NewAppError("", "", nil, "", http.StatusNotFound)
|
||||||
|
api.On("GetChannel", c.Id).Return(c, notFoundErr).Times(3)
|
||||||
|
|
||||||
|
otherErr := model.NewAppError("", "", nil, "", http.StatusInternalServerError)
|
||||||
|
api.On("GetChannel", c.Id).Return(c, otherErr).Times(1)
|
||||||
|
|
||||||
|
err := client.Channel.Create(c)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create channel, give up waiting", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
config := &model.Config{
|
||||||
|
SqlSettings: model.SqlSettings{
|
||||||
|
DataSourceReplicas: []string{"replica1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api.On("GetConfig").Return(config).Once()
|
||||||
|
|
||||||
|
c := &model.Channel{
|
||||||
|
Id: model.NewId(),
|
||||||
|
Name: "name",
|
||||||
|
DisplayName: "displayname",
|
||||||
|
}
|
||||||
|
api.On("CreateChannel", c).Return(c, nil).Once()
|
||||||
|
|
||||||
|
notFoundErr := model.NewAppError("", "", nil, "", http.StatusNotFound)
|
||||||
|
api.On("GetChannel", c.Id).Return(c, notFoundErr)
|
||||||
|
|
||||||
|
err := client.Channel.Create(c)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "giving up waiting")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSidebarCategory(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
category := model.SidebarCategoryWithChannels{}
|
||||||
|
|
||||||
|
api.On("CreateChannelSidebarCategory", "user_id", "team_id", &category).
|
||||||
|
Return(&model.SidebarCategoryWithChannels{
|
||||||
|
SidebarCategory: model.SidebarCategory{
|
||||||
|
Id: "id",
|
||||||
|
UserId: "user_id",
|
||||||
|
TeamId: "team_id",
|
||||||
|
},
|
||||||
|
Channels: []string{"channelA", "channelB"}},
|
||||||
|
nil)
|
||||||
|
|
||||||
|
err := client.Channel.CreateSidebarCategory("user_id", "team_id", &category)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t,
|
||||||
|
model.SidebarCategoryWithChannels{
|
||||||
|
SidebarCategory: model.SidebarCategory{Id: "id", UserId: "user_id", TeamId: "team_id"},
|
||||||
|
Channels: []string{"channelA", "channelB"}},
|
||||||
|
category)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
inputCategory := model.SidebarCategoryWithChannels{}
|
||||||
|
appErr := model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
api.On("CreateChannelSidebarCategory", "user_id", "team_id", &inputCategory).
|
||||||
|
Return(&model.SidebarCategoryWithChannels{}, appErr)
|
||||||
|
|
||||||
|
err := client.Channel.CreateSidebarCategory("user_id", "team_id", &inputCategory)
|
||||||
|
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSidebarCategories(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetChannelSidebarCategories", "user_id", "team_id").
|
||||||
|
Return(&model.OrderedSidebarCategories{
|
||||||
|
Categories: nil,
|
||||||
|
Order: []string{"channelA", "channelB"},
|
||||||
|
},
|
||||||
|
nil)
|
||||||
|
|
||||||
|
categories, err := client.Channel.GetSidebarCategories("user_id", "team_id")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t,
|
||||||
|
model.OrderedSidebarCategories{
|
||||||
|
Categories: nil,
|
||||||
|
Order: []string{"channelA", "channelB"},
|
||||||
|
},
|
||||||
|
*categories)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
api.On("GetChannelSidebarCategories", "user_id", "team_id").Return(nil, appErr)
|
||||||
|
|
||||||
|
_, err := client.Channel.GetSidebarCategories("user_id", "team_id")
|
||||||
|
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSidebarCategories(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
categories := []*model.SidebarCategoryWithChannels{
|
||||||
|
{
|
||||||
|
SidebarCategory: model.SidebarCategory{},
|
||||||
|
Channels: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
updatedCategories := []*model.SidebarCategoryWithChannels{
|
||||||
|
{
|
||||||
|
SidebarCategory: model.SidebarCategory{
|
||||||
|
Id: "id",
|
||||||
|
UserId: "user_id",
|
||||||
|
TeamId: "team_id",
|
||||||
|
},
|
||||||
|
Channels: []string{"channelA", "channelB"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
api.On("UpdateChannelSidebarCategories", "user_id", "team_id", categories).Return(updatedCategories, nil)
|
||||||
|
|
||||||
|
err := client.Channel.UpdateSidebarCategories("user_id", "team_id", categories)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, updatedCategories, categories)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
inputCategories := []*model.SidebarCategoryWithChannels{
|
||||||
|
{
|
||||||
|
SidebarCategory: model.SidebarCategory{},
|
||||||
|
Channels: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
appErr := model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
api.On("UpdateChannelSidebarCategories", "user_id", "team_id", inputCategories).Return(nil, appErr)
|
||||||
|
|
||||||
|
err := client.Channel.UpdateSidebarCategories("user_id", "team_id", inputCategories)
|
||||||
|
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
79
server/public/pluginapi/client.go
Обычный файл
79
server/public/pluginapi/client.go
Обычный файл
@@ -0,0 +1,79 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/blang/semver/v4"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a streamlined wrapper over the mattermost plugin API.
|
||||||
|
type Client struct {
|
||||||
|
api plugin.API
|
||||||
|
|
||||||
|
Bot BotService
|
||||||
|
Channel ChannelService
|
||||||
|
Cluster ClusterService
|
||||||
|
Configuration ConfigurationService
|
||||||
|
SlashCommand SlashCommandService
|
||||||
|
OAuth OAuthService
|
||||||
|
Emoji EmojiService
|
||||||
|
File FileService
|
||||||
|
Frontend FrontendService
|
||||||
|
Group GroupService
|
||||||
|
KV KVService
|
||||||
|
Log LogService
|
||||||
|
Mail MailService
|
||||||
|
Plugin PluginService
|
||||||
|
Post PostService
|
||||||
|
Session SessionService
|
||||||
|
Store *StoreService
|
||||||
|
System SystemService
|
||||||
|
Team TeamService
|
||||||
|
User UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new instance of Client.
|
||||||
|
//
|
||||||
|
// This client must only be created once per plugin to
|
||||||
|
// prevent reacquiring of resources.
|
||||||
|
func NewClient(api plugin.API, driver plugin.Driver) *Client {
|
||||||
|
return &Client{
|
||||||
|
api: api,
|
||||||
|
|
||||||
|
Bot: BotService{api: api},
|
||||||
|
Channel: ChannelService{api: api},
|
||||||
|
Cluster: ClusterService{api: api},
|
||||||
|
Configuration: ConfigurationService{api: api},
|
||||||
|
SlashCommand: SlashCommandService{api: api},
|
||||||
|
OAuth: OAuthService{api: api},
|
||||||
|
Emoji: EmojiService{api: api},
|
||||||
|
File: FileService{api: api},
|
||||||
|
Frontend: FrontendService{api: api},
|
||||||
|
Group: GroupService{api: api},
|
||||||
|
KV: KVService{api: api},
|
||||||
|
Log: LogService{api: api},
|
||||||
|
Mail: MailService{api: api},
|
||||||
|
Plugin: PluginService{api: api},
|
||||||
|
Post: PostService{api: api},
|
||||||
|
Session: SessionService{api: api},
|
||||||
|
Store: &StoreService{
|
||||||
|
api: api,
|
||||||
|
driver: driver,
|
||||||
|
},
|
||||||
|
System: SystemService{api: api},
|
||||||
|
Team: TeamService{api: api},
|
||||||
|
User: UserService{api: api},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureServerVersion(api plugin.API, required string) error {
|
||||||
|
serverVersion := api.GetServerVersion()
|
||||||
|
currentVersion := semver.MustParse(serverVersion)
|
||||||
|
requiredVersion := semver.MustParse(required)
|
||||||
|
|
||||||
|
if currentVersion.LT(requiredVersion) {
|
||||||
|
return errors.Errorf("incompatible server version for plugin, minimum required version: %s, current version: %s", required, serverVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
server/public/pluginapi/cluster.go
Обычный файл
23
server/public/pluginapi/cluster.go
Обычный файл
@@ -0,0 +1,23 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterService exposes methods to interact with cluster nodes.
|
||||||
|
type ClusterService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterService broadcasts a plugin event to all other running instances of
|
||||||
|
// the calling plugin that are present in the cluster.
|
||||||
|
//
|
||||||
|
// This method is used to allow plugin communication in a High-Availability cluster.
|
||||||
|
// The receiving side should implement the OnPluginClusterEvent hook
|
||||||
|
// to receive events sent through this method.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.36
|
||||||
|
func (c *ClusterService) PublishPluginEvent(ev model.PluginClusterEvent, opts model.PluginClusterEventSendOptions) error {
|
||||||
|
return c.api.PublishPluginClusterEvent(ev, opts)
|
||||||
|
}
|
||||||
3
server/public/pluginapi/cluster/doc.go
Обычный файл
3
server/public/pluginapi/cluster/doc.go
Обычный файл
@@ -0,0 +1,3 @@
|
|||||||
|
// package cluster exposes synchronization primitives to ensure correct behavior across multiple
|
||||||
|
// plugin instances in a Mattermost cluster.
|
||||||
|
package cluster
|
||||||
229
server/public/pluginapi/cluster/job.go
Обычный файл
229
server/public/pluginapi/cluster/job.go
Обычный файл
@@ -0,0 +1,229 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// cronPrefix is used to namespace key values created for a job from other key values
|
||||||
|
// created by a plugin.
|
||||||
|
cronPrefix = "cron_"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobPluginAPI is the plugin API interface required to schedule jobs.
|
||||||
|
type JobPluginAPI interface {
|
||||||
|
MutexPluginAPI
|
||||||
|
KVGet(key string) ([]byte, *model.AppError)
|
||||||
|
KVDelete(key string) *model.AppError
|
||||||
|
KVList(page, count int) ([]string, *model.AppError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobConfig defines the configuration of a scheduled job.
|
||||||
|
type JobConfig struct {
|
||||||
|
// Interval is the period of execution for the job.
|
||||||
|
Interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextWaitInterval is a callback computing the next wait interval for a job.
|
||||||
|
type NextWaitInterval func(now time.Time, metadata JobMetadata) time.Duration
|
||||||
|
|
||||||
|
// MakeWaitForInterval creates a function to scheduling a job to run on the given interval relative
|
||||||
|
// to the last finished timestamp.
|
||||||
|
//
|
||||||
|
// For example, if the job first starts at 12:01 PM, and is configured with interval 5 minutes,
|
||||||
|
// it will next run at:
|
||||||
|
//
|
||||||
|
// 12:06, 12:11, 12:16, ...
|
||||||
|
//
|
||||||
|
// If the job has not previously started, it will run immediately.
|
||||||
|
func MakeWaitForInterval(interval time.Duration) NextWaitInterval {
|
||||||
|
if interval == 0 {
|
||||||
|
panic("must specify non-zero ready interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(now time.Time, metadata JobMetadata) time.Duration {
|
||||||
|
sinceLastFinished := now.Sub(metadata.LastFinished)
|
||||||
|
if sinceLastFinished < interval {
|
||||||
|
return interval - sinceLastFinished
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeWaitForRoundedInterval creates a function, scheduling a job to run on the nearest rounded
|
||||||
|
// interval relative to the last finished timestamp.
|
||||||
|
//
|
||||||
|
// For example, if the job first starts at 12:04 PM, and is configured with interval 5 minutes,
|
||||||
|
// and is configured to round to 5 minute intervals, it will next run at:
|
||||||
|
//
|
||||||
|
// 12:05 PM, 12:10 PM, 12:15 PM, ...
|
||||||
|
//
|
||||||
|
// If the job has not previously started, it will run immediately. Note that this wait interval
|
||||||
|
// strategy does not guarantee a minimum interval between runs, only that subsequent runs will be
|
||||||
|
// scheduled on the rounded interval.
|
||||||
|
func MakeWaitForRoundedInterval(interval time.Duration) NextWaitInterval {
|
||||||
|
if interval == 0 {
|
||||||
|
panic("must specify non-zero ready interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(now time.Time, metadata JobMetadata) time.Duration {
|
||||||
|
if metadata.LastFinished.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
target := metadata.LastFinished.Add(interval).Truncate(interval)
|
||||||
|
untilTarget := target.Sub(now)
|
||||||
|
if untilTarget > 0 {
|
||||||
|
return untilTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is a scheduled job whose callback function is executed on a configured interval by at most
|
||||||
|
// one plugin instance at a time.
|
||||||
|
//
|
||||||
|
// Use scheduled jobs to perform background activity on a regular interval without having to
|
||||||
|
// explicitly coordinate with other instances of the same plugin that might repeat that effort.
|
||||||
|
type Job struct {
|
||||||
|
pluginAPI JobPluginAPI
|
||||||
|
key string
|
||||||
|
mutex *Mutex
|
||||||
|
nextWaitInterval NextWaitInterval
|
||||||
|
callback func()
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
stop chan bool
|
||||||
|
done chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobMetadata persists metadata about job execution.
|
||||||
|
type JobMetadata struct {
|
||||||
|
// LastFinished is the last time the job finished anywhere in the cluster.
|
||||||
|
LastFinished time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule creates a scheduled job.
|
||||||
|
func Schedule(pluginAPI JobPluginAPI, key string, nextWaitInterval NextWaitInterval, callback func()) (*Job, error) {
|
||||||
|
key = cronPrefix + key
|
||||||
|
|
||||||
|
mutex, err := NewMutex(pluginAPI, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to create job mutex")
|
||||||
|
}
|
||||||
|
|
||||||
|
job := &Job{
|
||||||
|
pluginAPI: pluginAPI,
|
||||||
|
key: key,
|
||||||
|
mutex: mutex,
|
||||||
|
nextWaitInterval: nextWaitInterval,
|
||||||
|
callback: callback,
|
||||||
|
stop: make(chan bool),
|
||||||
|
done: make(chan bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
go job.run()
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMetadata reads the job execution metadata from the kv store.
|
||||||
|
func (j *Job) readMetadata() (JobMetadata, error) {
|
||||||
|
data, appErr := j.pluginAPI.KVGet(j.key)
|
||||||
|
if appErr != nil {
|
||||||
|
return JobMetadata{}, errors.Wrap(appErr, "failed to read data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
return JobMetadata{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata JobMetadata
|
||||||
|
err := json.Unmarshal(data, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return JobMetadata{}, errors.Wrap(err, "failed to decode data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveMetadata writes updated job execution metadata from the kv store.
|
||||||
|
//
|
||||||
|
// It is assumed that the job mutex is held, negating the need to require an atomic write.
|
||||||
|
func (j *Job) saveMetadata(metadata JobMetadata) error {
|
||||||
|
data, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal data")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, appErr := j.pluginAPI.KVSetWithOptions(j.key, data, model.PluginKVSetOptions{})
|
||||||
|
if appErr != nil || !ok {
|
||||||
|
return errors.Wrap(appErr, "failed to set data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run attempts to run the scheduled job, guaranteeing only one instance is executing concurrently.
|
||||||
|
func (j *Job) run() {
|
||||||
|
defer close(j.done)
|
||||||
|
|
||||||
|
var waitInterval time.Duration
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-j.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(waitInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
// Acquire the corresponding job lock and hold it throughout execution.
|
||||||
|
j.mutex.Lock()
|
||||||
|
defer j.mutex.Unlock()
|
||||||
|
|
||||||
|
metadata, err := j.readMetadata()
|
||||||
|
if err != nil {
|
||||||
|
j.pluginAPI.LogError("failed to read job metadata", "err", err, "key", j.key)
|
||||||
|
waitInterval = nextWaitInterval(waitInterval, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it time to run the job?
|
||||||
|
waitInterval = j.nextWaitInterval(time.Now(), metadata)
|
||||||
|
if waitInterval > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the job
|
||||||
|
j.callback()
|
||||||
|
|
||||||
|
metadata.LastFinished = time.Now()
|
||||||
|
|
||||||
|
err = j.saveMetadata(metadata)
|
||||||
|
if err != nil {
|
||||||
|
j.pluginAPI.LogError("failed to write job data", "err", err, "key", j.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitInterval = j.nextWaitInterval(time.Now(), metadata)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates a scheduled job, preventing it from being scheduled on this plugin instance.
|
||||||
|
func (j *Job) Close() error {
|
||||||
|
j.stopOnce.Do(func() {
|
||||||
|
close(j.stop)
|
||||||
|
})
|
||||||
|
<-j.done
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
25
server/public/pluginapi/cluster/job_example_test.go
Обычный файл
25
server/public/pluginapi/cluster/job_example_test.go
Обычный файл
@@ -0,0 +1,25 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleSchedule() {
|
||||||
|
// Use p.API from your plugin instead.
|
||||||
|
pluginAPI := plugin.API(nil)
|
||||||
|
|
||||||
|
callback := func() {
|
||||||
|
// periodic work to do
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := Schedule(pluginAPI, "key", MakeWaitForInterval(5*time.Minute), callback)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to schedule job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// main thread
|
||||||
|
|
||||||
|
defer job.Close()
|
||||||
|
}
|
||||||
235
server/public/pluginapi/cluster/job_once.go
Обычный файл
235
server/public/pluginapi/cluster/job_once.go
Обычный файл
@@ -0,0 +1,235 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// oncePrefix is used to namespace key values created for a scheduleOnce job
|
||||||
|
oncePrefix = "once_"
|
||||||
|
|
||||||
|
// keysPerPage is the maximum number of keys to retrieve from the db per call
|
||||||
|
keysPerPage = 1000
|
||||||
|
|
||||||
|
// maxNumFails is the maximum number of KVStore read fails or failed attempts to run the
|
||||||
|
// callback until the scheduler cancels a job.
|
||||||
|
maxNumFails = 3
|
||||||
|
|
||||||
|
// waitAfterFail is the amount of time to wait after a failure
|
||||||
|
waitAfterFail = 1 * time.Second
|
||||||
|
|
||||||
|
// pollNewJobsInterval is the amount of time to wait between polling the db for new scheduled jobs
|
||||||
|
pollNewJobsInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// scheduleOnceJitter is the range of jitter to add to intervals to avoid contention issues
|
||||||
|
scheduleOnceJitter = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// propsLimit is the maximum length in bytes of the json-representation of a job's props.
|
||||||
|
// It exists to prevent job go rountines from consuming too much memory, as they are long running.
|
||||||
|
propsLimit = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobOnceMetadata struct {
|
||||||
|
Key string
|
||||||
|
RunAt time.Time
|
||||||
|
Props any
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobOnce struct {
|
||||||
|
pluginAPI JobPluginAPI
|
||||||
|
clusterMutex *Mutex
|
||||||
|
|
||||||
|
// key is the original key. It is prefixed with oncePrefix when used as a key in the KVStore
|
||||||
|
key string
|
||||||
|
props any
|
||||||
|
runAt time.Time
|
||||||
|
numFails int
|
||||||
|
|
||||||
|
// done signals the job.run go routine to exit
|
||||||
|
done chan bool
|
||||||
|
doneOnce sync.Once
|
||||||
|
|
||||||
|
// join is a join point for the job.run() goroutine to join the calling goroutine (in this case,
|
||||||
|
// the one calling job.Cancel)
|
||||||
|
join chan bool
|
||||||
|
joinOnce sync.Once
|
||||||
|
|
||||||
|
storedCallback *syncedCallback
|
||||||
|
activeJobs *syncedJobs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel terminates a scheduled job, preventing it from being scheduled on this plugin instance.
|
||||||
|
// It also removes the job from the db, preventing it from being run in the future.
|
||||||
|
func (j *JobOnce) Cancel() {
|
||||||
|
j.clusterMutex.Lock()
|
||||||
|
defer j.clusterMutex.Unlock()
|
||||||
|
|
||||||
|
j.cancelWhileHoldingMutex()
|
||||||
|
|
||||||
|
// join the running goroutine
|
||||||
|
j.joinOnce.Do(func() {
|
||||||
|
<-j.join
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJobOnce(pluginAPI JobPluginAPI, key string, runAt time.Time, callback *syncedCallback, jobs *syncedJobs, props any) (*JobOnce, error) {
|
||||||
|
mutex, err := NewMutex(pluginAPI, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to create job mutex")
|
||||||
|
}
|
||||||
|
|
||||||
|
propsBytes, err := json.Marshal(props)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to marshal props")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(propsBytes) > propsLimit {
|
||||||
|
return nil, errors.Errorf("props length extends limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JobOnce{
|
||||||
|
pluginAPI: pluginAPI,
|
||||||
|
clusterMutex: mutex,
|
||||||
|
key: key,
|
||||||
|
props: props,
|
||||||
|
runAt: runAt,
|
||||||
|
done: make(chan bool),
|
||||||
|
join: make(chan bool),
|
||||||
|
storedCallback: callback,
|
||||||
|
activeJobs: jobs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JobOnce) run() {
|
||||||
|
defer close(j.join)
|
||||||
|
|
||||||
|
wait := time.Until(j.runAt)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-j.done:
|
||||||
|
return
|
||||||
|
case <-time.After(wait + addJitter()):
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
// Acquire the cluster mutex while we're trying to do the job
|
||||||
|
j.clusterMutex.Lock()
|
||||||
|
defer j.clusterMutex.Unlock()
|
||||||
|
|
||||||
|
// Check that the job has not been completed
|
||||||
|
metadata, err := readMetadata(j.pluginAPI, j.key)
|
||||||
|
if err != nil {
|
||||||
|
j.numFails++
|
||||||
|
if j.numFails > maxNumFails {
|
||||||
|
j.cancelWhileHoldingMutex()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait a bit of time and try again
|
||||||
|
wait = waitAfterFail
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If key doesn't exist, or if the runAt has changed, the original job has been completed already
|
||||||
|
if metadata == nil || !j.runAt.Equal(metadata.RunAt) {
|
||||||
|
j.cancelWhileHoldingMutex()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
j.executeJob()
|
||||||
|
|
||||||
|
j.cancelWhileHoldingMutex()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JobOnce) executeJob() {
|
||||||
|
j.storedCallback.mu.Lock()
|
||||||
|
defer j.storedCallback.mu.Unlock()
|
||||||
|
|
||||||
|
j.storedCallback.callback(j.key, j.props)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMetadata reads the job's stored metadata. If the caller wishes to make an atomic
|
||||||
|
// read/write, the cluster mutex for job's key should be held.
|
||||||
|
func readMetadata(pluginAPI JobPluginAPI, key string) (*JobOnceMetadata, error) {
|
||||||
|
data, appErr := pluginAPI.KVGet(oncePrefix + key)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, errors.Wrap(normalizeAppErr(appErr), "failed to read data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata JobOnceMetadata
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to decode data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveMetadata writes the job's metadata to the kvstore. saveMetadata acquires the job's cluster lock.
|
||||||
|
// saveMetadata will not overwrite an existing key.
|
||||||
|
func (j *JobOnce) saveMetadata() error {
|
||||||
|
j.clusterMutex.Lock()
|
||||||
|
defer j.clusterMutex.Unlock()
|
||||||
|
|
||||||
|
metadata := JobOnceMetadata{
|
||||||
|
Key: j.key,
|
||||||
|
Props: j.props,
|
||||||
|
RunAt: j.runAt,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to marshal data")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, appErr := j.pluginAPI.KVSetWithOptions(oncePrefix+j.key, data, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: nil,
|
||||||
|
})
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("failed to set data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelWhileHoldingMutex assumes the caller holds the job's mutex.
|
||||||
|
func (j *JobOnce) cancelWhileHoldingMutex() {
|
||||||
|
// remove the job from the kv store, if it exists
|
||||||
|
_ = j.pluginAPI.KVDelete(oncePrefix + j.key)
|
||||||
|
|
||||||
|
j.activeJobs.mu.Lock()
|
||||||
|
defer j.activeJobs.mu.Unlock()
|
||||||
|
delete(j.activeJobs.jobs, j.key)
|
||||||
|
|
||||||
|
j.doneOnce.Do(func() {
|
||||||
|
close(j.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addJitter() time.Duration {
|
||||||
|
return time.Duration(rand.Int63n(int64(scheduleOnceJitter)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppErr(appErr *model.AppError) error {
|
||||||
|
if appErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return appErr
|
||||||
|
}
|
||||||
45
server/public/pluginapi/cluster/job_once_example_test.go
Обычный файл
45
server/public/pluginapi/cluster/job_once_example_test.go
Обычный файл
@@ -0,0 +1,45 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleJobOnceCalls(key string, props any) {
|
||||||
|
if key == "the key i'm watching for" {
|
||||||
|
log.Println(props)
|
||||||
|
// Work to do only once per cluster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleJobOnceScheduler_ScheduleOnce() {
|
||||||
|
// Use p.API from your plugin instead.
|
||||||
|
pluginAPI := plugin.API(nil)
|
||||||
|
|
||||||
|
// Get the scheduler, which you can pass throughout the plugin...
|
||||||
|
scheduler := GetJobOnceScheduler(pluginAPI)
|
||||||
|
|
||||||
|
// Set the plugin's callback handler
|
||||||
|
_ = scheduler.SetCallback(HandleJobOnceCalls)
|
||||||
|
|
||||||
|
// Now start the scheduler, which starts the poller and schedules all waiting jobs.
|
||||||
|
_ = scheduler.Start()
|
||||||
|
|
||||||
|
// main thread...
|
||||||
|
|
||||||
|
// add a job
|
||||||
|
_, _ = scheduler.ScheduleOnce("the key i'm watching for", time.Now().Add(2*time.Hour), struct{ foo string }{"aasd"})
|
||||||
|
|
||||||
|
// Maybe you want to check the scheduled jobs, or cancel them. This is completely optional--there
|
||||||
|
// is no need to cancel jobs, even if you are shutting down. Call Cancel only when you want to
|
||||||
|
// cancel a future job. Cancelling a job will prevent it from running in the future on this or
|
||||||
|
// any server.
|
||||||
|
jobs, _ := scheduler.ListScheduledJobs()
|
||||||
|
defer func() {
|
||||||
|
for _, j := range jobs {
|
||||||
|
scheduler.Cancel(j.Key)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
83
server/public/pluginapi/cluster/job_once_mem_test.go
Обычный файл
83
server/public/pluginapi/cluster/job_once_mem_test.go
Обычный файл
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemFootprint(t *testing.T) {
|
||||||
|
var memConsumed = func() uint64 {
|
||||||
|
runtime.GC()
|
||||||
|
var s runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&s)
|
||||||
|
return s.Sys
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("average k per jobOnce", func(t *testing.T) {
|
||||||
|
t.SkipNow()
|
||||||
|
|
||||||
|
makeKey := model.NewId
|
||||||
|
|
||||||
|
numJobs := 100000
|
||||||
|
jobs := make(map[string]*int32, numJobs)
|
||||||
|
for i := 0; i < numJobs; i++ {
|
||||||
|
jobs[makeKey()] = new(int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
count, ok := jobs[key]
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
s := GetJobOnceScheduler(mockPluginAPI)
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
getVal := func(key string) []byte {
|
||||||
|
data, _ := s.pluginAPI.KVGet(key)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
before := memConsumed()
|
||||||
|
|
||||||
|
for k := range jobs {
|
||||||
|
assert.Empty(t, getVal(oncePrefix+k))
|
||||||
|
_, err = s.ScheduleOnce(k, time.Now().Add(5*time.Minute), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+k))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
// Everything scheduled now:
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Equal(t, numJobs, len(s.activeJobs.jobs))
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
list, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, numJobs, len(list))
|
||||||
|
|
||||||
|
after := memConsumed()
|
||||||
|
|
||||||
|
fmt.Printf("\nthe %d jobs, scheduler, and goroutines require: %.2fmB memory, or %.3fkB each job\n",
|
||||||
|
numJobs,
|
||||||
|
float64(after-before)/(1024*1024),
|
||||||
|
(float64(after-before)/float64(numJobs))/1024)
|
||||||
|
})
|
||||||
|
}
|
||||||
236
server/public/pluginapi/cluster/job_once_scheduler.go
Обычный файл
236
server/public/pluginapi/cluster/job_once_scheduler.go
Обычный файл
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncedCallback uses the mutex to make things predictable for the client: the callback will be
|
||||||
|
// called once at a time (the client does not need to worry about concurrency within the callback)
|
||||||
|
type syncedCallback struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
callback func(string, any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncedJobs struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
jobs map[string]*JobOnce
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobOnceScheduler struct {
|
||||||
|
pluginAPI JobPluginAPI
|
||||||
|
|
||||||
|
startedMu sync.RWMutex
|
||||||
|
started bool
|
||||||
|
|
||||||
|
activeJobs *syncedJobs
|
||||||
|
storedCallback *syncedCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedulerOnce sync.Once
|
||||||
|
var s *JobOnceScheduler
|
||||||
|
|
||||||
|
// GetJobOnceScheduler returns a scheduler which is ready to have its callback set. Repeated
|
||||||
|
// calls will return the same scheduler.
|
||||||
|
func GetJobOnceScheduler(pluginAPI JobPluginAPI) *JobOnceScheduler {
|
||||||
|
schedulerOnce.Do(func() {
|
||||||
|
s = &JobOnceScheduler{
|
||||||
|
pluginAPI: pluginAPI,
|
||||||
|
activeJobs: &syncedJobs{
|
||||||
|
jobs: make(map[string]*JobOnce),
|
||||||
|
},
|
||||||
|
storedCallback: &syncedCallback{},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the Scheduler. It finds all previous ScheduleOnce jobs and starts them running, and
|
||||||
|
// fires any jobs that have reached or exceeded their runAt time. Thus, even if a cluster goes down
|
||||||
|
// and is restarted, Start will restart previously scheduled jobs.
|
||||||
|
func (s *JobOnceScheduler) Start() error {
|
||||||
|
s.startedMu.Lock()
|
||||||
|
defer s.startedMu.Unlock()
|
||||||
|
if s.started {
|
||||||
|
return errors.New("scheduler has already been started")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.verifyCallbackExists(); err != nil {
|
||||||
|
return errors.Wrap(err, "callback not found; cannot start scheduler")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.scheduleNewJobsFromDB(); err != nil {
|
||||||
|
return errors.Wrap(err, "could not start JobOnceScheduler due to error")
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.pollForNewScheduledJobs()
|
||||||
|
|
||||||
|
s.started = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCallback sets the scheduler's callback. When a job fires, the callback will be called with
|
||||||
|
// the job's id.
|
||||||
|
func (s *JobOnceScheduler) SetCallback(callback func(string, any)) error {
|
||||||
|
if callback == nil {
|
||||||
|
return errors.New("callback cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.storedCallback.mu.Lock()
|
||||||
|
defer s.storedCallback.mu.Unlock()
|
||||||
|
|
||||||
|
s.storedCallback.callback = callback
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScheduledJobs returns a list of the jobs in the db that have been scheduled. There is no
|
||||||
|
// guarantee that list is accurate by the time the caller reads the list. E.g., the jobs in the list
|
||||||
|
// may have been run, canceled, or new jobs may have scheduled.
|
||||||
|
func (s *JobOnceScheduler) ListScheduledJobs() ([]JobOnceMetadata, error) {
|
||||||
|
var ret []JobOnceMetadata
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
keys, err := s.pluginAPI.KVList(i, keysPerPage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "error getting KVList")
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if strings.HasPrefix(k, oncePrefix) {
|
||||||
|
metadata, err := readMetadata(s.pluginAPI, k[len(oncePrefix):])
|
||||||
|
if err != nil {
|
||||||
|
s.pluginAPI.LogError(errors.Wrap(err, "could not retrieve data from plugin kvstore for key: "+k).Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = append(ret, *metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) < keysPerPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleOnce creates a scheduled job that will run once. When the clock reaches runAt, the
|
||||||
|
// callback will be called with key and props as the argument.
|
||||||
|
//
|
||||||
|
// If the job key already exists in the db, this will return an error. To reschedule a job, first
|
||||||
|
// cancel the original then schedule it again.
|
||||||
|
func (s *JobOnceScheduler) ScheduleOnce(key string, runAt time.Time, props any) (*JobOnce, error) {
|
||||||
|
s.startedMu.RLock()
|
||||||
|
defer s.startedMu.RUnlock()
|
||||||
|
if !s.started {
|
||||||
|
return nil, errors.New("start the scheduler before adding jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := newJobOnce(s.pluginAPI, key, runAt, s.storedCallback, s.activeJobs, props)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "could not create new job")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = job.saveMetadata(); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "could not save job metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.runAndTrack(job)
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cancels a job by its key. This is useful if the plugin lost the original *JobOnce, or
|
||||||
|
// is stopping a job found in ListScheduledJobs().
|
||||||
|
func (s *JobOnceScheduler) Cancel(key string) {
|
||||||
|
// using an anonymous function because job.Close() below needs access to the activeJobs mutex
|
||||||
|
job := func() *JobOnce {
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
defer s.activeJobs.mu.RUnlock()
|
||||||
|
j, ok := s.activeJobs.jobs[key]
|
||||||
|
if ok {
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job wasn't active, so no need to call CancelWhileHoldingMutex (which shuts down the
|
||||||
|
// goroutine). There's a condition where another server in the cluster started the job, and
|
||||||
|
// the current server hasn't polled for it yet. To solve that case, delete it from the db.
|
||||||
|
mutex, err := NewMutex(s.pluginAPI, key)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginAPI.LogError(errors.Wrap(err, "failed to create job mutex in Cancel for key: "+key).Error())
|
||||||
|
}
|
||||||
|
mutex.Lock()
|
||||||
|
defer mutex.Unlock()
|
||||||
|
|
||||||
|
_ = s.pluginAPI.KVDelete(oncePrefix + key)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
job.Cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobOnceScheduler) scheduleNewJobsFromDB() error {
|
||||||
|
scheduled, err := s.ListScheduledJobs()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "could not read scheduled jobs from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range scheduled {
|
||||||
|
job, err := newJobOnce(s.pluginAPI, m.Key, m.RunAt, s.storedCallback, s.activeJobs, m.Props)
|
||||||
|
if err != nil {
|
||||||
|
s.pluginAPI.LogError(errors.Wrap(err, "could not create new job for key: "+m.Key).Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.runAndTrack(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobOnceScheduler) runAndTrack(job *JobOnce) {
|
||||||
|
s.activeJobs.mu.Lock()
|
||||||
|
defer s.activeJobs.mu.Unlock()
|
||||||
|
|
||||||
|
// has this been scheduled already on this server?
|
||||||
|
if _, ok := s.activeJobs.jobs[job.key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go job.run()
|
||||||
|
|
||||||
|
s.activeJobs.jobs[job.key] = job
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollForNewScheduledJobs will only be started once per plugin. It doesn't need to be stopped.
|
||||||
|
func (s *JobOnceScheduler) pollForNewScheduledJobs() {
|
||||||
|
for {
|
||||||
|
<-time.After(pollNewJobsInterval + addJitter())
|
||||||
|
|
||||||
|
if err := s.scheduleNewJobsFromDB(); err != nil {
|
||||||
|
s.pluginAPI.LogError("pluginAPI scheduleOnce poller encountered an error but is still polling", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobOnceScheduler) verifyCallbackExists() error {
|
||||||
|
s.storedCallback.mu.Lock()
|
||||||
|
defer s.storedCallback.mu.Unlock()
|
||||||
|
|
||||||
|
if s.storedCallback.callback == nil {
|
||||||
|
return errors.New("set callback before starting the scheduler")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
685
server/public/pluginapi/cluster/job_once_test.go
Обычный файл
685
server/public/pluginapi/cluster/job_once_test.go
Обычный файл
@@ -0,0 +1,685 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScheduleOnceParallel(t *testing.T) {
|
||||||
|
makeKey := model.NewId
|
||||||
|
|
||||||
|
// there is only one callback by design, so all tests need to add their key
|
||||||
|
// and callback handling code here.
|
||||||
|
jobKey1 := makeKey()
|
||||||
|
count1 := new(int32)
|
||||||
|
jobKey2 := makeKey()
|
||||||
|
count2 := new(int32)
|
||||||
|
jobKey3 := makeKey()
|
||||||
|
jobKey4 := makeKey()
|
||||||
|
count4 := new(int32)
|
||||||
|
jobKey5 := makeKey()
|
||||||
|
count5 := new(int32)
|
||||||
|
|
||||||
|
manyJobs := make(map[string]*int32)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
manyJobs[makeKey()] = new(int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
switch key {
|
||||||
|
case jobKey1:
|
||||||
|
atomic.AddInt32(count1, 1)
|
||||||
|
case jobKey2:
|
||||||
|
atomic.AddInt32(count2, 1)
|
||||||
|
case jobKey3:
|
||||||
|
return // do nothing, like an error occurred in the plugin
|
||||||
|
case jobKey4:
|
||||||
|
atomic.AddInt32(count4, 1)
|
||||||
|
case jobKey5:
|
||||||
|
atomic.AddInt32(count5, 1)
|
||||||
|
default:
|
||||||
|
count, ok := manyJobs[key]
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
getVal := func(key string) []byte {
|
||||||
|
data, _ := mockPluginAPI.KVGet(key)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
s := GetJobOnceScheduler(mockPluginAPI)
|
||||||
|
|
||||||
|
// should error if we try to start without callback
|
||||||
|
err := s.Start()
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
err = s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
|
||||||
|
t.Run("one scheduled job", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
job, err2 := s.ScheduleOnce(jobKey1, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey1))
|
||||||
|
|
||||||
|
time.Sleep(200*time.Millisecond + scheduleOnceJitter)
|
||||||
|
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey1))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey1])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
|
||||||
|
// It's okay to cancel jobs extra times, even if they're completed.
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
|
||||||
|
// Should have been called once
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(count1))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one job, stopped before firing", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
job, err2 := s.ScheduleOnce(jobKey2, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey2))
|
||||||
|
|
||||||
|
job.Cancel()
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey2))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey2])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
|
||||||
|
time.Sleep(2 * (waitAfterFail + scheduleOnceJitter))
|
||||||
|
|
||||||
|
// Should not have been called
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(count2))
|
||||||
|
|
||||||
|
// It's okay to cancel jobs extra times, even if they're completed.
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
job.Cancel()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed at the plugin, job removed from db", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
job, err2 := s.ScheduleOnce(jobKey3, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey3))
|
||||||
|
|
||||||
|
time.Sleep(200*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey3))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey3])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cancel and restart a job with the same key", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
job, err2 := s.ScheduleOnce(jobKey4, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey4))
|
||||||
|
|
||||||
|
job.Cancel()
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey4))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey4])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
|
||||||
|
job, err2 = s.ScheduleOnce(jobKey4, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey4))
|
||||||
|
|
||||||
|
time.Sleep(200*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(count4))
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey4))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey4])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("many scheduled jobs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for k := range manyJobs {
|
||||||
|
job, err2 := s.ScheduleOnce(k, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+k))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(200*time.Millisecond + scheduleOnceJitter)
|
||||||
|
|
||||||
|
for k, v := range manyJobs {
|
||||||
|
assert.Empty(t, getVal(oncePrefix+k))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[k])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
assert.Equal(t, int32(1), *v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cancel a job by key name", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
job, err2 := s.ScheduleOnce(jobKey5, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err2)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey5))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.NotEmpty(t, s.activeJobs.jobs[jobKey5])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
|
||||||
|
s.Cancel(jobKey5)
|
||||||
|
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey5))
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey5])
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
|
||||||
|
// cancel it again doesn't do anything:
|
||||||
|
s.Cancel(jobKey5)
|
||||||
|
|
||||||
|
time.Sleep(150*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(count5))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("starting the scheduler again will return an error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
newScheduler := GetJobOnceScheduler(mockPluginAPI)
|
||||||
|
err = newScheduler.Start()
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleOnceSequential(t *testing.T) {
|
||||||
|
makeKey := model.NewId
|
||||||
|
|
||||||
|
// get the existing scheduler
|
||||||
|
s := GetJobOnceScheduler(newMockPluginAPI(t))
|
||||||
|
getVal := func(key string) []byte {
|
||||||
|
data, _ := s.pluginAPI.KVGet(key)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
setMetadata := func(key string, metadata JobOnceMetadata) error {
|
||||||
|
data, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, appErr := s.pluginAPI.KVSetWithOptions(oncePrefix+key, data, model.PluginKVSetOptions{})
|
||||||
|
if !ok {
|
||||||
|
return errors.New("KVSetWithOptions failed")
|
||||||
|
}
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resetScheduler := func() {
|
||||||
|
s.activeJobs.mu.Lock()
|
||||||
|
defer s.activeJobs.mu.Unlock()
|
||||||
|
s.activeJobs.jobs = make(map[string]*JobOnce)
|
||||||
|
s.storedCallback.mu.Lock()
|
||||||
|
defer s.storedCallback.mu.Unlock()
|
||||||
|
s.storedCallback.callback = nil
|
||||||
|
s.startedMu.Lock()
|
||||||
|
defer s.startedMu.Unlock()
|
||||||
|
s.started = false
|
||||||
|
s.pluginAPI.(*mockPluginAPI).clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("starting the scheduler without a callback will return an error", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
err := s.Start()
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trying to schedule a job without starting will return an error", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {}
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.ScheduleOnce("will fail", time.Now(), nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("adding two callback works, only second one is called", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
newCount2 := new(int32)
|
||||||
|
newCount3 := new(int32)
|
||||||
|
|
||||||
|
callback2 := func(key string, _ any) {
|
||||||
|
atomic.AddInt32(newCount2, 1)
|
||||||
|
}
|
||||||
|
callback3 := func(key string, _ any) {
|
||||||
|
atomic.AddInt32(newCount3, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.SetCallback(callback2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.SetCallback(callback3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = s.ScheduleOnce("anything", time.Now().Add(50*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
time.Sleep(70*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(newCount2))
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(newCount3))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test paging keys from the db by inserting 3 pages of jobs and starting scheduler", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
numPagingJobs := keysPerPage*3 + 2
|
||||||
|
testPagingJobs := make(map[string]*int32)
|
||||||
|
for i := 0; i < numPagingJobs; i++ {
|
||||||
|
testPagingJobs[makeKey()] = new(int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
count, ok := testPagingJobs[key]
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the test paging jobs before starting scheduler
|
||||||
|
for k := range testPagingJobs {
|
||||||
|
assert.Empty(t, getVal(oncePrefix+k))
|
||||||
|
job, err := newJobOnce(s.pluginAPI, k, time.Now().Add(100*time.Millisecond), s.storedCallback, s.activeJobs, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = job.saveMetadata()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+k))
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, len(testPagingJobs), len(jobs))
|
||||||
|
|
||||||
|
err = s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// reschedule from the db:
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// wait for the testPagingJobs created in the setup to finish
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
numInDB := 0
|
||||||
|
numActive := 0
|
||||||
|
numCountsAtZero := 0
|
||||||
|
for k, v := range testPagingJobs {
|
||||||
|
if getVal(oncePrefix+k) != nil {
|
||||||
|
numInDB++
|
||||||
|
}
|
||||||
|
s.activeJobs.mu.RLock()
|
||||||
|
if s.activeJobs.jobs[k] != nil {
|
||||||
|
numActive++
|
||||||
|
}
|
||||||
|
s.activeJobs.mu.RUnlock()
|
||||||
|
if atomic.LoadInt32(v) == int32(0) {
|
||||||
|
numCountsAtZero++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 0, numInDB)
|
||||||
|
assert.Equal(t, 0, numActive)
|
||||||
|
assert.Equal(t, 0, numCountsAtZero)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed at the db", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
jobKey1 := makeKey()
|
||||||
|
count1 := new(int32)
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
if key == jobKey1 {
|
||||||
|
atomic.AddInt32(count1, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
|
||||||
|
job, err := s.ScheduleOnce(jobKey1, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey1))
|
||||||
|
assert.NotEmpty(t, s.activeJobs.jobs[jobKey1])
|
||||||
|
s.pluginAPI.(*mockPluginAPI).setFailingWithPrefix(oncePrefix)
|
||||||
|
|
||||||
|
// wait until the metadata has failed to read
|
||||||
|
time.Sleep((maxNumFails + 1) * (waitAfterFail + scheduleOnceJitter))
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(count1))
|
||||||
|
assert.Nil(t, getVal(oncePrefix+jobKey1))
|
||||||
|
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[jobKey1])
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey1))
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(count1))
|
||||||
|
|
||||||
|
s.pluginAPI.(*mockPluginAPI).setFailingWithPrefix("")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("simulate starting the plugin with 3 pending jobs in the db", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
jobKeys := make(map[string]*int32)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
jobKeys[makeKey()] = new(int32)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
count, ok := jobKeys[key]
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for k := range jobKeys {
|
||||||
|
job, err3 := newJobOnce(s.pluginAPI, k, time.Now().Add(100*time.Millisecond), s.storedCallback, s.activeJobs, nil)
|
||||||
|
require.NoError(t, err3)
|
||||||
|
err3 = job.saveMetadata()
|
||||||
|
require.NoError(t, err3)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// double checking they're in the db:
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs, 3)
|
||||||
|
|
||||||
|
// simulate starting the plugin
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(120*time.Millisecond + scheduleOnceJitter)
|
||||||
|
|
||||||
|
for k, v := range jobKeys {
|
||||||
|
assert.Empty(t, getVal(oncePrefix+k))
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[k])
|
||||||
|
assert.Equal(t, int32(1), *v)
|
||||||
|
}
|
||||||
|
jobs, err = s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("starting a job and polling before it's finished results in only one job running", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
jobKey := makeKey()
|
||||||
|
count := new(int32)
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
if key == jobKey {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
|
||||||
|
job, err := s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey))
|
||||||
|
s.activeJobs.mu.Lock()
|
||||||
|
assert.NotEmpty(t, s.activeJobs.jobs[jobKey])
|
||||||
|
assert.Len(t, s.activeJobs.jobs, 1)
|
||||||
|
s.activeJobs.mu.Unlock()
|
||||||
|
|
||||||
|
// simulate what the polling function will do for a long running job:
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey))
|
||||||
|
s.activeJobs.mu.Lock()
|
||||||
|
assert.NotEmpty(t, s.activeJobs.jobs[jobKey])
|
||||||
|
assert.Len(t, s.activeJobs.jobs, 1)
|
||||||
|
s.activeJobs.mu.Unlock()
|
||||||
|
|
||||||
|
// now wait for it to complete
|
||||||
|
time.Sleep(120*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(count))
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey))
|
||||||
|
s.activeJobs.mu.Lock()
|
||||||
|
assert.Empty(t, s.activeJobs.jobs)
|
||||||
|
s.activeJobs.mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("starting the same job again while it's still active will fail", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
jobKey := makeKey()
|
||||||
|
count := new(int32)
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
if key == jobKey {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
|
||||||
|
job, err := s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+jobKey))
|
||||||
|
assert.NotEmpty(t, s.activeJobs.jobs[jobKey])
|
||||||
|
assert.Len(t, s.activeJobs.jobs, 1)
|
||||||
|
|
||||||
|
// a plugin tries to start the same jobKey again:
|
||||||
|
job, err = s.ScheduleOnce(jobKey, time.Now().Add(10000*time.Millisecond), nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, job)
|
||||||
|
|
||||||
|
// now wait for first job to complete
|
||||||
|
time.Sleep(120*time.Millisecond + scheduleOnceJitter)
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(count))
|
||||||
|
assert.Empty(t, getVal(oncePrefix+jobKey))
|
||||||
|
assert.Empty(t, s.activeJobs.jobs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("simulate HA: canceling and setting a job with a different time--old one shouldn't fire", func(t *testing.T) {
|
||||||
|
resetScheduler()
|
||||||
|
|
||||||
|
key := makeKey()
|
||||||
|
jobKeys := make(map[string]*int32)
|
||||||
|
jobKeys[key] = new(int32)
|
||||||
|
|
||||||
|
// control is like the "control group" in an experiment. It will be overwritten,
|
||||||
|
// but with the same runAt. It should fire.
|
||||||
|
control := makeKey()
|
||||||
|
jobKeys[control] = new(int32)
|
||||||
|
|
||||||
|
callback := func(key string, _ any) {
|
||||||
|
count, ok := jobKeys[key]
|
||||||
|
if ok {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
originalRunAt := time.Now().Add(100 * time.Millisecond)
|
||||||
|
newRunAt := time.Now().Add(101 * time.Millisecond)
|
||||||
|
|
||||||
|
// store original
|
||||||
|
job, err := newJobOnce(s.pluginAPI, key, originalRunAt, s.storedCallback, s.activeJobs, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = job.saveMetadata()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+key))
|
||||||
|
|
||||||
|
// store oringal control
|
||||||
|
job2, err := newJobOnce(s.pluginAPI, control, originalRunAt, s.storedCallback, s.activeJobs, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = job2.saveMetadata()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, getVal(oncePrefix+control))
|
||||||
|
|
||||||
|
// double checking originals are in the db:
|
||||||
|
jobs, err := s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs, 2)
|
||||||
|
require.True(t, originalRunAt.Equal(jobs[0].RunAt))
|
||||||
|
require.True(t, originalRunAt.Equal(jobs[1].RunAt))
|
||||||
|
|
||||||
|
// simulate starting the plugin
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = s.scheduleNewJobsFromDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Now "cancel" the original and make a new job with the same key but a different time.
|
||||||
|
// However, because we have only one list of synced jobs, we can't make two jobs with the
|
||||||
|
// same key. So we'll simulate this by changing the job metadata in the db. When the original
|
||||||
|
// job fires, it should see that the runAt is different, and it will think it has been canceled.
|
||||||
|
err = setMetadata(key, JobOnceMetadata{
|
||||||
|
Key: key,
|
||||||
|
RunAt: newRunAt,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// overwrite the control with the same runAt. It should fire.
|
||||||
|
err = setMetadata(control, JobOnceMetadata{
|
||||||
|
Key: control,
|
||||||
|
RunAt: originalRunAt,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(120*time.Millisecond + scheduleOnceJitter)
|
||||||
|
|
||||||
|
// original job didn't fire the callback:
|
||||||
|
assert.Empty(t, getVal(oncePrefix+key))
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[key])
|
||||||
|
assert.Equal(t, int32(0), *jobKeys[key])
|
||||||
|
|
||||||
|
// control job did fire the callback:
|
||||||
|
assert.Empty(t, getVal(oncePrefix+control))
|
||||||
|
assert.Empty(t, s.activeJobs.jobs[control])
|
||||||
|
assert.Equal(t, int32(1), *jobKeys[control])
|
||||||
|
|
||||||
|
jobs, err = s.ListScheduledJobs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, jobs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleOnceProps(t *testing.T) {
|
||||||
|
t.Run("confirm props are returned", func(t *testing.T) {
|
||||||
|
s := GetJobOnceScheduler(newMockPluginAPI(t))
|
||||||
|
|
||||||
|
jobKey := model.NewId()
|
||||||
|
jobProps := struct {
|
||||||
|
Foo string
|
||||||
|
}{
|
||||||
|
Foo: "some foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var mut sync.Mutex
|
||||||
|
var called bool
|
||||||
|
callback := func(key string, props any) {
|
||||||
|
require.Equal(t, jobKey, key)
|
||||||
|
require.Equal(t, jobProps, props)
|
||||||
|
mut.Lock()
|
||||||
|
defer mut.Unlock()
|
||||||
|
called = true
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.SetCallback(callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if !s.started {
|
||||||
|
err = s.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.ScheduleOnce(jobKey, time.Now().Add(100*time.Millisecond), jobProps)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check if callback was called
|
||||||
|
require.Eventually(t, func() bool { mut.Lock(); defer mut.Unlock(); return called }, time.Second, 50*time.Millisecond)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("props to large", func(t *testing.T) {
|
||||||
|
s := GetJobOnceScheduler(newMockPluginAPI(t))
|
||||||
|
|
||||||
|
props := make([]byte, propsLimit)
|
||||||
|
for i := 0; i < propsLimit; i++ {
|
||||||
|
props[i] = 'a'
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.ScheduleOnce(model.NewId(), time.Now().Add(100*time.Millisecond), props)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
412
server/public/pluginapi/cluster/job_test.go
Обычный файл
412
server/public/pluginapi/cluster/job_test.go
Обычный файл
@@ -0,0 +1,412 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMakeWaitForInterval(t *testing.T) {
|
||||||
|
t.Run("panics on invalid interval", func(t *testing.T) {
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
MakeWaitForInterval(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const neverRun = -1 * time.Second
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
Description string
|
||||||
|
Interval time.Duration
|
||||||
|
LastFinished time.Duration
|
||||||
|
Expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"never run, 5 minutes",
|
||||||
|
5 * time.Minute,
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 1 minute ago, 5 minutes",
|
||||||
|
5 * time.Minute,
|
||||||
|
-1 * time.Minute,
|
||||||
|
4 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 2 minutes ago, 5 minutes",
|
||||||
|
5 * time.Minute,
|
||||||
|
-2 * time.Minute,
|
||||||
|
3 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 4 minutes 30 seconds ago, 5 minutes",
|
||||||
|
5 * time.Minute,
|
||||||
|
-4*time.Minute - 30*time.Second,
|
||||||
|
30 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 4 minutes 59 seconds ago, 5 minutes",
|
||||||
|
5 * time.Minute,
|
||||||
|
-4*time.Minute - 59*time.Second,
|
||||||
|
1 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"never run, 1 hour",
|
||||||
|
1 * time.Hour,
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 1 minute ago, 1 hour",
|
||||||
|
1 * time.Hour,
|
||||||
|
-1 * time.Minute,
|
||||||
|
59 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 20 minutes ago, 1 hour",
|
||||||
|
1 * time.Hour,
|
||||||
|
-20 * time.Minute,
|
||||||
|
40 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 55 minutes 30 seconds ago, 1 hour",
|
||||||
|
1 * time.Hour,
|
||||||
|
-55*time.Minute - 30*time.Second,
|
||||||
|
4*time.Minute + 30*time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"run 59 minutes 59 seconds ago, 1 hour",
|
||||||
|
1 * time.Hour,
|
||||||
|
-59*time.Minute - 59*time.Second,
|
||||||
|
1 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Description, func(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var lastFinished time.Time
|
||||||
|
if testCase.LastFinished != neverRun {
|
||||||
|
lastFinished = now.Add(testCase.LastFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := MakeWaitForInterval(testCase.Interval)(now, JobMetadata{
|
||||||
|
LastFinished: lastFinished,
|
||||||
|
})
|
||||||
|
assert.Equal(t, testCase.Expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeWaitForRoundedInterval(t *testing.T) {
|
||||||
|
t.Run("panics on invalid interval", func(t *testing.T) {
|
||||||
|
assert.Panics(t, func() {
|
||||||
|
MakeWaitForRoundedInterval(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const neverRun = -1 * time.Second
|
||||||
|
topOfTheHour := time.Now().Truncate(1 * time.Hour)
|
||||||
|
topOfTheDay := time.Now().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
Description string
|
||||||
|
Interval time.Duration
|
||||||
|
Now time.Time
|
||||||
|
LastFinished time.Duration
|
||||||
|
Expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour, never run",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour,
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour less 1 minute, never run",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour.Add(-1 * time.Minute),
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour less 1 minute, run 1 minute ago",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour.Add(-1 * time.Minute),
|
||||||
|
-1 * time.Minute,
|
||||||
|
1 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour plus 1 minute, run 2 minutes ago",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour.Add(1 * time.Minute),
|
||||||
|
-2 * time.Minute,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour plus 1 minute, run 30 seconds ago",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour.Add(1 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
4 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, top of the hour plus 7 minutes, run 30 seconds ago",
|
||||||
|
5 * time.Minute,
|
||||||
|
topOfTheHour.Add(7 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
3 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour, never run",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour,
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour less 1 minute, never run",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour.Add(-1 * time.Minute),
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour less 1 minute, run 1 minute ago",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour.Add(-1 * time.Minute),
|
||||||
|
-1 * time.Minute,
|
||||||
|
1 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour plus 1 minute, run 2 minutes ago",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour.Add(1 * time.Minute),
|
||||||
|
-2 * time.Minute,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour plus 1 minute, run 30 seconds ago",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour.Add(1 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
29 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"30 minutes, top of the hour plus 7 minutes, run 30 seconds ago",
|
||||||
|
30 * time.Minute,
|
||||||
|
topOfTheHour.Add(7 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
23 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day, never run",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay,
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day less 1 minute, never run",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay.Add(-1 * time.Minute),
|
||||||
|
neverRun,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day less 1 minute, run 1 minute ago",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay.Add(-1 * time.Minute),
|
||||||
|
-1 * time.Minute,
|
||||||
|
1 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day plus 1 minute, run 2 minutes ago",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay.Add(1 * time.Minute),
|
||||||
|
-2 * time.Minute,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day plus 1 minute, run 30 seconds ago",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay.Add(1 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
23*time.Hour + 59*time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"24 hours, top of the day plus 7 minutes, run 30 seconds ago",
|
||||||
|
24 * time.Hour,
|
||||||
|
topOfTheDay.Add(7 * time.Minute),
|
||||||
|
-30 * time.Second,
|
||||||
|
23*time.Hour + 53*time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Description, func(t *testing.T) {
|
||||||
|
var lastFinished time.Time
|
||||||
|
if testCase.LastFinished != neverRun {
|
||||||
|
lastFinished = testCase.Now.Add(testCase.LastFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := MakeWaitForRoundedInterval(testCase.Interval)(testCase.Now, JobMetadata{
|
||||||
|
LastFinished: lastFinished,
|
||||||
|
})
|
||||||
|
assert.Equal(t, testCase.Expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchedule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
makeKey := model.NewId
|
||||||
|
|
||||||
|
t.Run("single-threaded", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
count := new(int32)
|
||||||
|
callback := func() {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := Schedule(mockPluginAPI, makeKey(), MakeWaitForInterval(100*time.Millisecond), callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
err = job.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Shouldn't have hit 20 in this time frame
|
||||||
|
assert.Less(t, *count, int32(20))
|
||||||
|
|
||||||
|
// Should have hit at least 5 in this time frame
|
||||||
|
assert.Greater(t, *count, int32(5))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-threaded, single job", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
count := new(int32)
|
||||||
|
callback := func() {
|
||||||
|
atomic.AddInt32(count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs []*Job
|
||||||
|
|
||||||
|
key := makeKey()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
job, err := Schedule(mockPluginAPI, key, MakeWaitForInterval(100*time.Millisecond), callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
job := jobs[i]
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
err := job.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Shouldn't have hit 20 in this time frame
|
||||||
|
assert.Less(t, *count, int32(20))
|
||||||
|
|
||||||
|
// Should have hit at least 5 in this time frame
|
||||||
|
assert.Greater(t, *count, int32(5))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-threaded, multiple jobs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
countA := new(int32)
|
||||||
|
callbackA := func() {
|
||||||
|
atomic.AddInt32(countA, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
countB := new(int32)
|
||||||
|
callbackB := func() {
|
||||||
|
atomic.AddInt32(countB, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyA := makeKey()
|
||||||
|
keyB := makeKey()
|
||||||
|
|
||||||
|
var jobs []*Job
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
var key string
|
||||||
|
var callback func()
|
||||||
|
if i <= 1 {
|
||||||
|
key = keyA
|
||||||
|
callback = callbackA
|
||||||
|
} else {
|
||||||
|
key = keyB
|
||||||
|
callback = callbackB
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := Schedule(mockPluginAPI, key, MakeWaitForInterval(100*time.Millisecond), callback)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job)
|
||||||
|
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
job := jobs[i]
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
err := job.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Shouldn't have hit 20 in this time frame
|
||||||
|
assert.Less(t, *countA, int32(20))
|
||||||
|
|
||||||
|
// Should have hit at least 5 in this time frame
|
||||||
|
assert.Greater(t, *countA, int32(5))
|
||||||
|
|
||||||
|
// Shouldn't have hit 20 in this time frame
|
||||||
|
assert.Less(t, *countB, int32(20))
|
||||||
|
|
||||||
|
// Should have hit at least 5 in this time frame
|
||||||
|
assert.Greater(t, *countB, int32(5))
|
||||||
|
})
|
||||||
|
}
|
||||||
150
server/public/pluginapi/cluster/mock_plugin_api_test.go
Обычный файл
150
server/public/pluginapi/cluster/mock_plugin_api_test.go
Обычный файл
@@ -0,0 +1,150 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockPluginAPI struct {
|
||||||
|
t *testing.T
|
||||||
|
|
||||||
|
lock sync.Mutex
|
||||||
|
keyValues map[string][]byte
|
||||||
|
failing bool
|
||||||
|
failingWithPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockPluginAPI(t *testing.T) *mockPluginAPI {
|
||||||
|
return &mockPluginAPI{
|
||||||
|
t: t,
|
||||||
|
keyValues: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) setFailing(failing bool) {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
pluginAPI.failing = failing
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) setFailingWithPrefix(prefix string) {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
pluginAPI.failingWithPrefix = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) clear() {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
for k := range pluginAPI.keyValues {
|
||||||
|
delete(pluginAPI.keyValues, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) KVGet(key string) ([]byte, *model.AppError) {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
if pluginAPI.failing {
|
||||||
|
return nil, &model.AppError{Message: "fake error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pluginAPI.failingWithPrefix != "" && strings.HasPrefix(key, pluginAPI.failingWithPrefix) {
|
||||||
|
return nil, &model.AppError{Message: "fake error for prefix " + pluginAPI.failingWithPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginAPI.keyValues[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) KVDelete(key string) *model.AppError {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
if pluginAPI.failing {
|
||||||
|
return &model.AppError{Message: "fake error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pluginAPI.failingWithPrefix != "" && strings.HasPrefix(key, pluginAPI.failingWithPrefix) {
|
||||||
|
return &model.AppError{Message: "fake error for prefix " + pluginAPI.failingWithPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(pluginAPI.keyValues, key)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) KVList(page, count int) ([]string, *model.AppError) {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
if pluginAPI.failing {
|
||||||
|
return nil, &model.AppError{Message: "fake error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(pluginAPI.keyValues))
|
||||||
|
for k := range pluginAPI.keyValues {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// have to sort, because we're paging below
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
start := min(page*count, len(keys))
|
||||||
|
end := min((page+1)*count, len(keys))
|
||||||
|
return keys[start:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) KVSetWithOptions(key string, value []byte, options model.PluginKVSetOptions) (bool, *model.AppError) {
|
||||||
|
pluginAPI.lock.Lock()
|
||||||
|
defer pluginAPI.lock.Unlock()
|
||||||
|
|
||||||
|
if pluginAPI.failing {
|
||||||
|
return false, &model.AppError{Message: "fake error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pluginAPI.failingWithPrefix != "" && strings.HasPrefix(key, pluginAPI.failingWithPrefix) {
|
||||||
|
return false, &model.AppError{Message: "fake error for prefix " + pluginAPI.failingWithPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Atomic {
|
||||||
|
if actualValue := pluginAPI.keyValues[key]; !bytes.Equal(actualValue, options.OldValue) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == nil {
|
||||||
|
delete(pluginAPI.keyValues, key)
|
||||||
|
} else {
|
||||||
|
pluginAPI.keyValues[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pluginAPI *mockPluginAPI) LogError(msg string, keyValuePairs ...interface{}) {
|
||||||
|
if pluginAPI.t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginAPI.t.Helper()
|
||||||
|
|
||||||
|
params := []interface{}{msg}
|
||||||
|
params = append(params, keyValuePairs...)
|
||||||
|
|
||||||
|
pluginAPI.t.Log(params...)
|
||||||
|
}
|
||||||
185
server/public/pluginapi/cluster/mutex.go
Обычный файл
185
server/public/pluginapi/cluster/mutex.go
Обычный файл
@@ -0,0 +1,185 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// mutexPrefix is used to namespace key values created for a mutex from other key values
|
||||||
|
// created by a plugin.
|
||||||
|
mutexPrefix = "mutex_"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ttl is the interval after which a locked mutex will expire unless refreshed
|
||||||
|
ttl = time.Second * 15
|
||||||
|
|
||||||
|
// refreshInterval is the interval on which the mutex will be refreshed when locked
|
||||||
|
refreshInterval = ttl / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// MutexPluginAPI is the plugin API interface required to manage mutexes.
|
||||||
|
type MutexPluginAPI interface {
|
||||||
|
KVSetWithOptions(key string, value []byte, options model.PluginKVSetOptions) (bool, *model.AppError)
|
||||||
|
LogError(msg string, keyValuePairs ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutex is similar to sync.Mutex, except usable by multiple plugin instances across a cluster.
|
||||||
|
//
|
||||||
|
// Internally, a mutex relies on an atomic key-value set operation as exposed by the Mattermost
|
||||||
|
// plugin API.
|
||||||
|
//
|
||||||
|
// Mutexes with different names are unrelated. Mutexes with the same name from different plugins
|
||||||
|
// are unrelated. Pick a unique name for each mutex your plugin requires.
|
||||||
|
//
|
||||||
|
// A Mutex must not be copied after first use.
|
||||||
|
type Mutex struct {
|
||||||
|
pluginAPI MutexPluginAPI
|
||||||
|
key string
|
||||||
|
|
||||||
|
// lock guards the variables used to manage the refresh task, and is not itself related to
|
||||||
|
// the cluster-wide lock.
|
||||||
|
lock sync.Mutex
|
||||||
|
stopRefresh chan bool
|
||||||
|
refreshDone chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMutex creates a mutex with the given key name.
|
||||||
|
//
|
||||||
|
// Panics if key is empty.
|
||||||
|
func NewMutex(pluginAPI MutexPluginAPI, key string) (*Mutex, error) {
|
||||||
|
key, err := makeLockKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Mutex{
|
||||||
|
pluginAPI: pluginAPI,
|
||||||
|
key: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeLockKey returns the prefixed key used to namespace mutex keys.
|
||||||
|
func makeLockKey(key string) (string, error) {
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("must specify valid mutex key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutexPrefix + key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lock makes a single attempt to atomically lock the mutex, returning true only if successful.
|
||||||
|
func (m *Mutex) tryLock() (bool, error) {
|
||||||
|
ok, err := m.pluginAPI.KVSetWithOptions(m.key, []byte{1}, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: nil, // No existing key value.
|
||||||
|
ExpireInSeconds: int64(ttl / time.Second),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "failed to set mutex kv")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshLock rewrites the lock key value with a new expiry, returning true only if successful.
|
||||||
|
func (m *Mutex) refreshLock() error {
|
||||||
|
ok, err := m.pluginAPI.KVSetWithOptions(m.key, []byte{1}, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte{1},
|
||||||
|
ExpireInSeconds: int64(ttl / time.Second),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to refresh mutex kv")
|
||||||
|
} else if !ok {
|
||||||
|
return errors.New("unexpectedly failed to refresh mutex kv")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock locks m. If the mutex is already locked by any plugin instance, including the current one,
|
||||||
|
// the calling goroutine blocks until the mutex can be locked.
|
||||||
|
func (m *Mutex) Lock() {
|
||||||
|
_ = m.LockWithContext(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockWithContext locks m unless the context is canceled. If the mutex is already locked by any plugin
|
||||||
|
// instance, including the current one, the calling goroutine blocks until the mutex can be locked,
|
||||||
|
// or the context is canceled.
|
||||||
|
//
|
||||||
|
// The mutex is locked only if a nil error is returned.
|
||||||
|
func (m *Mutex) LockWithContext(ctx context.Context) error {
|
||||||
|
var waitInterval time.Duration
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(waitInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, err := m.tryLock()
|
||||||
|
if err != nil {
|
||||||
|
m.pluginAPI.LogError("failed to lock mutex", "err", err, "lock_key", m.key)
|
||||||
|
waitInterval = nextWaitInterval(waitInterval, err)
|
||||||
|
continue
|
||||||
|
} else if !locked {
|
||||||
|
waitInterval = nextWaitInterval(waitInterval, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := make(chan bool)
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
t := time.NewTicker(refreshInterval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
err := m.refreshLock()
|
||||||
|
if err != nil {
|
||||||
|
m.pluginAPI.LogError("failed to refresh mutex", "err", err, "lock_key", m.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
m.lock.Lock()
|
||||||
|
m.stopRefresh = stop
|
||||||
|
m.refreshDone = done
|
||||||
|
m.lock.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock unlocks m. It is a run-time error if m is not locked on entry to Unlock.
|
||||||
|
//
|
||||||
|
// Just like sync.Mutex, a locked Lock is not associated with a particular goroutine or plugin
|
||||||
|
// instance. It is allowed for one goroutine or plugin instance to lock a Lock and then arrange
|
||||||
|
// for another goroutine or plugin instance to unlock it. In practice, ownership of the lock should
|
||||||
|
// remain within a single plugin instance.
|
||||||
|
func (m *Mutex) Unlock() {
|
||||||
|
m.lock.Lock()
|
||||||
|
if m.stopRefresh == nil {
|
||||||
|
m.lock.Unlock()
|
||||||
|
panic("mutex has not been acquired")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(m.stopRefresh)
|
||||||
|
m.stopRefresh = nil
|
||||||
|
<-m.refreshDone
|
||||||
|
m.lock.Unlock()
|
||||||
|
|
||||||
|
// If an error occurs deleting, the mutex kv will still expire, allowing later retry.
|
||||||
|
_, _ = m.pluginAPI.KVSetWithOptions(m.key, nil, model.PluginKVSetOptions{})
|
||||||
|
}
|
||||||
20
server/public/pluginapi/cluster/mutex_example_test.go
Обычный файл
20
server/public/pluginapi/cluster/mutex_example_test.go
Обычный файл
@@ -0,0 +1,20 @@
|
|||||||
|
package cluster_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/cluster"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:staticcheck
|
||||||
|
func ExampleMutex() {
|
||||||
|
// Use p.API from your plugin instead.
|
||||||
|
pluginAPI := plugin.API(nil)
|
||||||
|
|
||||||
|
m, err := cluster.NewMutex(pluginAPI, "key")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
m.Lock()
|
||||||
|
// critical section
|
||||||
|
m.Unlock()
|
||||||
|
}
|
||||||
276
server/public/pluginapi/cluster/mutex_test.go
Обычный файл
276
server/public/pluginapi/cluster/mutex_test.go
Обычный файл
@@ -0,0 +1,276 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustNewMutex(pluginAPI MutexPluginAPI, key string) *Mutex {
|
||||||
|
m, err := NewMutex(pluginAPI, key)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeLockKey(t *testing.T) {
|
||||||
|
t.Run("fails when empty", func(t *testing.T) {
|
||||||
|
key, err := makeLockKey("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Empty(t, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not-empty", func(t *testing.T) {
|
||||||
|
testCases := map[string]string{
|
||||||
|
"key": mutexPrefix + "key",
|
||||||
|
"other": mutexPrefix + "other",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expected := range testCases {
|
||||||
|
actual, err := makeLockKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func lock(t *testing.T, m *Mutex) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
defer close(done)
|
||||||
|
m.Lock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
require.Fail(t, "failed to lock mutex within 1 second")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock(t *testing.T, m *Mutex, panics bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
defer close(done)
|
||||||
|
if panics {
|
||||||
|
assert.Panics(t, m.Unlock)
|
||||||
|
} else {
|
||||||
|
assert.NotPanics(t, m.Unlock)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
require.Fail(t, "failed to unlock mutex within 1 second")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
makeKey := model.NewId
|
||||||
|
|
||||||
|
t.Run("successful lock/unlock cycle", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
lock(t, m)
|
||||||
|
unlock(t, m, false)
|
||||||
|
lock(t, m)
|
||||||
|
unlock(t, m, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unlock when not locked", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
unlock(t, m, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blocking lock", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
lock(t, m)
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
m.Lock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
case <-done:
|
||||||
|
require.Fail(t, "second goroutine should not have locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(t, m, false)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(pollWaitInterval * 2):
|
||||||
|
require.Fail(t, "second goroutine should have locked")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed lock", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
|
||||||
|
mockPluginAPI.setFailing(true)
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
m.Lock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
case <-done:
|
||||||
|
require.Fail(t, "goroutine should not have locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
mockPluginAPI.setFailing(false)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
require.Fail(t, "goroutine should have locked")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed unlock", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
key := makeKey()
|
||||||
|
m := mustNewMutex(mockPluginAPI, key)
|
||||||
|
lock(t, m)
|
||||||
|
|
||||||
|
mockPluginAPI.setFailing(true)
|
||||||
|
|
||||||
|
unlock(t, m, false)
|
||||||
|
|
||||||
|
// Simulate expiry
|
||||||
|
mockPluginAPI.clear()
|
||||||
|
mockPluginAPI.setFailing(false)
|
||||||
|
|
||||||
|
lock(t, m)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("discrete keys", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m1 := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
lock(t, m1)
|
||||||
|
|
||||||
|
m2 := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
lock(t, m2)
|
||||||
|
|
||||||
|
m3 := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
lock(t, m3)
|
||||||
|
|
||||||
|
unlock(t, m1, false)
|
||||||
|
unlock(t, m3, false)
|
||||||
|
|
||||||
|
lock(t, m1)
|
||||||
|
|
||||||
|
unlock(t, m2, false)
|
||||||
|
unlock(t, m1, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with uncancelled context", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
key := makeKey()
|
||||||
|
m := mustNewMutex(mockPluginAPI, key)
|
||||||
|
|
||||||
|
m.Lock()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
err := m.LockWithContext(ctx)
|
||||||
|
require.Nil(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(ttl + pollWaitInterval*2):
|
||||||
|
case <-done:
|
||||||
|
require.Fail(t, "goroutine should not have locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(pollWaitInterval * 2):
|
||||||
|
require.Fail(t, "goroutine should have locked after unlock")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with canceled context", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mockPluginAPI := newMockPluginAPI(t)
|
||||||
|
|
||||||
|
m := mustNewMutex(mockPluginAPI, makeKey())
|
||||||
|
|
||||||
|
m.Lock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
err := m.LockWithContext(ctx)
|
||||||
|
require.NotNil(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(ttl + pollWaitInterval*2):
|
||||||
|
case <-done:
|
||||||
|
require.Fail(t, "goroutine should not have locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(pollWaitInterval * 2):
|
||||||
|
require.Fail(t, "goroutine should have aborted after cancellation")
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
43
server/public/pluginapi/cluster/wait.go
Обычный файл
43
server/public/pluginapi/cluster/wait.go
Обычный файл
@@ -0,0 +1,43 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// minWaitInterval is the minimum amount of time to wait between locking attempts
|
||||||
|
minWaitInterval = 1 * time.Second
|
||||||
|
|
||||||
|
// maxWaitInterval is the maximum amount of time to wait between locking attempts
|
||||||
|
maxWaitInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// pollWaitInterval is the usual time to wait between unsuccessful locking attempts
|
||||||
|
pollWaitInterval = 1 * time.Second
|
||||||
|
|
||||||
|
// jitterWaitInterval is the amount of jitter to add when waiting to avoid thundering herds
|
||||||
|
jitterWaitInterval = minWaitInterval / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// nextWaitInterval determines how long to wait until the next lock retry.
|
||||||
|
func nextWaitInterval(lastWaitInterval time.Duration, err error) time.Duration {
|
||||||
|
nextWaitInterval := lastWaitInterval
|
||||||
|
|
||||||
|
if nextWaitInterval <= 0 {
|
||||||
|
nextWaitInterval = minWaitInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
nextWaitInterval *= 2
|
||||||
|
if nextWaitInterval > maxWaitInterval {
|
||||||
|
nextWaitInterval = maxWaitInterval
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextWaitInterval = pollWaitInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some jitter to avoid unnecessary collision between competing plugin instances.
|
||||||
|
nextWaitInterval += time.Duration(rand.Int63n(int64(jitterWaitInterval)) - int64(jitterWaitInterval)/2)
|
||||||
|
|
||||||
|
return nextWaitInterval
|
||||||
|
}
|
||||||
156
server/public/pluginapi/cluster/wait_test.go
Обычный файл
156
server/public/pluginapi/cluster/wait_test.go
Обычный файл
@@ -0,0 +1,156 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNextWaitInterval(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
Description string
|
||||||
|
lastWaitInterval time.Duration
|
||||||
|
err error
|
||||||
|
expectedRange [2]time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"0, no error",
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"0, error",
|
||||||
|
0,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
2*time.Second - jitterWaitInterval/2,
|
||||||
|
2*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"negative, no error",
|
||||||
|
-100 * time.Second,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"negative, error",
|
||||||
|
-100 * time.Second,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
2*time.Second - jitterWaitInterval/2,
|
||||||
|
2*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"1 second, no error",
|
||||||
|
1 * time.Second,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"1 second, error",
|
||||||
|
1 * time.Second,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
2*time.Second - jitterWaitInterval/2,
|
||||||
|
2*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"10 seconds, no error",
|
||||||
|
10 * time.Second,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"10 second, error",
|
||||||
|
10 * time.Second,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
20*time.Second - jitterWaitInterval/2,
|
||||||
|
20*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"4 minutes, no error",
|
||||||
|
4 * time.Minute,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"4 minutes, error",
|
||||||
|
4 * time.Minute,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
5*time.Minute - jitterWaitInterval/2,
|
||||||
|
5*time.Minute + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, no error",
|
||||||
|
5 * time.Minute,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"5 minutes, error",
|
||||||
|
5 * time.Minute,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
5*time.Minute - jitterWaitInterval/2,
|
||||||
|
5*time.Minute + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"10minutes, no error",
|
||||||
|
10 * time.Minute,
|
||||||
|
nil,
|
||||||
|
[2]time.Duration{
|
||||||
|
1*time.Second - jitterWaitInterval/2,
|
||||||
|
1*time.Second + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"10minutes, error",
|
||||||
|
10 * time.Minute,
|
||||||
|
errors.New("test"),
|
||||||
|
[2]time.Duration{
|
||||||
|
5*time.Minute - jitterWaitInterval/2,
|
||||||
|
5*time.Minute + jitterWaitInterval/2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Description, func(t *testing.T) {
|
||||||
|
actualWaitInterval := nextWaitInterval(
|
||||||
|
testCase.lastWaitInterval,
|
||||||
|
testCase.err,
|
||||||
|
)
|
||||||
|
assert.GreaterOrEqual(t, int64(actualWaitInterval), int64(testCase.expectedRange[0]))
|
||||||
|
assert.LessOrEqual(t, int64(actualWaitInterval), int64(testCase.expectedRange[1]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/public/pluginapi/cluster_test.go
Обычный файл
48
server/public/pluginapi/cluster_test.go
Обычный файл
@@ -0,0 +1,48 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishPluginClusterEvent(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("PublishPluginClusterEvent",
|
||||||
|
model.PluginClusterEvent{Id: "someID", Data: []byte("foo")},
|
||||||
|
model.PluginClusterEventSendOptions{SendType: model.PluginClusterEventSendTypeReliable},
|
||||||
|
).Return(nil)
|
||||||
|
|
||||||
|
err := client.Cluster.PublishPluginEvent(
|
||||||
|
model.PluginClusterEvent{Id: "someID", Data: []byte("foo")},
|
||||||
|
model.PluginClusterEventSendOptions{SendType: model.PluginClusterEventSendTypeReliable},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("PublishPluginClusterEvent",
|
||||||
|
model.PluginClusterEvent{Id: "someID", Data: []byte("foo")},
|
||||||
|
model.PluginClusterEventSendOptions{SendType: model.PluginClusterEventSendTypeReliable},
|
||||||
|
).Return(errors.New("someError"))
|
||||||
|
|
||||||
|
err := client.Cluster.PublishPluginEvent(
|
||||||
|
model.PluginClusterEvent{Id: "someID", Data: []byte("foo")},
|
||||||
|
model.PluginClusterEventSendOptions{SendType: model.PluginClusterEventSendTypeReliable},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
55
server/public/pluginapi/configuration.go
Обычный файл
55
server/public/pluginapi/configuration.go
Обычный файл
@@ -0,0 +1,55 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigurationService exposes methods to manipulate the server and plugin configuration.
|
||||||
|
type ConfigurationService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPluginConfiguration loads the plugin's configuration. dest should be a pointer to a
|
||||||
|
// struct to which the configuration JSON can be unmarshalled.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ConfigurationService) LoadPluginConfiguration(dest interface{}) error {
|
||||||
|
// TODO: Isn't this method redundant given GetPluginConfig() and even GetConfig()?
|
||||||
|
return c.api.LoadPluginConfiguration(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig fetches the currently persisted config.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ConfigurationService) GetConfig() *model.Config {
|
||||||
|
return c.api.GetConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnsanitizedConfig fetches the currently persisted config without removing secrets.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.16
|
||||||
|
func (c *ConfigurationService) GetUnsanitizedConfig() *model.Config {
|
||||||
|
return c.api.GetUnsanitizedConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig sets the given config and persists the changes
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (c *ConfigurationService) SaveConfig(cfg *model.Config) error {
|
||||||
|
return normalizeAppErr(c.api.SaveConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPluginConfig fetches the currently persisted config of plugin
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ConfigurationService) GetPluginConfig() map[string]interface{} {
|
||||||
|
return c.api.GetPluginConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePluginConfig sets the given config for plugin and persists the changes
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (c *ConfigurationService) SavePluginConfig(cfg map[string]interface{}) error {
|
||||||
|
return normalizeAppErr(c.api.SavePluginConfig(cfg))
|
||||||
|
}
|
||||||
17
server/public/pluginapi/email.go
Обычный файл
17
server/public/pluginapi/email.go
Обычный файл
@@ -0,0 +1,17 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailService exposes methods to send email.
|
||||||
|
type MailService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends an email to a specific address.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.7
|
||||||
|
func (m *MailService) Send(to, subject, htmlBody string) error {
|
||||||
|
return normalizeAppErr(m.api.SendMail(to, subject, htmlBody))
|
||||||
|
}
|
||||||
54
server/public/pluginapi/emoji.go
Обычный файл
54
server/public/pluginapi/emoji.go
Обычный файл
@@ -0,0 +1,54 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmojiService exposes methods to manipulate emojis.
|
||||||
|
type EmojiService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets a custom emoji by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (e *EmojiService) Get(id string) (*model.Emoji, error) {
|
||||||
|
emoji, appErr := e.api.GetEmoji(id)
|
||||||
|
|
||||||
|
return emoji, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName gets a custom emoji by its name.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (e *EmojiService) GetByName(name string) (*model.Emoji, error) {
|
||||||
|
emoji, appErr := e.api.GetEmojiByName(name)
|
||||||
|
|
||||||
|
return emoji, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImage gets a custom emoji's content and format by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (e *EmojiService) GetImage(id string) (io.Reader, string, error) {
|
||||||
|
contentBytes, format, appErr := e.api.GetEmojiImage(id)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, "", normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(contentBytes), format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves a list of custom emojis.
|
||||||
|
// sortBy parameter can be: "name".
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (e *EmojiService) List(sortBy string, page, count int) ([]*model.Emoji, error) {
|
||||||
|
emojis, appErr := e.api.GetEmojiList(sortBy, page, count)
|
||||||
|
|
||||||
|
return emojis, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
131
server/public/pluginapi/emoji_test.go
Обычный файл
131
server/public/pluginapi/emoji_test.go
Обычный файл
@@ -0,0 +1,131 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetEmoji(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetEmoji", "1").Return(&model.Emoji{Id: "2"}, nil)
|
||||||
|
|
||||||
|
emoji, err := client.Emoji.Get("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2", emoji.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetEmoji", "1").Return(nil, appErr)
|
||||||
|
|
||||||
|
emoji, err := client.Emoji.Get("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, emoji)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEmojiByName(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetEmojiByName", "1").Return(&model.Emoji{Id: "2"}, nil)
|
||||||
|
|
||||||
|
emoji, err := client.Emoji.GetByName("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2", emoji.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetEmojiByName", "1").Return(nil, appErr)
|
||||||
|
|
||||||
|
emoji, err := client.Emoji.GetByName("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, emoji)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEmojiImage(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetEmojiImage", "1").Return([]byte{1}, "jpg", nil)
|
||||||
|
|
||||||
|
content, format, err := client.Emoji.GetImage("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
contentBytes, err := io.ReadAll(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []byte{1}, contentBytes)
|
||||||
|
require.Equal(t, "jpg", format)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetEmojiImage", "1").Return(nil, "", appErr)
|
||||||
|
|
||||||
|
content, format, err := client.Emoji.GetImage("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, content)
|
||||||
|
require.Zero(t, format)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListEmojis(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetEmojiList", "1", 2, 3).Return([]*model.Emoji{
|
||||||
|
{Id: "4"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
emojis, err := client.Emoji.List("1", 2, 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, emojis, 1)
|
||||||
|
require.Equal(t, "4", emojis[0].Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetEmojiList", "1", 2, 3).Return(nil, appErr)
|
||||||
|
|
||||||
|
emojis, err := client.Emoji.List("1", 2, 3)
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, emojis)
|
||||||
|
})
|
||||||
|
}
|
||||||
39
server/public/pluginapi/error.go
Обычный файл
39
server/public/pluginapi/error.go
Обычный файл
@@ -0,0 +1,39 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned by the plugin API when an object is not found.
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// normalizeAppErr returns a truly nil error if appErr is nil as well as normalizing a class
|
||||||
|
// of non-nil AppErrors to simplify use within plugins.
|
||||||
|
//
|
||||||
|
// This doesn't happen automatically when a *model.AppError is cast to an error, since the
|
||||||
|
// resulting error interface has a concrete type with a nil value. This leads to the seemingly
|
||||||
|
// impossible:
|
||||||
|
//
|
||||||
|
// var err error
|
||||||
|
// err = func() *model.AppError { return nil }()
|
||||||
|
// if err != nil {
|
||||||
|
// panic("err != nil, which surprises most")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Fix this problem for all plugin authors by normalizing to special case the handling of a nil
|
||||||
|
// *model.AppError. See https://golang.org/doc/faq#nil_error for more details.
|
||||||
|
func normalizeAppErr(appErr *model.AppError) error {
|
||||||
|
if appErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if appErr.StatusCode == http.StatusNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return appErr
|
||||||
|
}
|
||||||
21
server/public/pluginapi/example_client_test.go
Обычный файл
21
server/public/pluginapi/example_client_test.go
Обычный файл
@@ -0,0 +1,21 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
plugin.MattermostPlugin
|
||||||
|
client *pluginapi.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) OnActivate() error {
|
||||||
|
p.client = pluginapi.NewClient(p.API, p.Driver)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
}
|
||||||
53
server/public/pluginapi/experimental/bot/bot.go
Обычный файл
53
server/public/pluginapi/experimental/bot/bot.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ...interface{}) {
|
||||||
|
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 ...interface{}) {
|
||||||
|
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 ...interface{}) {
|
||||||
|
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 ...interface{}) {
|
||||||
|
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 " + level + ") " + message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *adminCCLogger) dmAdmins(format string, args ...interface{}) error {
|
||||||
|
for _, id := range l.userIDs {
|
||||||
|
_, err := l.dmer.DM(id, format, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See License for license information.
|
||||||
|
|
||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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]interface{}{}
|
||||||
|
}
|
||||||
|
for k, v := range logContext {
|
||||||
|
newLogger.logContext[k] = v
|
||||||
|
}
|
||||||
|
return &newLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogger) WithError(err error) Logger {
|
||||||
|
newLogger := *l
|
||||||
|
if len(newLogger.logContext) == 0 {
|
||||||
|
newLogger.logContext = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
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 ...interface{}) {
|
||||||
|
measure(l.logContext)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
l.logAPI.LogDebug(message, toKeyValuePairs(l.logContext)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogger) Errorf(format string, args ...interface{}) {
|
||||||
|
measure(l.logContext)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
l.logAPI.LogError(message, toKeyValuePairs(l.logContext)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogger) Infof(format string, args ...interface{}) {
|
||||||
|
measure(l.logContext)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
l.logAPI.LogInfo(message, toKeyValuePairs(l.logContext)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *defaultLogger) Warnf(format string, args ...interface{}) {
|
||||||
|
measure(l.logContext)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
l.logAPI.LogWarn(message, toKeyValuePairs(l.logContext)...)
|
||||||
|
}
|
||||||
78
server/public/pluginapi/experimental/bot/logger/logger.go
Обычный файл
78
server/public/pluginapi/experimental/bot/logger/logger.go
Обычный файл
@@ -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]interface{}
|
||||||
|
|
||||||
|
// 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 ...interface{})
|
||||||
|
// Errorf logs a formatted string as an error message.
|
||||||
|
Errorf(format string, args ...interface{})
|
||||||
|
// Infof logs a formatted string as an info message.
|
||||||
|
Infof(format string, args ...interface{})
|
||||||
|
// Warnf logs a formatted string as an warning message.
|
||||||
|
Warnf(format string, args ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]interface{}) (out []interface{}) {
|
||||||
|
for k, v := range in {
|
||||||
|
out = append(out, k, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
17
server/public/pluginapi/experimental/bot/logger/nil_logger.go
Обычный файл
17
server/public/pluginapi/experimental/bot/logger/nil_logger.go
Обычный файл
@@ -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, ...interface{}) {}
|
||||||
|
func (l *nilLogger) Errorf(string, ...interface{}) {}
|
||||||
|
func (l *nilLogger) Infof(string, ...interface{}) {}
|
||||||
|
func (l *nilLogger) Warnf(string, ...interface{}) {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package telemetrylogger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type telemetryLogger struct {
|
||||||
|
logger.Logger
|
||||||
|
logLevel logger.LogLevel
|
||||||
|
tracker telemetry.Tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
New promotes the provided logger into a telemetry logger, storing all events below the logLevel
|
||||||
|
through the tracker.
|
||||||
|
|
||||||
|
- l Logger: A logger to promote.
|
||||||
|
|
||||||
|
- logLevel: The highest type of message to be stored in telemetry.
|
||||||
|
|
||||||
|
- tracker: The telemetry tracker to store the messages.
|
||||||
|
*/
|
||||||
|
func New(l logger.Logger, logLevel logger.LogLevel, tracker telemetry.Tracker) logger.Logger {
|
||||||
|
return &telemetryLogger{
|
||||||
|
Logger: l,
|
||||||
|
logLevel: logLevel,
|
||||||
|
tracker: tracker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromAPI creates a telemetryLogger directly from a LogAPI instead of passing a logger.
|
||||||
|
func NewFromAPI(api common.LogAPI, logLevel logger.LogLevel, tracker telemetry.Tracker) logger.Logger {
|
||||||
|
return New(logger.New(api), logLevel, tracker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *telemetryLogger) Debugf(format string, args ...interface{}) {
|
||||||
|
l.Logger.Debugf(format, args...)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
if logger.Level(l.logLevel) >= 4 {
|
||||||
|
l.logToTelemetry("DEBUG", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *telemetryLogger) Errorf(format string, args ...interface{}) {
|
||||||
|
l.Logger.Errorf(format, args...)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
if logger.Level(l.logLevel) >= 1 {
|
||||||
|
l.logToTelemetry("ERROR", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *telemetryLogger) Infof(format string, args ...interface{}) {
|
||||||
|
l.Logger.Infof(format, args...)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
if logger.Level(l.logLevel) >= 3 {
|
||||||
|
l.logToTelemetry("INFO", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *telemetryLogger) Warnf(format string, args ...interface{}) {
|
||||||
|
l.Logger.Warnf(format, args...)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
if logger.Level(l.logLevel) >= 2 {
|
||||||
|
l.logToTelemetry("WARN", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *telemetryLogger) logToTelemetry(level, message string) {
|
||||||
|
properties := map[string]interface{}{}
|
||||||
|
properties["message"] = message
|
||||||
|
for k, v := range l.Context() {
|
||||||
|
properties["context_"+k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = l.tracker.TrackEvent("logger_"+level, properties)
|
||||||
|
}
|
||||||
61
server/public/pluginapi/experimental/bot/logger/test_logger.go
Обычный файл
61
server/public/pluginapi/experimental/bot/logger/test_logger.go
Обычный файл
@@ -0,0 +1,61 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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]interface{}{}
|
||||||
|
}
|
||||||
|
for k, v := range logContext {
|
||||||
|
newl.logContext[k] = v
|
||||||
|
}
|
||||||
|
return &newl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *testLogger) WithError(err error) Logger {
|
||||||
|
newl := *l
|
||||||
|
if len(newl.logContext) == 0 {
|
||||||
|
newl.logContext = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
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 ...interface{}) {
|
||||||
|
out := fmt.Sprintf(prefix+": "+format, args...)
|
||||||
|
if len(l.logContext) > 0 {
|
||||||
|
measure(l.logContext)
|
||||||
|
out += fmt.Sprintf(" -- %+v", l.logContext)
|
||||||
|
}
|
||||||
|
l.TB.Logf(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *testLogger) Debugf(format string, args ...interface{}) { l.logf("DEBUG", format, args...) }
|
||||||
|
func (l *testLogger) Errorf(format string, args ...interface{}) { l.logf("ERROR", format, args...) }
|
||||||
|
func (l *testLogger) Infof(format string, args ...interface{}) { l.logf("INFO", format, args...) }
|
||||||
|
func (l *testLogger) Warnf(format string, args ...interface{}) { l.logf("WARN", format, args...) }
|
||||||
77
server/public/pluginapi/experimental/bot/mocks/mock_bot.go
Обычный файл
77
server/public/pluginapi/experimental/bot/mocks/mock_bot.go
Обычный файл
@@ -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))
|
||||||
|
}
|
||||||
159
server/public/pluginapi/experimental/bot/mocks/mock_logger.go
Обычный файл
159
server/public/pluginapi/experimental/bot/mocks/mock_logger.go
Обычный файл
@@ -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)
|
||||||
|
}
|
||||||
151
server/public/pluginapi/experimental/bot/mocks/mock_poster.go
Обычный файл
151
server/public/pluginapi/experimental/bot/mocks/mock_poster.go
Обычный файл
@@ -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.SlackAttachment) (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)
|
||||||
|
}
|
||||||
@@ -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 ...interface{}) (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 Slack attachments.
|
||||||
|
// Often used to include post actions.
|
||||||
|
func (p *defaultPoster) DMWithAttachments(mattermostUserID string, attachments ...*model.SlackAttachment) (string, error) {
|
||||||
|
post := model.Post{}
|
||||||
|
model.ParseSlackAttachment(&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 ...interface{}) {
|
||||||
|
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 ...interface{}) 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
|
||||||
|
}
|
||||||
@@ -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 := []interface{}{"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.SlackAttachment{
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
}
|
||||||
|
|
||||||
|
post := &model.Post{}
|
||||||
|
|
||||||
|
model.ParseSlackAttachment(post, attachments)
|
||||||
|
|
||||||
|
postWithID := model.Post{
|
||||||
|
Id: expectedPostID,
|
||||||
|
UserId: botID,
|
||||||
|
ChannelId: dmChannelID,
|
||||||
|
Type: model.PostTypeSlackAttachment,
|
||||||
|
Props: model.StringInterface{
|
||||||
|
"attachments": 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 := []interface{}{"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 := []interface{}{"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 := []interface{}{"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...)
|
||||||
|
})
|
||||||
|
}
|
||||||
14
server/public/pluginapi/experimental/bot/poster/import.go
Обычный файл
14
server/public/pluginapi/experimental/bot/poster/import.go
Обычный файл
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
38
server/public/pluginapi/experimental/bot/poster/poster.go
Обычный файл
38
server/public/pluginapi/experimental/bot/poster/poster.go
Обычный файл
@@ -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 Slack attachments.
|
||||||
|
// Often used to include post actions.
|
||||||
|
DMWithAttachments(mattermostUserID string, attachments ...*model.SlackAttachment) (string, error)
|
||||||
|
|
||||||
|
// Ephemeral sends an ephemeral message to a user
|
||||||
|
Ephemeral(mattermostUserID, channelID, format string, args ...interface{})
|
||||||
|
|
||||||
|
// UpdatePostByID updates the post with postID with the formatted message
|
||||||
|
UpdatePostByID(postID, format string, args ...interface{}) 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 ...interface{}) (string, error)
|
||||||
|
}
|
||||||
31
server/public/pluginapi/experimental/command/command.go
Обычный файл
31
server/public/pluginapi/experimental/command/command.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
74
server/public/pluginapi/experimental/command/info.go
Обычный файл
74
server/public/pluginapi/experimental/command/info.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
21
server/public/pluginapi/experimental/common/kvstore.go
Обычный файл
21
server/public/pluginapi/experimental/common/kvstore.go
Обычный файл
@@ -0,0 +1,21 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
type KVStore interface {
|
||||||
|
Set(key string, value interface{}, options ...pluginapi.KVSetOption) (bool, error)
|
||||||
|
SetWithExpiry(key string, value interface{}, ttl time.Duration) error
|
||||||
|
CompareAndSet(key string, oldValue, value interface{}) (bool, error)
|
||||||
|
CompareAndDelete(key string, oldValue interface{}) (bool, error)
|
||||||
|
Get(key string, o interface{}) error
|
||||||
|
Delete(key string) error
|
||||||
|
DeleteAll() error
|
||||||
|
ListKeys(page, count int, options ...pluginapi.ListKeysOption) ([]string, error)
|
||||||
|
}
|
||||||
8
server/public/pluginapi/experimental/common/logapi.go
Обычный файл
8
server/public/pluginapi/experimental/common/logapi.go
Обычный файл
@@ -0,0 +1,8 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
type LogAPI interface {
|
||||||
|
LogError(message string, keyValuePairs ...interface{})
|
||||||
|
LogWarn(message string, keyValuePairs ...interface{})
|
||||||
|
LogInfo(message string, keyValuePairs ...interface{})
|
||||||
|
LogDebug(message string, keyValuePairs ...interface{})
|
||||||
|
}
|
||||||
22
server/public/pluginapi/experimental/common/markdown.go
Обычный файл
22
server/public/pluginapi/experimental/common/markdown.go
Обычный файл
@@ -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 interface{}) 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 interface{}) string {
|
||||||
|
return fmt.Sprintf("\n```json\n%s\n```\n", JSON(ref))
|
||||||
|
}
|
||||||
24
server/public/pluginapi/experimental/common/slack_attachments.go
Обычный файл
24
server/public/pluginapi/experimental/common/slack_attachments.go
Обычный файл
@@ -0,0 +1,24 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SlackAttachmentError(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)
|
||||||
|
}
|
||||||
28
server/public/pluginapi/experimental/common/url.go
Обычный файл
28
server/public/pluginapi/experimental/common/url.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
254
server/public/pluginapi/experimental/flow/flow.go
Обычный файл
254
server/public/pluginapi/experimental/flow/flow.go
Обычный файл
@@ -0,0 +1,254 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Name string
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextStepKey = "step"
|
||||||
|
contextButtonKey = "button"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Flow struct {
|
||||||
|
UserID string
|
||||||
|
state *flowState
|
||||||
|
|
||||||
|
name Name
|
||||||
|
api *pluginapi.Client
|
||||||
|
pluginURL string
|
||||||
|
botUserID string
|
||||||
|
|
||||||
|
steps map[Name]Step
|
||||||
|
index []Name
|
||||||
|
done func(userID string, state State) error
|
||||||
|
|
||||||
|
debugLogState bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFlow creates a new flow using direct messages with the user.
|
||||||
|
//
|
||||||
|
// name must be a unique identifier for the flow within the plugin.
|
||||||
|
func NewFlow(name Name, api *pluginapi.Client, pluginURL, botUserID string) *Flow {
|
||||||
|
return &Flow{
|
||||||
|
name: name,
|
||||||
|
api: api,
|
||||||
|
pluginURL: pluginURL,
|
||||||
|
botUserID: botUserID,
|
||||||
|
steps: map[Name]Step{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) WithSteps(orderedSteps ...Step) *Flow {
|
||||||
|
if f.steps == nil {
|
||||||
|
f.steps = map[Name]Step{}
|
||||||
|
}
|
||||||
|
for _, step := range orderedSteps {
|
||||||
|
stepName := step.name
|
||||||
|
if _, ok := f.steps[stepName]; ok {
|
||||||
|
f.api.Log.Warn("ignored duplicate step name", "name", stepName, "flow", f.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.steps[stepName] = step
|
||||||
|
f.index = append(f.index, stepName)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) OnDone(done func(string, State) error) *Flow {
|
||||||
|
f.done = done
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) InitHTTP(r *mux.Router) *Flow {
|
||||||
|
flowRouter := r.PathPrefix("/").Subrouter()
|
||||||
|
flowRouter.HandleFunc(namePath(f.name)+"/button", f.handleButtonHTTP).Methods(http.MethodPost)
|
||||||
|
flowRouter.HandleFunc(namePath(f.name)+"/dialog", f.handleDialogHTTP).Methods(http.MethodPost)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) WithDebugLog() *Flow {
|
||||||
|
f.debugLogState = true
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForUser creates a new flow using direct messages with the user.
|
||||||
|
func (f *Flow) ForUser(userID string) *Flow {
|
||||||
|
clone := *f
|
||||||
|
clone.UserID = userID
|
||||||
|
clone.state = nil
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) GetCurrentStep() (Name, error) {
|
||||||
|
state, err := f.getState()
|
||||||
|
if err != nil {
|
||||||
|
// Don't return an error if no flow is running
|
||||||
|
if errors.Is(err, errStateNotFound) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.StepName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) GetState() State {
|
||||||
|
state, _ := f.getState()
|
||||||
|
return state.AppState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) Start(appState State) error {
|
||||||
|
if len(f.index) == 0 {
|
||||||
|
return errors.New("no steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := f.storeState(flowState{
|
||||||
|
AppState: appState,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Go(f.index[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) Finish() error {
|
||||||
|
state, err := f.getState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = f.removeState()
|
||||||
|
|
||||||
|
if f.done != nil {
|
||||||
|
err = f.done(f.UserID, state.AppState)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) Go(toName Name) error {
|
||||||
|
state, err := f.getState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if toName == state.StepName {
|
||||||
|
// Stay at the current step, nothing to do
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Moving onto a different step, mark the current step as "Done"
|
||||||
|
if state.StepName != "" && !state.Done {
|
||||||
|
from, ok := f.steps[state.StepName]
|
||||||
|
if !ok {
|
||||||
|
return errors.Errorf("%s: step not found", toName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var donePost *model.Post
|
||||||
|
donePost, err = from.done(f, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if donePost != nil {
|
||||||
|
donePost.Id = state.PostID
|
||||||
|
err = f.api.Post.UpdatePost(donePost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toName == "" {
|
||||||
|
return f.Finish()
|
||||||
|
}
|
||||||
|
to, ok := f.steps[toName]
|
||||||
|
if !ok {
|
||||||
|
return errors.Errorf("%s: step not found", toName)
|
||||||
|
}
|
||||||
|
|
||||||
|
post, terminal, err := to.do(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.processButtonPostActions(post)
|
||||||
|
|
||||||
|
if f.debugLogState {
|
||||||
|
data, _ := json.MarshalIndent(state, "", " ")
|
||||||
|
post.Message = fmt.Sprintf("State:\n```\n%s\n```\n", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.api.Post.DM(f.botUserID, f.UserID, post)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if terminal {
|
||||||
|
return f.Finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.StepName = toName
|
||||||
|
state.Done = false
|
||||||
|
state.PostID = post.Id
|
||||||
|
err = f.storeState(state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if to.autoForward {
|
||||||
|
var nextName Name
|
||||||
|
|
||||||
|
if to.forwardTo != "" {
|
||||||
|
nextName = to.forwardTo
|
||||||
|
} else {
|
||||||
|
nextName = f.next(toName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextName != "" {
|
||||||
|
return f.Go(nextName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Flow) next(fromName Name) Name {
|
||||||
|
for i, n := range f.index {
|
||||||
|
if fromName == n {
|
||||||
|
if i+1 < len(f.index) {
|
||||||
|
return f.index[i+1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func namePath(name Name) string {
|
||||||
|
return "/" + url.PathEscape(strings.Trim(string(name), "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Goto(toName Name) func(*Flow) (Name, State, error) {
|
||||||
|
return func(_ *Flow) (Name, State, error) {
|
||||||
|
return toName, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialogGoto(toName Name) func(*Flow, map[string]interface{}) (Name, State, map[string]string, error) {
|
||||||
|
return func(_ *Flow, submitted map[string]interface{}) (Name, State, map[string]string, error) {
|
||||||
|
stateUpdate := State{}
|
||||||
|
for k, v := range submitted {
|
||||||
|
stateUpdate[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return toName, stateUpdate, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
210
server/public/pluginapi/experimental/flow/handler.go
Обычный файл
210
server/public/pluginapi/experimental/flow/handler.go
Обычный файл
@@ -0,0 +1,210 @@
|
|||||||
|
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See License for license information.
|
||||||
|
|
||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *Flow) handleButtonHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Header.Get("Mattermost-User-ID")
|
||||||
|
if userID == "" {
|
||||||
|
common.SlackAttachmentError(w, errors.New("Not authorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f = f.ForUser(userID)
|
||||||
|
|
||||||
|
var request model.PostActionIntegrationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
common.SlackAttachmentError(w, errors.New("invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedButton is 1-based
|
||||||
|
fromName, selectedButton, err := buttonContext(&request)
|
||||||
|
if err != nil {
|
||||||
|
common.SlackAttachmentError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
donePost, err := f.handleButton(fromName, selectedButton, request.TriggerId)
|
||||||
|
if err != nil {
|
||||||
|
common.SlackAttachmentError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(model.PostActionIntegrationResponse{
|
||||||
|
Update: donePost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) handleDialogHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Header.Get("Mattermost-User-ID")
|
||||||
|
if userID == "" {
|
||||||
|
common.DialogError(w, errors.New("not authorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f = f.ForUser(userID)
|
||||||
|
|
||||||
|
var request model.SubmitDialogRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
common.DialogError(w, errors.New("invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fromName, selectedButton, err := dialogContext(&request)
|
||||||
|
if err != nil {
|
||||||
|
common.DialogError(w, errors.Wrap(err, "invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDialog updates the post
|
||||||
|
donePost, fieldErrors, err := f.handleDialog(fromName, selectedButton, request.Submission)
|
||||||
|
if err != nil || len(fieldErrors) != 0 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp := model.SubmitDialogResponse{
|
||||||
|
Errors: fieldErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
resp.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = f.api.Post.UpdatePost(donePost)
|
||||||
|
if err != nil {
|
||||||
|
common.DialogError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(model.SubmitDialogResponse{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) handleButton(fromName Name, selectedButton int, triggerID string) (*model.Post, error) {
|
||||||
|
post, _, err := f.handle(fromName, selectedButton, nil, triggerID, true)
|
||||||
|
return post, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) handleDialog(
|
||||||
|
fromName Name, selectedButton int, submission map[string]interface{},
|
||||||
|
) (
|
||||||
|
*model.Post, map[string]string, error,
|
||||||
|
) {
|
||||||
|
return f.handle(fromName, selectedButton, submission, "", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) handle(
|
||||||
|
fromName Name, selectedButton int, submission map[string]interface{}, triggerID string, asButton bool,
|
||||||
|
) (
|
||||||
|
*model.Post, map[string]string, error,
|
||||||
|
) {
|
||||||
|
state, err := f.getState()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if state.StepName != fromName {
|
||||||
|
return nil, nil, errors.Errorf("click from an inactive step: %v", fromName)
|
||||||
|
}
|
||||||
|
from, ok := f.steps[fromName]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.Errorf("step %q not found", fromName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedButton == 0 || selectedButton > len(from.buttons) {
|
||||||
|
return nil, nil, errors.Errorf("button number %v to high or too low, only %v buttons", selectedButton, len(from.buttons))
|
||||||
|
}
|
||||||
|
b := from.buttons[selectedButton-1]
|
||||||
|
|
||||||
|
var updated State
|
||||||
|
toName := fromName
|
||||||
|
var fieldErrors map[string]string
|
||||||
|
if asButton {
|
||||||
|
if b.OnClick != nil {
|
||||||
|
toName, updated, err = b.OnClick(f)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if b.OnDialogSubmit != nil {
|
||||||
|
toName, updated, fieldErrors, err = b.OnDialogSubmit(f, submission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil || len(fieldErrors) > 0 {
|
||||||
|
return nil, fieldErrors, err
|
||||||
|
}
|
||||||
|
state.AppState = state.AppState.MergeWith(updated)
|
||||||
|
state.Done = true
|
||||||
|
err = f.storeState(state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty next step name in the response indicates advancing to the next step
|
||||||
|
// in the flow. To stay on the same step the handlers should return the step
|
||||||
|
// name.
|
||||||
|
if toName == "" {
|
||||||
|
toName = f.next(fromName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asButton && b.Dialog != nil {
|
||||||
|
if b.OnDialogSubmit == nil {
|
||||||
|
return nil, nil, errors.Errorf("no submit function for dialog, step: %s", fromName)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogRequest := model.OpenDialogRequest{
|
||||||
|
TriggerId: triggerID,
|
||||||
|
URL: f.pluginURL + namePath(f.name) + "/dialog",
|
||||||
|
Dialog: processDialog(b.Dialog, state.AppState),
|
||||||
|
}
|
||||||
|
dialogRequest.Dialog.State = fmt.Sprintf("%v,%v", fromName, selectedButton)
|
||||||
|
|
||||||
|
err = f.api.Frontend.OpenInteractiveDialog(dialogRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toName == fromName {
|
||||||
|
// Nothing else to do
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
donePost, err := from.done(f, selectedButton)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
donePost.Id = state.PostID
|
||||||
|
f.processButtonPostActions(donePost)
|
||||||
|
|
||||||
|
err = f.Go(toName)
|
||||||
|
if err != nil {
|
||||||
|
f.api.Log.Warn("failed to advance flow to next step", "flow_name", f.name, "from", fromName, "to", toName, "error", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the "done" post for the from step - leave updating up to the
|
||||||
|
// API-specific caller.
|
||||||
|
return donePost, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) processButtonPostActions(post *model.Post) {
|
||||||
|
attachments, ok := post.GetProp("attachments").([]*model.SlackAttachment)
|
||||||
|
if !ok || len(attachments) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sa := attachments[0]
|
||||||
|
for _, a := range sa.Actions {
|
||||||
|
if a.Integration == nil {
|
||||||
|
a.Integration = &model.PostActionIntegration{}
|
||||||
|
}
|
||||||
|
a.Integration.URL = f.pluginURL + namePath(f.name) + "/button"
|
||||||
|
}
|
||||||
|
}
|
||||||
146
server/public/pluginapi/experimental/flow/state.go
Обычный файл
146
server/public/pluginapi/experimental/flow/state.go
Обычный файл
@@ -0,0 +1,146 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errStateNotFound = errors.New("flow state not found")
|
||||||
|
|
||||||
|
// State is the "app"'s state
|
||||||
|
type State map[string]interface{}
|
||||||
|
|
||||||
|
func (s State) MergeWith(update State) State {
|
||||||
|
n := State{}
|
||||||
|
for k, v := range s {
|
||||||
|
n[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range update {
|
||||||
|
n[k] = v
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetString return the value to a given key as a string.
|
||||||
|
// If the key is not found or isn't a string, an empty string is returned.
|
||||||
|
func (s State) GetString(key string) string {
|
||||||
|
vRaw, ok := s[key]
|
||||||
|
if ok {
|
||||||
|
v, ok := vRaw.(string)
|
||||||
|
if ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInt return the value to a given key as a int.
|
||||||
|
// If the key is not found or isn't an int, zero is returned.
|
||||||
|
func (s State) GetInt(key string) int {
|
||||||
|
vRaw, ok := s[key]
|
||||||
|
if ok {
|
||||||
|
v, ok := vRaw.(int)
|
||||||
|
if ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBool return the value to a given key as a bool.
|
||||||
|
// If the key is not found or isn't a bool, false is returned.
|
||||||
|
func (s State) GetBool(key string) bool {
|
||||||
|
vRaw, ok := s[key]
|
||||||
|
if ok {
|
||||||
|
v, ok := vRaw.(bool)
|
||||||
|
if ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-serializable flow state.
|
||||||
|
type flowState struct {
|
||||||
|
// The name of the step.
|
||||||
|
StepName Name
|
||||||
|
|
||||||
|
Done bool
|
||||||
|
|
||||||
|
// ID of the post produced by the step.
|
||||||
|
PostID string
|
||||||
|
|
||||||
|
// Application-level state.
|
||||||
|
AppState State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) storeState(state flowState) error {
|
||||||
|
if f.UserID == "" {
|
||||||
|
return errors.New("no user specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set AppState to differentiate an existing flow
|
||||||
|
if state.AppState == nil {
|
||||||
|
state.AppState = State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := f.api.KV.Set(kvKey(f.UserID, f.name), state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("value not set without errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.state = &state
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) getState() (flowState, error) {
|
||||||
|
if f.UserID == "" {
|
||||||
|
return flowState{}, errors.New("no user specified")
|
||||||
|
}
|
||||||
|
if f.state != nil {
|
||||||
|
return *f.state, nil
|
||||||
|
}
|
||||||
|
state := flowState{}
|
||||||
|
err := f.api.KV.Get(kvKey(f.UserID, f.name), &state)
|
||||||
|
if err != nil {
|
||||||
|
return flowState{}, err
|
||||||
|
}
|
||||||
|
if state.AppState == nil {
|
||||||
|
return flowState{}, errStateNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
f.state = &state
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) removeState() error {
|
||||||
|
if f.UserID == "" {
|
||||||
|
return errors.New("no user specified")
|
||||||
|
}
|
||||||
|
f.state = nil
|
||||||
|
return f.api.KV.Delete(kvKey(f.UserID, f.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvKey(userID string, flowName Name) string {
|
||||||
|
return "_flow-" + userID + "-" + string(flowName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatState(source string, state State) string {
|
||||||
|
t, err := template.New("message").Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return source + " ###ERROR: " + err.Error()
|
||||||
|
}
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
err = t.Execute(buf, state)
|
||||||
|
if err != nil {
|
||||||
|
return source + " ###ERROR: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
269
server/public/pluginapi/experimental/flow/step.go
Обычный файл
269
server/public/pluginapi/experimental/flow/step.go
Обычный файл
@@ -0,0 +1,269 @@
|
|||||||
|
package flow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Color string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ColorDefault Color = "default"
|
||||||
|
ColorPrimary Color = "primary"
|
||||||
|
ColorSuccess Color = "success"
|
||||||
|
ColorGood Color = "good"
|
||||||
|
ColorWarning Color = "warning"
|
||||||
|
ColorDanger Color = "danger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Step struct {
|
||||||
|
name Name
|
||||||
|
template *model.SlackAttachment
|
||||||
|
forwardTo Name
|
||||||
|
autoForward bool
|
||||||
|
terminal bool
|
||||||
|
onRender func(f *Flow)
|
||||||
|
buttons []Button
|
||||||
|
}
|
||||||
|
|
||||||
|
type Button struct {
|
||||||
|
Name string
|
||||||
|
Disabled bool
|
||||||
|
Color Color
|
||||||
|
|
||||||
|
// OnClick is called when the button is clicked. It returns the next step's
|
||||||
|
// name and the state updates to apply.
|
||||||
|
//
|
||||||
|
// If Dialog is also specified, OnClick is executed first.
|
||||||
|
OnClick func(f *Flow) (Name, State, error)
|
||||||
|
|
||||||
|
// Dialog is the interactive dialog to display if the button is clicked
|
||||||
|
// (OnClick is executed first). OnDialogSubmit must be provided.
|
||||||
|
Dialog *model.Dialog
|
||||||
|
|
||||||
|
// Function that is called when the dialog box is submitted. It can return a
|
||||||
|
// general error, or field-specific errors. On success it returns the name
|
||||||
|
// of the next step, and the state updates to apply.
|
||||||
|
OnDialogSubmit func(f *Flow, submitted map[string]interface{}) (Name, State, map[string]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStep(name Name) Step {
|
||||||
|
return Step{
|
||||||
|
name: name,
|
||||||
|
template: &model.SlackAttachment{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithButton(buttons ...Button) Step {
|
||||||
|
s.buttons = append(s.buttons, buttons...)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) Terminal() Step {
|
||||||
|
s.terminal = true
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) OnRender(f func(*Flow)) Step {
|
||||||
|
s.onRender = f
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) Next(name Name) Step {
|
||||||
|
s.forwardTo = name
|
||||||
|
s.autoForward = true
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithImage(imageURL string) Step {
|
||||||
|
if u, err := url.Parse(imageURL); err == nil {
|
||||||
|
if u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") {
|
||||||
|
s.template.ImageURL = imageURL
|
||||||
|
} else {
|
||||||
|
s.template.ImageURL = u.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithColor(color Color) Step {
|
||||||
|
s.template.Color = string(color)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithPretext(text string) Step {
|
||||||
|
s.template.Pretext = text
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithField(title, value string) Step {
|
||||||
|
s.template.Fields = append(s.template.Fields, &model.SlackAttachmentField{
|
||||||
|
Title: title,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithTitle(text string) Step {
|
||||||
|
s.template.Title = text
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) WithText(text string) Step {
|
||||||
|
s.template.Text = text
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) do(f *Flow) (*model.Post, bool, error) {
|
||||||
|
if s.onRender != nil {
|
||||||
|
s.onRender(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.render(f, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) done(f *Flow, selectedButton int) (*model.Post, error) {
|
||||||
|
post, _, err := s.render(f, true, selectedButton)
|
||||||
|
return post, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Step) render(f *Flow, done bool, selectedButton int) (*model.Post, bool, error) {
|
||||||
|
sa := f.processAttachment(s.template)
|
||||||
|
post := model.Post{}
|
||||||
|
model.ParseSlackAttachment(&post, []*model.SlackAttachment{sa})
|
||||||
|
|
||||||
|
if s.terminal {
|
||||||
|
// Nothing else to do, do not display buttons on terminal posts.
|
||||||
|
return &post, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons := processButtons(s.buttons, f.state.AppState)
|
||||||
|
|
||||||
|
attachments, ok := post.GetProp("attachments").([]*model.SlackAttachment)
|
||||||
|
if !ok || len(attachments) != 1 {
|
||||||
|
return nil, false, errors.New("expected 1 slack attachment")
|
||||||
|
}
|
||||||
|
var actions []*model.PostAction
|
||||||
|
if done {
|
||||||
|
if selectedButton > 0 {
|
||||||
|
action := renderButton(buttons[selectedButton-1], s.name, selectedButton, f.state.AppState)
|
||||||
|
action.Disabled = true
|
||||||
|
actions = append(actions, action)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i, b := range buttons {
|
||||||
|
actions = append(actions, renderButton(b, s.name, i+1, f.state.AppState))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachments[0].Actions = actions
|
||||||
|
return &post, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flow) processAttachment(attachment *model.SlackAttachment) *model.SlackAttachment {
|
||||||
|
if attachment == nil {
|
||||||
|
return &model.SlackAttachment{Text: "ERROR"}
|
||||||
|
}
|
||||||
|
a := *attachment
|
||||||
|
a.Pretext = formatState(attachment.Pretext, f.state.AppState)
|
||||||
|
a.Title = formatState(attachment.Title, f.state.AppState)
|
||||||
|
a.Text = formatState(attachment.Text, f.state.AppState)
|
||||||
|
|
||||||
|
for _, field := range a.Fields {
|
||||||
|
field.Title = formatState(field.Title, f.state.AppState)
|
||||||
|
v := field.Value.(string)
|
||||||
|
if v != "" {
|
||||||
|
field.Value = formatState(v, f.state.AppState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Fallback = fmt.Sprintf("%s: %s", a.Title, a.Text)
|
||||||
|
|
||||||
|
if attachment.ImageURL != "" {
|
||||||
|
if u, err := url.Parse(attachment.ImageURL); err == nil {
|
||||||
|
if u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") {
|
||||||
|
a.ImageURL = attachment.ImageURL
|
||||||
|
} else {
|
||||||
|
a.ImageURL = f.pluginURL + "/" + strings.TrimPrefix(attachment.ImageURL, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func processButtons(in []Button, state State) []Button {
|
||||||
|
var out []Button
|
||||||
|
for _, b := range in {
|
||||||
|
button := b
|
||||||
|
button.Name = formatState(b.Name, state)
|
||||||
|
out = append(out, button)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDialog(in *model.Dialog, state State) model.Dialog {
|
||||||
|
d := *in
|
||||||
|
d.Title = formatState(d.Title, state)
|
||||||
|
d.IntroductionText = formatState(d.IntroductionText, state)
|
||||||
|
d.SubmitLabel = formatState(d.SubmitLabel, state)
|
||||||
|
for i := range d.Elements {
|
||||||
|
d.Elements[i].DisplayName = formatState(d.Elements[i].DisplayName, state)
|
||||||
|
d.Elements[i].Name = formatState(d.Elements[i].Name, state)
|
||||||
|
d.Elements[i].Default = formatState(d.Elements[i].Default, state)
|
||||||
|
d.Elements[i].Placeholder = formatState(d.Elements[i].Placeholder, state)
|
||||||
|
d.Elements[i].HelpText = formatState(d.Elements[i].HelpText, state)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderButton(b Button, stepName Name, i int, state State) *model.PostAction {
|
||||||
|
return &model.PostAction{
|
||||||
|
Name: formatState(b.Name, state),
|
||||||
|
Disabled: b.Disabled,
|
||||||
|
Style: string(b.Color),
|
||||||
|
Integration: &model.PostActionIntegration{
|
||||||
|
Context: map[string]interface{}{
|
||||||
|
contextStepKey: string(stepName),
|
||||||
|
contextButtonKey: strconv.Itoa(i),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonContext(request *model.PostActionIntegrationRequest) (Name, int, error) {
|
||||||
|
fromString, ok := request.Context[contextStepKey].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", 0, errors.New("missing step name")
|
||||||
|
}
|
||||||
|
fromName := Name(fromString)
|
||||||
|
|
||||||
|
buttonStr, ok := request.Context[contextButtonKey].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", 0, errors.New("missing button id")
|
||||||
|
}
|
||||||
|
buttonIndex, err := strconv.Atoi(buttonStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, errors.Wrap(err, "invalid button number")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromName, buttonIndex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialogContext(request *model.SubmitDialogRequest) (Name, int, error) {
|
||||||
|
data := strings.Split(request.State, ",")
|
||||||
|
if len(data) != 2 {
|
||||||
|
return "", 0, errors.New("invalid request")
|
||||||
|
}
|
||||||
|
fromName := Name(data[0])
|
||||||
|
buttonIndex, err := strconv.Atoi(data[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, errors.Wrap(err, "malformed button number")
|
||||||
|
}
|
||||||
|
return fromName, buttonIndex, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 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 (
|
||||||
|
gomock "github.com/golang/mock/gomock"
|
||||||
|
oauth2 "golang.org/x/oauth2"
|
||||||
|
reflect "reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
105
server/public/pluginapi/experimental/oauther/mocks/mock_oauther.go
Обычный файл
105
server/public/pluginapi/experimental/oauther/mocks/mock_oauther.go
Обычный файл
@@ -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)
|
||||||
|
}
|
||||||
191
server/public/pluginapi/experimental/oauther/oauth2.go
Обычный файл
191
server/public/pluginapi/experimental/oauther/oauth2.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
105
server/public/pluginapi/experimental/oauther/oauth2_complete.go
Обычный файл
105
server/public/pluginapi/experimental/oauther/oauth2_complete.go
Обычный файл
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
server/public/pluginapi/experimental/oauther/oauth2_connect.go
Обычный файл
38
server/public/pluginapi/experimental/oauther/oauth2_connect.go
Обычный файл
@@ -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)
|
||||||
|
}
|
||||||
47
server/public/pluginapi/experimental/oauther/options.go
Обычный файл
47
server/public/pluginapi/experimental/oauther/options.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
70
server/public/pluginapi/experimental/panel/handler.go
Обычный файл
70
server/public/pluginapi/experimental/panel/handler.go
Обычный файл
@@ -0,0 +1,70 @@
|
|||||||
|
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.SlackAttachmentError(w, errors.New("Not authorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request model.PostActionIntegrationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
common.SlackAttachmentError(w, errors.New("invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := request.Context[settings.ContextIDKey]
|
||||||
|
if !ok {
|
||||||
|
common.SlackAttachmentError(w, errors.New("missing setting id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := request.Context[settings.ContextButtonValueKey]
|
||||||
|
if !ok {
|
||||||
|
value, ok = request.Context[settings.ContextOptionValueKey]
|
||||||
|
if !ok {
|
||||||
|
common.SlackAttachmentError(w, errors.New("valid key not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idString := id.(string)
|
||||||
|
err := sh.panel.Set(mattermostUserID, idString, value)
|
||||||
|
if err != nil {
|
||||||
|
common.SlackAttachmentError(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)
|
||||||
|
}
|
||||||
118
server/public/pluginapi/experimental/panel/mocks/mock_panel.go
Обычный файл
118
server/public/pluginapi/experimental/panel/mocks/mock_panel.go
Обычный файл
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
149
server/public/pluginapi/experimental/panel/mocks/mock_setting.go
Обычный файл
149
server/public/pluginapi/experimental/panel/mocks/mock_setting.go
Обычный файл
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlackAttachments mocks base method.
|
||||||
|
func (m *MockSetting) GetSlackAttachments(arg0, arg1 string, arg2 bool) (*model.SlackAttachment, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetSlackAttachments", arg0, arg1, arg2)
|
||||||
|
ret0, _ := ret[0].(*model.SlackAttachment)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlackAttachments indicates an expected call of GetSlackAttachments.
|
||||||
|
func (mr *MockSettingMockRecorder) GetSlackAttachments(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSlackAttachments", reflect.TypeOf((*MockSetting)(nil).GetSlackAttachments), 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)
|
||||||
|
}
|
||||||
171
server/public/pluginapi/experimental/panel/panel.go
Обычный файл
171
server/public/pluginapi/experimental/panel/panel.go
Обычный файл
@@ -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 interface{}) 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 interface{}) 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, " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
sas := []*model.SlackAttachment{}
|
||||||
|
for _, key := range p.settingKeys {
|
||||||
|
s := p.settings[key]
|
||||||
|
sa, loopErr := s.GetSlackAttachments(userID, p.pluginURL+p.settingHandler, p.isSettingDisabled(userID, s))
|
||||||
|
if loopErr != nil {
|
||||||
|
p.logger.Errorf("error creating the slack attachment, err=" + 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=", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.store.SetPanelPostID(userID, postID)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Errorf("could not set the post IDs, err=", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panel) ToPost(userID string) (*model.Post, error) {
|
||||||
|
post := &model.Post{}
|
||||||
|
|
||||||
|
sas := []*model.SlackAttachment{}
|
||||||
|
for _, key := range p.settingKeys {
|
||||||
|
s := p.settings[key]
|
||||||
|
sa, err := s.GetSlackAttachments(userID, p.pluginURL+p.settingHandler, p.isSettingDisabled(userID, s))
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Errorf("error creating the slack attachment for setting %s, err=%s", s.GetID(), err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sas = append(sas, sa)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.ParseSlackAttachment(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)
|
||||||
|
}
|
||||||
@@ -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 interface{}) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
114
server/public/pluginapi/experimental/panel/settings/bool_setting.go
Обычный файл
114
server/public/pluginapi/experimental/panel/settings/bool_setting.go
Обычный файл
@@ -0,0 +1,114 @@
|
|||||||
|
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 interface{}) 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) (interface{}, 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) GetSlackAttachments(userID, settingHandler string, disabled bool) (*model.SlackAttachment, 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{
|
||||||
|
Name: "Yes",
|
||||||
|
Integration: &model.PostActionIntegration{
|
||||||
|
URL: settingHandler,
|
||||||
|
Context: map[string]interface{}{
|
||||||
|
ContextIDKey: s.id,
|
||||||
|
ContextButtonValueKey: TrueString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
actionFalse := model.PostAction{
|
||||||
|
Name: "No",
|
||||||
|
Integration: &model.PostActionIntegration{
|
||||||
|
URL: settingHandler,
|
||||||
|
Context: map[string]interface{}{
|
||||||
|
ContextIDKey: s.id,
|
||||||
|
ContextButtonValueKey: FalseString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
actions = []*model.PostAction{&actionTrue, &actionFalse}
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("%s\n%s", s.description, currentValueMessage)
|
||||||
|
sa := model.SlackAttachment{
|
||||||
|
Title: title,
|
||||||
|
Text: text,
|
||||||
|
Fallback: fmt.Sprintf("%s: %s", title, text),
|
||||||
|
Actions: actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *boolSetting) IsDisabled(foreignValue interface{}) bool {
|
||||||
|
return foreignValue == FalseString
|
||||||
|
}
|
||||||
@@ -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) GetSlackAttachments(userID, settingHandler string, disabled bool) (*model.SlackAttachment, error) {
|
||||||
|
title := fmt.Sprintf("Setting: %s", s.title)
|
||||||
|
sa := model.SlackAttachment{
|
||||||
|
Title: title,
|
||||||
|
Text: s.description,
|
||||||
|
Fallback: fmt.Sprintf("%s: %s", title, s.description),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emptySetting) Get(userID string) (interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *emptySetting) Set(userID string, value interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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 interface{}) error {
|
||||||
|
err := s.store.SetSetting(userID, s.id, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *optionSetting) Get(userID string) (interface{}, 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) GetSlackAttachments(userID, settingHandler string, disabled bool) (*model.SlackAttachment, 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{
|
||||||
|
Name: "Select an option:",
|
||||||
|
Integration: &model.PostActionIntegration{
|
||||||
|
URL: settingHandler + "?" + s.id + "=true",
|
||||||
|
Context: map[string]interface{}{
|
||||||
|
ContextIDKey: s.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: "select",
|
||||||
|
Options: stringsToOptions(s.options),
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = []*model.PostAction{&actionOptions}
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("%s\n%s", s.description, currentValueMessage)
|
||||||
|
sa := model.SlackAttachment{
|
||||||
|
Title: title,
|
||||||
|
Text: text,
|
||||||
|
Fallback: fmt.Sprintf("%s: %s", title, text),
|
||||||
|
Actions: actions,
|
||||||
|
}
|
||||||
|
return &sa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *optionSetting) IsDisabled(foreignValue interface{}) bool {
|
||||||
|
return foreignValue == FalseString
|
||||||
|
}
|
||||||
@@ -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) (interface{}, 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 interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *readOnlySetting) GetSlackAttachments(userID, settingHandler string, disabled bool) (*model.SlackAttachment, 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.SlackAttachment{
|
||||||
|
Title: title,
|
||||||
|
Text: text,
|
||||||
|
Fallback: fmt.Sprintf("%s: %s", title, text),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *readOnlySetting) IsDisabled(foreignValue interface{}) bool {
|
||||||
|
return foreignValue == FalseString
|
||||||
|
}
|
||||||
33
server/public/pluginapi/experimental/panel/settings/setting.go
Обычный файл
33
server/public/pluginapi/experimental/panel/settings/setting.go
Обычный файл
@@ -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 interface{}) error
|
||||||
|
Get(userID string) (interface{}, error)
|
||||||
|
GetID() string
|
||||||
|
GetDependency() string
|
||||||
|
IsDisabled(foreignValue interface{}) bool
|
||||||
|
GetTitle() string
|
||||||
|
GetDescription() string
|
||||||
|
GetSlackAttachments(userID, settingHandler string, disabled bool) (*model.SlackAttachment, error)
|
||||||
|
}
|
||||||
7
server/public/pluginapi/experimental/panel/settings/store.go
Обычный файл
7
server/public/pluginapi/experimental/panel/settings/store.go
Обычный файл
@@ -0,0 +1,7 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
// SettingStore defines the behavior needed to set and get settings
|
||||||
|
type SettingStore interface {
|
||||||
|
SetSetting(userID, settingID string, value interface{}) error
|
||||||
|
GetSetting(userID, settingID string) (interface{}, error)
|
||||||
|
}
|
||||||
16
server/public/pluginapi/experimental/panel/settings/utils.go
Обычный файл
16
server/public/pluginapi/experimental/panel/settings/utils.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
53
server/public/pluginapi/experimental/panel/store.go
Обычный файл
53
server/public/pluginapi/experimental/panel/store.go
Обычный файл
@@ -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
|
||||||
|
}
|
||||||
76
server/public/pluginapi/experimental/telemetry/doc.go
Обычный файл
76
server/public/pluginapi/experimental/telemetry/doc.go
Обычный файл
@@ -0,0 +1,76 @@
|
|||||||
|
// Package telemetry allows you to add telemetry to your plugins.
|
||||||
|
// For Rudder, you can set the data plane URL and the write key on build time,
|
||||||
|
// to allow having different keys for production and development.
|
||||||
|
// If you are working on a Mattermost project, the data plane URL is already set.
|
||||||
|
// In order to default to the development key we have to set an environment variable during build time.
|
||||||
|
// Copy the following lines in build/custom.mk to setup that variable.
|
||||||
|
//
|
||||||
|
// ifndef MM_RUDDER_WRITE_KEY
|
||||||
|
// MM_RUDDER_WRITE_KEY = 1d5bMvdrfWClLxgK1FvV3s4U1tg
|
||||||
|
// endif
|
||||||
|
//
|
||||||
|
// To use this environment variable to set the key in the plugin,
|
||||||
|
// you have to add this line after the previous ones.
|
||||||
|
//
|
||||||
|
// LDFLAGS += -X "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry.rudderWriteKey=$(MM_RUDDER_WRITE_KEY)"
|
||||||
|
//
|
||||||
|
// MM_RUDDER_WRITE_KEY environment variable must be set also during CI
|
||||||
|
// to the production write key ("1dP7Oi78p0PK1brYLsfslgnbD1I").
|
||||||
|
// If you want to use your own data plane URL, add also this line and
|
||||||
|
// make sure the MM_RUDDER_DATAPLANE_URL environment variable is set.
|
||||||
|
//
|
||||||
|
// LDFLAGS += -X "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry.rudderDataPlaneURL=$(MM_RUDDER_DATAPLANE_URL)"
|
||||||
|
//
|
||||||
|
// In order to use telemetry you should:
|
||||||
|
//
|
||||||
|
// 1. Add the new fields to the plugin
|
||||||
|
//
|
||||||
|
// type Plugin struct {
|
||||||
|
// plugin.MattermostPlugin
|
||||||
|
// ...
|
||||||
|
// telemetryClient telemetry.Client
|
||||||
|
// tracker telemetry.Tracker
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 2. Start the telemetry client and tracker on plugin activate
|
||||||
|
//
|
||||||
|
// func (p *Plugin) OnActivate() error {
|
||||||
|
// p.telemetryClient, err = telemetry.NewRudderClient()
|
||||||
|
// if err != nil {
|
||||||
|
// p.API.LogWarn("telemetry client not started", "error", err.Error())
|
||||||
|
// }
|
||||||
|
// ...
|
||||||
|
// p.tracker = telemetry.NewTracker(
|
||||||
|
// p.telemetryClient,
|
||||||
|
// p.API.GetDiagnosticId(),
|
||||||
|
// p.API.GetServerVersion(),
|
||||||
|
// Manifest.Id,
|
||||||
|
// Manifest.Version,
|
||||||
|
// "plugin_short_namame",
|
||||||
|
// telemetry.NewTrackerConfig(p.API.GetConfig()),
|
||||||
|
// logger.New(p.API)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 3. Trigger tracker changes when configuration changes
|
||||||
|
//
|
||||||
|
// func (p *Plugin) OnConfigurationChange() error {
|
||||||
|
// ...
|
||||||
|
// if p.tracker != nil {
|
||||||
|
// p.tracker.ReloadConfig(telemetry.NewTrackerConfig(p.API.GetConfig()))
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 4. Close the client on plugin deactivate
|
||||||
|
//
|
||||||
|
// func (p *Plugin) OnDeactivate() error {
|
||||||
|
// if p.telemetryClient != nil {
|
||||||
|
// err := p.telemetryClient.Close()
|
||||||
|
// if err != nil {
|
||||||
|
// p.API.LogWarn("OnDeactivate: failed to close telemetryClient", "error", err.Error())
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
package telemetry
|
||||||
49
server/public/pluginapi/experimental/telemetry/rudder.go
Обычный файл
49
server/public/pluginapi/experimental/telemetry/rudder.go
Обычный файл
@@ -0,0 +1,49 @@
|
|||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
rudder "github.com/rudderlabs/analytics-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rudderDataPlaneURL is set to the common Data Plane URL for all Mattermost Projects.
|
||||||
|
// It can be set during build time. More info in the package documentation.
|
||||||
|
var rudderDataPlaneURL = "https://pdat.matterlytics.com"
|
||||||
|
|
||||||
|
// rudderWriteKey is set during build time. More info in the package documentation.
|
||||||
|
var rudderWriteKey string
|
||||||
|
|
||||||
|
// NewRudderClient creates a new telemetry client with Rudder using the default configuration.
|
||||||
|
func NewRudderClient() (Client, error) {
|
||||||
|
return NewRudderClientWithCredentials(rudderWriteKey, rudderDataPlaneURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRudderClientWithCredentials lets you create a Rudder client with your own credentials.
|
||||||
|
func NewRudderClientWithCredentials(writeKey, dataPlaneURL string) (Client, error) {
|
||||||
|
client, err := rudder.NewWithConfig(writeKey, dataPlaneURL, rudder.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &rudderWrapper{client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rudderWrapper struct {
|
||||||
|
client rudder.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rudderWrapper) Enqueue(t Track) error {
|
||||||
|
var context *rudder.Context
|
||||||
|
if t.InstallationID != "" {
|
||||||
|
context = &rudder.Context{Traits: map[string]any{"installationId": t.InstallationID}}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.client.Enqueue(rudder.Track{
|
||||||
|
UserId: t.UserID,
|
||||||
|
Event: t.Event,
|
||||||
|
Context: context,
|
||||||
|
Properties: t.Properties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rudderWrapper) Close() error {
|
||||||
|
return r.client.Close()
|
||||||
|
}
|
||||||
179
server/public/pluginapi/experimental/telemetry/tracker.go
Обычный файл
179
server/public/pluginapi/experimental/telemetry/tracker.go
Обычный файл
@@ -0,0 +1,179 @@
|
|||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackerConfig struct {
|
||||||
|
EnabledTracking bool
|
||||||
|
EnabledLogging bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTrackerConfig returns a new trackerConfig from the current values of the model.Config.
|
||||||
|
func NewTrackerConfig(config *model.Config) TrackerConfig {
|
||||||
|
var enabledTracking, enabledLogging bool
|
||||||
|
if config == nil {
|
||||||
|
return TrackerConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableDiagnostics := config.LogSettings.EnableDiagnostics; enableDiagnostics != nil {
|
||||||
|
enabledTracking = *enableDiagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableDeveloper := config.ServiceSettings.EnableDeveloper; enableDeveloper != nil {
|
||||||
|
enabledLogging = *enableDeveloper
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackerConfig{
|
||||||
|
EnabledTracking: enabledTracking,
|
||||||
|
EnabledLogging: enabledLogging,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracker defines a telemetry tracker
|
||||||
|
type Tracker interface {
|
||||||
|
// TrackEvent registers an event through the configured telemetry client
|
||||||
|
TrackEvent(event string, properties map[string]interface{}) error
|
||||||
|
// TrackUserEvent registers an event through the configured telemetry client associated to a user
|
||||||
|
TrackUserEvent(event string, userID string, properties map[string]interface{}) error
|
||||||
|
// Reload Config re-evaluates tracker config to determine if tracking behavior should change
|
||||||
|
ReloadConfig(config TrackerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client defines a telemetry client
|
||||||
|
type Client interface {
|
||||||
|
// Enqueue adds a tracker event (Track) to be registered
|
||||||
|
Enqueue(t Track) error
|
||||||
|
// Close closes the client connection, flushing any event left on the queue
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track defines an event ready for the client to process
|
||||||
|
type Track struct {
|
||||||
|
UserID string
|
||||||
|
Event string
|
||||||
|
Properties map[string]interface{}
|
||||||
|
InstallationID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type tracker struct {
|
||||||
|
client Client
|
||||||
|
diagnosticID string
|
||||||
|
serverVersion string
|
||||||
|
pluginID string
|
||||||
|
pluginVersion string
|
||||||
|
telemetryShortName string
|
||||||
|
configLock sync.RWMutex
|
||||||
|
config TrackerConfig
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTracker creates a default Tracker
|
||||||
|
// - c Client: A telemetry client. If nil, the tracker will not track any event.
|
||||||
|
// - diagnosticID: Server unique ID used for telemetry.
|
||||||
|
// - severVersion: Mattermost server version.
|
||||||
|
// - pluginID: The plugin ID.
|
||||||
|
// - pluginVersion: The plugin version.
|
||||||
|
// - telemetryShortName: Short name for the plugin to use in telemetry. Used to avoid dot separated names like `com.company.pluginName`.
|
||||||
|
// If a empty string is provided, it will use the pluginID.
|
||||||
|
// - config: Whether the system has enabled sending telemetry data. If false, the tracker will not track any event.
|
||||||
|
// - l Logger: A logger to debug event tracking and some important changes (it won't log if nil is passed as logger).
|
||||||
|
func NewTracker(
|
||||||
|
c Client,
|
||||||
|
diagnosticID,
|
||||||
|
serverVersion,
|
||||||
|
pluginID,
|
||||||
|
pluginVersion,
|
||||||
|
telemetryShortName string,
|
||||||
|
config TrackerConfig,
|
||||||
|
l logger.Logger,
|
||||||
|
) Tracker {
|
||||||
|
if telemetryShortName == "" {
|
||||||
|
telemetryShortName = pluginID
|
||||||
|
}
|
||||||
|
return &tracker{
|
||||||
|
telemetryShortName: telemetryShortName,
|
||||||
|
client: c,
|
||||||
|
diagnosticID: diagnosticID,
|
||||||
|
serverVersion: serverVersion,
|
||||||
|
pluginID: pluginID,
|
||||||
|
pluginVersion: pluginVersion,
|
||||||
|
logger: l,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracker) ReloadConfig(config TrackerConfig) {
|
||||||
|
t.configLock.Lock()
|
||||||
|
defer t.configLock.Unlock()
|
||||||
|
|
||||||
|
if config.EnabledTracking != t.config.EnabledTracking {
|
||||||
|
if config.EnabledTracking {
|
||||||
|
t.debugf("Enabling plugin telemetry")
|
||||||
|
} else {
|
||||||
|
t.debugf("Disabling plugin telemetry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.config.EnabledTracking = config.EnabledTracking
|
||||||
|
t.config.EnabledLogging = config.EnabledLogging
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note that config lock is handled by the caller.
|
||||||
|
func (t *tracker) debugf(message string, args ...interface{}) {
|
||||||
|
if t.logger == nil || !t.config.EnabledLogging {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.logger.Debugf(message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracker) TrackEvent(event string, properties map[string]interface{}) error {
|
||||||
|
t.configLock.RLock()
|
||||||
|
defer t.configLock.RUnlock()
|
||||||
|
|
||||||
|
event = t.telemetryShortName + "_" + event
|
||||||
|
if !t.config.EnabledTracking || t.client == nil {
|
||||||
|
t.debugf("Plugin telemetry event `%s` tracked, but not sent due to configuration", event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if properties == nil {
|
||||||
|
properties = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
properties["PluginID"] = t.pluginID
|
||||||
|
properties["PluginVersion"] = t.pluginVersion
|
||||||
|
properties["ServerVersion"] = t.serverVersion
|
||||||
|
|
||||||
|
// if we are part of a cloud installation, add it's ID to the tracked event's context.
|
||||||
|
installationID := os.Getenv("MM_CLOUD_INSTALLATION_ID")
|
||||||
|
|
||||||
|
err := t.client.Enqueue(Track{
|
||||||
|
// We consider the server the "user" on the telemetry system. Any reference to the actual user is passed by properties.
|
||||||
|
UserID: t.diagnosticID,
|
||||||
|
Event: event,
|
||||||
|
Properties: properties,
|
||||||
|
InstallationID: installationID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot enqueue the track")
|
||||||
|
}
|
||||||
|
t.debugf("Tracked plugin telemetry event `%s`", event)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracker) TrackUserEvent(event, userID string, properties map[string]interface{}) error {
|
||||||
|
if properties == nil {
|
||||||
|
properties = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
properties["UserActualID"] = userID
|
||||||
|
return t.TrackEvent(event, properties)
|
||||||
|
}
|
||||||
85
server/public/pluginapi/file.go
Обычный файл
85
server/public/pluginapi/file.go
Обычный файл
@@ -0,0 +1,85 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileService exposes methods to manipulate files, most often as post attachments.
|
||||||
|
type FileService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets content of a file by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.8
|
||||||
|
func (f *FileService) Get(id string) (io.Reader, error) {
|
||||||
|
contentBytes, appErr := f.api.GetFile(id)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(contentBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByPath reads a file by its path on the dist.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.3
|
||||||
|
func (f *FileService) GetByPath(path string) (io.Reader, error) {
|
||||||
|
contentBytes, appErr := f.api.ReadFile(path)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(contentBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo gets a file's info by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.3
|
||||||
|
func (f *FileService) GetInfo(id string) (*model.FileInfo, error) {
|
||||||
|
info, appErr := f.api.GetFileInfo(id)
|
||||||
|
|
||||||
|
return info, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLink gets the public link of a file by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (f *FileService) GetLink(id string) (string, error) {
|
||||||
|
link, appErr := f.api.GetFileLink(id)
|
||||||
|
|
||||||
|
return link, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload uploads a file to a channel to be later attached to a post.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (f *FileService) Upload(content io.Reader, fileName, channelID string) (*model.FileInfo, error) {
|
||||||
|
contentBytes, err := io.ReadAll(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, appErr := f.api.UploadFile(contentBytes, channelID, fileName)
|
||||||
|
|
||||||
|
return info, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyInfos duplicates the FileInfo objects referenced by the given file ids, recording
|
||||||
|
// the given user id as the new creator and returning the new set of file ids.
|
||||||
|
//
|
||||||
|
// The duplicate FileInfo objects are not initially linked to a post, but may now be passed
|
||||||
|
// on creation of a post.
|
||||||
|
// Use this API to duplicate a post and its file attachments without actually duplicating
|
||||||
|
// the uploaded files.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (f *FileService) CopyInfos(ids []string, userID string) ([]string, error) {
|
||||||
|
newIDs, appErr := f.api.CopyFileInfos(userID, ids)
|
||||||
|
|
||||||
|
return newIDs, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
185
server/public/pluginapi/file_test.go
Обычный файл
185
server/public/pluginapi/file_test.go
Обычный файл
@@ -0,0 +1,185 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFile(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetFile", "1").Return([]byte{2}, nil)
|
||||||
|
|
||||||
|
content, err := client.File.Get("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
contentBytes, err := io.ReadAll(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []byte{2}, contentBytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetFile", "1").Return(nil, appErr)
|
||||||
|
|
||||||
|
content, err := client.File.Get("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileByPath(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("ReadFile", "1").Return([]byte{2}, nil)
|
||||||
|
|
||||||
|
content, err := client.File.GetByPath("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
contentBytes, err := io.ReadAll(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []byte{2}, contentBytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("ReadFile", "1").Return(nil, appErr)
|
||||||
|
|
||||||
|
content, err := client.File.GetByPath("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileInfo(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetFileInfo", "1").Return(&model.FileInfo{Id: "2"}, nil)
|
||||||
|
|
||||||
|
info, err := client.File.GetInfo("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2", info.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetFileInfo", "1").Return(nil, appErr)
|
||||||
|
|
||||||
|
info, err := client.File.GetInfo("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, info)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileLink(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("GetFileLink", "1").Return("2", nil)
|
||||||
|
|
||||||
|
link, err := client.File.GetLink("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2", link)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("GetFileLink", "1").Return("", appErr)
|
||||||
|
|
||||||
|
link, err := client.File.GetLink("1")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFile(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("UploadFile", []byte{1}, "3", "2").Return(&model.FileInfo{Id: "4"}, nil)
|
||||||
|
|
||||||
|
info, err := client.File.Upload(bytes.NewReader([]byte{1}), "2", "3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "4", info.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("UploadFile", []byte{1}, "3", "2").Return(nil, appErr)
|
||||||
|
|
||||||
|
info, err := client.File.Upload(bytes.NewReader([]byte{1}), "2", "3")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, info)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFileInfos(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("CopyFileInfos", "3", []string{"1", "2"}).Return([]string{"4", "5"}, nil)
|
||||||
|
|
||||||
|
newIDs, err := client.File.CopyInfos([]string{"1", "2"}, "3")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"4", "5"}, newIDs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failure", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
appErr := newAppError()
|
||||||
|
|
||||||
|
api.On("CopyFileInfos", "3", []string{"1", "2"}).Return(nil, appErr)
|
||||||
|
|
||||||
|
newIDs, err := client.File.CopyInfos([]string{"1", "2"}, "3")
|
||||||
|
require.Equal(t, appErr, err)
|
||||||
|
require.Zero(t, newIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
30
server/public/pluginapi/frontend.go
Обычный файл
30
server/public/pluginapi/frontend.go
Обычный файл
@@ -0,0 +1,30 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrontendService exposes methods to interact with the frontend.
|
||||||
|
type FrontendService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenInteractiveDialog will open an interactive dialog on a user's client that
|
||||||
|
// generated the trigger ID. Used with interactive message buttons, menus
|
||||||
|
// and slash commands.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (f *FrontendService) OpenInteractiveDialog(dialog model.OpenDialogRequest) error {
|
||||||
|
return normalizeAppErr(f.api.OpenInteractiveDialog(dialog))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishWebSocketEvent sends an event to WebSocket connections.
|
||||||
|
// event is the type and will be prepended with "custom_<pluginid>_".
|
||||||
|
// payload is the data sent with the event. Interface values must be primitive Go types or mattermost-server/model types.
|
||||||
|
// broadcast determines to which users to send the event.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (f *FrontendService) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *model.WebsocketBroadcast) {
|
||||||
|
f.api.PublishWebSocketEvent(event, payload, broadcast)
|
||||||
|
}
|
||||||
57
server/public/pluginapi/group.go
Обычный файл
57
server/public/pluginapi/group.go
Обычный файл
@@ -0,0 +1,57 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupService exposes methods to manipulate groups.
|
||||||
|
type GroupService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets a group by ID.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (g *GroupService) Get(groupID string) (*model.Group, error) {
|
||||||
|
group, appErr := g.api.GetGroup(groupID)
|
||||||
|
|
||||||
|
return group, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByName gets a group by name.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (g *GroupService) GetByName(name string) (*model.Group, error) {
|
||||||
|
group, appErr := g.api.GetGroupByName(name)
|
||||||
|
|
||||||
|
return group, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberUsers gets a page of users from the given group.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.35
|
||||||
|
func (g *GroupService) GetMemberUsers(groupID string, page, perPage int) ([]*model.User, error) {
|
||||||
|
users, appErr := g.api.GetGroupMemberUsers(groupID, page, perPage)
|
||||||
|
|
||||||
|
return users, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBySource gets a list of all groups for the given source.
|
||||||
|
//
|
||||||
|
// @tag Group
|
||||||
|
// Minimum server version: 5.35
|
||||||
|
func (g *GroupService) GetBySource(groupSource model.GroupSource) ([]*model.Group, error) {
|
||||||
|
groups, appErr := g.api.GetGroupsBySource(groupSource)
|
||||||
|
|
||||||
|
return groups, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUser gets the groups a user is in.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (g *GroupService) ListForUser(userID string) ([]*model.Group, error) {
|
||||||
|
groups, appErr := g.api.GetGroupsForUser(userID)
|
||||||
|
|
||||||
|
return groups, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
2
server/public/pluginapi/i18n/doc.go
Обычный файл
2
server/public/pluginapi/i18n/doc.go
Обычный файл
@@ -0,0 +1,2 @@
|
|||||||
|
// package i18n provides methods to read translations files and localize strings.
|
||||||
|
package i18n
|
||||||
136
server/public/pluginapi/i18n/i18n.go
Обычный файл
136
server/public/pluginapi/i18n/i18n.go
Обычный файл
@@ -0,0 +1,136 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginAPI is the plugin API interface required to manage translations.
|
||||||
|
type PluginAPI interface {
|
||||||
|
GetBundlePath() (string, error)
|
||||||
|
GetConfig() *model.Config
|
||||||
|
GetUser(userID string) (*model.User, *model.AppError)
|
||||||
|
LogWarn(msg string, keyValuePairs ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is a string that can be localized.
|
||||||
|
//
|
||||||
|
// See https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n?tab=doc#Message for more details.
|
||||||
|
type Message = i18n.Message
|
||||||
|
|
||||||
|
// LocalizeConfig configures a call to the Localize method on Localizer.
|
||||||
|
//
|
||||||
|
// See https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n?tab=doc#LocalizeConfig for more details.
|
||||||
|
type LocalizeConfig = i18n.LocalizeConfig
|
||||||
|
|
||||||
|
// Localizer provides Localize and MustLocalize methods that return localized messages.
|
||||||
|
//
|
||||||
|
// See https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2/i18n?tab=doc#Localizer for more details.
|
||||||
|
type Localizer = i18n.Localizer
|
||||||
|
|
||||||
|
// Bundle stores a set of messages and pluralization rules.
|
||||||
|
// Most plugins only need a single bundle
|
||||||
|
// that is initialized on activation.
|
||||||
|
// It is not goroutine safe to modify the bundle while Localizers
|
||||||
|
// are reading from it.
|
||||||
|
type Bundle struct {
|
||||||
|
*i18n.Bundle
|
||||||
|
api PluginAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitBundle loads all localization files from a given path into a bundle and return this.
|
||||||
|
// path is a relative path in the plugin bundle, e.g. assets/i18n.
|
||||||
|
// Every file except the ones named active.*.json.
|
||||||
|
// The default language is English.
|
||||||
|
func InitBundle(api PluginAPI, path string) (*Bundle, error) {
|
||||||
|
bundle := &Bundle{
|
||||||
|
Bundle: i18n.NewBundle(language.English),
|
||||||
|
api: api,
|
||||||
|
}
|
||||||
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
|
|
||||||
|
bundlePath, err := api.GetBundlePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get bundle path")
|
||||||
|
}
|
||||||
|
|
||||||
|
i18nDir := filepath.Join(bundlePath, path)
|
||||||
|
|
||||||
|
files, err := os.ReadDir(i18nDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to open i18n directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !strings.HasPrefix(file.Name(), "active.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(file.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Name() == "active.en.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = bundle.LoadMessageFile(filepath.Join(i18nDir, file.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to load message file %s", file.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserLocalizer returns a localizer that localizes in the users locale.
|
||||||
|
func (b *Bundle) GetUserLocalizer(userID string) *i18n.Localizer {
|
||||||
|
user, err := b.api.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
b.api.LogWarn("Failed get user's locale", "error", err.Error())
|
||||||
|
return b.GetServerLocalizer()
|
||||||
|
}
|
||||||
|
|
||||||
|
return i18n.NewLocalizer(b.Bundle, user.Locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerLocalizer returns a localizer that localizes in the default server locale.
|
||||||
|
//
|
||||||
|
// This is useful for situations where a messages is shown to every user,
|
||||||
|
// independent of the users locale.
|
||||||
|
func (b *Bundle) GetServerLocalizer() *i18n.Localizer {
|
||||||
|
local := *b.api.GetConfig().LocalizationSettings.DefaultServerLocale
|
||||||
|
|
||||||
|
return i18n.NewLocalizer(b.Bundle, local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalizeDefaultMessage localizer the provided message.
|
||||||
|
// An empty string is returned when the localization fails.
|
||||||
|
func (b *Bundle) LocalizeDefaultMessage(l *Localizer, m *Message) string {
|
||||||
|
s, err := l.LocalizeMessage(m)
|
||||||
|
if err != nil {
|
||||||
|
b.api.LogWarn("Failed to localize message", "message ID", m.ID, "error", err.Error())
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalizeWithConfig localizer the provided localize config.
|
||||||
|
// An empty string is returned when the localization fails.
|
||||||
|
func (b *Bundle) LocalizeWithConfig(l *Localizer, lc *LocalizeConfig) string {
|
||||||
|
s, err := l.Localize(lc)
|
||||||
|
if err != nil {
|
||||||
|
b.api.LogWarn("Failed to localize with config", "error", err.Error())
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
296
server/public/pluginapi/i18n/i18n_test.go
Обычный файл
296
server/public/pluginapi/i18n/i18n_test.go
Обычный файл
@@ -0,0 +1,296 @@
|
|||||||
|
package i18n_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:govet
|
||||||
|
func ExampleInitBundle() {
|
||||||
|
type Plugin struct {
|
||||||
|
plugin.MattermostPlugin
|
||||||
|
|
||||||
|
b *i18n.Bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
p := Plugin{}
|
||||||
|
b, err := i18n.InitBundle(p.API, filepath.Join("assets", "i18n"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitBundle(t *testing.T) {
|
||||||
|
t.Run("fine", func(t *testing.T) {
|
||||||
|
dir, err := os.MkdirTemp("", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
// Create assets/i18n dir
|
||||||
|
i18nDir := filepath.Join(dir, "assets", "i18n")
|
||||||
|
err = os.MkdirAll(i18nDir, 0o700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file := filepath.Join(i18nDir, "active.de.json")
|
||||||
|
content := []byte("{}")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add en translation file.
|
||||||
|
// InitBundle should ignore it.
|
||||||
|
file = filepath.Join(i18nDir, "active.en.json")
|
||||||
|
content = []byte("")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add json junk file
|
||||||
|
file = filepath.Join(i18nDir, "foo.json")
|
||||||
|
content = []byte("")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add active. junk file
|
||||||
|
file = filepath.Join(i18nDir, "active.foo")
|
||||||
|
content = []byte("")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetBundlePath").Return(dir, nil)
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, "assets/i18n")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, b)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []language.Tag{language.English, language.German}, b.LanguageTags())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fine", func(t *testing.T) {
|
||||||
|
dir, err := os.MkdirTemp("", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
// Create assets/i18n dir
|
||||||
|
i18nDir := filepath.Join(dir, "assets", "i18n")
|
||||||
|
err = os.MkdirAll(i18nDir, 0o700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
file := filepath.Join(i18nDir, "active.de.json")
|
||||||
|
content := []byte("{}")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add translation file with invalid content
|
||||||
|
file = filepath.Join(i18nDir, "active.es.json")
|
||||||
|
content = []byte("foo bar")
|
||||||
|
err = os.WriteFile(file, content, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetBundlePath").Return(dir, nil)
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, "assets/i18n")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizeDefaultMessage(t *testing.T) {
|
||||||
|
t.Run("fine", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "en"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetServerLocalizer()
|
||||||
|
m := &i18n.Message{
|
||||||
|
Other: "test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, m.Other, b.LocalizeDefaultMessage(l, m))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty message", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "en"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
api.On("LogWarn", mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return()
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetServerLocalizer()
|
||||||
|
m := &i18n.Message{}
|
||||||
|
|
||||||
|
assert.Equal(t, "", b.LocalizeDefaultMessage(l, m))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizeWithConfig(t *testing.T) {
|
||||||
|
t.Run("fine", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "en"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetServerLocalizer()
|
||||||
|
lc := &i18n.LocalizeConfig{
|
||||||
|
DefaultMessage: &i18n.Message{
|
||||||
|
Other: "test messsage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, lc.DefaultMessage.Other, b.LocalizeWithConfig(l, lc))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty config", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "en"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
api.On("LogWarn", mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return()
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetServerLocalizer()
|
||||||
|
lc := &i18n.LocalizeConfig{}
|
||||||
|
|
||||||
|
assert.Equal(t, "", b.LocalizeWithConfig(l, lc))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty message", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "en"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
api.On("LogWarn", mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return()
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetServerLocalizer()
|
||||||
|
lc := &i18n.LocalizeConfig{
|
||||||
|
DefaultMessage: &i18n.Message{},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "", b.LocalizeWithConfig(l, lc))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func TestGetUserLocalizer(t *testing.T) {
|
||||||
|
t.Run("fine", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetUser", "userID").Return(&model.User{
|
||||||
|
Locale: "de",
|
||||||
|
}, nil)
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetUserLocalizer("userID")
|
||||||
|
assert.NotNil(t, l)
|
||||||
|
|
||||||
|
enMessage := &i18n.Message{
|
||||||
|
Other: "a",
|
||||||
|
}
|
||||||
|
|
||||||
|
deMessage := &i18n.Message{
|
||||||
|
Other: "b",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.Bundle.AddMessages(language.German, deMessage)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, deMessage.Other, b.LocalizeDefaultMessage(l, enMessage))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defaultServerLocale := "es"
|
||||||
|
api.On("GetConfig").Return(&model.Config{
|
||||||
|
LocalizationSettings: model.LocalizationSettings{
|
||||||
|
DefaultServerLocale: &defaultServerLocale,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
api.On("GetBundlePath").Return(".", nil)
|
||||||
|
api.On("GetUser", "userID").Return(nil, &model.AppError{})
|
||||||
|
api.On("LogWarn", mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string"),
|
||||||
|
mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return()
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
|
||||||
|
b, err := i18n.InitBundle(api, ".")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l := b.GetUserLocalizer("userID")
|
||||||
|
assert.NotNil(t, l)
|
||||||
|
|
||||||
|
enMessage := &i18n.Message{
|
||||||
|
Other: "a",
|
||||||
|
}
|
||||||
|
|
||||||
|
esMessage := &i18n.Message{
|
||||||
|
Other: "b",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.Bundle.AddMessages(language.Spanish, esMessage)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, esMessage.Other, b.LocalizeDefaultMessage(l, enMessage))
|
||||||
|
})
|
||||||
|
}
|
||||||
319
server/public/pluginapi/kv.go
Обычный файл
319
server/public/pluginapi/kv.go
Обычный файл
@@ -0,0 +1,319 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// numRetries is the number of times the setAtomicWithRetries will retry before returning an error.
|
||||||
|
const numRetries = 5
|
||||||
|
|
||||||
|
// KVService exposes methods to read and write key-value pairs for the active plugin.
|
||||||
|
//
|
||||||
|
// This service cannot be used to read or write key-value pairs for other plugins.
|
||||||
|
type KVService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Should this be un exported?
|
||||||
|
type KVSetOptions struct {
|
||||||
|
model.PluginKVSetOptions
|
||||||
|
oldValue interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KVSetOption is an option passed to Set() operation.
|
||||||
|
type KVSetOption func(*KVSetOptions)
|
||||||
|
|
||||||
|
// SetAtomic guarantees the write will occur only when the current value of matches the given old
|
||||||
|
// value. A client is expected to read the old value first, then pass it back to ensure the value
|
||||||
|
// has not since been modified.
|
||||||
|
func SetAtomic(oldValue interface{}) KVSetOption {
|
||||||
|
return func(o *KVSetOptions) {
|
||||||
|
o.Atomic = true
|
||||||
|
o.oldValue = oldValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExpiry configures a key value to expire after the given duration relative to now.
|
||||||
|
func SetExpiry(ttl time.Duration) KVSetOption {
|
||||||
|
return func(o *KVSetOptions) {
|
||||||
|
o.ExpireInSeconds = int64(ttl / time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a key-value pair, unique per plugin.
|
||||||
|
// Keys prefixed with `mmi_` are reserved for use by this package and will fail to be set.
|
||||||
|
//
|
||||||
|
// Returns (false, err) if DB error occurred
|
||||||
|
// Returns (false, nil) if the value was not set
|
||||||
|
// Returns (true, nil) if the value was set
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) Set(key string, value interface{}, options ...KVSetOption) (bool, error) {
|
||||||
|
if strings.HasPrefix(key, "mmi_") {
|
||||||
|
return false, errors.New("'mmi_' prefix is not allowed for keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := KVSetOptions{}
|
||||||
|
for _, o := range options {
|
||||||
|
o(&opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueBytes []byte
|
||||||
|
if value != nil {
|
||||||
|
// Assume JSON encoding, unless explicitly given a byte slice.
|
||||||
|
var isValueInBytes bool
|
||||||
|
valueBytes, isValueInBytes = value.([]byte)
|
||||||
|
if !isValueInBytes {
|
||||||
|
var err error
|
||||||
|
valueBytes, err = json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrapf(err, "failed to marshal value %v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downstreamOpts := model.PluginKVSetOptions{
|
||||||
|
Atomic: opts.Atomic,
|
||||||
|
ExpireInSeconds: opts.ExpireInSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.oldValue != nil {
|
||||||
|
oldValueBytes, isOldValueInBytes := opts.oldValue.([]byte)
|
||||||
|
if isOldValueInBytes {
|
||||||
|
downstreamOpts.OldValue = oldValueBytes
|
||||||
|
} else {
|
||||||
|
data, err := json.Marshal(opts.oldValue)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrapf(err, "failed to marshal value %v", opts.oldValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
downstreamOpts.OldValue = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written, appErr := k.api.KVSetWithOptions(key, valueBytes, downstreamOpts)
|
||||||
|
return written, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWithExpiry sets a key-value pair with the given expiration duration relative to now.
|
||||||
|
//
|
||||||
|
// Deprecated: SetWithExpiry exists to streamline adoption of this package for existing plugins.
|
||||||
|
// Use Set with the appropriate options instead.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) SetWithExpiry(key string, value interface{}, ttl time.Duration) error {
|
||||||
|
_, err := k.Set(key, value, SetExpiry(ttl))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareAndSet writes a key-value pair if the current value matches the given old value.
|
||||||
|
//
|
||||||
|
// Returns (false, err) if DB error occurred
|
||||||
|
// Returns (false, nil) if the value was not set
|
||||||
|
// Returns (true, nil) if the value was set
|
||||||
|
//
|
||||||
|
// Deprecated: CompareAndSet exists to streamline adoption of this package for existing plugins.
|
||||||
|
// Use Set with the appropriate options instead.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) CompareAndSet(key string, oldValue, value interface{}) (bool, error) {
|
||||||
|
return k.Set(key, value, SetAtomic(oldValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareAndDelete deletes a key-value pair if the current value matches the given old value.
|
||||||
|
//
|
||||||
|
// Returns (false, err) if DB error occurred
|
||||||
|
// Returns (false, nil) if current value != oldValue or key does not exist when deleting
|
||||||
|
// Returns (true, nil) if current value == oldValue and the key was deleted
|
||||||
|
//
|
||||||
|
// Deprecated: CompareAndDelete exists to streamline adoption of this package for existing plugins.
|
||||||
|
// Use Set with the appropriate options instead.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) CompareAndDelete(key string, oldValue interface{}) (bool, error) {
|
||||||
|
return k.Set(key, nil, SetAtomic(oldValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAtomicWithRetries will set a key-value pair atomically using compare and set semantics:
|
||||||
|
// it will read key's value (to get oldValue), perform valueFunc (to get newValue),
|
||||||
|
// and compare and set (comparing oldValue and setting newValue).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// `key` is the key to get and set.
|
||||||
|
// `valueFunc` is a user-provided function that will take the old value as a []byte and
|
||||||
|
// return the new value or an error. If valueFunc needs to operate on
|
||||||
|
// oldValue, it will need to use the oldValue as a []byte, or convert
|
||||||
|
// oldValue into the expected type (e.g., by parsing it, or marshaling it
|
||||||
|
// into the expected struct). It should then return the newValue as the type
|
||||||
|
// expected to be stored.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
//
|
||||||
|
// Returns err if the key could not be retrieved (DB error), valueFunc returned an error,
|
||||||
|
// if the key could not be set (DB error), or if the key could not be set (after retries).
|
||||||
|
// Returns nil if the value was set.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) SetAtomicWithRetries(key string, valueFunc func(oldValue []byte) (newValue interface{}, err error)) error {
|
||||||
|
for i := 0; i < numRetries; i++ {
|
||||||
|
var oldVal []byte
|
||||||
|
if err := k.Get(key, &oldVal); err != nil {
|
||||||
|
return errors.Wrapf(err, "failed to get value for key %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
newVal, err := valueFunc(oldVal)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "valueFunc failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if saved, err := k.Set(key, newVal, SetAtomic(oldVal)); err != nil {
|
||||||
|
return errors.Wrapf(err, "DB failed to set value for key %s", key)
|
||||||
|
} else if saved {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// small delay to allow cooperative scheduling to do its thing
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to set value after %d retries", numRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets the value for the given key into the given interface.
|
||||||
|
//
|
||||||
|
// An error is returned only if the value cannot be fetched. A non-existent key will return no
|
||||||
|
// error, with nothing written to the given interface.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.2
|
||||||
|
func (k *KVService) Get(key string, o interface{}) error {
|
||||||
|
data, appErr := k.api.KVGet(key)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytesOut, ok := o.(*[]byte); ok {
|
||||||
|
*bytesOut = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, o); err != nil {
|
||||||
|
return errors.Wrapf(err, "failed to unmarshal value for key %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes the given key-value pair.
|
||||||
|
//
|
||||||
|
// An error is returned only if the value failed to be deleted. A non-existent key will return
|
||||||
|
// no error.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (k *KVService) Delete(key string) error {
|
||||||
|
_, err := k.Set(key, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAll removes all key-value pairs.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (k *KVService) DeleteAll() error {
|
||||||
|
return normalizeAppErr(k.api.KVDeleteAll())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListKeysOption used to configure a ListKeys() operation.
|
||||||
|
type ListKeysOption func(*listKeysOptions)
|
||||||
|
|
||||||
|
// listKeysOptions holds configurations of a ListKeys() operation.
|
||||||
|
type listKeysOptions struct {
|
||||||
|
checkers []func(key string) (keep bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *listKeysOptions) checkAll(key string) (keep bool, err error) {
|
||||||
|
for _, check := range o.checkers {
|
||||||
|
keep, err := check(key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !keep {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// key made it through all checkers
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix only return keys that start with the given string.
|
||||||
|
func WithPrefix(prefix string) ListKeysOption {
|
||||||
|
return WithChecker(func(key string) (keep bool, err error) {
|
||||||
|
return strings.HasPrefix(key, prefix), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithChecker allows for a custom filter function to determine which keys to return.
|
||||||
|
// Returning true will keep the key and false will filter it out. Returning an error
|
||||||
|
// will halt KVListWithOptions immediately and pass the error up (with no other results).
|
||||||
|
func WithChecker(f func(key string) (keep bool, err error)) ListKeysOption {
|
||||||
|
return func(args *listKeysOptions) {
|
||||||
|
args.checkers = append(args.checkers, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListKeys lists all keys that match the given options. If no options are provided then all keys are returned.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (k *KVService) ListKeys(page, count int, options ...ListKeysOption) ([]string, error) {
|
||||||
|
// convert functional options into args struct
|
||||||
|
args := &listKeysOptions{
|
||||||
|
checkers: nil,
|
||||||
|
}
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get our keys a batch at a time, filter out the ones we don't want based on our args
|
||||||
|
// any errors will hault the whole process and return the error raw
|
||||||
|
|
||||||
|
keys, appErr := k.api.KVList(page, count)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args.checkers) == 0 {
|
||||||
|
// no checkers, just return the keys
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := make([]string, 0)
|
||||||
|
// we have a filter, so check each key, all checkers must say key
|
||||||
|
// for us to keep a key
|
||||||
|
for _, key := range keys {
|
||||||
|
keep, err := args.checkAll(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// didn't get filtered out, add to our return
|
||||||
|
ret = append(ret, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
681
server/public/pluginapi/kv_test.go
Обычный файл
681
server/public/pluginapi/kv_test.go
Обычный файл
@@ -0,0 +1,681 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAppError() *model.AppError {
|
||||||
|
return model.NewAppError("here", "id", nil, "an error occurred", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKVSet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value interface{}
|
||||||
|
options []pluginapi.KVSetOption
|
||||||
|
expectedValue []byte
|
||||||
|
expectedOptions model.PluginKVSetOptions
|
||||||
|
upserted bool
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"[]byte value",
|
||||||
|
"1",
|
||||||
|
2,
|
||||||
|
[]pluginapi.KVSetOption{},
|
||||||
|
[]byte(`2`),
|
||||||
|
model.PluginKVSetOptions{},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"string value",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
[]pluginapi.KVSetOption{},
|
||||||
|
[]byte(`"2"`),
|
||||||
|
model.PluginKVSetOptions{},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"struct value",
|
||||||
|
"1",
|
||||||
|
struct{ A string }{"2"},
|
||||||
|
[]pluginapi.KVSetOption{},
|
||||||
|
[]byte(`{"A":"2"}`),
|
||||||
|
model.PluginKVSetOptions{},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"compare and set []byte value",
|
||||||
|
"1",
|
||||||
|
[]byte{2},
|
||||||
|
[]pluginapi.KVSetOption{
|
||||||
|
pluginapi.SetAtomic([]byte{3}),
|
||||||
|
},
|
||||||
|
[]byte{2},
|
||||||
|
model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte{3},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"compare and set string value",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
[]pluginapi.KVSetOption{
|
||||||
|
pluginapi.SetAtomic("3"),
|
||||||
|
},
|
||||||
|
[]byte(`"2"`),
|
||||||
|
model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte(`"3"`),
|
||||||
|
}, true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"value is nil",
|
||||||
|
"1",
|
||||||
|
nil,
|
||||||
|
[]pluginapi.KVSetOption{},
|
||||||
|
nil,
|
||||||
|
model.PluginKVSetOptions{},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"current value is nil",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
[]pluginapi.KVSetOption{
|
||||||
|
pluginapi.SetAtomic(nil),
|
||||||
|
},
|
||||||
|
[]byte(`"2"`),
|
||||||
|
model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: nil,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"value is nil, current value is []byte",
|
||||||
|
"1",
|
||||||
|
nil,
|
||||||
|
[]pluginapi.KVSetOption{
|
||||||
|
pluginapi.SetAtomic([]byte{3}),
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte{3},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
}, {
|
||||||
|
"error",
|
||||||
|
"1",
|
||||||
|
[]byte{2},
|
||||||
|
[]pluginapi.KVSetOption{},
|
||||||
|
[]byte{2},
|
||||||
|
model.PluginKVSetOptions{},
|
||||||
|
false,
|
||||||
|
newAppError(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVSetWithOptions", test.key, test.expectedValue, test.expectedOptions).Return(test.upserted, test.err)
|
||||||
|
|
||||||
|
upserted, err := client.KV.Set(test.key, test.value, test.options...)
|
||||||
|
if test.err != nil {
|
||||||
|
require.Error(t, err, test.name)
|
||||||
|
require.False(t, upserted, test.name)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err, test.name)
|
||||||
|
assert.True(t, upserted, test.name)
|
||||||
|
}
|
||||||
|
api.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetWithExpiry(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVSetWithOptions", "1", []byte(`2`), model.PluginKVSetOptions{
|
||||||
|
ExpireInSeconds: 60,
|
||||||
|
}).Return(true, nil)
|
||||||
|
|
||||||
|
err := client.KV.SetWithExpiry("1", 2, time.Minute)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAndSet(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVSetWithOptions", "1", []byte("2"), model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte("3"),
|
||||||
|
}).Return(true, nil)
|
||||||
|
|
||||||
|
upserted, err := client.KV.CompareAndSet("1", 3, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, upserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareAndDelete(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVSetWithOptions", "1", []byte(nil), model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: []byte("2"),
|
||||||
|
}).Return(true, nil)
|
||||||
|
|
||||||
|
deleted, err := client.KV.CompareAndDelete("1", 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAtomicWithRetries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
valueFunc func(t *testing.T) func(old []byte) (interface{}, error)
|
||||||
|
setupAPI func(api *plugintest.API)
|
||||||
|
wantErr bool
|
||||||
|
expectedErrPrefix string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries success after first attempt",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(true, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test success after first attempt, old is struct and as expected",
|
||||||
|
key: "testNum2",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
type toStore struct {
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
var fromDB toStore
|
||||||
|
if err := json.Unmarshal(old, &fromDB); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, fromDB.Value, "old not as expected")
|
||||||
|
return toStore{2}, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
type toStore struct {
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
oldJSONBytes, _ := json.Marshal(toStore{1})
|
||||||
|
newJSONBytes, _ := json.Marshal(toStore{2})
|
||||||
|
api.On("KVGet", "testNum2").Return(oldJSONBytes, nil)
|
||||||
|
api.On("KVSetWithOptions", "testNum2", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(true, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test success after first attempt, old is an int value and as expected",
|
||||||
|
key: "testNum2",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
fromDB, err := strconv.Atoi(string(old))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, fromDB, "old not as expected")
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum2").Return(oldJSONBytes, nil)
|
||||||
|
api.On("KVSetWithOptions", "testNum2", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(true, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries success on fourth attempt",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil).Times(4)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(false, nil).Times(3)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(true, nil).Once()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries success on fourth attempt because value was changed between calls to KVGet",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil).Times(4)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(false, nil).Times(3)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(true, nil).Once()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries failure on get",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return nil, errors.New("should not have got here")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
api.On("KVGet", "testNum").Return(nil, newAppError()).Once()
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
expectedErrPrefix: "failed to get value for key testNum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries failure on valueFunc",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return nil, errors.New("some user provided error")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil).Once()
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
expectedErrPrefix: "valueFunc failed: some user provided error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries DB failure on set",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(false, newAppError()).Once()
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
expectedErrPrefix: "DB failed to set value for key testNum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries failure on five set attempts -- depends on numRetries constant being = 5",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
oldJSONBytes, _ := json.Marshal(1)
|
||||||
|
newJSONBytes, _ := json.Marshal(2)
|
||||||
|
api.On("KVGet", "testNum").Return(oldJSONBytes, nil).Times(5)
|
||||||
|
api.On("KVSetWithOptions", "testNum", newJSONBytes, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: oldJSONBytes,
|
||||||
|
}).Return(false, nil).Times(5)
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
expectedErrPrefix: "failed to set value after 5 retries",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Test SetAtomicWithRetries success after five set attempts -- depends on numRetries constant being = 5",
|
||||||
|
key: "testNum",
|
||||||
|
valueFunc: func(t *testing.T) func(old []byte) (interface{}, error) {
|
||||||
|
return func(old []byte) (interface{}, error) {
|
||||||
|
fromDB, err := strconv.Atoi(string(old))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fromDB + 1, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setupAPI: func(api *plugintest.API) {
|
||||||
|
i1, _ := json.Marshal(1)
|
||||||
|
i2, _ := json.Marshal(2)
|
||||||
|
i3, _ := json.Marshal(3)
|
||||||
|
i4, _ := json.Marshal(4)
|
||||||
|
i5, _ := json.Marshal(5)
|
||||||
|
i6, _ := json.Marshal(6)
|
||||||
|
api.On("KVGet", "testNum").Return(i1, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", i2, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: i1,
|
||||||
|
}).Return(false, nil).Once()
|
||||||
|
api.On("KVGet", "testNum").Return(i2, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", i3, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: i2,
|
||||||
|
}).Return(false, nil).Once()
|
||||||
|
api.On("KVGet", "testNum").Return(i3, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", i4, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: i3,
|
||||||
|
}).Return(false, nil).Once()
|
||||||
|
api.On("KVGet", "testNum").Return(i4, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", i5, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: i4,
|
||||||
|
}).Return(false, nil).Once()
|
||||||
|
api.On("KVGet", "testNum").Return(i5, nil).Once()
|
||||||
|
api.On("KVSetWithOptions", "testNum", i6, model.PluginKVSetOptions{
|
||||||
|
Atomic: true,
|
||||||
|
OldValue: i5,
|
||||||
|
}).Return(true, nil).Once()
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
tt.setupAPI(api)
|
||||||
|
|
||||||
|
err := client.KV.SetAtomicWithRetries(tt.key, tt.valueFunc(t))
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("SetAtomicWithRetries() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(err.Error(), tt.expectedErrPrefix) {
|
||||||
|
t.Errorf("SetAtomicWithRetries() error = %s, expected prefix = %s", err, tt.expectedErrPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
aStringJSON, _ := json.Marshal("2")
|
||||||
|
|
||||||
|
api.On("KVGet", "1").Return(aStringJSON, nil)
|
||||||
|
|
||||||
|
var out string
|
||||||
|
err := client.KV.Get("1", &out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "2", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNilKey(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVGet", "1").Return(nil, nil)
|
||||||
|
|
||||||
|
var out string
|
||||||
|
err := client.KV.Get("1", &out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInBytes(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVGet", "1").Return([]byte{2}, nil)
|
||||||
|
|
||||||
|
var out []byte
|
||||||
|
err := client.KV.Get("1", &out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte{2}, out)
|
||||||
|
api.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVSetWithOptions", "1", []byte(nil), model.PluginKVSetOptions{}).Return(true, nil)
|
||||||
|
|
||||||
|
err := client.KV.Delete("1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAll(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVDeleteAll").Return(nil)
|
||||||
|
|
||||||
|
err := client.KV.DeleteAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListKeys(t *testing.T) {
|
||||||
|
t.Run("No keys", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(nil, nil)
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100)
|
||||||
|
|
||||||
|
assert.Empty(t, keys)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Basic Success, one page", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 1, 2).Return(getKeys(2), nil)
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, getKeys(2), keys)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, filter prefix, one", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithPrefix("key99"))
|
||||||
|
assert.ElementsMatch(t, keys, []string{"key99"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, filter prefix, all", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithPrefix("notkey"))
|
||||||
|
assert.Empty(t, keys)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, filter prefix, none", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithPrefix("key"))
|
||||||
|
assert.ElementsMatch(t, keys, getKeys(100))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, checker func, one", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
check := func(key string) (bool, error) {
|
||||||
|
if key == "key1" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithChecker(check))
|
||||||
|
assert.ElementsMatch(t, keys, []string{"key1"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, checker func, all", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
check := func(key string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithChecker(check))
|
||||||
|
assert.Empty(t, keys)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, two page, checker func, none", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return(getKeys(100), nil)
|
||||||
|
|
||||||
|
check := func(key string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithChecker(check))
|
||||||
|
assert.ElementsMatch(t, keys, getKeys(100))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error, checker func", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return([]string{"key1"}, nil)
|
||||||
|
|
||||||
|
check := func(key string) (bool, error) {
|
||||||
|
return true, &model.AppError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithChecker(check))
|
||||||
|
assert.Empty(t, keys)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success, filter and checker func, partial on both", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
api.On("KVList", 0, 100).Return([]string{"key1", "key2", "notkey3", "key4", "key5"}, nil)
|
||||||
|
|
||||||
|
check := func(key string) (bool, error) {
|
||||||
|
if key == "key1" || key == "key5" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := client.KV.ListKeys(0, 100, pluginapi.WithPrefix("key"), pluginapi.WithChecker(check))
|
||||||
|
assert.ElementsMatch(t, keys, []string{"key2", "key4"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKeys(count int) []string {
|
||||||
|
ret := make([]string, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
ret[i] = "key" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
107
server/public/pluginapi/license.go
Обычный файл
107
server/public/pluginapi/license.go
Обычный файл
@@ -0,0 +1,107 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
e10 = "E10"
|
||||||
|
e20 = "E20"
|
||||||
|
professional = "professional"
|
||||||
|
enterprise = "enterprise"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsEnterpriseLicensedOrDevelopment returns true when the server is licensed with any Mattermost
|
||||||
|
// Enterprise License, or has `EnableDeveloper` and `EnableTesting` configuration settings
|
||||||
|
// enabled signaling a non-production, developer mode.
|
||||||
|
func IsEnterpriseLicensedOrDevelopment(config *model.Config, license *model.License) bool {
|
||||||
|
if license != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsConfiguredForDevelopment(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSkuShortName returns whether the SKU short name is one of the known strings;
|
||||||
|
// namely: E10 or professional, or E20 or enterprise
|
||||||
|
func isValidSkuShortName(license *model.License) bool {
|
||||||
|
if license == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch license.SkuShortName {
|
||||||
|
case e10, e20, professional, enterprise:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsE10LicensedOrDevelopment returns true when the server is at least licensed with a legacy Mattermost
|
||||||
|
// Enterprise E10 License or a Mattermost Professional License, or has `EnableDeveloper` and
|
||||||
|
// `EnableTesting` configuration settings enabled, signaling a non-production, developer mode.
|
||||||
|
func IsE10LicensedOrDevelopment(config *model.Config, license *model.License) bool {
|
||||||
|
if license != nil &&
|
||||||
|
(license.SkuShortName == e10 || license.SkuShortName == professional ||
|
||||||
|
license.SkuShortName == e20 || license.SkuShortName == enterprise) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidSkuShortName(license) {
|
||||||
|
// As a fallback for licenses whose SKU short name is unknown, make a best effort to try
|
||||||
|
// and use the presence of a known E10/Professional feature as a check to determine licensing.
|
||||||
|
if license != nil &&
|
||||||
|
license.Features != nil &&
|
||||||
|
license.Features.LDAP != nil &&
|
||||||
|
*license.Features.LDAP {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsConfiguredForDevelopment(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsE20LicensedOrDevelopment returns true when the server is licensed with a legacy Mattermost
|
||||||
|
// Enterprise E20 License or a Mattermost Enterprise License, or has `EnableDeveloper` and
|
||||||
|
// `EnableTesting` configuration settings enabled, signaling a non-production, developer mode.
|
||||||
|
func IsE20LicensedOrDevelopment(config *model.Config, license *model.License) bool {
|
||||||
|
if license != nil && (license.SkuShortName == e20 || license.SkuShortName == enterprise) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidSkuShortName(license) {
|
||||||
|
// As a fallback for licenses whose SKU short name is unknown, make a best effort to try
|
||||||
|
// and use the presence of a known E20/Enterprise feature as a check to determine licensing.
|
||||||
|
if license != nil &&
|
||||||
|
license.Features != nil &&
|
||||||
|
license.Features.FutureFeatures != nil &&
|
||||||
|
*license.Features.FutureFeatures {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsConfiguredForDevelopment(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfiguredForDevelopment returns true when the server has `EnableDeveloper` and `EnableTesting`
|
||||||
|
// configuration settings enabled, signaling a non-production, developer mode.
|
||||||
|
func IsConfiguredForDevelopment(config *model.Config) bool {
|
||||||
|
if config != nil &&
|
||||||
|
config.ServiceSettings.EnableTesting != nil &&
|
||||||
|
*config.ServiceSettings.EnableTesting &&
|
||||||
|
config.ServiceSettings.EnableDeveloper != nil &&
|
||||||
|
*config.ServiceSettings.EnableDeveloper {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCloud returns true when the server is on cloud, and false otherwise.
|
||||||
|
func IsCloud(license *model.License) bool {
|
||||||
|
if license == nil || license.Features == nil || license.Features.Cloud == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return *license.Features.Cloud
|
||||||
|
}
|
||||||
330
server/public/pluginapi/license_test.go
Обычный файл
330
server/public/pluginapi/license_test.go
Обычный файл
@@ -0,0 +1,330 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsEnterpriseLicensedOrDevelopment(t *testing.T) {
|
||||||
|
t.Run("license, no config", func(t *testing.T) {
|
||||||
|
assert.True(t, IsEnterpriseLicensedOrDevelopment(nil, &model.License{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license, nil config", func(t *testing.T) {
|
||||||
|
assert.True(t, IsEnterpriseLicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: nil, EnableTesting: nil}},
|
||||||
|
&model.License{},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, no config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsEnterpriseLicensedOrDevelopment(nil, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, nil config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsEnterpriseLicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: nil, EnableTesting: nil}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only developer mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsEnterpriseLicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(false)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only testing mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsEnterpriseLicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(false), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, developer and testing mode", func(t *testing.T) {
|
||||||
|
assert.True(t, IsEnterpriseLicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsE20LicensedOrDevelopment(t *testing.T) {
|
||||||
|
t.Run("nil license features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{Features: &model.Features{}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{Features: &model.Features{
|
||||||
|
FutureFeatures: bToP(false),
|
||||||
|
}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("enabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{Features: &model.Features{
|
||||||
|
FutureFeatures: bToP(true),
|
||||||
|
}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, no config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, nil config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: nil, EnableTesting: nil}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only developer mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(false)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only testing mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(false), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, developer and testing mode", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E10 SKU name, disabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E10",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E10 SKU name, enabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E10",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with professional SKU name, disabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "professional",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with professional SKU name, enabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "professional",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
t.Run("license with E20 SKU name, disabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E20",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E20 SKU name, enabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E20",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with enterprise SKU name, disabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "enterprise",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with enterprise SKU name, enabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "enterprise",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with unknown SKU name, disabled future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "unknown",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with unknown SKU name, enabled future features", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE20LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "unknown",
|
||||||
|
Features: &model.Features{FutureFeatures: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsE10LicensedOrDevelopment(t *testing.T) {
|
||||||
|
t.Run("nil license features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(nil, &model.License{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil future features", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(nil, &model.License{Features: &model.Features{}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(nil, &model.License{Features: &model.Features{
|
||||||
|
LDAP: bToP(false),
|
||||||
|
}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{Features: &model.Features{
|
||||||
|
LDAP: bToP(true),
|
||||||
|
}}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, no config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(nil, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, nil config", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: nil, EnableTesting: nil}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only developer mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(false)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, only testing mode", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(false), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no license, developer and testing mode", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(
|
||||||
|
&model.Config{ServiceSettings: model.ServiceSettings{EnableDeveloper: bToP(true), EnableTesting: bToP(true)}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E10 SKU name, disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E10",
|
||||||
|
Features: &model.Features{LDAP: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E10 SKU name, enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E10",
|
||||||
|
Features: &model.Features{LDAP: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with professional SKU name, disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "professional",
|
||||||
|
Features: &model.Features{LDAP: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with professional SKU name, enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "professional",
|
||||||
|
Features: &model.Features{LDAP: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
t.Run("license with E20 SKU name, disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E20",
|
||||||
|
Features: &model.Features{LDAP: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with E20 SKU name, enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "E20",
|
||||||
|
Features: &model.Features{LDAP: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with enterprise SKU name, disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "enterprise",
|
||||||
|
Features: &model.Features{LDAP: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with enterprise SKU name, enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "enterprise",
|
||||||
|
Features: &model.Features{LDAP: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with unknown SKU name, disabled LDAP", func(t *testing.T) {
|
||||||
|
assert.False(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "unknown",
|
||||||
|
Features: &model.Features{LDAP: bToP(false)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with unknown SKU name, enabled LDAP", func(t *testing.T) {
|
||||||
|
assert.True(t, IsE10LicensedOrDevelopment(nil, &model.License{
|
||||||
|
SkuShortName: "unknown",
|
||||||
|
Features: &model.Features{LDAP: bToP(true)},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSKUShortName(t *testing.T) {
|
||||||
|
t.Run("nil license", func(t *testing.T) {
|
||||||
|
assert.False(t, isValidSkuShortName(nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with valid E10 SKU name", func(t *testing.T) {
|
||||||
|
assert.True(t, isValidSkuShortName(&model.License{SkuShortName: "E10"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with valid E20 SKU name", func(t *testing.T) {
|
||||||
|
assert.True(t, isValidSkuShortName(&model.License{SkuShortName: "E20"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with valid professional SKU name", func(t *testing.T) {
|
||||||
|
assert.True(t, isValidSkuShortName(&model.License{SkuShortName: "professional"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with valid enterprise SKU name", func(t *testing.T) {
|
||||||
|
assert.True(t, isValidSkuShortName(&model.License{SkuShortName: "enterprise"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("license with invalid SKU name", func(t *testing.T) {
|
||||||
|
assert.False(t, isValidSkuShortName(&model.License{SkuShortName: "invalid"}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bToP(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
33
server/public/pluginapi/log.go
Обычный файл
33
server/public/pluginapi/log.go
Обычный файл
@@ -0,0 +1,33 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogService exposes methods to log to the Mattermost server log.
|
||||||
|
//
|
||||||
|
// Note that standard error is automatically sent to the Mattermost server log, and standard
|
||||||
|
// output is redirected to standard error. This service enables optional structured logging.
|
||||||
|
type LogService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs an error message, optionally structured with alternating key, value parameters.
|
||||||
|
func (l *LogService) Error(message string, keyValuePairs ...interface{}) {
|
||||||
|
l.api.LogError(message, keyValuePairs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs an error message, optionally structured with alternating key, value parameters.
|
||||||
|
func (l *LogService) Warn(message string, keyValuePairs ...interface{}) {
|
||||||
|
l.api.LogWarn(message, keyValuePairs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs an error message, optionally structured with alternating key, value parameters.
|
||||||
|
func (l *LogService) Info(message string, keyValuePairs ...interface{}) {
|
||||||
|
l.api.LogInfo(message, keyValuePairs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs an error message, optionally structured with alternating key, value parameters.
|
||||||
|
func (l *LogService) Debug(message string, keyValuePairs ...interface{}) {
|
||||||
|
l.api.LogDebug(message, keyValuePairs...)
|
||||||
|
}
|
||||||
69
server/public/pluginapi/logrus.go
Обычный файл
69
server/public/pluginapi/logrus.go
Обычный файл
@@ -0,0 +1,69 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogrusHook is a logrus.Hook for emitting plugin logs through the RPC API for inclusion in the
|
||||||
|
// server logs.
|
||||||
|
//
|
||||||
|
// To configure the default Logrus logger for use with plugin logging, simply invoke:
|
||||||
|
//
|
||||||
|
// pluginapi.ConfigureLogrus(logrus.StandardLogger(), pluginAPIClient)
|
||||||
|
//
|
||||||
|
// Alternatively, construct your own logger to pass to pluginapi.ConfigureLogrus.
|
||||||
|
type LogrusHook struct {
|
||||||
|
log LogService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogrusHook creates a new instance of LogrusHook.
|
||||||
|
func NewLogrusHook(log LogService) *LogrusHook {
|
||||||
|
return &LogrusHook{
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Levels allows LogrusHook to process any log level.
|
||||||
|
func (lh *LogrusHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire proxies logrus entries through the plugin API at the appropriate level.
|
||||||
|
func (lh *LogrusHook) Fire(entry *logrus.Entry) error {
|
||||||
|
fields := []interface{}{}
|
||||||
|
for key, value := range entry.Data {
|
||||||
|
fields = append(fields, key, fmt.Sprintf("%+v", value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Caller != nil {
|
||||||
|
fields = append(fields, "plugin_caller", fmt.Sprintf("%s:%d", entry.Caller.File, entry.Caller.Line))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entry.Level {
|
||||||
|
case logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel:
|
||||||
|
lh.log.Error(entry.Message, fields...)
|
||||||
|
case logrus.WarnLevel:
|
||||||
|
lh.log.Warn(entry.Message, fields...)
|
||||||
|
case logrus.InfoLevel:
|
||||||
|
lh.log.Info(entry.Message, fields...)
|
||||||
|
case logrus.DebugLevel, logrus.TraceLevel:
|
||||||
|
lh.log.Debug(entry.Message, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLogrus configures the given logrus logger with a hook to proxy through the RPC API,
|
||||||
|
// discarding the default output to avoid duplicating the events across the standard STDOUT proxy.
|
||||||
|
func ConfigureLogrus(logger *logrus.Logger, client *Client) {
|
||||||
|
hook := NewLogrusHook(client.Log)
|
||||||
|
logger.Hooks.Add(hook)
|
||||||
|
logger.SetOutput(io.Discard)
|
||||||
|
logrus.SetReportCaller(true)
|
||||||
|
|
||||||
|
// By default, log everything to the server, and let it decide what gets through.
|
||||||
|
logrus.SetLevel(logrus.TraceLevel)
|
||||||
|
}
|
||||||
85
server/public/pluginapi/logrus_test.go
Обычный файл
85
server/public/pluginapi/logrus_test.go
Обычный файл
@@ -0,0 +1,85 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogrus(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
Level logrus.Level
|
||||||
|
APICall string
|
||||||
|
}{
|
||||||
|
{logrus.PanicLevel, "LogError"},
|
||||||
|
{logrus.FatalLevel, "LogError"},
|
||||||
|
{logrus.ErrorLevel, "LogError"},
|
||||||
|
{logrus.WarnLevel, "LogWarn"},
|
||||||
|
{logrus.InfoLevel, "LogInfo"},
|
||||||
|
{logrus.DebugLevel, "LogDebug"},
|
||||||
|
{logrus.TraceLevel, "LogDebug"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Level.String(), func(t *testing.T) {
|
||||||
|
logger := logrus.New()
|
||||||
|
logger.SetLevel(logrus.TraceLevel) // not testing logrus filtering
|
||||||
|
logger.ReportCaller = true
|
||||||
|
|
||||||
|
api := &plugintest.API{}
|
||||||
|
defer api.AssertExpectations(t)
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
pluginapi.ConfigureLogrus(logger, client)
|
||||||
|
|
||||||
|
// Parameter order of map is non-deterministic, so expect either.
|
||||||
|
api.On(testCase.APICall, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
|
||||||
|
entry := logger.WithFields(logrus.Fields{
|
||||||
|
"a": "a",
|
||||||
|
"b": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if testCase.Level == logrus.PanicLevel {
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
assert.NotNil(t, r, "expected panic")
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
entry.Panic("message")
|
||||||
|
}()
|
||||||
|
<-done
|
||||||
|
} else {
|
||||||
|
entry.Log(testCase.Level, "message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert the required API call was executed at most once.
|
||||||
|
if api.AssertNumberOfCalls(t, testCase.APICall, 1) {
|
||||||
|
call := api.Calls[0]
|
||||||
|
for i := 1; i < len(call.Arguments)-1; i += 2 {
|
||||||
|
argument := call.Arguments[i]
|
||||||
|
value := call.Arguments[i+1]
|
||||||
|
|
||||||
|
switch argument {
|
||||||
|
case "a":
|
||||||
|
assert.Equal(t, "a", value, "unexpected value for a")
|
||||||
|
case "b":
|
||||||
|
assert.Equal(t, "1", value, "unexpected value for b")
|
||||||
|
case "plugin_caller":
|
||||||
|
assert.IsType(t, "string", value)
|
||||||
|
default:
|
||||||
|
assert.Fail(t, "unexpected argument and value", "%v: %v", argument, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
55
server/public/pluginapi/oauth.go
Обычный файл
55
server/public/pluginapi/oauth.go
Обычный файл
@@ -0,0 +1,55 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserService exposes methods to manipulate OAuth Apps.
|
||||||
|
type OAuthService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new OAuth App.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (o *OAuthService) Create(app *model.OAuthApp) error {
|
||||||
|
createdApp, appErr := o.api.CreateOAuthApp(app)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
*app = *createdApp
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets an existing OAuth App by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (o *OAuthService) Get(appID string) (*model.OAuthApp, error) {
|
||||||
|
app, appErr := o.api.GetOAuthApp(appID)
|
||||||
|
|
||||||
|
return app, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an existing OAuth App.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (o *OAuthService) Update(app *model.OAuthApp) error {
|
||||||
|
updatedApp, appErr := o.api.UpdateOAuthApp(app)
|
||||||
|
if appErr != nil {
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
*app = *updatedApp
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes an existing OAuth App by id.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.38
|
||||||
|
func (o *OAuthService) Delete(appID string) error {
|
||||||
|
return normalizeAppErr(o.api.DeleteOAuthApp(appID))
|
||||||
|
}
|
||||||
170
server/public/pluginapi/plugin_test.go
Обычный файл
170
server/public/pluginapi/plugin_test.go
Обычный файл
@@ -0,0 +1,170 @@
|
|||||||
|
package pluginapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
|
||||||
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstallPluginFromURL(t *testing.T) {
|
||||||
|
replace := true
|
||||||
|
|
||||||
|
t.Run("incompatible server version", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetServerVersion").Return("5.1.0")
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
_, err := client.Plugin.InstallPluginFromURL("", true)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "incompatible server version for plugin, minimum required version: 5.18.0, current version: 5.1.0", err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error while parsing the download url", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetServerVersion").Return("5.19.0")
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
_, err := client.Plugin.InstallPluginFromURL("http://%41:8080/", replace)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "error while parsing url: parse \"http://%41:8080/\": invalid URL escape \"%41\"", err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("errors out while downloading file", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetServerVersion").Return("5.19.0")
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
res.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
url := testServer.URL
|
||||||
|
|
||||||
|
_, err := client.Plugin.InstallPluginFromURL(url, replace)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "received 500 status code while downloading plugin from server", err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("downloads the file successfully", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetServerVersion").Return("5.19.0")
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
tarData, err := os.ReadFile(filepath.Join("../../../tests", "testplugin.tar.gz"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
expectedManifest := &model.Manifest{Id: "testplugin"}
|
||||||
|
api.On("InstallPlugin", mock.Anything, false).Return(expectedManifest, nil)
|
||||||
|
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
res.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = res.Write(tarData)
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
url := testServer.URL
|
||||||
|
|
||||||
|
manifest, err := client.Plugin.InstallPluginFromURL(url, false)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "testplugin", manifest.Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the url pointing to server is incorrect", func(t *testing.T) {
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetServerVersion").Return("5.19.0")
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
res.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
url := testServer.URL
|
||||||
|
|
||||||
|
_, err := client.Plugin.InstallPluginFromURL(url, false)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "received 404 status code while downloading plugin from server", err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPluginAssetURL(t *testing.T) {
|
||||||
|
siteURL := "https://mattermost.example.com"
|
||||||
|
api := &plugintest.API{}
|
||||||
|
api.On("GetConfig").Return(&model.Config{ServiceSettings: model.ServiceSettings{SiteURL: &siteURL}})
|
||||||
|
|
||||||
|
client := pluginapi.NewClient(api, &plugintest.Driver{})
|
||||||
|
|
||||||
|
t.Run("Valid asset directory was provided", func(t *testing.T) {
|
||||||
|
pluginID := "mattermost-1234"
|
||||||
|
dir := "assets"
|
||||||
|
wantedURL := "https://mattermost.example.com/mattermost-1234/assets"
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dir)
|
||||||
|
|
||||||
|
assert.Equalf(t, wantedURL, gotURL, "GetPluginAssetURL(%q, %q) got=%q; want=%v", pluginID, dir, gotURL, wantedURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Valid asset directory path was provided", func(t *testing.T) {
|
||||||
|
pluginID := "mattermost-1234"
|
||||||
|
dirPath := "/mattermost/assets"
|
||||||
|
wantedURL := "https://mattermost.example.com/mattermost-1234/mattermost/assets"
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dirPath)
|
||||||
|
|
||||||
|
assert.Equalf(t, wantedURL, gotURL, "GetPluginAssetURL(%q, %q) got=%q; want=%q", pluginID, dirPath, gotURL, wantedURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Valid pluginID was provided", func(t *testing.T) {
|
||||||
|
pluginID := "mattermost-1234"
|
||||||
|
dir := "assets"
|
||||||
|
wantedURL := "https://mattermost.example.com/mattermost-1234/assets"
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dir)
|
||||||
|
|
||||||
|
assert.Equalf(t, wantedURL, gotURL, "GetPluginAssetURL(%q, %q) got=%q; want=%q", pluginID, dir, gotURL, wantedURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid asset directory name was provided", func(t *testing.T) {
|
||||||
|
pluginID := "mattermost-1234"
|
||||||
|
dir := ""
|
||||||
|
want := ""
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dir)
|
||||||
|
|
||||||
|
assert.Emptyf(t, gotURL, "GetPluginAssetURL(%q, %q) got=%s; want=%q", pluginID, dir, gotURL, want)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Invalid pluginID was provided", func(t *testing.T) {
|
||||||
|
pluginID := ""
|
||||||
|
dir := "assets"
|
||||||
|
want := ""
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dir)
|
||||||
|
|
||||||
|
assert.Emptyf(t, gotURL, "GetPluginAssetURL(%q, %q) got=%q; want=%q", pluginID, dir, gotURL, want)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
siteURL = ""
|
||||||
|
api.On("GetConfig").Return(&model.Config{ServiceSettings: model.ServiceSettings{SiteURL: &siteURL}})
|
||||||
|
|
||||||
|
t.Run("Empty SiteURL was configured", func(t *testing.T) {
|
||||||
|
pluginID := "mattermost-1234"
|
||||||
|
dir := "assets"
|
||||||
|
want := ""
|
||||||
|
gotURL, err := client.System.GetPluginAssetURL(pluginID, dir)
|
||||||
|
|
||||||
|
assert.Emptyf(t, gotURL, "GetPluginAssetURL(%q, %q) got=%q; want=%q", pluginID, dir, gotURL, want)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
114
server/public/pluginapi/plugins.go
Обычный файл
114
server/public/pluginapi/plugins.go
Обычный файл
@@ -0,0 +1,114 @@
|
|||||||
|
package pluginapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
"github.com/mattermost/mattermost/server/public/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginService exposes methods to manipulate the set of plugins as well as communicate with
|
||||||
|
// other plugin instances.
|
||||||
|
type PluginService struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// List will return a list of plugin manifests for currently active plugins.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (p *PluginService) List() ([]*model.Manifest, error) {
|
||||||
|
manifests, appErr := p.api.GetPlugins()
|
||||||
|
|
||||||
|
return manifests, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install will upload another plugin with tar.gz file.
|
||||||
|
// Previous version will be replaced on replace true.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (p *PluginService) Install(file io.Reader, replace bool) (*model.Manifest, error) {
|
||||||
|
manifest, appErr := p.api.InstallPlugin(file, replace)
|
||||||
|
|
||||||
|
return manifest, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallPluginFromURL installs the plugin from the provided url.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (p *PluginService) InstallPluginFromURL(downloadURL string, replace bool) (*model.Manifest, error) {
|
||||||
|
err := ensureServerVersion(p.api, "5.18.0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "error while parsing url")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Hour}
|
||||||
|
response, err := client.Get(parsedURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unable to download the plugin")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.Errorf("received %d status code while downloading plugin from server", response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := p.Install(response.Body, replace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "unable to install plugin on server")
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable will enable an plugin installed.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (p *PluginService) Enable(id string) error {
|
||||||
|
appErr := p.api.EnablePlugin(id)
|
||||||
|
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable will disable an enabled plugin.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (p *PluginService) Disable(id string) error {
|
||||||
|
appErr := p.api.DisablePlugin(id)
|
||||||
|
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove will disable and delete a plugin.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (p *PluginService) Remove(id string) error {
|
||||||
|
appErr := p.api.RemovePlugin(id)
|
||||||
|
|
||||||
|
return normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPluginStatus will return the status of a plugin.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.6
|
||||||
|
func (p *PluginService) GetPluginStatus(id string) (*model.PluginStatus, error) {
|
||||||
|
pluginStatus, appErr := p.api.GetPluginStatus(id)
|
||||||
|
|
||||||
|
return pluginStatus, normalizeAppErr(appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP allows inter-plugin requests to plugin APIs.
|
||||||
|
//
|
||||||
|
// Minimum server version: 5.18
|
||||||
|
func (p *PluginService) HTTP(request *http.Request) *http.Response {
|
||||||
|
return p.api.PluginHTTP(request)
|
||||||
|
}
|
||||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user