[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>
This commit is contained in:
Ben Schumacher 2023-08-21 09:50:30 +02:00 committed by GitHub
parent bc11b29807
commit 3ee5432664
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 14912 additions and 5 deletions

View File

@ -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))))
@ -210,6 +210,7 @@ endif
include config.mk
include build/*.mk
include public/Makefile
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
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

View File

@ -9,10 +9,10 @@ import (
"github.com/mattermost/mattermost/server/public/model"
"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/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/platform/shared/driver"
)
type MyPlugin struct {

14
server/public/Makefile Normal file
View File

@ -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

View File

@ -4,9 +4,12 @@ go 1.19
require (
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/francoispqt/gojay v1.2.13
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/graph-gophers/graphql-go v1.5.1-0.20230110080634-edea822f558a
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/ldap v0.0.0-20201202150706-ee0e6284187d
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/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/tinylib/msgp v1.1.8
github.com/vmihailenco/msgpack/v5 v5.3.5
golang.org/x/crypto v0.10.0
golang.org/x/oauth2 v0.7.0
golang.org/x/text v0.10.0
golang.org/x/tools v0.10.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.15.0 // 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/philhofer/fwd v1.1.2 // 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/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/wiggin77/merror v1.0.5 // 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/net v0.11.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/grpc v1.56.1 // indirect
google.golang.org/protobuf v1.31.0 // indirect

View File

@ -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/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=
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/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/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/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/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
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/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.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.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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/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/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/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
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/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/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/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.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/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/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=
@ -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/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/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/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
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.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.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.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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
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/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/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
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/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8=
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=
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
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-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-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.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
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.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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-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-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-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-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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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-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.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/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-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-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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-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-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-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-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-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-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-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-20220811171246-fbc7d0a398ab/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/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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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-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-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.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.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/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-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-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-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
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.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.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-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=

View File

@ -191,7 +191,8 @@ type Manifest struct {
// 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"`
}

View File

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

View File

@ -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() {}

View File

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

View File

@ -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)
})
}

View File

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

View File

@ -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)
}

View File

@ -0,0 +1,3 @@
// package cluster exposes synchronization primitives to ensure correct behavior across multiple
// plugin instances in a Mattermost cluster.
package cluster

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
})
}

View File

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

View File

@ -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)
})
}

View File

@ -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))
})
}

View File

@ -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...)
}

View File

@ -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{})
}

View File

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

View File

@ -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:
}
})
}

View File

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

View File

@ -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]))
})
}
}

View File

@ -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)
})
}

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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)
})
}

View File

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

View File

@ -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() {
}

View File

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

View File

@ -0,0 +1,97 @@
package admincclogger
import (
"fmt"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
)
type adminCCLogger struct {
logger.Logger
dmer poster.DMer
logLevel logger.LogLevel
includeContext bool
userIDs []string
}
/*
New promotes the provided logger into a admin cc logger, sending direct messages to all the admin
ids provided through the dmer provided, about all events below the logLevel. If logVerbose is set,
it will also send the context.
- l Logger: A logger to promote.
- dmer DMer: A DMer to send the messages to the admins.
- logLevel: The highest type of message to be stored in telemetry.
- includeContext: Whether the log context should be messaged to the admins.
- userIDs: The user IDs of the admins.
*/
func New(l logger.Logger, dmer poster.DMer, logLevel logger.LogLevel, includeContext bool, userIDs ...string) logger.Logger {
return &adminCCLogger{
Logger: l,
dmer: dmer,
logLevel: logLevel,
includeContext: includeContext,
userIDs: userIDs,
}
}
// NewFromAPI creates a adminCCLogger directly from a LogAPI instead of passing a logger.
func NewFromAPI(api common.LogAPI, dmer poster.DMer, logLevel logger.LogLevel, includeContext bool, userIDs ...string) logger.Logger {
return New(logger.New(api), dmer, logLevel, includeContext, userIDs...)
}
func (l *adminCCLogger) Debugf(format string, args ...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
}

View File

@ -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)...)
}

View File

@ -0,0 +1,78 @@
package logger
import "time"
const (
timed = "__since"
elapsed = "Elapsed"
ErrorKey = "error"
)
// LogLevel defines the level of log messages
type LogLevel string
const (
// LogLevelDebug denotes debug messages
LogLevelDebug = "debug"
// LogLevelInfo denotes info messages
LogLevelInfo = "info"
// LogLevelWarn denotes warn messages
LogLevelWarn = "warn"
// LogLevelError denotes error messages
LogLevelError = "error"
)
// LogContext defines the context for the logs.
type LogContext map[string]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
}

