@mention support (#1147)

This commit is contained in:
Doug Lauder 2021-09-13 15:36:36 -04:00 committed by GitHub
parent 20aafbc376
commit 8425f53b2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1210 additions and 82 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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