Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
59
server/boards/services/notify/notifylogger/logger_backend.go
Обычный файл
59
server/boards/services/notify/notifylogger/logger_backend.go
Обычный файл
@@ -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/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyLogger"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
logger mlog.LoggerIFace
|
||||
level mlog.Level
|
||||
}
|
||||
|
||||
func New(logger mlog.LoggerIFace, 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
|
||||
}
|
||||
10
server/boards/services/notify/notifymentions/app_api.go
Обычный файл
10
server/boards/services/notify/notifymentions/app_api.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package notifymentions
|
||||
|
||||
import "github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
type AppAPI interface {
|
||||
GetMemberForBoard(boardID, userID string) (*model.BoardMember, error)
|
||||
AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error)
|
||||
}
|
||||
18
server/boards/services/notify/notifymentions/delivery.go
Обычный файл
18
server/boards/services/notify/notifymentions/delivery.go
Обычный файл
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// MentionDelivery provides an interface for delivering @mention notifications to other systems, such as
|
||||
// channels server via plugin API.
|
||||
// On success the user id of the user mentioned is returned.
|
||||
type MentionDelivery interface {
|
||||
MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error)
|
||||
UserByUsername(mentionUsername string) (*mm_model.User, error)
|
||||
}
|
||||
98
server/boards/services/notify/notifymentions/extract.go
Обычный файл
98
server/boards/services/notify/notifymentions/extract.go
Обычный файл
@@ -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/boards/services/notify/notifymentions/extract_test.go
Обычный файл
115
server/boards/services/notify/notifymentions/extract_test.go
Обычный файл
@@ -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/boards/services/notify/notifymentions/mentions.go
Обычный файл
34
server/boards/services/notify/notifymentions/mentions.go
Обычный файл
@@ -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/mattermost-server/v6/server/boards/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
|
||||
}
|
||||
241
server/boards/services/notify/notifymentions/mentions_backend.go
Обычный файл
241
server/boards/services/notify/notifymentions/mentions_backend.go
Обычный файл
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifymentions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifyMentions"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMentionPermission = errors.New("mention not permitted")
|
||||
)
|
||||
|
||||
type MentionListener interface {
|
||||
OnMention(userID string, evt notify.BlockChangeEvent)
|
||||
}
|
||||
|
||||
type BackendParams struct {
|
||||
AppAPI AppAPI
|
||||
Permissions permissions.PermissionsService
|
||||
Delivery MentionDelivery
|
||||
Logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
// Backend provides the notification backend for @mentions.
|
||||
type Backend struct {
|
||||
appAPI AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery MentionDelivery
|
||||
logger mlog.LoggerIFace
|
||||
|
||||
mux sync.RWMutex
|
||||
listeners []MentionListener
|
||||
}
|
||||
|
||||
func New(params BackendParams) *Backend {
|
||||
return &Backend{
|
||||
appAPI: params.AppAPI,
|
||||
permissions: params.Permissions,
|
||||
delivery: params.Delivery,
|
||||
logger: params.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) AddListener(l MentionListener) {
|
||||
b.mux.Lock()
|
||||
defer b.mux.Unlock()
|
||||
b.listeners = append(b.listeners, l)
|
||||
b.logger.Debug("Mention listener added.", mlog.Int("listener_count", len(b.listeners)))
|
||||
}
|
||||
|
||||
func (b *Backend) RemoveListener(l MentionListener) {
|
||||
b.mux.Lock()
|
||||
defer b.mux.Unlock()
|
||||
list := make([]MentionListener, 0, len(b.listeners))
|
||||
for _, listener := range b.listeners {
|
||||
if listener != l {
|
||||
list = append(list, listener)
|
||||
}
|
||||
}
|
||||
b.listeners = list
|
||||
b.logger.Debug("Mention listener removed.", mlog.Int("listener_count", len(b.listeners)))
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
if evt.Board == nil || evt.Card == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if evt.Action == notify.Delete {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch evt.BlockChanged.Type {
|
||||
case model.TypeText, model.TypeComment, model.TypeImage:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
mentions := extractMentions(evt.BlockChanged)
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldMentions := extractMentions(evt.BlockOld)
|
||||
merr := merror.New()
|
||||
|
||||
b.mux.RLock()
|
||||
listeners := make([]MentionListener, len(b.listeners))
|
||||
copy(listeners, b.listeners)
|
||||
b.mux.RUnlock()
|
||||
|
||||
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())
|
||||
|
||||
userID, err := b.deliverMentionNotification(username, extract, evt)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMentionPermission) {
|
||||
b.logger.Debug("Cannot deliver notification", mlog.String("user", username), mlog.Err(err))
|
||||
} else {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification for @%s: %w", username, err))
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
// was a `@` followed by something other than a username.
|
||||
continue
|
||||
}
|
||||
|
||||
b.logger.Debug("Mention notification delivered",
|
||||
mlog.String("user", username),
|
||||
mlog.Int("listener_count", len(listeners)),
|
||||
)
|
||||
|
||||
for _, listener := range listeners {
|
||||
safeCallListener(listener, userID, evt, b.logger)
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func safeCallListener(listener MentionListener, userID string, evt notify.BlockChangeEvent, logger mlog.LoggerIFace) {
|
||||
// don't let panicky listeners stop notifications
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Error("panic calling @mention notification listener", mlog.Any("err", r))
|
||||
}
|
||||
}()
|
||||
listener.OnMention(userID, evt)
|
||||
}
|
||||
|
||||
func (b *Backend) deliverMentionNotification(username string, extract string, evt notify.BlockChangeEvent) (string, error) {
|
||||
mentionedUser, err := b.delivery.UserByUsername(username)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// not really an error; could just be someone typed "@sometext"
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot lookup mentioned user: %w", err)
|
||||
}
|
||||
|
||||
if evt.ModifiedBy == nil {
|
||||
return "", fmt.Errorf("invalid user cannot mention: %w", ErrMentionPermission)
|
||||
}
|
||||
|
||||
if evt.Board.Type == model.BoardTypeOpen {
|
||||
// public board rules:
|
||||
// - admin, editor, commenter: can mention anyone on team (mentioned users are automatically added to board)
|
||||
// - guest: can mention board members
|
||||
switch {
|
||||
case evt.ModifiedBy.SchemeAdmin, evt.ModifiedBy.SchemeEditor, evt.ModifiedBy.SchemeCommenter:
|
||||
if !b.permissions.HasPermissionToTeam(mentionedUser.Id, evt.TeamID, model.PermissionViewTeam) {
|
||||
return "", fmt.Errorf("%s cannot mention non-team member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
// add mentioned user to board (if not already a member)
|
||||
member, err := b.appAPI.GetMemberForBoard(evt.Board.ID, mentionedUser.Id)
|
||||
if member == nil || model.IsErrNotFound(err) {
|
||||
// create memberships based on minimum board role
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: mentionedUser.Id,
|
||||
BoardID: evt.Board.ID,
|
||||
SchemeViewer: evt.Board.MinimumRole == model.BoardRoleViewer ||
|
||||
evt.Board.MinimumRole == model.BoardRoleCommenter ||
|
||||
evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeCommenter: evt.Board.MinimumRole == model.BoardRoleCommenter ||
|
||||
evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeEditor: evt.Board.MinimumRole == model.BoardRoleEditor,
|
||||
}
|
||||
if _, err = b.appAPI.AddMemberToBoard(newBoardMember); err != nil {
|
||||
return "", fmt.Errorf("cannot add mentioned user %s to board %s: %w", mentionedUser.Id, evt.Board.ID, err)
|
||||
}
|
||||
b.logger.Debug("auto-added mentioned user to board",
|
||||
mlog.String("user_id", mentionedUser.Id),
|
||||
mlog.String("board_id", evt.Board.ID),
|
||||
mlog.String("board_type", string(evt.Board.Type)),
|
||||
)
|
||||
} else {
|
||||
b.logger.Debug("skipping auto-add mentioned user to board; already a member",
|
||||
mlog.String("user_id", mentionedUser.Id),
|
||||
mlog.String("board_id", evt.Board.ID),
|
||||
mlog.String("board_type", string(evt.Board.Type)),
|
||||
)
|
||||
}
|
||||
case evt.ModifiedBy.SchemeViewer:
|
||||
// viewer should not have gotten this far since they cannot add text to a card
|
||||
return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
default:
|
||||
// this is a guest
|
||||
if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) {
|
||||
return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// private board rules:
|
||||
// - admin, editor, commenter, guest: can mention board members
|
||||
switch {
|
||||
case evt.ModifiedBy.SchemeViewer:
|
||||
// viewer should not have gotten this far since they cannot add text to a card
|
||||
return "", fmt.Errorf("%s (viewer) cannot mention user %s: %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
default:
|
||||
// everyone else can mention board members
|
||||
if !b.permissions.HasPermissionToBoard(mentionedUser.Id, evt.Board.ID, model.PermissionViewBoard) {
|
||||
return "", fmt.Errorf("%s cannot mention non-board member %s : %w", evt.ModifiedBy.UserID, mentionedUser.Id, ErrMentionPermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.delivery.MentionDeliver(mentionedUser, extract, evt)
|
||||
}
|
||||
52
server/boards/services/notify/notifymentions/mentions_test.go
Обычный файл
52
server/boards/services/notify/notifymentions/mentions_test.go
Обычный файл
@@ -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/mattermost-server/v6/server/boards/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: model.TypeComment,
|
||||
Title: text,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMap(mentions ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
for _, mention := range mentions {
|
||||
m[mention] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
25
server/boards/services/notify/notifysubscriptions/app_api.go
Обычный файл
25
server/boards/services/notify/notifysubscriptions/app_api.go
Обычный файл
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
type AppAPI interface {
|
||||
GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]*model.Block, error)
|
||||
GetBlockHistoryNewestChildren(parentID string, opts model.QueryBlockHistoryChildOptions) ([]*model.Block, bool, error)
|
||||
GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error)
|
||||
|
||||
GetUserByID(userID string) (*model.User, error)
|
||||
|
||||
CreateSubscription(sub *model.Subscription) (*model.Subscription, error)
|
||||
GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error)
|
||||
UpdateSubscribersNotifiedAt(blockID string, notifyAt int64) error
|
||||
|
||||
UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error)
|
||||
GetNextNotificationHint(remove bool) (*model.NotificationHint, error)
|
||||
}
|
||||
17
server/boards/services/notify/notifysubscriptions/delivery.go
Обычный файл
17
server/boards/services/notify/notifysubscriptions/delivery.go
Обычный файл
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// SubscriptionDelivery provides an interface for delivering subscription notifications to other systems, such as
|
||||
// channels server via plugin API.
|
||||
type SubscriptionDelivery interface {
|
||||
SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriberType model.SubscriberType,
|
||||
attachments []*mm_model.SlackAttachment) error
|
||||
}
|
||||
364
server/boards/services/notify/notifysubscriptions/diff.go
Обычный файл
364
server/boards/services/notify/notifysubscriptions/diff.go
Обычный файл
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// Diff represents a difference between two versions of a block.
|
||||
type Diff struct {
|
||||
Board *model.Board
|
||||
Card *model.Block
|
||||
Authors StringMap
|
||||
|
||||
BlockType model.BlockType
|
||||
OldBlock *model.Block
|
||||
NewBlock *model.Block
|
||||
|
||||
UpdateAt int64 // the UpdateAt of the latest version of the block
|
||||
|
||||
schemaDiffs []SchemaDiff
|
||||
PropDiffs []PropDiff
|
||||
|
||||
Diffs []*Diff // Diffs for child blocks
|
||||
}
|
||||
|
||||
type PropDiff struct {
|
||||
ID string // property id
|
||||
Index int
|
||||
Name string
|
||||
OldValue string
|
||||
NewValue string
|
||||
}
|
||||
|
||||
type SchemaDiff struct {
|
||||
Board *model.Board
|
||||
|
||||
OldPropDef *model.PropDef
|
||||
NewPropDef *model.PropDef
|
||||
}
|
||||
|
||||
type diffGenerator struct {
|
||||
board *model.Board
|
||||
card *model.Block
|
||||
|
||||
store AppAPI
|
||||
hint *model.NotificationHint
|
||||
lastNotifyAt int64
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generateDiffs() ([]*Diff, error) {
|
||||
// use block_history to fetch blocks in case they were deleted and no longer exist in blocks table.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
Limit: 1,
|
||||
Descending: true,
|
||||
}
|
||||
blocks, err := dg.store.GetBlockHistory(dg.hint.BlockID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get block for notification: %w", err)
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("block not found for notification: %w", err)
|
||||
}
|
||||
block := blocks[0]
|
||||
|
||||
if dg.board == nil || dg.card == nil {
|
||||
return nil, fmt.Errorf("cannot generate diff for block %s; must have a valid board and card: %w", dg.hint.BlockID, err)
|
||||
}
|
||||
|
||||
// parse board's property schema here so it only happens once.
|
||||
schema, err := model.ParsePropertySchema(dg.board)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse property schema for board %s: %w", dg.board.ID, err)
|
||||
}
|
||||
|
||||
switch block.Type {
|
||||
case model.TypeBoard:
|
||||
dg.logger.Warn("generateDiffs for board skipped", mlog.String("block_id", block.ID))
|
||||
// TODO: Fix this
|
||||
// return dg.generateDiffsForBoard(block, schema)
|
||||
return nil, nil
|
||||
case model.TypeCard:
|
||||
diff, err := dg.generateDiffsForCard(block, schema)
|
||||
if err != nil || diff == nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Diff{diff}, nil
|
||||
default:
|
||||
diff, err := dg.generateDiffForBlock(block, schema)
|
||||
if err != nil || diff == nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Diff{diff}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fix this
|
||||
/*
|
||||
func (dg *diffGenerator) generateDiffsForBoard(board *model.Board, schema model.PropSchema) ([]*Diff, error) {
|
||||
opts := model.QuerySubtreeOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
}
|
||||
|
||||
find all child blocks of the board that updated since last notify.
|
||||
blocks, err := dg.store.GetSubTree2(board.ID, board.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get subtree for board %s: %w", board.ID, err)
|
||||
}
|
||||
|
||||
var diffs []*Diff
|
||||
|
||||
generate diff for board title change or description
|
||||
boardDiff, err := dg.generateDiffForBlock(board, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for board %s: %w", board.ID, err)
|
||||
}
|
||||
|
||||
if boardDiff != nil {
|
||||
TODO: phase 2 feature (generate schema diffs and add to board diff) goes here.
|
||||
diffs = append(diffs, boardDiff)
|
||||
}
|
||||
|
||||
for _, b := range blocks {
|
||||
block := b
|
||||
if block.Type == model.TypeCard {
|
||||
cardDiffs, err := dg.generateDiffsForCard(&block, schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diffs = append(diffs, cardDiffs)
|
||||
}
|
||||
}
|
||||
return diffs, nil
|
||||
}
|
||||
*/
|
||||
|
||||
func (dg *diffGenerator) generateDiffsForCard(card *model.Block, schema model.PropSchema) (*Diff, error) {
|
||||
// generate diff for card title change and properties.
|
||||
cardDiff, err := dg.generateDiffForBlock(card, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for card %s: %w", card.ID, err)
|
||||
}
|
||||
|
||||
// fetch all card content blocks that were updated after last notify
|
||||
opts := model.QueryBlockHistoryChildOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
}
|
||||
blocks, _, err := dg.store.GetBlockHistoryNewestChildren(card.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get subtree for card %s: %w", card.ID, err)
|
||||
}
|
||||
|
||||
authors := make(StringMap)
|
||||
|
||||
// walk child blocks
|
||||
var childDiffs []*Diff
|
||||
for i := range blocks {
|
||||
if blocks[i].ID == card.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
blockDiff, err := dg.generateDiffForBlock(blocks[i], schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not generate diff for block %s: %w", blocks[i].ID, err)
|
||||
}
|
||||
if blockDiff != nil {
|
||||
childDiffs = append(childDiffs, blockDiff)
|
||||
authors.Append(blockDiff.Authors)
|
||||
}
|
||||
}
|
||||
|
||||
dg.logger.Debug("generateDiffsForCard",
|
||||
mlog.Bool("has_top_changes", cardDiff != nil),
|
||||
mlog.Int("subtree", len(blocks)),
|
||||
mlog.Array("author_names", authors.Values()),
|
||||
mlog.Int("child_diffs", len(childDiffs)),
|
||||
)
|
||||
|
||||
if len(childDiffs) != 0 {
|
||||
if cardDiff == nil { // will be nil if the card has no other changes besides child diffs
|
||||
cardDiff = &Diff{
|
||||
Board: dg.board,
|
||||
Card: card,
|
||||
Authors: make(StringMap),
|
||||
BlockType: card.Type,
|
||||
OldBlock: card,
|
||||
NewBlock: card,
|
||||
UpdateAt: card.UpdateAt,
|
||||
PropDiffs: nil,
|
||||
schemaDiffs: nil,
|
||||
}
|
||||
}
|
||||
cardDiff.Diffs = childDiffs
|
||||
}
|
||||
cardDiff.Authors.Append(authors)
|
||||
|
||||
return cardDiff, nil
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generateDiffForBlock(newBlock *model.Block, schema model.PropSchema) (*Diff, error) {
|
||||
dg.logger.Debug("generateDiffForBlock - new block",
|
||||
mlog.String("block_id", newBlock.ID),
|
||||
mlog.String("block_type", string(newBlock.Type)),
|
||||
mlog.String("modified_by", newBlock.ModifiedBy),
|
||||
mlog.Int64("update_at", newBlock.UpdateAt),
|
||||
)
|
||||
|
||||
// find the version of the block as it was at the time of last notify.
|
||||
opts := model.QueryBlockHistoryOptions{
|
||||
BeforeUpdateAt: dg.lastNotifyAt + 1,
|
||||
Limit: 1,
|
||||
Descending: true,
|
||||
}
|
||||
history, err := dg.store.GetBlockHistory(newBlock.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get block history for block %s: %w", newBlock.ID, err)
|
||||
}
|
||||
|
||||
var oldBlock *model.Block
|
||||
if len(history) != 0 {
|
||||
oldBlock = history[0]
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - old block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.String("block_type", string(oldBlock.Type)),
|
||||
mlog.Int64("before_update_at", dg.lastNotifyAt),
|
||||
mlog.String("modified_by", oldBlock.ModifiedBy),
|
||||
mlog.Int64("update_at", oldBlock.UpdateAt),
|
||||
)
|
||||
}
|
||||
|
||||
// find all the versions of the blocks that changed so we can gather all the author usernames.
|
||||
opts = model.QueryBlockHistoryOptions{
|
||||
AfterUpdateAt: dg.lastNotifyAt,
|
||||
Descending: true,
|
||||
}
|
||||
chgBlocks, err := dg.store.GetBlockHistory(newBlock.ID, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting block history for block %s: %w", newBlock.ID, err)
|
||||
}
|
||||
authors := make(StringMap)
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - authors",
|
||||
mlog.Int64("after_update_at", dg.lastNotifyAt),
|
||||
mlog.Int("history_count", len(chgBlocks)),
|
||||
)
|
||||
|
||||
// have to loop through history slice because GetBlockHistory does not return pointers.
|
||||
for _, b := range chgBlocks {
|
||||
user, err := dg.store.GetUserByID(b.ModifiedBy)
|
||||
if err != nil || user == nil {
|
||||
dg.logger.Error("could not fetch username for block",
|
||||
mlog.String("modified_by", b.ModifiedBy),
|
||||
mlog.Err(err),
|
||||
)
|
||||
authors.Add(b.ModifiedBy, "unknown_user") // todo: localize this when server has i18n
|
||||
} else {
|
||||
authors.Add(user.ID, user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
propDiffs := dg.generatePropDiffs(oldBlock, newBlock, schema)
|
||||
|
||||
dg.logger.Debug("generateDiffForBlock - results",
|
||||
mlog.String("block_id", newBlock.ID),
|
||||
mlog.String("block_type", string(newBlock.Type)),
|
||||
mlog.Array("author_names", authors.Values()),
|
||||
mlog.Int("history_count", len(history)),
|
||||
mlog.Int("prop_diff_count", len(propDiffs)),
|
||||
)
|
||||
|
||||
diff := &Diff{
|
||||
Board: dg.board,
|
||||
Card: dg.card,
|
||||
Authors: authors,
|
||||
BlockType: newBlock.Type,
|
||||
OldBlock: oldBlock,
|
||||
NewBlock: newBlock,
|
||||
UpdateAt: newBlock.UpdateAt,
|
||||
PropDiffs: propDiffs,
|
||||
schemaDiffs: nil,
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func (dg *diffGenerator) generatePropDiffs(oldBlock, newBlock *model.Block, schema model.PropSchema) []PropDiff {
|
||||
var propDiffs []PropDiff
|
||||
|
||||
oldProps, err := model.ParseProperties(oldBlock, schema, dg.store)
|
||||
if err != nil {
|
||||
dg.logger.Error("Cannot parse properties for old block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
|
||||
newProps, err := model.ParseProperties(newBlock, schema, dg.store)
|
||||
if err != nil {
|
||||
dg.logger.Error("Cannot parse properties for new block",
|
||||
mlog.String("block_id", oldBlock.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
|
||||
// look for new or changed properties.
|
||||
for k, prop := range newProps {
|
||||
oldP, ok := oldProps[k]
|
||||
if ok {
|
||||
// prop changed
|
||||
if prop.Value != oldP.Value {
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: prop.Value,
|
||||
OldValue: oldP.Value,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// prop added
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: prop.Value,
|
||||
OldValue: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// look for deleted properties
|
||||
for k, prop := range oldProps {
|
||||
_, ok := newProps[k]
|
||||
if !ok {
|
||||
// prop deleted
|
||||
propDiffs = append(propDiffs, PropDiff{
|
||||
ID: prop.ID,
|
||||
Index: prop.Index,
|
||||
Name: prop.Name,
|
||||
NewValue: "",
|
||||
OldValue: prop.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return sortPropDiffs(propDiffs)
|
||||
}
|
||||
|
||||
func sortPropDiffs(propDiffs []PropDiff) []PropDiff {
|
||||
if len(propDiffs) == 0 {
|
||||
return propDiffs
|
||||
}
|
||||
|
||||
sort.Slice(propDiffs, func(i, j int) bool {
|
||||
return propDiffs[i].Index < propDiffs[j].Index
|
||||
})
|
||||
return propDiffs
|
||||
}
|
||||
184
server/boards/services/notify/notifysubscriptions/diff2markdown.go
Обычный файл
184
server/boards/services/notify/notifysubscriptions/diff2markdown.go
Обычный файл
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func generateMarkdownDiff(oldText string, newText string, logger mlog.LoggerIFace) string {
|
||||
oldTxtNorm := normalizeText(oldText)
|
||||
newTxtNorm := normalizeText(newText)
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
|
||||
diffs := dmp.DiffMain(oldTxtNorm, newTxtNorm, false)
|
||||
|
||||
diffs = dmp.DiffCleanupSemantic(diffs)
|
||||
diffs = dmp.DiffCleanupEfficiency(diffs)
|
||||
|
||||
// check there is at least one insert or delete
|
||||
var editFound bool
|
||||
for _, d := range diffs {
|
||||
if (d.Type == diffmatchpatch.DiffInsert || d.Type == diffmatchpatch.DiffDelete) && strings.TrimSpace(d.Text) != "" {
|
||||
editFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !editFound {
|
||||
logger.Debug("skipping notification for superficial diff")
|
||||
return ""
|
||||
}
|
||||
|
||||
cfg := markDownCfg{
|
||||
insertOpen: "`",
|
||||
insertClose: "`",
|
||||
deleteOpen: "~~`",
|
||||
deleteClose: "`~~",
|
||||
}
|
||||
markdown := generateMarkdown(diffs, cfg)
|
||||
markdown = strings.ReplaceAll(markdown, "¶", "\n")
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
const (
|
||||
truncLenEquals = 60
|
||||
truncLenInserts = 120
|
||||
truncLenDeletes = 80
|
||||
)
|
||||
|
||||
type markDownCfg struct {
|
||||
insertOpen string
|
||||
insertClose string
|
||||
deleteOpen string
|
||||
deleteClose string
|
||||
}
|
||||
|
||||
func generateMarkdown(diffs []diffmatchpatch.Diff, cfg markDownCfg) string {
|
||||
sb := &strings.Builder{}
|
||||
|
||||
var first, last bool
|
||||
|
||||
for i, diff := range diffs {
|
||||
first = i == 0
|
||||
last = i == len(diffs)-1
|
||||
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffInsert:
|
||||
sb.WriteString(cfg.insertOpen)
|
||||
sb.WriteString(truncate(diff.Text, truncLenInserts, first, last))
|
||||
sb.WriteString(cfg.insertClose)
|
||||
|
||||
case diffmatchpatch.DiffDelete:
|
||||
sb.WriteString(cfg.deleteOpen)
|
||||
sb.WriteString(truncate(diff.Text, truncLenDeletes, first, last))
|
||||
sb.WriteString(cfg.deleteClose)
|
||||
|
||||
case diffmatchpatch.DiffEqual:
|
||||
sb.WriteString(truncate(diff.Text, truncLenEquals, first, last))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int, first bool, last bool) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
var result string
|
||||
|
||||
switch {
|
||||
case first:
|
||||
// truncate left
|
||||
result = " ... " + rightWords(s, maxLen)
|
||||
case last:
|
||||
// truncate right
|
||||
result = leftWords(s, maxLen) + " ... "
|
||||
default:
|
||||
// truncate in the middle
|
||||
half := len(s) / 2
|
||||
|
||||
left := leftWords(s[:half], maxLen/2)
|
||||
right := rightWords(s[half:], maxLen/2)
|
||||
|
||||
result = left + " ... " + right
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(result, "¶", "↩")
|
||||
}
|
||||
|
||||
func normalizeText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "\n\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\n", "¶")
|
||||
return s
|
||||
}
|
||||
|
||||
// leftWords returns approximately maxLen characters from the left part of the source string by truncating on the right,
|
||||
// with best effort to include whole words.
|
||||
func leftWords(s string, maxLen int) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
fields := strings.Fields(s)
|
||||
fields = words(fields, maxLen)
|
||||
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// rightWords returns approximately maxLen from the right part of the source string by truncating from the left,
|
||||
// with best effort to include whole words.
|
||||
func rightWords(s string, maxLen int) string {
|
||||
if len(s) < maxLen {
|
||||
return s
|
||||
}
|
||||
fields := strings.Fields(s)
|
||||
|
||||
// reverse the fields so that the right-most words end up at the beginning.
|
||||
reverse(fields)
|
||||
|
||||
fields = words(fields, maxLen)
|
||||
|
||||
// reverse the fields again so that the original order is restored.
|
||||
reverse(fields)
|
||||
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func reverse(ss []string) {
|
||||
ssLen := len(ss)
|
||||
for i := 0; i < ssLen/2; i++ {
|
||||
ss[i], ss[ssLen-i-1] = ss[ssLen-i-1], ss[i]
|
||||
}
|
||||
}
|
||||
|
||||
// words returns a subslice containing approximately maxChars of characters. The last item may be truncated.
|
||||
func words(words []string, maxChars int) []string {
|
||||
var count int
|
||||
result := make([]string, 0, len(words))
|
||||
|
||||
for i, w := range words {
|
||||
wordLen := len(w)
|
||||
if wordLen+count > maxChars {
|
||||
switch {
|
||||
case i == 0:
|
||||
result = append(result, w[:maxChars])
|
||||
case wordLen < 8:
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
count += wordLen
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_reverse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ss []string
|
||||
want []string
|
||||
}{
|
||||
{name: "even", ss: []string{"one", "two", "three", "four"}, want: []string{"four", "three", "two", "one"}},
|
||||
{name: "odd", ss: []string{"one", "two", "three"}, want: []string{"three", "two", "one"}},
|
||||
{name: "one", ss: []string{"one"}, want: []string{"one"}},
|
||||
{name: "empty", ss: []string{}, want: []string{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reverse(tt.ss)
|
||||
assert.Equal(t, tt.want, tt.ss)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// card change notifications.
|
||||
defAddCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has added the card {{. | makeLink}}\n"
|
||||
defModifyCardNotify = "###### {{.Authors | printAuthors \"unknown_user\" }} has modified the card {{. | makeLink}} on the board {{. | makeBoardLink}}\n"
|
||||
defDeleteCardNotify = "{{.Authors | printAuthors \"unknown_user\" }} has deleted the card {{. | makeLink}}\n"
|
||||
)
|
||||
|
||||
var (
|
||||
// templateCache is a map of text templateCache keyed by languange code.
|
||||
templateCache = make(map[string]*template.Template)
|
||||
templateCacheMux sync.Mutex
|
||||
)
|
||||
|
||||
// DiffConvOpts provides options when converting diffs to slack attachments.
|
||||
type DiffConvOpts struct {
|
||||
Language string
|
||||
MakeCardLink func(block *model.Block, board *model.Board, card *model.Block) string
|
||||
MakeBoardLink func(board *model.Board) string
|
||||
Logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
// getTemplate returns a new or cached named template based on the language specified.
|
||||
func getTemplate(name string, opts DiffConvOpts, def string) (*template.Template, error) {
|
||||
templateCacheMux.Lock()
|
||||
defer templateCacheMux.Unlock()
|
||||
|
||||
key := name + "&" + opts.Language
|
||||
t, ok := templateCache[key]
|
||||
if !ok {
|
||||
t = template.New(key)
|
||||
|
||||
if opts.MakeCardLink == nil {
|
||||
opts.MakeCardLink = func(block *model.Block, _ *model.Board, _ *model.Block) string {
|
||||
return fmt.Sprintf("`%s`", block.Title)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.MakeBoardLink == nil {
|
||||
opts.MakeBoardLink = func(board *model.Board) string {
|
||||
return fmt.Sprintf("`%s`", board.Title)
|
||||
}
|
||||
}
|
||||
myFuncs := template.FuncMap{
|
||||
"getBoardDescription": getBoardDescription,
|
||||
"makeLink": func(diff *Diff) string {
|
||||
return opts.MakeCardLink(diff.NewBlock, diff.Board, diff.Card)
|
||||
},
|
||||
"makeBoardLink": func(diff *Diff) string {
|
||||
return opts.MakeBoardLink(diff.Board)
|
||||
},
|
||||
"stripNewlines": func(s string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ "))
|
||||
},
|
||||
"printAuthors": func(empty string, authors StringMap) string {
|
||||
return makeAuthorsList(authors, empty)
|
||||
},
|
||||
}
|
||||
t.Funcs(myFuncs)
|
||||
|
||||
s := def // TODO: lookup i18n string when supported on server
|
||||
t2, err := t.Parse(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse markdown template '%s' for notifications: %w", key, err)
|
||||
}
|
||||
templateCache[key] = t2
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func makeAuthorsList(authors StringMap, empty string) string {
|
||||
if len(authors) == 0 {
|
||||
return empty
|
||||
}
|
||||
prefix := ""
|
||||
sb := &strings.Builder{}
|
||||
for _, name := range authors.Values() {
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString("@")
|
||||
sb.WriteString(strings.TrimSpace(name))
|
||||
prefix = ", "
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// execTemplate executes the named template corresponding to the template name and language specified.
|
||||
func execTemplate(w io.Writer, name string, opts DiffConvOpts, def string, data interface{}) error {
|
||||
t, err := getTemplate(name, opts, def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// Diffs2SlackAttachments converts a slice of `Diff` to slack attachments to be used in a post.
|
||||
func Diffs2SlackAttachments(diffs []*Diff, opts DiffConvOpts) ([]*mm_model.SlackAttachment, error) {
|
||||
var attachments []*mm_model.SlackAttachment
|
||||
merr := merror.New()
|
||||
|
||||
for _, d := range diffs {
|
||||
// only handle cards for now.
|
||||
if d.BlockType == model.TypeCard {
|
||||
a, err := cardDiff2SlackAttachment(d, opts)
|
||||
if err != nil {
|
||||
merr.Append(err)
|
||||
continue
|
||||
}
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments, a)
|
||||
}
|
||||
}
|
||||
return attachments, merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func cardDiff2SlackAttachment(cardDiff *Diff, opts DiffConvOpts) (*mm_model.SlackAttachment, error) {
|
||||
// sanity check
|
||||
if cardDiff.NewBlock == nil && cardDiff.OldBlock == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
attachment := &mm_model.SlackAttachment{}
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// card added
|
||||
if cardDiff.NewBlock != nil && cardDiff.OldBlock == nil {
|
||||
if err := execTemplate(buf, "AddCardNotify", opts, defAddCardNotify, cardDiff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// card deleted
|
||||
if (cardDiff.NewBlock == nil || cardDiff.NewBlock.DeleteAt != 0) && cardDiff.OldBlock != nil {
|
||||
buf.Reset()
|
||||
if err := execTemplate(buf, "DeleteCardNotify", opts, defDeleteCardNotify, cardDiff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
// at this point new and old block are non-nil
|
||||
|
||||
opts.Logger.Debug("cardDiff2SlackAttachment",
|
||||
mlog.String("board_id", cardDiff.Board.ID),
|
||||
mlog.String("card_id", cardDiff.Card.ID),
|
||||
mlog.String("new_block_id", cardDiff.NewBlock.ID),
|
||||
mlog.String("old_block_id", cardDiff.OldBlock.ID),
|
||||
mlog.Int("childDiffs", len(cardDiff.Diffs)),
|
||||
)
|
||||
|
||||
buf.Reset()
|
||||
if err := execTemplate(buf, "ModifyCardNotify", opts, defModifyCardNotify, cardDiff); err != nil {
|
||||
return nil, fmt.Errorf("cannot write notification for card %s: %w", cardDiff.NewBlock.ID, err)
|
||||
}
|
||||
attachment.Pretext = buf.String()
|
||||
attachment.Fallback = attachment.Pretext
|
||||
|
||||
// title changes
|
||||
attachment.Fields = appendTitleChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// property changes
|
||||
attachment.Fields = appendPropertyChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// comment add/delete
|
||||
attachment.Fields = appendCommentChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// File Attachment add/delete
|
||||
attachment.Fields = appendAttachmentChanges(attachment.Fields, cardDiff)
|
||||
|
||||
// content/description changes
|
||||
attachment.Fields = appendContentChanges(attachment.Fields, cardDiff, opts.Logger)
|
||||
|
||||
if len(attachment.Fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func appendTitleChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
if cardDiff.NewBlock.Title != cardDiff.OldBlock.Title {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Title",
|
||||
Value: fmt.Sprintf("%s ~~`%s`~~", stripNewlines(cardDiff.NewBlock.Title), stripNewlines(cardDiff.OldBlock.Title)),
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendPropertyChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
if len(cardDiff.PropDiffs) == 0 {
|
||||
return fields
|
||||
}
|
||||
|
||||
for _, propDiff := range cardDiff.PropDiffs {
|
||||
if propDiff.NewValue == propDiff.OldValue {
|
||||
continue
|
||||
}
|
||||
|
||||
var val string
|
||||
if propDiff.OldValue != "" {
|
||||
val = fmt.Sprintf("%s ~~`%s`~~", stripNewlines(propDiff.NewValue), stripNewlines(propDiff.OldValue))
|
||||
} else {
|
||||
val = propDiff.NewValue
|
||||
}
|
||||
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: propDiff.Name,
|
||||
Value: val,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendCommentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
if child.BlockType == model.TypeComment {
|
||||
var format string
|
||||
var msg string
|
||||
if child.NewBlock != nil && child.OldBlock == nil {
|
||||
// added comment
|
||||
format = "%s"
|
||||
msg = child.NewBlock.Title
|
||||
}
|
||||
|
||||
if (child.NewBlock == nil || child.NewBlock.DeleteAt != 0) && child.OldBlock != nil {
|
||||
// deleted comment
|
||||
format = "~~`%s`~~"
|
||||
msg = stripNewlines(child.OldBlock.Title)
|
||||
}
|
||||
|
||||
if format != "" {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Comment by " + makeAuthorsList(child.Authors, "unknown_user"), // todo: localize this when server has i18n
|
||||
Value: fmt.Sprintf(format, msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendAttachmentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
if child.BlockType == model.TypeAttachment {
|
||||
var format string
|
||||
var msg string
|
||||
if child.NewBlock != nil && child.OldBlock == nil {
|
||||
format = "Added an attachment: **`%s`**"
|
||||
msg = child.NewBlock.Title
|
||||
} else {
|
||||
format = "Removed ~~`%s`~~ attachment"
|
||||
msg = stripNewlines(child.OldBlock.Title)
|
||||
}
|
||||
|
||||
if format != "" {
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Changed by " + makeAuthorsList(child.Authors, "unknown_user"), // TODO: localize this when server has i18n
|
||||
Value: fmt.Sprintf(format, msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func appendContentChanges(fields []*mm_model.SlackAttachmentField, cardDiff *Diff, logger mlog.LoggerIFace) []*mm_model.SlackAttachmentField {
|
||||
for _, child := range cardDiff.Diffs {
|
||||
var opAdd, opDelete bool
|
||||
var opString string
|
||||
|
||||
switch {
|
||||
case child.OldBlock == nil && child.NewBlock != nil:
|
||||
opAdd = true
|
||||
opString = "added" // TODO: localize when i18n added to server
|
||||
case child.NewBlock == nil || child.NewBlock.DeleteAt != 0:
|
||||
opDelete = true
|
||||
opString = "deleted"
|
||||
default:
|
||||
opString = "modified"
|
||||
}
|
||||
|
||||
var newTitle, oldTitle string
|
||||
if child.OldBlock != nil {
|
||||
oldTitle = child.OldBlock.Title
|
||||
}
|
||||
if child.NewBlock != nil {
|
||||
newTitle = child.NewBlock.Title
|
||||
}
|
||||
|
||||
switch child.BlockType {
|
||||
case model.TypeDivider, model.TypeComment:
|
||||
// do nothing
|
||||
continue
|
||||
case model.TypeImage:
|
||||
if newTitle == "" {
|
||||
newTitle = "An image was " + opString + "." // TODO: localize when i18n added to server
|
||||
}
|
||||
oldTitle = ""
|
||||
case model.TypeAttachment:
|
||||
if newTitle == "" {
|
||||
newTitle = "A file attachment was " + opString + "." // TODO: localize when i18n added to server
|
||||
}
|
||||
oldTitle = ""
|
||||
default:
|
||||
if !opAdd {
|
||||
if opDelete {
|
||||
newTitle = ""
|
||||
}
|
||||
// only strip newlines when modifying or deleting
|
||||
oldTitle = stripNewlines(oldTitle)
|
||||
newTitle = stripNewlines(newTitle)
|
||||
}
|
||||
if newTitle == oldTitle {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.Trace("appendContentChanges",
|
||||
mlog.String("type", string(child.BlockType)),
|
||||
mlog.String("opString", opString),
|
||||
mlog.String("oldTitle", oldTitle),
|
||||
mlog.String("newTitle", newTitle),
|
||||
)
|
||||
|
||||
markdown := generateMarkdownDiff(oldTitle, newTitle, logger)
|
||||
if markdown == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields = append(fields, &mm_model.SlackAttachmentField{
|
||||
Short: false,
|
||||
Title: "Description",
|
||||
Value: markdown,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
282
server/boards/services/notify/notifysubscriptions/notifier.go
Обычный файл
282
server/boards/services/notify/notifysubscriptions/notifier.go
Обычный файл
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
defBlockNotificationFreq = time.Minute * 2
|
||||
enqueueNotifyHintTimeout = time.Second * 10
|
||||
hintQueueSize = 20
|
||||
)
|
||||
|
||||
var (
|
||||
errEnqueueNotifyHintTimeout = errors.New("enqueue notify hint timed out")
|
||||
)
|
||||
|
||||
// notifier provides block change notifications for subscribers. Block change events are batched
|
||||
// via notifications hints written to the database so that fewer notifications are sent for active
|
||||
// blocks.
|
||||
type notifier struct {
|
||||
serverRoot string
|
||||
store AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery SubscriptionDelivery
|
||||
logger mlog.LoggerIFace
|
||||
|
||||
hints chan *model.NotificationHint
|
||||
|
||||
mux sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newNotifier(params BackendParams) *notifier {
|
||||
return ¬ifier{
|
||||
serverRoot: params.ServerRoot,
|
||||
store: params.AppAPI,
|
||||
permissions: params.Permissions,
|
||||
delivery: params.Delivery,
|
||||
logger: params.Logger,
|
||||
done: nil,
|
||||
hints: make(chan *model.NotificationHint, hintQueueSize),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) start() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done == nil {
|
||||
n.done = make(chan struct{})
|
||||
go n.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) stop() {
|
||||
n.mux.Lock()
|
||||
defer n.mux.Unlock()
|
||||
|
||||
if n.done != nil {
|
||||
close(n.done)
|
||||
n.done = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) loop() {
|
||||
done := n.done
|
||||
var nextNotify time.Time
|
||||
|
||||
for {
|
||||
hint, err := n.store.GetNextNotificationHint(false)
|
||||
switch {
|
||||
case model.IsErrNotFound(err):
|
||||
// no hints in table; wait up to an hour or when `onNotifyHint` is called again
|
||||
nextNotify = time.Now().Add(time.Hour * 1)
|
||||
n.logger.Debug("notify loop - no hints in queue", mlog.Time("next_check", nextNotify))
|
||||
case err != nil:
|
||||
// try again in a minute
|
||||
nextNotify = time.Now().Add(time.Minute * 1)
|
||||
n.logger.Error("notify loop - error fetching next notification", mlog.Err(err))
|
||||
case hint.NotifyAt > utils.GetMillis():
|
||||
// next hint is not ready yet; sleep until hint.NotifyAt
|
||||
nextNotify = utils.GetTimeForMillis(hint.NotifyAt)
|
||||
default:
|
||||
// it's time to notify
|
||||
n.notify()
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("subscription notifier loop",
|
||||
mlog.Time("next_notify", nextNotify),
|
||||
)
|
||||
|
||||
select {
|
||||
case <-n.hints:
|
||||
// A new hint was added. Wake up and check if next hint is ready to go.
|
||||
case <-time.After(time.Until(nextNotify)):
|
||||
// Next scheduled hint should be ready now.
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) onNotifyHint(hint *model.NotificationHint) error {
|
||||
n.logger.Debug("onNotifyHint - enqueing hint", mlog.Any("hint", hint))
|
||||
|
||||
select {
|
||||
case n.hints <- hint:
|
||||
case <-time.After(enqueueNotifyHintTimeout):
|
||||
return errEnqueueNotifyHintTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notifier) notify() {
|
||||
var hint *model.NotificationHint
|
||||
var err error
|
||||
|
||||
hint, err = n.store.GetNextNotificationHint(true)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// Expected when multiple nodes in a cluster try to process the same hint at the same time.
|
||||
// This simply means the other node won. Returning here will simply try fetching another hint.
|
||||
return
|
||||
}
|
||||
n.logger.Error("notify - error fetching next notification", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = n.notifySubscribers(hint); err != nil {
|
||||
n.logger.Error("Error notifying subscribers", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) notifySubscribers(hint *model.NotificationHint) error {
|
||||
// get the subscriber list
|
||||
subs, err := n.store.GetSubscribersForBlock(hint.BlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
n.logger.Debug("notifySubscribers - no subscribers", mlog.Any("hint", hint))
|
||||
return nil
|
||||
}
|
||||
|
||||
// subs slice is sorted by `NotifiedAt`, therefore subs[0] contains the oldest NotifiedAt needed
|
||||
oldestNotifiedAt := subs[0].NotifiedAt
|
||||
|
||||
// need the block's board and card.
|
||||
board, card, err := n.store.GetBoardAndCardByID(hint.BlockID)
|
||||
if err != nil || board == nil || card == nil {
|
||||
return fmt.Errorf("could not get board & card for block %s: %w", hint.BlockID, err)
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - subscribers",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("board_id", board.ID),
|
||||
mlog.String("card_id", card.ID),
|
||||
mlog.Int("sub_count", len(subs)),
|
||||
)
|
||||
|
||||
dg := &diffGenerator{
|
||||
board: board,
|
||||
card: card,
|
||||
store: n.store,
|
||||
hint: hint,
|
||||
lastNotifyAt: oldestNotifiedAt,
|
||||
logger: n.logger,
|
||||
}
|
||||
diffs, err := dg.generateDiffs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - diffs",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.Int("diff_count", len(diffs)),
|
||||
)
|
||||
|
||||
if len(diffs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
diffAuthors := make(StringMap)
|
||||
for _, d := range diffs {
|
||||
diffAuthors.Append(d.Authors)
|
||||
}
|
||||
|
||||
opts := DiffConvOpts{
|
||||
Language: "en", // TODO: use correct language when i18n is available on server.
|
||||
MakeCardLink: func(block *model.Block, board *model.Board, card *model.Block) string {
|
||||
return fmt.Sprintf("[%s](%s)", block.Title, utils.MakeCardLink(n.serverRoot, board.TeamID, board.ID, card.ID))
|
||||
},
|
||||
MakeBoardLink: func(board *model.Board) string {
|
||||
return fmt.Sprintf("[%s](%s)", board.Title, utils.MakeBoardLink(n.serverRoot, board.TeamID, board.ID))
|
||||
},
|
||||
Logger: n.logger,
|
||||
}
|
||||
|
||||
attachments, err := Diffs2SlackAttachments(diffs, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
if len(attachments) > 0 {
|
||||
for _, sub := range subs {
|
||||
// don't notify the author of their own changes.
|
||||
authorName, isAuthor := diffAuthors[sub.SubscriberID]
|
||||
if isAuthor && len(diffAuthors) == 1 {
|
||||
n.logger.Debug("notifySubscribers - skipping author",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("author_id", sub.SubscriberID),
|
||||
mlog.String("author_username", authorName),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure the subscriber still has permissions for the board.
|
||||
if !n.permissions.HasPermissionToBoard(sub.SubscriberID, board.ID, model.PermissionViewBoard) {
|
||||
n.logger.Debug("notifySubscribers - skipping non-board member",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("subscriber_id", sub.SubscriberID),
|
||||
mlog.String("board_id", board.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Debug("notifySubscribers - deliver",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
mlog.String("subscriber_id", sub.SubscriberID),
|
||||
mlog.String("subscriber_type", string(sub.SubscriberType)),
|
||||
)
|
||||
|
||||
if err = n.delivery.SubscriptionDeliverSlackAttachments(board.TeamID, sub.SubscriberID, sub.SubscriberType, attachments); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot deliver notification to subscriber %s [%s]: %w",
|
||||
sub.SubscriberID, sub.SubscriberType, err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
n.logger.Debug("notifySubscribers - skip delivery; no chg",
|
||||
mlog.Any("hint", hint),
|
||||
mlog.String("modified_by_id", hint.ModifiedByID),
|
||||
)
|
||||
}
|
||||
|
||||
// find the new NotifiedAt based on the newest diff.
|
||||
var notifiedAt int64
|
||||
for _, d := range diffs {
|
||||
if d.UpdateAt > notifiedAt {
|
||||
notifiedAt = d.UpdateAt
|
||||
}
|
||||
for _, c := range d.Diffs {
|
||||
if c.UpdateAt > notifiedAt {
|
||||
notifiedAt = c.UpdateAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the last notified_at for all subscribers since we at least attempted to notify all of them.
|
||||
err = dg.store.UpdateSubscribersNotifiedAt(dg.hint.BlockID, notifiedAt)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("could not update subscribers notified_at for block %s: %w", dg.hint.BlockID, err))
|
||||
}
|
||||
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
backendName = "notifySubscriptions"
|
||||
)
|
||||
|
||||
type BackendParams struct {
|
||||
ServerRoot string
|
||||
AppAPI AppAPI
|
||||
Permissions permissions.PermissionsService
|
||||
Delivery SubscriptionDelivery
|
||||
Logger mlog.LoggerIFace
|
||||
NotifyFreqCardSeconds int
|
||||
NotifyFreqBoardSeconds int
|
||||
}
|
||||
|
||||
// Backend provides the notification backend for subscriptions.
|
||||
type Backend struct {
|
||||
appAPI AppAPI
|
||||
permissions permissions.PermissionsService
|
||||
delivery SubscriptionDelivery
|
||||
notifier *notifier
|
||||
logger mlog.LoggerIFace
|
||||
notifyFreqCardSeconds int
|
||||
notifyFreqBoardSeconds int
|
||||
}
|
||||
|
||||
func New(params BackendParams) *Backend {
|
||||
return &Backend{
|
||||
appAPI: params.AppAPI,
|
||||
delivery: params.Delivery,
|
||||
permissions: params.Permissions,
|
||||
notifier: newNotifier(params),
|
||||
logger: params.Logger,
|
||||
notifyFreqCardSeconds: params.NotifyFreqCardSeconds,
|
||||
notifyFreqBoardSeconds: params.NotifyFreqBoardSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) Start() error {
|
||||
b.logger.Debug("Starting subscriptions backend",
|
||||
mlog.Int("freq_card", b.notifyFreqCardSeconds),
|
||||
mlog.Int("freq_board", b.notifyFreqBoardSeconds),
|
||||
)
|
||||
b.notifier.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) ShutDown() error {
|
||||
b.logger.Debug("Stopping subscriptions backend")
|
||||
b.notifier.stop()
|
||||
_ = b.logger.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) Name() string {
|
||||
return backendName
|
||||
}
|
||||
|
||||
func (b *Backend) getBlockUpdateFreq(blockType model.BlockType) time.Duration {
|
||||
// check for env variable override
|
||||
sFreq := os.Getenv("MM_BOARDS_NOTIFY_FREQ_SECONDS")
|
||||
if sFreq != "" && sFreq != "0" {
|
||||
if freq, err := strconv.ParseInt(sFreq, 10, 64); err != nil {
|
||||
b.logger.Error("Environment variable MM_BOARDS_NOTIFY_FREQ_SECONDS invalid (ignoring)", mlog.Err(err))
|
||||
} else {
|
||||
return time.Second * time.Duration(freq)
|
||||
}
|
||||
}
|
||||
|
||||
switch blockType {
|
||||
case model.TypeCard:
|
||||
return time.Second * time.Duration(b.notifyFreqCardSeconds)
|
||||
default:
|
||||
return defBlockNotificationFreq
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) BlockChanged(evt notify.BlockChangeEvent) error {
|
||||
if evt.Board == nil {
|
||||
b.logger.Warn("No board found for block, skipping notify",
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
merr := merror.New()
|
||||
var err error
|
||||
|
||||
// if new card added, automatically subscribe the author.
|
||||
if evt.Action == notify.Add && evt.BlockChanged.Type == model.TypeCard {
|
||||
sub := &model.Subscription{
|
||||
BlockType: model.TypeCard,
|
||||
BlockID: evt.BlockChanged.ID,
|
||||
SubscriberType: model.SubTypeUser,
|
||||
SubscriberID: evt.ModifiedBy.UserID,
|
||||
}
|
||||
|
||||
if _, err = b.appAPI.CreateSubscription(sub); err != nil {
|
||||
b.logger.Warn("Cannot subscribe card author to card",
|
||||
mlog.String("card_id", evt.BlockChanged.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// notify board subscribers
|
||||
subs, err := b.appAPI.GetSubscribersForBlock(evt.Board.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for board %s: %w", evt.Board.ID, err))
|
||||
}
|
||||
if err = b.notifySubscribers(subs, evt.Board.ID, model.TypeBoard, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify board subscribers for board %s: %w", evt.Board.ID, err))
|
||||
}
|
||||
|
||||
if evt.Card == nil {
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// notify card subscribers
|
||||
subs, err = b.appAPI.GetSubscribersForBlock(evt.Card.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for card %s: %w", evt.Card.ID, err))
|
||||
}
|
||||
if err = b.notifySubscribers(subs, evt.Card.ID, model.TypeCard, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify card subscribers for card %s: %w", evt.Card.ID, err))
|
||||
}
|
||||
|
||||
// notify block subscribers (if/when other types can be subscribed to)
|
||||
if evt.Board.ID != evt.BlockChanged.ID && evt.Card.ID != evt.BlockChanged.ID {
|
||||
subs, err := b.appAPI.GetSubscribersForBlock(evt.BlockChanged.ID)
|
||||
if err != nil {
|
||||
merr.Append(fmt.Errorf("cannot fetch subscribers for block %s: %w", evt.BlockChanged.ID, err))
|
||||
}
|
||||
if err := b.notifySubscribers(subs, evt.BlockChanged.ID, evt.BlockChanged.Type, evt.ModifiedBy.UserID); err != nil {
|
||||
merr.Append(fmt.Errorf("cannot notify block subscribers for block %s: %w", evt.BlockChanged.ID, err))
|
||||
}
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// notifySubscribers triggers a change notification for subscribers by writing a notification hint to the database.
|
||||
func (b *Backend) notifySubscribers(subs []*model.Subscriber, blockID string, idType model.BlockType, modifiedByID string) error {
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hint := &model.NotificationHint{
|
||||
BlockType: idType,
|
||||
BlockID: blockID,
|
||||
ModifiedByID: modifiedByID,
|
||||
}
|
||||
|
||||
hint, err := b.appAPI.UpsertNotificationHint(hint, b.getBlockUpdateFreq(idType))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upsert notification hint: %w", err)
|
||||
}
|
||||
if err := b.notifier.onNotifyHint(hint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnMention satisfies the `MentionListener` interface and is called whenever a @mention notification
|
||||
// is sent. Here we create a subscription for the mentioned user to the card.
|
||||
func (b *Backend) OnMention(userID string, evt notify.BlockChangeEvent) {
|
||||
if evt.Card == nil {
|
||||
b.logger.Debug("Cannot subscribe mentioned user to nil card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// user mentioned must be a board member to subscribe to card.
|
||||
if !b.permissions.HasPermissionToBoard(userID, evt.Board.ID, model.PermissionViewBoard) {
|
||||
b.logger.Debug("Not subscribing mentioned non-board member to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("block_id", evt.BlockChanged.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
sub := &model.Subscription{
|
||||
BlockType: model.TypeCard,
|
||||
BlockID: evt.Card.ID,
|
||||
SubscriberType: model.SubTypeUser,
|
||||
SubscriberID: userID,
|
||||
}
|
||||
|
||||
var err error
|
||||
if _, err = b.appAPI.CreateSubscription(sub); err != nil {
|
||||
b.logger.Warn("Cannot subscribe mentioned user to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("card_id", evt.Card.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b.logger.Debug("Subscribed mentioned user to card",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("card_id", evt.Card.ID),
|
||||
)
|
||||
}
|
||||
60
server/boards/services/notify/notifysubscriptions/util.go
Обычный файл
60
server/boards/services/notify/notifysubscriptions/util.go
Обычный файл
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notifysubscriptions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
func getBoardDescription(board *model.Block) string {
|
||||
if board == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
descr, ok := board.Fields["description"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
description, ok := descr.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
func stripNewlines(s string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(s, "\n", "¶ "))
|
||||
}
|
||||
|
||||
type StringMap map[string]string
|
||||
|
||||
func (sm StringMap) Add(k string, v string) {
|
||||
sm[k] = v
|
||||
}
|
||||
|
||||
func (sm StringMap) Append(m StringMap) {
|
||||
for k, v := range m {
|
||||
sm[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (sm StringMap) Keys() []string {
|
||||
keys := make([]string, 0, len(sm))
|
||||
for k := range sm {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (sm StringMap) Values() []string {
|
||||
values := make([]string, 0, len(sm))
|
||||
for _, v := range sm {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
40
server/boards/services/notify/plugindelivery/mention_deliver.go
Обычный файл
40
server/boards/services/notify/plugindelivery/mention_deliver.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// MentionDeliver notifies a user they have been mentioned in a blockv ia the plugin API.
|
||||
func (pd *PluginDelivery) MentionDeliver(mentionedUser *mm_model.User, extract string, evt notify.BlockChangeEvent) (string, error) {
|
||||
author, err := pd.api.GetUserByID(evt.ModifiedBy.UserID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot find user: %w", err)
|
||||
}
|
||||
|
||||
channel, err := pd.getDirectChannel(evt.TeamID, mentionedUser.Id, pd.botID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get direct channel: %w", err)
|
||||
}
|
||||
link := utils.MakeCardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID, evt.Card.ID)
|
||||
boardLink := utils.MakeBoardLink(pd.serverRoot, evt.Board.TeamID, evt.Board.ID)
|
||||
|
||||
post := &mm_model.Post{
|
||||
UserId: pd.botID,
|
||||
ChannelId: channel.Id,
|
||||
Message: formatMessage(author.Username, extract, evt.Card.Title, link, evt.BlockChanged, boardLink, evt.Board.Title),
|
||||
}
|
||||
|
||||
if _, err := pd.api.CreatePost(post); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return mentionedUser.Id, nil
|
||||
}
|
||||
24
server/boards/services/notify/plugindelivery/message.go
Обычный файл
24
server/boards/services/notify/plugindelivery/message.go
Обычный файл
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: localize these when i18n is available.
|
||||
defCommentTemplate = "@%s mentioned you in a comment on the card [%s](%s) in board [%s](%s)\n> %s"
|
||||
defDescriptionTemplate = "@%s mentioned you in the card [%s](%s) in board [%s](%s)\n> %s"
|
||||
)
|
||||
|
||||
func formatMessage(author string, extract string, card string, link string, block *model.Block, boardLink string, board string) string {
|
||||
template := defDescriptionTemplate
|
||||
if block.Type == model.TypeComment {
|
||||
template = defCommentTemplate
|
||||
}
|
||||
return fmt.Sprintf(template, author, card, link, board, boardLink, extract)
|
||||
}
|
||||
52
server/boards/services/notify/plugindelivery/plugin_delivery.go
Обычный файл
52
server/boards/services/notify/plugindelivery/plugin_delivery.go
Обычный файл
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type servicesAPI interface {
|
||||
// GetDirectChannelOrCreate gets a direct message channel,
|
||||
// or creates one if it does not already exist
|
||||
GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error)
|
||||
|
||||
// CreatePost creates a post.
|
||||
CreatePost(post *mm_model.Post) (*mm_model.Post, error)
|
||||
|
||||
// GetUserByID gets a user by their ID.
|
||||
GetUserByID(userID string) (*mm_model.User, error)
|
||||
|
||||
// GetUserByUsername gets a user by their username.
|
||||
GetUserByUsername(name string) (*mm_model.User, error)
|
||||
|
||||
// GetTeamMember gets a team member by their user id.
|
||||
GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error)
|
||||
|
||||
// GetChannelByID gets a Channel by its ID.
|
||||
GetChannelByID(channelID string) (*mm_model.Channel, error)
|
||||
|
||||
// GetChannelMember gets a channel member by userID.
|
||||
GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error)
|
||||
|
||||
// CreateMember adds a user to the specified team. Safe to call if the user is
|
||||
// already a member of the team.
|
||||
CreateMember(teamID string, userID string) (*mm_model.TeamMember, error)
|
||||
}
|
||||
|
||||
// PluginDelivery provides ability to send notifications to direct message channels via Mattermost plugin API.
|
||||
type PluginDelivery struct {
|
||||
botID string
|
||||
serverRoot string
|
||||
api servicesAPI
|
||||
}
|
||||
|
||||
// New creates a PluginDelivery instance.
|
||||
func New(botID string, serverRoot string, api servicesAPI) *PluginDelivery {
|
||||
return &PluginDelivery{
|
||||
botID: botID,
|
||||
serverRoot: serverRoot,
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedSubscriberType = errors.New("invalid subscriber type")
|
||||
)
|
||||
|
||||
// SubscriptionDeliverSlashAttachments notifies a user that changes were made to a block they are subscribed to.
|
||||
func (pd *PluginDelivery) SubscriptionDeliverSlackAttachments(teamID string, subscriberID string, subscriptionType model.SubscriberType,
|
||||
attachments []*mm_model.SlackAttachment) error {
|
||||
// check subscriber is member of channel
|
||||
_, err := pd.api.GetUserByID(subscriberID)
|
||||
if err != nil {
|
||||
if model.IsErrNotFound(err) {
|
||||
// subscriber is not a member of the channel; fail silently.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot fetch channel member for user %s: %w", subscriberID, err)
|
||||
}
|
||||
|
||||
channelID, err := pd.getDirectChannelID(teamID, subscriberID, subscriptionType, pd.botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
post := &mm_model.Post{
|
||||
UserId: pd.botID,
|
||||
ChannelId: channelID,
|
||||
}
|
||||
|
||||
mm_model.ParseSlackAttachment(post, attachments)
|
||||
|
||||
_, err = pd.api.CreatePost(post)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pd *PluginDelivery) getDirectChannelID(teamID string, subscriberID string, subscriberType model.SubscriberType, botID string) (string, error) {
|
||||
switch subscriberType {
|
||||
case model.SubTypeUser:
|
||||
user, err := pd.api.GetUserByID(subscriberID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot find user: %w", err)
|
||||
}
|
||||
channel, err := pd.getDirectChannel(teamID, user.Id, botID)
|
||||
if err != nil || channel == nil {
|
||||
return "", fmt.Errorf("cannot get direct channel: %w", err)
|
||||
}
|
||||
return channel.Id, nil
|
||||
case model.SubTypeChannel:
|
||||
return subscriberID, nil
|
||||
default:
|
||||
return "", ErrUnsupportedSubscriberType
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PluginDelivery) getDirectChannel(teamID string, userID string, botID string) (*mm_model.Channel, error) {
|
||||
// first ensure the bot is a member of the team.
|
||||
_, err := pd.api.CreateMember(teamID, botID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot add bot to team %s: %w", teamID, err)
|
||||
}
|
||||
return pd.api.GetDirectChannelOrCreate(userID, botID)
|
||||
}
|
||||
55
server/boards/services/notify/plugindelivery/user.go
Обычный файл
55
server/boards/services/notify/plugindelivery/user.go
Обычный файл
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
const (
|
||||
usernameSpecialChars = ".-_ "
|
||||
)
|
||||
|
||||
func (pd *PluginDelivery) UserByUsername(username string) (*mm_model.User, error) {
|
||||
// check for usernames that might have trailing punctuation
|
||||
var user *mm_model.User
|
||||
var err error
|
||||
ok := true
|
||||
trimmed := username
|
||||
for ok {
|
||||
user, err = pd.api.GetUserByUsername(trimmed)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
trimmed, ok = trimUsernameSpecialChar(trimmed)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
152
server/boards/services/notify/plugindelivery/user_test.go
Обычный файл
152
server/boards/services/notify/plugindelivery/user_test.go
Обычный файл
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package plugindelivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var (
|
||||
defTeamID = mm_model.NewId()
|
||||
|
||||
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_",
|
||||
}
|
||||
user5 = &mm_model.User{
|
||||
Id: mm_model.NewId(),
|
||||
Username: "wrong_team",
|
||||
}
|
||||
|
||||
mockUsers = map[string]*mm_model.User{
|
||||
"dlauder": user1,
|
||||
"steve.mqueen": user2,
|
||||
"bart_": user3,
|
||||
"wrong_team": user5,
|
||||
}
|
||||
)
|
||||
|
||||
func Test_userByUsername(t *testing.T) {
|
||||
servicesAPI := newServicesAPIMock(mockUsers)
|
||||
delivery := New("bot_id", "server_root", servicesAPI)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uname string
|
||||
teamID 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 := delivery.UserByUsername(tt.uname)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("userByUsername() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("userByUsername()\ngot:\n%v\nwant:\n%v\n", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type servicesAPIMock struct {
|
||||
users map[string]*mm_model.User
|
||||
}
|
||||
|
||||
func newServicesAPIMock(users map[string]*mm_model.User) servicesAPIMock {
|
||||
return servicesAPIMock{
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetUserByUsername(name string) (*mm_model.User, error) {
|
||||
user, ok := m.users[name]
|
||||
if !ok {
|
||||
return nil, model.NewErrNotFound(name)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetDirectChannelOrCreate(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetUserByID(userID string) (*mm_model.User, error) {
|
||||
for _, user := range m.users {
|
||||
if user.Id == userID {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
return nil, model.NewErrNotFound(userID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
user, err := m.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if teamID != defTeamID {
|
||||
return nil, model.NewErrNotFound(teamID)
|
||||
}
|
||||
|
||||
member := &mm_model.TeamMember{
|
||||
UserId: user.Id,
|
||||
TeamId: teamID,
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetChannelByID(channelID string) (*mm_model.Channel, error) {
|
||||
return nil, model.NewErrNotFound(channelID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
|
||||
return nil, model.NewErrNotFound(userID)
|
||||
}
|
||||
|
||||
func (m servicesAPIMock) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member := &mm_model.TeamMember{
|
||||
UserId: userID,
|
||||
TeamId: teamID,
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
109
server/boards/services/notify/service.go
Обычный файл
109
server/boards/services/notify/service.go
Обычный файл
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/wiggin77/merror"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/boards/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
Add Action = "add"
|
||||
Update Action = "update"
|
||||
Delete Action = "delete"
|
||||
)
|
||||
|
||||
type BlockChangeEvent struct {
|
||||
Action Action
|
||||
TeamID string
|
||||
Board *model.Board
|
||||
Card *model.Block
|
||||
BlockChanged *model.Block
|
||||
BlockOld *model.Block
|
||||
ModifiedBy *model.BoardMember
|
||||
}
|
||||
|
||||
// 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.LoggerIFace
|
||||
}
|
||||
|
||||
// New creates a notification service with one or more Backends capable of sending notifications.
|
||||
func New(logger mlog.LoggerIFace, 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user