View File

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

View File

@ -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)
}

View File

@ -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...) }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,76 @@
package poster
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type defaultPoster struct {
postAPI PostAPI
id string
}
// NewPoster creates a new default poster
func NewPoster(postAPI PostAPI, id string) Poster {
return &defaultPoster{
postAPI: postAPI,
id: id,
}
}
// DM posts a simple Direct Message to the specified user
func (p *defaultPoster) DM(mattermostUserID, format string, args ...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
}

View File

@ -0,0 +1,414 @@
package poster
import (
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
"github.com/mattermost/mattermost/server/public/pluginapi"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster/mock_import"
)
const (
botID = "test-bot-user"
userID = "test-user-1"
dmChannelID = "dm-channel-id"
)
func TestInterface(t *testing.T) {
t.Run("Plugin API satisfy the interface", func(t *testing.T) {
api := &plugintest.API{}
driver := &plugintest.Driver{}
client := pluginapi.NewClient(api, driver)
_ = NewPoster(&client.Post, botID)
})
}
func TestDM(t *testing.T) {
format := "test format, string: %s int: %d value: %v"
args := []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...)
})
}

View File

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

View File

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

View File

@ -0,0 +1,38 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package poster
import (
"github.com/mattermost/mattermost/server/public/model"
)
// Poster defines an entity that can post DMs and Ephemerals and update and delete those posts
type Poster interface {
DMer
// DMWithAttachments posts a Direct Message that contains 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)
}

View File

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

View File

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

View File

@ -0,0 +1,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)
}

View File

@ -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{})
}

View File

@ -0,0 +1,22 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
package common
import (
"encoding/json"
"fmt"
)
func JSON(ref 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))
}

View File

@ -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)
}

View File

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

View File

@ -0,0 +1,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
}
}

View File

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

View File

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

View File

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

View File

@ -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))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,171 @@
package panel
import (
"errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/logger"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/bot/poster"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/common"
"github.com/mattermost/mattermost/server/public/pluginapi/experimental/panel/settings"
)
type Panel interface {
Set(userID, settingID string, value 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)
}

View File

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

View File

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

View File

@ -0,0 +1,41 @@
package settings
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type emptySetting struct {
baseSetting
}
// NewEmptySetting creates a new panel value with no setting attached
func NewEmptySetting(id, title, description string) Setting {
return &emptySetting{
baseSetting: baseSetting{
id: id,
title: title,
description: description,
},
}
}
func (s *emptySetting) 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
}

View File

@ -0,0 +1,91 @@
package settings
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type optionSetting struct {
baseSetting
options []string
store SettingStore
}
// NewOptionSetting creates a new setting input to select from a dropdown
func NewOptionSetting(id, title, description, dependsOn string, options []string, store SettingStore) Setting {
return &optionSetting{
baseSetting: baseSetting{
title: title,
description: description,
id: id,
dependsOn: dependsOn,
},
options: options,
store: store,
}
}
func (s *optionSetting) Set(userID string, value 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
}

View File

@ -0,0 +1,69 @@
package settings
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
type readOnlySetting struct {
baseSetting
store SettingStore
}
// NewReadOnlySetting creates a new panel value that only read from the setting
func NewReadOnlySetting(id, title, description, dependsOn string, store SettingStore) Setting {
return &readOnlySetting{
baseSetting: baseSetting{
title: title,
description: description,
id: id,
dependsOn: dependsOn,
},
store: store,
}
}
func (s *readOnlySetting) Get(userID string) (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
}

View File

@ -0,0 +1,33 @@
package settings
import (
"github.com/mattermost/mattermost/server/public/model"
)
const (
// ContextIDKey defines the key used in the context to store the ID
ContextIDKey = "setting_id"
// ContextButtonValueKey defines the key used in the context to store a button value
ContextButtonValueKey = "button_value"
// ContextOptionValueKey defines the key used in the context to store a selected option value
ContextOptionValueKey = "selected_option"
// DisabledString defines the string used to show that a setting is disabled
DisabledString = "Disabled"
// TrueString codify the boolean true into a string
TrueString = "true"
// FalseString codify the boolean false into a string
FalseString = "false"
)
// Setting defines the behavior of each element a the panel
type Setting interface {
Set(userID string, value 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)
}

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1,2 @@
// package i18n provides methods to read translations files and localize strings.
package i18n

View File

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

View File

@ -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))
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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...)
}

View File

@ -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)
}

View File

@ -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)
}
}
}
})
}
}

View File

@ -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))
}

View File

@ -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)
})
}

View File

@ -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)
}

Some files were not shown because too many files have changed in this diff Show More