parent
20aafbc376
commit
8425f53b2a
23 changed files with 1209 additions and 81 deletions
@ -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) |
||||
} |
@ -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) |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -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 |
||||
} |
@ -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() |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
@ -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) |
||||
} |
@ -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" |
||||
} |
@ -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" |
||||
} |
@ -0,0 +1,108 @@ |
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notify |
||||
|
||||
import ( |
||||
"sync" |
||||
|
||||
"github.com/mattermost/focalboard/server/model" |
||||
"github.com/wiggin77/merror" |
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog" |
||||
) |
||||
|
||||
type Action string |
||||
|
||||
const ( |
||||
Add Action = "add" |
||||
Update Action = "update" |
||||
Delete Action = "delete" |
||||
) |
||||
|
||||
type BlockChangeEvent struct { |
||||
Action Action |
||||
Workspace string |
||||
Board *model.Block |
||||
Card *model.Block |
||||
BlockChanged *model.Block |
||||
BlockOld *model.Block |
||||
UserID string |
||||
} |
||||
|
||||
// Backend provides an interface for sending notifications.
|
||||
type Backend interface { |
||||
Start() error |
||||
ShutDown() error |
||||
BlockChanged(evt BlockChangeEvent) error |
||||
Name() string |
||||
} |
||||
|
||||
// Service is a service that sends notifications based on block activity using one or more backends.
|
||||
type Service struct { |
||||
mux sync.RWMutex |
||||
backends []Backend |
||||
logger *mlog.Logger |
||||
} |
||||
|
||||
// New creates a notification service with one or more Backends capable of sending notifications.
|
||||
func New(logger *mlog.Logger, backends ...Backend) (*Service, error) { |
||||
notify := &Service{ |
||||
backends: make([]Backend, 0, len(backends)), |
||||
logger: logger, |
||||
} |
||||
|
||||
merr := merror.New() |
||||
for _, backend := range backends { |
||||
if err := notify.AddBackend(backend); err != nil { |
||||
merr.Append(err) |
||||
} else { |
||||
logger.Info("Initialized notification backend", mlog.String("name", backend.Name())) |
||||
} |
||||
} |
||||
return notify, merr.ErrorOrNil() |
||||
} |
||||
|
||||
// AddBackend adds a backend to the list that will be informed of any block changes.
|
||||
func (s *Service) AddBackend(backend Backend) error { |
||||
if err := backend.Start(); err != nil { |
||||
return err |
||||
} |
||||
s.mux.Lock() |
||||
defer s.mux.Unlock() |
||||
s.backends = append(s.backends, backend) |
||||
return nil |
||||
} |
||||
|
||||
// Shutdown calls shutdown for all backends.
|
||||
func (s *Service) Shutdown() error { |
||||
s.mux.Lock() |
||||
defer s.mux.Unlock() |
||||
|
||||
merr := merror.New() |
||||
for _, backend := range s.backends { |
||||
if err := backend.ShutDown(); err != nil { |
||||
merr.Append(err) |
||||
} |
||||
} |
||||
s.backends = nil |
||||
return merr.ErrorOrNil() |
||||
} |
||||
|
||||
// BlockChanged should be called whenever a block is added/updated/deleted.
|
||||
// All backends are informed of the event.
|
||||
func (s *Service) BlockChanged(evt BlockChangeEvent) { |
||||
s.mux.RLock() |
||||
defer s.mux.RUnlock() |
||||
|
||||
for _, backend := range s.backends { |
||||
if err := backend.BlockChanged(evt); err != nil { |
||||
s.logger.Error("Error delivering notification", |
||||
mlog.String("backend", backend.Name()), |
||||
mlog.String("action", string(evt.Action)), |
||||
mlog.String("block_id", evt.BlockChanged.ID), |
||||
mlog.Err(err), |
||||
) |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue