mirror of
https://github.com/mattermost/focalboard.git
synced 2024-12-21 13:38:56 +02:00
@mention support (#1147)
This commit is contained in:
parent
20aafbc376
commit
8425f53b2a
33
NOTICE.txt
33
NOTICE.txt
@ -748,3 +748,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## wiggin77/merror
|
||||
|
||||
This product contains 'merror' by GitHub user 'wiggin77'.
|
||||
|
||||
Multiple Error aggregator for Go.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/wiggin77/merror
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 wiggin77
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
@ -60,7 +60,17 @@ func runServer(port int) (*server.Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server, err := server.New(config, sessionToken, db, logger, "", nil)
|
||||
params := server.Params{
|
||||
Cfg: config,
|
||||
SingleUserToken: sessionToken,
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
ServerID: "",
|
||||
WSAdapter: nil,
|
||||
NotifyBackends: nil,
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
||||
return nil, err
|
||||
|
61
mattermost-plugin/server/notifications.go
Normal file
61
mattermost-plugin/server/notifications.go
Normal file
@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifymentions"
|
||||
"github.com/mattermost/focalboard/server/services/notify/plugindelivery"
|
||||
|
||||
pluginapi "github.com/mattermost/mattermost-plugin-api"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
botUsername = "boards"
|
||||
botDisplayname = "Boards"
|
||||
botDescription = "Created by Boards plugin."
|
||||
)
|
||||
|
||||
func createMentionsNotifyBackend(client *pluginapi.Client, serverRoot string, logger *mlog.Logger) (notify.Backend, error) {
|
||||
bot := &model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayname,
|
||||
Description: botDescription,
|
||||
}
|
||||
botID, err := client.Bot.EnsureBot(bot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err)
|
||||
}
|
||||
|
||||
pluginAPI := &pluginAPIAdapter{client: client}
|
||||
|
||||
delivery := plugindelivery.New(botID, serverRoot, pluginAPI)
|
||||
|
||||
backend := notifymentions.New(delivery, logger)
|
||||
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
type pluginAPIAdapter struct {
|
||||
client *pluginapi.Client
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) {
|
||||
return da.client.Channel.GetDirect(userID1, userID2)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) CreatePost(post *model.Post) error {
|
||||
return da.client.Post.CreatePost(post)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetUserByID(userID string) (*model.User, error) {
|
||||
return da.client.User.Get(userID)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetUserByUsername(name string) (*model.User, error) {
|
||||
return da.client.User.GetByUsername(name)
|
||||
}
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/mattermost/focalboard/server/auth"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||
"github.com/mattermost/focalboard/server/services/store/sqlstore"
|
||||
@ -132,7 +133,22 @@ func (p *Plugin) OnActivate() error {
|
||||
|
||||
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db))
|
||||
|
||||
server, err := server.New(cfg, "", db, logger, serverID, p.wsPluginAdapter)
|
||||
mentionsBackend, err := createMentionsNotifyBackend(client, cfg.ServerRoot, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating mentions notifications backend: %w", err)
|
||||
}
|
||||
|
||||
params := server.Params{
|
||||
Cfg: cfg,
|
||||
SingleUserToken: "",
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
ServerID: serverID,
|
||||
WSAdapter: p.wsPluginAdapter,
|
||||
NotifyBackends: []notify.Backend{mentionsBackend},
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
||||
return err
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/mattermost/focalboard/server/auth"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/metrics"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/webhook"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
@ -14,34 +15,37 @@ import (
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Auth *auth.Auth
|
||||
Store store.Store
|
||||
FilesBackend filestore.FileBackend
|
||||
Webhook *webhook.Client
|
||||
Metrics *metrics.Metrics
|
||||
Logger *mlog.Logger
|
||||
Auth *auth.Auth
|
||||
Store store.Store
|
||||
FilesBackend filestore.FileBackend
|
||||
Webhook *webhook.Client
|
||||
Metrics *metrics.Metrics
|
||||
Notifications *notify.Service
|
||||
Logger *mlog.Logger
|
||||
}
|
||||
|
||||
type App struct {
|
||||
config *config.Configuration
|
||||
store store.Store
|
||||
auth *auth.Auth
|
||||
wsAdapter ws.Adapter
|
||||
filesBackend filestore.FileBackend
|
||||
webhook *webhook.Client
|
||||
metrics *metrics.Metrics
|
||||
logger *mlog.Logger
|
||||
config *config.Configuration
|
||||
store store.Store
|
||||
auth *auth.Auth
|
||||
wsAdapter ws.Adapter
|
||||
filesBackend filestore.FileBackend
|
||||
webhook *webhook.Client
|
||||
metrics *metrics.Metrics
|
||||
notifications *notify.Service
|
||||
logger *mlog.Logger
|
||||
}
|
||||
|
||||
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
|
||||
return &App{
|
||||
config: config,
|
||||
store: services.Store,
|
||||
auth: services.Auth,
|
||||
wsAdapter: wsAdapter,
|
||||
filesBackend: services.FilesBackend,
|
||||
webhook: services.Webhook,
|
||||
metrics: services.Metrics,
|
||||
logger: services.Logger,
|
||||
config: config,
|
||||
store: services.Store,
|
||||
auth: services.Auth,
|
||||
wsAdapter: wsAdapter,
|
||||
filesBackend: services.FilesBackend,
|
||||
webhook: services.Webhook,
|
||||
metrics: services.Metrics,
|
||||
notifications: services.Notifications,
|
||||
logger: services.Logger,
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,10 @@ package app
|
||||
|
||||
import (
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
func (a *App) GetBlocks(c store.Container, parentID string, blockType string) ([]model.Block, error) {
|
||||
@ -30,40 +33,64 @@ func (a *App) GetParentID(c store.Container, blockID string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, userID string) error {
|
||||
err := a.store.PatchBlock(c, blockID, blockPatch, userID)
|
||||
oldBlock, err := a.store.GetBlock(c, blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = a.store.PatchBlock(c, blockID, blockPatch, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
block, err := a.store.GetBlock(c, blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *block)
|
||||
go a.webhook.NotifyUpdate(*block)
|
||||
go func() {
|
||||
a.webhook.NotifyUpdate(*block)
|
||||
a.notifyBlockChanged(notify.Update, c, block, oldBlock, userID)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlock(c store.Container, block model.Block, userID string) error {
|
||||
err := a.store.InsertBlock(c, &block, userID)
|
||||
if err == nil {
|
||||
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, block)
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
go func() {
|
||||
a.webhook.NotifyUpdate(block)
|
||||
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
|
||||
}()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string) error {
|
||||
needsNotify := make([]model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
err := a.store.InsertBlock(c, &blocks[i], userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
needsNotify = append(needsNotify, blocks[i])
|
||||
|
||||
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, blocks[i])
|
||||
a.metrics.IncrementBlocksInserted(len(blocks))
|
||||
go a.webhook.NotifyUpdate(blocks[i])
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for _, b := range needsNotify {
|
||||
block := b
|
||||
a.webhook.NotifyUpdate(block)
|
||||
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -80,7 +107,7 @@ func (a *App) GetAllBlocks(c store.Container) ([]model.Block, error) {
|
||||
}
|
||||
|
||||
func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string) error {
|
||||
parentID, err := a.GetParentID(c, blockID)
|
||||
block, err := a.store.GetBlock(c, blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -90,12 +117,69 @@ func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string)
|
||||
return err
|
||||
}
|
||||
|
||||
a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, parentID)
|
||||
a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, block.ParentID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
|
||||
go func() {
|
||||
a.notifyBlockChanged(notify.Update, c, block, block, modifiedBy)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
|
||||
return a.store.GetBlockCountsByType()
|
||||
}
|
||||
|
||||
func (a *App) notifyBlockChanged(action notify.Action, c store.Container, block *model.Block, oldBlock *model.Block, userID string) {
|
||||
if a.notifications == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// find card and board for the changed block.
|
||||
board, card, err := a.getBoardAndCard(c, block)
|
||||
if err != nil {
|
||||
a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
evt := notify.BlockChangeEvent{
|
||||
Action: action,
|
||||
Workspace: c.WorkspaceID,
|
||||
Board: board,
|
||||
Card: card,
|
||||
BlockChanged: block,
|
||||
BlockOld: oldBlock,
|
||||
UserID: userID,
|
||||
}
|
||||
a.notifications.BlockChanged(evt)
|
||||
}
|
||||
|
||||
const (
|
||||
maxSearchDepth = 50
|
||||
)
|
||||
|
||||
// getBoardAndCard returns the first parent of type `card` and first parent of type `board` for the specified block.
|
||||
// `board` and/or `card` may return nil without error if the block does not belong to a board or card.
|
||||
func (a *App) getBoardAndCard(c store.Container, block *model.Block) (board *model.Block, card *model.Block, err error) {
|
||||
var count int // don't let invalid blocks hierarchy cause infinite loop.
|
||||
iter := block
|
||||
for {
|
||||
count++
|
||||
if board == nil && iter.Type == "board" {
|
||||
board = iter
|
||||
}
|
||||
|
||||
if card == nil && iter.Type == "card" {
|
||||
card = iter
|
||||
}
|
||||
|
||||
if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth {
|
||||
break
|
||||
}
|
||||
|
||||
iter, err = a.store.GetBlock(c, iter.ParentID)
|
||||
if err != nil {
|
||||
return board, card, err
|
||||
}
|
||||
}
|
||||
return board, card, nil
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ require (
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/wiggin77/merror v1.0.3
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
)
|
||||
|
@ -75,7 +75,15 @@ func newTestServer(singleUserToken string) *server.Server {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
srv, err := server.New(cfg, singleUserToken, db, logger, "", nil)
|
||||
|
||||
params := server.Params{
|
||||
Cfg: cfg,
|
||||
SingleUserToken: singleUserToken,
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
srv, err := server.New(params)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -165,7 +165,14 @@ func main() {
|
||||
logger.Fatal("server.NewStore ERROR", mlog.Err(err))
|
||||
}
|
||||
|
||||
server, err := server.New(config, singleUserToken, db, logger, "", nil)
|
||||
params := server.Params{
|
||||
Cfg: config,
|
||||
SingleUserToken: singleUserToken,
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
if err != nil {
|
||||
logger.Fatal("server.New ERROR", mlog.Err(err))
|
||||
}
|
||||
@ -245,7 +252,14 @@ func startServer(webPath string, filesPath string, port int, singleUserToken, db
|
||||
logger.Fatal("server.NewStore ERROR", mlog.Err(err))
|
||||
}
|
||||
|
||||
pServer, err = server.New(config, singleUserToken, db, logger, "", nil)
|
||||
params := server.Params{
|
||||
Cfg: config,
|
||||
SingleUserToken: singleUserToken,
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
pServer, err = server.New(params)
|
||||
if err != nil {
|
||||
logger.Fatal("server.New ERROR", mlog.Err(err))
|
||||
}
|
||||
|
46
server/server/params.go
Normal file
46
server/server/params.go
Normal file
@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type Params struct {
|
||||
Cfg *config.Configuration
|
||||
SingleUserToken string
|
||||
DBStore store.Store
|
||||
Logger *mlog.Logger
|
||||
ServerID string
|
||||
WSAdapter ws.Adapter
|
||||
NotifyBackends []notify.Backend
|
||||
}
|
||||
|
||||
func (p Params) CheckValid() error {
|
||||
if p.Cfg == nil {
|
||||
return ErrServerParam{name: "Cfg", issue: "cannot be nil"}
|
||||
}
|
||||
|
||||
if p.DBStore == nil {
|
||||
return ErrServerParam{name: "DbStore", issue: "cannot be nil"}
|
||||
}
|
||||
|
||||
if p.Logger == nil {
|
||||
return ErrServerParam{name: "Logger", issue: "cannot be nil"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ErrServerParam struct {
|
||||
name string
|
||||
issue string
|
||||
}
|
||||
|
||||
func (e ErrServerParam) Error() string {
|
||||
return fmt.Sprintf("invalid server params: %s %s", e.name, e.issue)
|
||||
}
|
@ -22,6 +22,8 @@ import (
|
||||
"github.com/mattermost/focalboard/server/services/audit"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/metrics"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifylogger"
|
||||
"github.com/mattermost/focalboard/server/services/scheduler"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||
@ -60,6 +62,7 @@ type Server struct {
|
||||
metricsService *metrics.Metrics
|
||||
metricsUpdaterTask *scheduler.ScheduledTask
|
||||
auditService *audit.Audit
|
||||
notificationService *notify.Service
|
||||
servicesStartStopMutex sync.Mutex
|
||||
|
||||
localRouter *mux.Router
|
||||
@ -67,37 +70,41 @@ type Server struct {
|
||||
api *api.API
|
||||
}
|
||||
|
||||
func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
||||
logger *mlog.Logger, serverID string, wsAdapter ws.Adapter) (*Server, error) {
|
||||
authenticator := auth.New(cfg, db)
|
||||
func New(params Params) (*Server, error) {
|
||||
if err := params.CheckValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authenticator := auth.New(params.Cfg, params.DBStore)
|
||||
|
||||
// if no ws adapter is provided, we spin up a websocket server
|
||||
wsAdapter := params.WSAdapter
|
||||
if wsAdapter == nil {
|
||||
wsAdapter = ws.NewServer(authenticator, singleUserToken, cfg.AuthMode == MattermostAuthMod, logger)
|
||||
wsAdapter = ws.NewServer(authenticator, params.SingleUserToken, params.Cfg.AuthMode == MattermostAuthMod, params.Logger)
|
||||
}
|
||||
|
||||
filesBackendSettings := filestore.FileBackendSettings{}
|
||||
filesBackendSettings.DriverName = cfg.FilesDriver
|
||||
filesBackendSettings.Directory = cfg.FilesPath
|
||||
filesBackendSettings.AmazonS3AccessKeyId = cfg.FilesS3Config.AccessKeyID
|
||||
filesBackendSettings.AmazonS3SecretAccessKey = cfg.FilesS3Config.SecretAccessKey
|
||||
filesBackendSettings.AmazonS3Bucket = cfg.FilesS3Config.Bucket
|
||||
filesBackendSettings.AmazonS3PathPrefix = cfg.FilesS3Config.PathPrefix
|
||||
filesBackendSettings.AmazonS3Region = cfg.FilesS3Config.Region
|
||||
filesBackendSettings.AmazonS3Endpoint = cfg.FilesS3Config.Endpoint
|
||||
filesBackendSettings.AmazonS3SSL = cfg.FilesS3Config.SSL
|
||||
filesBackendSettings.AmazonS3SignV2 = cfg.FilesS3Config.SignV2
|
||||
filesBackendSettings.AmazonS3SSE = cfg.FilesS3Config.SSE
|
||||
filesBackendSettings.AmazonS3Trace = cfg.FilesS3Config.Trace
|
||||
filesBackendSettings.DriverName = params.Cfg.FilesDriver
|
||||
filesBackendSettings.Directory = params.Cfg.FilesPath
|
||||
filesBackendSettings.AmazonS3AccessKeyId = params.Cfg.FilesS3Config.AccessKeyID
|
||||
filesBackendSettings.AmazonS3SecretAccessKey = params.Cfg.FilesS3Config.SecretAccessKey
|
||||
filesBackendSettings.AmazonS3Bucket = params.Cfg.FilesS3Config.Bucket
|
||||
filesBackendSettings.AmazonS3PathPrefix = params.Cfg.FilesS3Config.PathPrefix
|
||||
filesBackendSettings.AmazonS3Region = params.Cfg.FilesS3Config.Region
|
||||
filesBackendSettings.AmazonS3Endpoint = params.Cfg.FilesS3Config.Endpoint
|
||||
filesBackendSettings.AmazonS3SSL = params.Cfg.FilesS3Config.SSL
|
||||
filesBackendSettings.AmazonS3SignV2 = params.Cfg.FilesS3Config.SignV2
|
||||
filesBackendSettings.AmazonS3SSE = params.Cfg.FilesS3Config.SSE
|
||||
filesBackendSettings.AmazonS3Trace = params.Cfg.FilesS3Config.Trace
|
||||
|
||||
filesBackend, appErr := filestore.NewFileBackend(filesBackendSettings)
|
||||
if appErr != nil {
|
||||
logger.Error("Unable to initialize the files storage", mlog.Err(appErr))
|
||||
params.Logger.Error("Unable to initialize the files storage", mlog.Err(appErr))
|
||||
|
||||
return nil, errors.New("unable to initialize the files storage")
|
||||
}
|
||||
|
||||
webhookClient := webhook.NewClient(cfg, logger)
|
||||
webhookClient := webhook.NewClient(params.Cfg, params.Logger)
|
||||
|
||||
// Init metrics
|
||||
instanceInfo := metrics.InstanceInfo{
|
||||
@ -113,21 +120,28 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
||||
if errAudit != nil {
|
||||
return nil, fmt.Errorf("unable to create the audit service: %w", errAudit)
|
||||
}
|
||||
if err := auditService.Configure(cfg.AuditCfgFile, cfg.AuditCfgJSON); err != nil {
|
||||
if err := auditService.Configure(params.Cfg.AuditCfgFile, params.Cfg.AuditCfgJSON); err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize the audit service: %w", err)
|
||||
}
|
||||
|
||||
appServices := app.Services{
|
||||
Auth: authenticator,
|
||||
Store: db,
|
||||
FilesBackend: filesBackend,
|
||||
Webhook: webhookClient,
|
||||
Metrics: metricsService,
|
||||
Logger: logger,
|
||||
// Init notification services
|
||||
notificationService, errNotify := initNotificationService(params.NotifyBackends, params.Logger)
|
||||
if errNotify != nil {
|
||||
return nil, fmt.Errorf("cannot initialize notification service: %w", errNotify)
|
||||
}
|
||||
app := app.New(cfg, wsAdapter, appServices)
|
||||
|
||||
focalboardAPI := api.NewAPI(app, singleUserToken, cfg.AuthMode, logger, auditService)
|
||||
appServices := app.Services{
|
||||
Auth: authenticator,
|
||||
Store: params.DBStore,
|
||||
FilesBackend: filesBackend,
|
||||
Webhook: webhookClient,
|
||||
Metrics: metricsService,
|
||||
Notifications: notificationService,
|
||||
Logger: params.Logger,
|
||||
}
|
||||
app := app.New(params.Cfg, wsAdapter, appServices)
|
||||
|
||||
focalboardAPI := api.NewAPI(app, params.SingleUserToken, params.Cfg.AuthMode, params.Logger, auditService)
|
||||
|
||||
// Local router for admin APIs
|
||||
localRouter := mux.NewRouter()
|
||||
@ -135,18 +149,19 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
||||
|
||||
// Init workspace
|
||||
if _, err := app.GetRootWorkspace(); err != nil {
|
||||
logger.Error("Unable to get root workspace", mlog.Err(err))
|
||||
params.Logger.Error("Unable to get root workspace", mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webServer := web.NewServer(cfg.WebPath, cfg.ServerRoot, cfg.Port, cfg.UseSSL, cfg.LocalOnly, logger)
|
||||
webServer := web.NewServer(params.Cfg.WebPath, params.Cfg.ServerRoot, params.Cfg.Port,
|
||||
params.Cfg.UseSSL, params.Cfg.LocalOnly, params.Logger)
|
||||
// if the adapter is a routed service, register it before the API
|
||||
if routedService, ok := wsAdapter.(web.RoutedService); ok {
|
||||
webServer.AddRoutes(routedService)
|
||||
}
|
||||
webServer.AddRoutes(focalboardAPI)
|
||||
|
||||
settings, err := db.GetSystemSettings()
|
||||
settings, err := params.DBStore.GetSystemSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -155,33 +170,34 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
||||
telemetryID := settings["TelemetryID"]
|
||||
if len(telemetryID) == 0 {
|
||||
telemetryID = uuid.New().String()
|
||||
if err = db.SetSystemSetting("TelemetryID", uuid.New().String()); err != nil {
|
||||
if err = params.DBStore.SetSystemSetting("TelemetryID", uuid.New().String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
telemetryOpts := telemetryOptions{
|
||||
app: app,
|
||||
cfg: cfg,
|
||||
cfg: params.Cfg,
|
||||
telemetryID: telemetryID,
|
||||
serverID: serverID,
|
||||
logger: logger,
|
||||
singleUser: len(singleUserToken) > 0,
|
||||
serverID: params.ServerID,
|
||||
logger: params.Logger,
|
||||
singleUser: len(params.SingleUserToken) > 0,
|
||||
}
|
||||
telemetryService := initTelemetry(telemetryOpts)
|
||||
|
||||
server := Server{
|
||||
config: cfg,
|
||||
wsAdapter: wsAdapter,
|
||||
webServer: webServer,
|
||||
store: db,
|
||||
filesBackend: filesBackend,
|
||||
telemetry: telemetryService,
|
||||
metricsServer: metrics.NewMetricsServer(cfg.PrometheusAddress, metricsService, logger),
|
||||
metricsService: metricsService,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
localRouter: localRouter,
|
||||
api: focalboardAPI,
|
||||
config: params.Cfg,
|
||||
wsAdapter: wsAdapter,
|
||||
webServer: webServer,
|
||||
store: params.DBStore,
|
||||
filesBackend: filesBackend,
|
||||
telemetry: telemetryService,
|
||||
metricsServer: metrics.NewMetricsServer(params.Cfg.PrometheusAddress, metricsService, params.Logger),
|
||||
metricsService: metricsService,
|
||||
auditService: auditService,
|
||||
notificationService: notificationService,
|
||||
logger: params.Logger,
|
||||
localRouter: localRouter,
|
||||
api: focalboardAPI,
|
||||
}
|
||||
|
||||
server.initHandlers()
|
||||
@ -314,6 +330,10 @@ func (s *Server) Shutdown() error {
|
||||
s.logger.Warn("Error occurred when shutting down audit service", mlog.Err(err))
|
||||
}
|
||||
|
||||
if err := s.notificationService.Shutdown(); err != nil {
|
||||
s.logger.Warn("Error occurred when shutting down notification service", mlog.Err(err))
|
||||
}
|
||||
|
||||
defer s.logger.Info("Server.Shutdown")
|
||||
|
||||
return s.store.Shutdown()
|
||||
@ -450,3 +470,12 @@ func initTelemetry(opts telemetryOptions) *telemetry.Service {
|
||||
})
|
||||
return telemetryService
|
||||
}
|
||||
|
||||
func initNotificationService(backends []notify.Backend, logger *mlog.Logger) (*notify.Service, error) {
|
||||
loggerBackend := notifylogger.New(logger, mlog.LvlDebug)
|
||||
|
||||
backends = append(backends, loggerBackend)
|
||||
|
||||
service, err := notify.New(logger, backends...)
|
||||
return service, err
|
||||
}
|
||||
|
59
server/services/notify/notifylogger/logger_backend.go
Normal file
59
server/services/notify/notifylogger/logger_backend.go
Normal file
@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifylogger
|
||||
|
||||
import (
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyLogger"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
logger *mlog.Logger
|
||||
level mlog.Level
|
||||
}
|
||||
|
||||
func New(logger *mlog.Logger, level mlog.Level) *Backend {
|
||||
return &Backend{
|
||||
logger: logger,
|
||||
level: level,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
var board string
|
||||
var card string
|
||||
|
||||
if evt.Board != nil {
|
||||
board = evt.Board.Title
|
||||
}
|
||||
if evt.Card != nil {
|
||||
card = evt.Card.Title
|
||||
}
|
||||
|
||||
b.logger.Log(b.level, "Block change event",
|
||||
mlog.String("action", string(evt.Action)),
|
||||
mlog.String("board", board),
|
||||
mlog.String("card", card),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
12
server/services/notify/notifymentions/delivery.go
Normal file
12
server/services/notify/notifymentions/delivery.go
Normal file
@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import "github.com/mattermost/focalboard/server/services/notify"
|
||||
|
||||
// Delivery provides an interface for delivering notifications to other systems, such as
|
||||
// MM server or email.
|
||||
type Delivery interface {
|
||||
Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error
|
||||
}
|
98
server/services/notify/notifymentions/extract.go
Normal file
98
server/services/notify/notifymentions/extract.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
defPrefixLines = 2
|
||||
defPrefixMaxChars = 100
|
||||
defSuffixLines = 2
|
||||
defSuffixMaxChars = 100
|
||||
)
|
||||
|
||||
type limits struct {
|
||||
prefixLines int
|
||||
prefixMaxChars int
|
||||
suffixLines int
|
||||
suffixMaxChars int
|
||||
}
|
||||
|
||||
func newLimits() limits {
|
||||
return limits{
|
||||
prefixLines: defPrefixLines,
|
||||
prefixMaxChars: defPrefixMaxChars,
|
||||
suffixLines: defSuffixLines,
|
||||
suffixMaxChars: defSuffixMaxChars,
|
||||
}
|
||||
}
|
||||
|
||||
// extractText returns all or a subset of the input string, such that
|
||||
// no more than `prefixLines` lines preceding the mention and `suffixLines`
|
||||
// lines after the mention are returned, and no more than approx
|
||||
// prefixMaxChars+suffixMaxChars are returned.
|
||||
func extractText(s string, mention string, limits limits) string {
|
||||
if !strings.HasPrefix(mention, "@") {
|
||||
mention = "@" + mention
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
|
||||
// find first line with mention
|
||||
found := -1
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, mention) {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := safeConcat(lines, found-limits.prefixLines, found)
|
||||
suffix := safeConcat(lines, found+1, found+limits.suffixLines+1)
|
||||
combined := strings.TrimSpace(strings.Join([]string{prefix, lines[found], suffix}, "\n"))
|
||||
|
||||
// find mention position within
|
||||
pos := strings.Index(combined, mention)
|
||||
pos = max(pos, 0)
|
||||
|
||||
return safeSubstr(combined, pos-limits.prefixMaxChars, pos+limits.suffixMaxChars)
|
||||
}
|
||||
|
||||
func safeConcat(lines []string, start int, end int) string {
|
||||
count := len(lines)
|
||||
start = min(max(start, 0), count)
|
||||
end = min(max(end, start), count)
|
||||
|
||||
var sb strings.Builder
|
||||
for i := start; i < end; i++ {
|
||||
if lines[i] != "" {
|
||||
sb.WriteString(lines[i])
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func safeSubstr(s string, start int, end int) string {
|
||||
count := len(s)
|
||||
start = min(max(start, 0), count)
|
||||
end = min(max(end, start), count)
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func min(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
115
server/services/notify/notifymentions/extract_test.go
Normal file
115
server/services/notify/notifymentions/extract_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
s0 = "Zero is in the mind @billy."
|
||||
s1 = "This is line 1."
|
||||
s2 = "Line two is right here."
|
||||
s3 = "Three is the line I am."
|
||||
s4 = "'Four score and seven years...', said @lincoln."
|
||||
s5 = "Fast Five was arguably the best F&F film."
|
||||
s6 = "Big Hero 6 may have an inflated sense of self."
|
||||
s7 = "The seventh sign, @sarah, will be a failed unit test."
|
||||
)
|
||||
|
||||
var (
|
||||
all = []string{s0, s1, s2, s3, s4, s5, s6, s7}
|
||||
allConcat = strings.Join(all, "\n")
|
||||
|
||||
extractLimits = limits{
|
||||
prefixLines: 2,
|
||||
prefixMaxChars: 100,
|
||||
suffixLines: 2,
|
||||
suffixMaxChars: 100,
|
||||
}
|
||||
)
|
||||
|
||||
func join(s ...string) string {
|
||||
return strings.Join(s, "\n")
|
||||
}
|
||||
|
||||
func Test_extractText(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
mention string
|
||||
limits limits
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "good", want: join(s2, s3, s4, s5, s6), args: args{mention: "@lincoln", limits: extractLimits, s: allConcat}},
|
||||
{name: "not found", want: "", args: args{mention: "@bogus", limits: extractLimits, s: allConcat}},
|
||||
{name: "one line", want: join(s4), args: args{mention: "@lincoln", limits: extractLimits, s: s4}},
|
||||
{name: "two lines", want: join(s4, s5), args: args{mention: "@lincoln", limits: extractLimits, s: join(s4, s5)}},
|
||||
{name: "zero lines", want: "", args: args{mention: "@lincoln", limits: extractLimits, s: ""}},
|
||||
{name: "first line mention", want: join(s0, s1, s2), args: args{mention: "@billy", limits: extractLimits, s: allConcat}},
|
||||
{name: "last line mention", want: join(s5[7:], s6, s7), args: args{mention: "@sarah", limits: extractLimits, s: allConcat}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractText(tt.args.s, tt.args.mention, tt.args.limits); got != tt.want {
|
||||
t.Errorf("extractText()\ngot:\n%v\nwant:\n%v\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_safeConcat(t *testing.T) {
|
||||
type args struct {
|
||||
lines []string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "out of range", want: join(s0, s1, s2, s3, s4, s5, s6, s7), args: args{start: -22, end: 99, lines: all}},
|
||||
{name: "2,3", want: join(s2, s3), args: args{start: 2, end: 4, lines: all}},
|
||||
{name: "mismatch", want: "", args: args{start: 4, end: 2, lines: all}},
|
||||
{name: "empty", want: "", args: args{start: 2, end: 4, lines: []string{}}},
|
||||
{name: "nil", want: "", args: args{start: 2, end: 4, lines: nil}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeConcat(tt.args.lines, tt.args.start, tt.args.end); got != tt.want {
|
||||
t.Errorf("safeConcat() = [%v], want [%v]", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_safeSubstr(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{name: "good", want: "is line", args: args{start: 33, end: 40, s: join(s0, s1, s2)}},
|
||||
{name: "out of range", want: allConcat, args: args{start: -10, end: 1000, s: allConcat}},
|
||||
{name: "mismatch", want: "", args: args{start: 33, end: 26, s: allConcat}},
|
||||
{name: "empty", want: "", args: args{start: 2, end: 4, s: ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeSubstr(tt.args.s, tt.args.start, tt.args.end); got != tt.want {
|
||||
t.Errorf("safeSubstr()\ngot:\n[%v]\nwant:\n[%v]\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
34
server/services/notify/notifymentions/mentions.go
Normal file
34
server/services/notify/notifymentions/mentions.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var atMentionRegexp = regexp.MustCompile(`\B@[[:alnum:]][[:alnum:]\.\-_:]*`)
|
||||
|
||||
// extractMentions extracts any mentions in the specified block and returns
|
||||
// a slice of usernames.
|
||||
func extractMentions(block *model.Block) map[string]struct{} {
|
||||
mentions := make(map[string]struct{})
|
||||
if block == nil || !strings.Contains(block.Title, "@") {
|
||||
return mentions
|
||||
}
|
||||
|
||||
str := block.Title
|
||||
|
||||
for _, match := range atMentionRegexp.FindAllString(str, -1) {
|
||||
name := mm_model.NormalizeUsername(match[1:])
|
||||
if mm_model.IsValidUsernameAllowRemote(name) {
|
||||
mentions[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return mentions
|
||||
}
|
79
server/services/notify/notifymentions/mentions_backend.go
Normal file
79
server/services/notify/notifymentions/mentions_backend.go
Normal file
@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyMentions"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
delivery Delivery
|
||||
logger *mlog.Logger
|
||||
}
|
||||
|
||||
func New(delivery Delivery, logger *mlog.Logger) *Backend {
|
||||
return &Backend{
|
||||
delivery: delivery,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
if evt.Board == nil || evt.Card == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if evt.Action == notify.Delete {
|
||||
return nil
|
||||
}
|
||||
|
||||
if evt.BlockChanged.Type != "text" && evt.BlockChanged.Type != "comment" {
|
||||
return nil
|
||||
}
|
||||
|
||||
mentions := extractMentions(evt.BlockChanged)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldMentions := extractMentions(evt.BlockOld)
|
||||
merr := merror.New()
|
||||
|
||||
for username := range mentions {
|
||||
if _, exists := oldMentions[username]; exists {
|
||||
// the mention already existed; no need to notify again
|
||||
continue
|
||||
}
|
||||
|
||||
extract := extractText(evt.BlockChanged.Title, username, newLimits())
|
||||
|
||||
err := b.delivery.Deliver(username, extract, evt)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err))
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
52
server/services/notify/notifymentions/mentions_test.go
Normal file
52
server/services/notify/notifymentions/mentions_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func Test_extractMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
block *model.Block
|
||||
want map[string]struct{}
|
||||
}{
|
||||
{name: "empty", block: makeBlock(""), want: makeMap()},
|
||||
{name: "zero mentions", block: makeBlock("This is some text."), want: makeMap()},
|
||||
{name: "one mention", block: makeBlock("Hello @user1"), want: makeMap("user1")},
|
||||
{name: "multiple mentions", block: makeBlock("Hello @user1, @user2 and @user3"), want: makeMap("user1", "user2", "user3")},
|
||||
{name: "include period", block: makeBlock("Hello @user1."), want: makeMap("user1.")},
|
||||
{name: "include underscore", block: makeBlock("Hello @user1_"), want: makeMap("user1_")},
|
||||
{name: "don't include comma", block: makeBlock("Hello @user1,"), want: makeMap("user1")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractMentions(tt.block); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("extractMentions() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeBlock(text string) *model.Block {
|
||||
return &model.Block{
|
||||
ID: mm_model.NewId(),
|
||||
Type: "comment",
|
||||
Title: text,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMap(mentions ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
for _, mention := range mentions {
|
||||
m[mention] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
28
server/services/notify/plugindelivery/message.go
Normal file
28
server/services/notify/plugindelivery/message.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: localize these when i18n is available.
|
||||
defCommentTemplate = "@%s mentioned you in a comment on the card [%s](%s)\n> %s"
|
||||
defDescriptionTemplate = "@%s mentioned you in the card [%s](%s)\n> %s"
|
||||
)
|
||||
|
||||
func formatMessage(author string, extract string, card string, link string, block *model.Block) string {
|
||||
template := defDescriptionTemplate
|
||||
if block.Type == "comment" {
|
||||
template = defCommentTemplate
|
||||
}
|
||||
return fmt.Sprintf(template, author, card, link, extract)
|
||||
}
|
||||
|
||||
func makeLink(serverRoot string, workspace string, board string, card string) string {
|
||||
return fmt.Sprintf("%s/workspace/%s/%s/0/%s/", serverRoot, workspace, board, card)
|
||||
}
|
72
server/services/notify/plugindelivery/plugin_delivery.go
Normal file
72
server/services/notify/plugindelivery/plugin_delivery.go
Normal file
@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type PluginAPI interface {
|
||||
// GetDirectChannel gets a direct message channel.
|
||||
// If the channel does not exist it will create it.
|
||||
GetDirectChannel(userID1, userID2 string) (*model.Channel, error)
|
||||
|
||||
// CreatePost creates a post.
|
||||
CreatePost(post *model.Post) error
|
||||
|
||||
// GetUserByIS gets a user by their ID.
|
||||
GetUserByID(userID string) (*model.User, error)
|
||||
|
||||
// GetUserByUsername gets a user by their username.
|
||||
GetUserByUsername(name string) (*model.User, error)
|
||||
}
|
||||
|
||||
// PluginDelivery provides ability to send notifications to direct message channels via Mattermost plugin API.
|
||||
type PluginDelivery struct {
|
||||
botID string
|
||||
serverRoot string
|
||||
api PluginAPI
|
||||
}
|
||||
|
||||
func New(botID string, serverRoot string, api PluginAPI) *PluginDelivery {
|
||||
return &PluginDelivery{
|
||||
botID: botID,
|
||||
serverRoot: serverRoot,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PluginDelivery) Deliver(mentionUsername string, extract string, evt notify.BlockChangeEvent) error {
|
||||
user, err := userFromUsername(pd.api, mentionUsername)
|
||||
if err != nil {
|
||||
if isErrNotFound(err) {
|
||||
// not really an error; could just be someone typed "@sometext"
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("cannot lookup mentioned user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
author, err := pd.api.GetUserByID(evt.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot find user: %w", err)
|
||||
}
|
||||
|
||||
channel, err := pd.api.GetDirectChannel(user.Id, pd.botID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get direct channel: %w", err)
|
||||
}
|
||||
link := makeLink(pd.serverRoot, evt.Workspace, evt.Board.ID, evt.Card.ID)
|
||||
|
||||
post := &model.Post{
|
||||
UserId: pd.botID,
|
||||
ChannelId: channel.Id,
|
||||
Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged),
|
||||
}
|
||||
return pd.api.CreatePost(post)
|
||||
}
|
60
server/services/notify/plugindelivery/user.go
Normal file
60
server/services/notify/plugindelivery/user.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
const (
|
||||
usernameSpecialChars = ".-_ "
|
||||
)
|
||||
|
||||
func userFromUsername(api PluginAPI, username string) (*mm_model.User, error) {
|
||||
user, err := api.GetUserByUsername(username)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// only continue if the error is `ErrNotFound`
|
||||
if !isErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check for usernames in substrings without trailing punctuation
|
||||
trimmed, ok := trimUsernameSpecialChar(username)
|
||||
for ; ok; trimmed, ok = trimUsernameSpecialChar(trimmed) {
|
||||
userFromTrimmed, err2 := api.GetUserByUsername(trimmed)
|
||||
if err2 != nil && !isErrNotFound(err2) {
|
||||
return nil, err2
|
||||
}
|
||||
|
||||
if err2 == nil {
|
||||
return userFromTrimmed, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// trimUsernameSpecialChar tries to remove the last character from word if it
|
||||
// is a special character for usernames (dot, dash or underscore). If not, it
|
||||
// returns the same string.
|
||||
func trimUsernameSpecialChar(word string) (string, bool) {
|
||||
len := len(word)
|
||||
|
||||
if len > 0 && strings.LastIndexAny(word, usernameSpecialChars) == (len-1) {
|
||||
return word[:len-1], true
|
||||
}
|
||||
|
||||
return word, false
|
||||
}
|
||||
|
||||
// isErrNotFound returns true if the error is a plugin.ErrNotFound. The pluginAPI converts
|
||||
// AppError to the plugin.ErrNotFound var.
|
||||
// TODO: add a `IsErrNotFound` method to the plugin API.
|
||||
func isErrNotFound(err error) bool {
|
||||
return err != nil && err.Error() == "not found"
|
||||
}
|
105
server/services/notify/plugindelivery/user_test.go
Normal file
105
server/services/notify/plugindelivery/user_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var (
|
||||
user1 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "dlauder",
|
||||
}
|
||||
user2 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "steve.mqueen",
|
||||
}
|
||||
user3 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "bart_",
|
||||
}
|
||||
user4 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "missing_",
|
||||
}
|
||||
|
||||
mockUsers = map[string]*mm_model.User{
|
||||
"dlauder": user1,
|
||||
"steve.mqueen": user2,
|
||||
"bart_": user3,
|
||||
}
|
||||
)
|
||||
|
||||
func Test_userFromUsername(t *testing.T) {
|
||||
delivery := newPlugAPIMock(mockUsers)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uname string
|
||||
want *mm_model.User
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "user1", uname: user1.Username, want: user1, wantErr: false},
|
||||
{name: "user1 with period", uname: user1.Username + ".", want: user1, wantErr: false},
|
||||
{name: "user1 with period plus more", uname: user1.Username + ". ", want: user1, wantErr: false},
|
||||
{name: "user2 with periods", uname: user2.Username + "...", want: user2, wantErr: false},
|
||||
{name: "user2 with underscore", uname: user2.Username + "_", want: user2, wantErr: false},
|
||||
{name: "user2 with hyphen plus more", uname: user2.Username + "- ", want: user2, wantErr: false},
|
||||
{name: "user2 with hyphen plus all", uname: user2.Username + ".-_ ", want: user2, wantErr: false},
|
||||
{name: "user3 with underscore", uname: user3.Username + "_", want: user3, wantErr: false},
|
||||
{name: "user4 missing", uname: user4.Username, want: nil, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := userFromUsername(delivery, tt.uname)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("userFromUsername() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("userFromUsername() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pluginAPIMock struct {
|
||||
users map[string]*mm_model.User
|
||||
}
|
||||
|
||||
func newPlugAPIMock(users map[string]*mm_model.User) pluginAPIMock {
|
||||
return pluginAPIMock{
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
func (m pluginAPIMock) GetUserByUsername(name string) (*mm_model.User, error) {
|
||||
user, ok := m.users[name]
|
||||
if !ok {
|
||||
return nil, ErrNotFound{}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m pluginAPIMock) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m pluginAPIMock) CreatePost(post *mm_model.Post) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m pluginAPIMock) GetUserByID(userID string) (*mm_model.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type ErrNotFound struct{}
|
||||
|
||||
func (e ErrNotFound) Error() string {
|
||||
return "not found"
|
||||
}
|
108
server/services/notify/service.go
Normal file
108
server/services/notify/service.go
Normal file
@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
Add Action = "add"
|
||||
Update Action = "update"
|
||||
Delete Action = "delete"
|
||||
)
|
||||
|
||||
type BlockChangeEvent struct {
|
||||
Action Action
|
||||
Workspace string
|
||||
Board *model.Block
|
||||
Card *model.Block
|
||||
BlockChanged *model.Block
|
||||
BlockOld *model.Block
|
||||
UserID string
|
||||
}
|
||||
|
||||
// Backend provides an interface for sending notifications.
|
||||
type Backend interface {
|
||||
Start() error
|
||||
ShutDown() error
|
||||
BlockChanged(evt BlockChangeEvent) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Service is a service that sends notifications based on block activity using one or more backends.
|
||||
type Service struct {
|
||||
mux sync.RWMutex
|
||||
backends []Backend
|
||||
logger *mlog.Logger
|
||||
}
|
||||
|
||||
// New creates a notification service with one or more Backends capable of sending notifications.
|
||||
func New(logger *mlog.Logger, backends ...Backend) (*Service, error) {
|
||||
notify := &Service{
|
||||
backends: make([]Backend, 0, len(backends)),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
for _, backend := range backends {
|
||||
if err := notify.AddBackend(backend); err != nil {
|
||||
merr.Append(err)
|
||||
} else {
|
||||
logger.Info("Initialized notification backend", mlog.String("name", backend.Name()))
|
||||
}
|
||||
}
|
||||
return notify, merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// AddBackend adds a backend to the list that will be informed of any block changes.
|
||||
func (s *Service) AddBackend(backend Backend) error {
|
||||
if err := backend.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
s.backends = append(s.backends, backend)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown calls shutdown for all backends.
|
||||
func (s *Service) Shutdown() error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
merr := merror.New()
|
||||
for _, backend := range s.backends {
|
||||
if err := backend.ShutDown(); err != nil {
|
||||
merr.Append(err)
|
||||
}
|
||||
}
|
||||
s.backends = nil
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// BlockChanged should be called whenever a block is added/updated/deleted.
|
||||
// All backends are informed of the event.
|
||||
func (s *Service) BlockChanged(evt BlockChangeEvent) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
for _, backend := range s.backends {
|
||||
if err := backend.BlockChanged(evt); err != nil {
|
||||
s.logger.Error("Error delivering notification",
|
||||
mlog.String("backend", backend.Name()),
|
||||
mlog.String("action", string(evt.Action)),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user