You've already forked focalboard
mirror of
https://github.com/mattermost/focalboard.git
synced 2025-07-15 23:54:29 +02:00
@mention support (#1147)
This commit is contained in:
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.
|
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
|
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 {
|
if err != nil {
|
||||||
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
||||||
return nil, 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/auth"
|
||||||
"github.com/mattermost/focalboard/server/server"
|
"github.com/mattermost/focalboard/server/server"
|
||||||
"github.com/mattermost/focalboard/server/services/config"
|
"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"
|
||||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||||
"github.com/mattermost/focalboard/server/services/store/sqlstore"
|
"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))
|
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 {
|
if err != nil {
|
||||||
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
||||||
return err
|
return err
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/mattermost/focalboard/server/auth"
|
"github.com/mattermost/focalboard/server/auth"
|
||||||
"github.com/mattermost/focalboard/server/services/config"
|
"github.com/mattermost/focalboard/server/services/config"
|
||||||
"github.com/mattermost/focalboard/server/services/metrics"
|
"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/store"
|
||||||
"github.com/mattermost/focalboard/server/services/webhook"
|
"github.com/mattermost/focalboard/server/services/webhook"
|
||||||
"github.com/mattermost/focalboard/server/ws"
|
"github.com/mattermost/focalboard/server/ws"
|
||||||
@ -19,6 +20,7 @@ type Services struct {
|
|||||||
FilesBackend filestore.FileBackend
|
FilesBackend filestore.FileBackend
|
||||||
Webhook *webhook.Client
|
Webhook *webhook.Client
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
|
Notifications *notify.Service
|
||||||
Logger *mlog.Logger
|
Logger *mlog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +32,7 @@ type App struct {
|
|||||||
filesBackend filestore.FileBackend
|
filesBackend filestore.FileBackend
|
||||||
webhook *webhook.Client
|
webhook *webhook.Client
|
||||||
metrics *metrics.Metrics
|
metrics *metrics.Metrics
|
||||||
|
notifications *notify.Service
|
||||||
logger *mlog.Logger
|
logger *mlog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +45,7 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services)
|
|||||||
filesBackend: services.FilesBackend,
|
filesBackend: services.FilesBackend,
|
||||||
webhook: services.Webhook,
|
webhook: services.Webhook,
|
||||||
metrics: services.Metrics,
|
metrics: services.Metrics,
|
||||||
|
notifications: services.Notifications,
|
||||||
logger: services.Logger,
|
logger: services.Logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,10 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mattermost/focalboard/server/model"
|
"github.com/mattermost/focalboard/server/model"
|
||||||
|
"github.com/mattermost/focalboard/server/services/notify"
|
||||||
"github.com/mattermost/focalboard/server/services/store"
|
"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) {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
a.metrics.IncrementBlocksPatched(1)
|
a.metrics.IncrementBlocksPatched(1)
|
||||||
block, err := a.store.GetBlock(c, blockID)
|
block, err := a.store.GetBlock(c, blockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *block)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) InsertBlock(c store.Container, block model.Block, userID string) error {
|
func (a *App) InsertBlock(c store.Container, block model.Block, userID string) error {
|
||||||
err := a.store.InsertBlock(c, &block, userID)
|
err := a.store.InsertBlock(c, &block, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, block)
|
||||||
a.metrics.IncrementBlocksInserted(1)
|
a.metrics.IncrementBlocksInserted(1)
|
||||||
|
go func() {
|
||||||
|
a.webhook.NotifyUpdate(block)
|
||||||
|
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string) error {
|
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string) error {
|
||||||
|
needsNotify := make([]model.Block, 0, len(blocks))
|
||||||
for i := range blocks {
|
for i := range blocks {
|
||||||
err := a.store.InsertBlock(c, &blocks[i], userID)
|
err := a.store.InsertBlock(c, &blocks[i], userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
needsNotify = append(needsNotify, blocks[i])
|
||||||
|
|
||||||
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, blocks[i])
|
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, blocks[i])
|
||||||
a.metrics.IncrementBlocksInserted(len(blocks))
|
a.metrics.IncrementBlocksInserted(1)
|
||||||
go a.webhook.NotifyUpdate(blocks[i])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for _, b := range needsNotify {
|
||||||
|
block := b
|
||||||
|
a.webhook.NotifyUpdate(block)
|
||||||
|
a.notifyBlockChanged(notify.Add, c, &block, nil, userID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -90,12 +117,69 @@ func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, parentID)
|
a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, block.ParentID)
|
||||||
a.metrics.IncrementBlocksDeleted(1)
|
a.metrics.IncrementBlocksDeleted(1)
|
||||||
|
go func() {
|
||||||
|
a.notifyBlockChanged(notify.Update, c, block, block, modifiedBy)
|
||||||
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
|
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
|
||||||
return a.store.GetBlockCountsByType()
|
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/jwalterweatherman v1.1.0 // indirect
|
||||||
github.com/spf13/viper v1.7.1
|
github.com/spf13/viper v1.7.1
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
|
github.com/wiggin77/merror v1.0.3
|
||||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
)
|
)
|
||||||
|
@ -75,7 +75,15 @@ func newTestServer(singleUserToken string) *server.Server {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
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 {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
@ -165,7 +165,14 @@ func main() {
|
|||||||
logger.Fatal("server.NewStore ERROR", mlog.Err(err))
|
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 {
|
if err != nil {
|
||||||
logger.Fatal("server.New ERROR", mlog.Err(err))
|
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))
|
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 {
|
if err != nil {
|
||||||
logger.Fatal("server.New ERROR", mlog.Err(err))
|
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/audit"
|
||||||
"github.com/mattermost/focalboard/server/services/config"
|
"github.com/mattermost/focalboard/server/services/config"
|
||||||
"github.com/mattermost/focalboard/server/services/metrics"
|
"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/scheduler"
|
||||||
"github.com/mattermost/focalboard/server/services/store"
|
"github.com/mattermost/focalboard/server/services/store"
|
||||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||||
@ -60,6 +62,7 @@ type Server struct {
|
|||||||
metricsService *metrics.Metrics
|
metricsService *metrics.Metrics
|
||||||
metricsUpdaterTask *scheduler.ScheduledTask
|
metricsUpdaterTask *scheduler.ScheduledTask
|
||||||
auditService *audit.Audit
|
auditService *audit.Audit
|
||||||
|
notificationService *notify.Service
|
||||||
servicesStartStopMutex sync.Mutex
|
servicesStartStopMutex sync.Mutex
|
||||||
|
|
||||||
localRouter *mux.Router
|
localRouter *mux.Router
|
||||||
@ -67,37 +70,41 @@ type Server struct {
|
|||||||
api *api.API
|
api *api.API
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
func New(params Params) (*Server, error) {
|
||||||
logger *mlog.Logger, serverID string, wsAdapter ws.Adapter) (*Server, error) {
|
if err := params.CheckValid(); err != nil {
|
||||||
authenticator := auth.New(cfg, db)
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator := auth.New(params.Cfg, params.DBStore)
|
||||||
|
|
||||||
// if no ws adapter is provided, we spin up a websocket server
|
// if no ws adapter is provided, we spin up a websocket server
|
||||||
|
wsAdapter := params.WSAdapter
|
||||||
if wsAdapter == nil {
|
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 := filestore.FileBackendSettings{}
|
||||||
filesBackendSettings.DriverName = cfg.FilesDriver
|
filesBackendSettings.DriverName = params.Cfg.FilesDriver
|
||||||
filesBackendSettings.Directory = cfg.FilesPath
|
filesBackendSettings.Directory = params.Cfg.FilesPath
|
||||||
filesBackendSettings.AmazonS3AccessKeyId = cfg.FilesS3Config.AccessKeyID
|
filesBackendSettings.AmazonS3AccessKeyId = params.Cfg.FilesS3Config.AccessKeyID
|
||||||
filesBackendSettings.AmazonS3SecretAccessKey = cfg.FilesS3Config.SecretAccessKey
|
filesBackendSettings.AmazonS3SecretAccessKey = params.Cfg.FilesS3Config.SecretAccessKey
|
||||||
filesBackendSettings.AmazonS3Bucket = cfg.FilesS3Config.Bucket
|
filesBackendSettings.AmazonS3Bucket = params.Cfg.FilesS3Config.Bucket
|
||||||
filesBackendSettings.AmazonS3PathPrefix = cfg.FilesS3Config.PathPrefix
|
filesBackendSettings.AmazonS3PathPrefix = params.Cfg.FilesS3Config.PathPrefix
|
||||||
filesBackendSettings.AmazonS3Region = cfg.FilesS3Config.Region
|
filesBackendSettings.AmazonS3Region = params.Cfg.FilesS3Config.Region
|
||||||
filesBackendSettings.AmazonS3Endpoint = cfg.FilesS3Config.Endpoint
|
filesBackendSettings.AmazonS3Endpoint = params.Cfg.FilesS3Config.Endpoint
|
||||||
filesBackendSettings.AmazonS3SSL = cfg.FilesS3Config.SSL
|
filesBackendSettings.AmazonS3SSL = params.Cfg.FilesS3Config.SSL
|
||||||
filesBackendSettings.AmazonS3SignV2 = cfg.FilesS3Config.SignV2
|
filesBackendSettings.AmazonS3SignV2 = params.Cfg.FilesS3Config.SignV2
|
||||||
filesBackendSettings.AmazonS3SSE = cfg.FilesS3Config.SSE
|
filesBackendSettings.AmazonS3SSE = params.Cfg.FilesS3Config.SSE
|
||||||
filesBackendSettings.AmazonS3Trace = cfg.FilesS3Config.Trace
|
filesBackendSettings.AmazonS3Trace = params.Cfg.FilesS3Config.Trace
|
||||||
|
|
||||||
filesBackend, appErr := filestore.NewFileBackend(filesBackendSettings)
|
filesBackend, appErr := filestore.NewFileBackend(filesBackendSettings)
|
||||||
if appErr != nil {
|
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")
|
return nil, errors.New("unable to initialize the files storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookClient := webhook.NewClient(cfg, logger)
|
webhookClient := webhook.NewClient(params.Cfg, params.Logger)
|
||||||
|
|
||||||
// Init metrics
|
// Init metrics
|
||||||
instanceInfo := metrics.InstanceInfo{
|
instanceInfo := metrics.InstanceInfo{
|
||||||
@ -113,21 +120,28 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
|||||||
if errAudit != nil {
|
if errAudit != nil {
|
||||||
return nil, fmt.Errorf("unable to create the audit service: %w", errAudit)
|
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)
|
return nil, fmt.Errorf("unable to initialize the audit service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init notification services
|
||||||
|
notificationService, errNotify := initNotificationService(params.NotifyBackends, params.Logger)
|
||||||
|
if errNotify != nil {
|
||||||
|
return nil, fmt.Errorf("cannot initialize notification service: %w", errNotify)
|
||||||
|
}
|
||||||
|
|
||||||
appServices := app.Services{
|
appServices := app.Services{
|
||||||
Auth: authenticator,
|
Auth: authenticator,
|
||||||
Store: db,
|
Store: params.DBStore,
|
||||||
FilesBackend: filesBackend,
|
FilesBackend: filesBackend,
|
||||||
Webhook: webhookClient,
|
Webhook: webhookClient,
|
||||||
Metrics: metricsService,
|
Metrics: metricsService,
|
||||||
Logger: logger,
|
Notifications: notificationService,
|
||||||
|
Logger: params.Logger,
|
||||||
}
|
}
|
||||||
app := app.New(cfg, wsAdapter, appServices)
|
app := app.New(params.Cfg, wsAdapter, appServices)
|
||||||
|
|
||||||
focalboardAPI := api.NewAPI(app, singleUserToken, cfg.AuthMode, logger, auditService)
|
focalboardAPI := api.NewAPI(app, params.SingleUserToken, params.Cfg.AuthMode, params.Logger, auditService)
|
||||||
|
|
||||||
// Local router for admin APIs
|
// Local router for admin APIs
|
||||||
localRouter := mux.NewRouter()
|
localRouter := mux.NewRouter()
|
||||||
@ -135,18 +149,19 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
|||||||
|
|
||||||
// Init workspace
|
// Init workspace
|
||||||
if _, err := app.GetRootWorkspace(); err != nil {
|
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
|
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 the adapter is a routed service, register it before the API
|
||||||
if routedService, ok := wsAdapter.(web.RoutedService); ok {
|
if routedService, ok := wsAdapter.(web.RoutedService); ok {
|
||||||
webServer.AddRoutes(routedService)
|
webServer.AddRoutes(routedService)
|
||||||
}
|
}
|
||||||
webServer.AddRoutes(focalboardAPI)
|
webServer.AddRoutes(focalboardAPI)
|
||||||
|
|
||||||
settings, err := db.GetSystemSettings()
|
settings, err := params.DBStore.GetSystemSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -155,31 +170,32 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store,
|
|||||||
telemetryID := settings["TelemetryID"]
|
telemetryID := settings["TelemetryID"]
|
||||||
if len(telemetryID) == 0 {
|
if len(telemetryID) == 0 {
|
||||||
telemetryID = uuid.New().String()
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
telemetryOpts := telemetryOptions{
|
telemetryOpts := telemetryOptions{
|
||||||
app: app,
|
app: app,
|
||||||
cfg: cfg,
|
cfg: params.Cfg,
|
||||||
telemetryID: telemetryID,
|
telemetryID: telemetryID,
|
||||||
serverID: serverID,
|
serverID: params.ServerID,
|
||||||
logger: logger,
|
logger: params.Logger,
|
||||||
singleUser: len(singleUserToken) > 0,
|
singleUser: len(params.SingleUserToken) > 0,
|
||||||
}
|
}
|
||||||
telemetryService := initTelemetry(telemetryOpts)
|
telemetryService := initTelemetry(telemetryOpts)
|
||||||
|
|
||||||
server := Server{
|
server := Server{
|
||||||
config: cfg,
|
config: params.Cfg,
|
||||||
wsAdapter: wsAdapter,
|
wsAdapter: wsAdapter,
|
||||||
webServer: webServer,
|
webServer: webServer,
|
||||||
store: db,
|
store: params.DBStore,
|
||||||
filesBackend: filesBackend,
|
filesBackend: filesBackend,
|
||||||
telemetry: telemetryService,
|
telemetry: telemetryService,
|
||||||
metricsServer: metrics.NewMetricsServer(cfg.PrometheusAddress, metricsService, logger),
|
metricsServer: metrics.NewMetricsServer(params.Cfg.PrometheusAddress, metricsService, params.Logger),
|
||||||
metricsService: metricsService,
|
metricsService: metricsService,
|
||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
logger: logger,
|
notificationService: notificationService,
|
||||||
|
logger: params.Logger,
|
||||||
localRouter: localRouter,
|
localRouter: localRouter,
|
||||||
api: focalboardAPI,
|
api: focalboardAPI,
|
||||||
}
|
}
|
||||||
@ -314,6 +330,10 @@ func (s *Server) Shutdown() error {
|
|||||||
s.logger.Warn("Error occurred when shutting down audit service", mlog.Err(err))
|
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")
|
defer s.logger.Info("Server.Shutdown")
|
||||||
|
|
||||||
return s.store.Shutdown()
|
return s.store.Shutdown()
|
||||||
@ -450,3 +470,12 @@ func initTelemetry(opts telemetryOptions) *telemetry.Service {
|
|||||||
})
|
})
|
||||||
return telemetryService
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user