diff --git a/server/plugin/api.go b/server/plugin/api.go index 55c8eb3..00a2e1c 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -1,10 +1,7 @@ package plugin import ( - "encoding/json" - "fmt" "net/http" - "strconv" "strings" "github.com/gorilla/mux" @@ -12,190 +9,6 @@ import ( "github.com/mattermost/mattermost/server/public/plugin" ) -type httpResponse struct { - Status string `json:"status"` - Error string `json:"error,omitempty"` - Data interface{} `json:"data,omitempty"` -} - -type SentryTag struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type SentryExceptionValue struct { - Type string `json:"type"` - Value string `json:"value"` - Stacktrace struct { - Frames []struct { - Filename string `json:"filename"` - Function string `json:"function"` - Module string `json:"module"` - Lineno int `json:"lineno"` - Colno int `json:"colno"` - AbsPath string `json:"abs_path"` - ContextLine string `json:"context_line"` - } `json:"frames"` - } `json:"stacktrace"` -} - -type SentryException struct { - Values []SentryExceptionValue `json:"values"` -} - -type SentryEvent struct { - Project int `json:"project"` - Title string `json:"title"` - Message string `json:"message,omitempty"` - Level string `json:"level"` - Culprit string `json:"culprit,omitempty"` - Logger string `json:"logger,omitempty"` - Platform string `json:"platform,omitempty"` - WebURL string `json:"web_url"` - IssueID string `json:"issue_id"` - Tags [][]string `json:"tags"` // ← вот здесь - Exception *SentryException `json:"exception,omitempty"` -} - -type SentryPayload struct { - Action string `json:"action"` - Data struct { - Event SentryEvent `json:"event"` - TriggeredRule string `json:"triggered_rule"` - } `json:"data"` -} - -func levelToColor(level string) string { - switch strings.ToLower(level) { - case "fatal": - return "#B10DC9" // фиолетовый, критический - case "error": - return "#FF4136" // красный - case "warning": - return "#FF851B" // оранжевый - case "log": - return "#AAAAAA" // серый - case "info": - return "#0074D9" // синий - case "debug": - return "#2ECC40" // зелёный - default: - return "#AAAAAA" // серый для неизвестных - } -} - -func getTagFromArray(tags [][]string, key string) string { - for _, t := range tags { - if len(t) == 2 && t[0] == key { - return t[1] - } - } - return "" -} - -func formatStacktrace(ex *SentryExceptionValue) string { - if ex == nil || len(ex.Stacktrace.Frames) == 0 { - return "" - } - - lines := make([]string, 0, len(ex.Stacktrace.Frames)) - for _, f := range ex.Stacktrace.Frames { - lines = append(lines, fmt.Sprintf("%s:%d %s - %s", f.Filename, f.Lineno, f.Function, f.ContextLine)) - } - return strings.Join(lines, "\n") -} - -func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - var payload SentryPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - p.API.LogError("Failed to decode Sentry payload", "error", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - event := payload.Data.Event - - // Найти канал по projectID - channelID, err := p.getChannelForProject(strconv.Itoa(event.Project)) - if err != nil || channelID == "" { - p.API.LogWarn("No channel linked for project", "project", event.Project) - w.WriteHeader(http.StatusOK) - return - } - - // Основные теги - environment := getTagFromArray(event.Tags, "environment") - release := getTagFromArray(event.Tags, "release") - user := getTagFromArray(event.Tags, "user") - - // Составляем attachment - attachment := &model.SlackAttachment{ - Color: levelToColor(event.Level), - Title: event.Title, - TitleLink: event.WebURL, - Text: event.Message, - Fields: []*model.SlackAttachmentField{ - {Title: "Project ID", Value: strconv.Itoa(event.Project), Short: true}, - {Title: "Issue ID", Value: event.IssueID, Short: true}, - {Title: "Environment", Value: environment, Short: true}, - {Title: "Level", Value: event.Level, Short: true}, - {Title: "Culprit", Value: event.Culprit, Short: false}, - {Title: "Logger", Value: event.Logger, Short: true}, - {Title: "Platform", Value: event.Platform, Short: true}, - {Title: "Release", Value: release, Short: true}, - {Title: "User", Value: user, Short: true}, - }, - } - - // if event.Exception != nil && len(event.Exception.Values) > 0 { - // for _, ex := range event.Exception.Values { - // attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{ - // Title: "Exception", - // Value: fmt.Sprintf("Type: %s\nValue: %s\nStacktrace:\n%s", ex.Type, ex.Value, formatStacktrace(&ex)), - // Short: true, - // }) - // } - // } - - for _, tag := range event.Tags { - if len(tag) != 2 { - continue - } - - key := tag[0] - value := tag[1] - - if key == "environment" || key == "release" || key == "user" { - continue - } - - attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{ - Title: key, - Value: value, - Short: true, - }) - } - - // Создаем пост - post := &model.Post{ - UserId: p.botUserID, - ChannelId: channelID, - Props: map[string]interface{}{ - "attachments": []*model.SlackAttachment{attachment}, - }, - } - - if _, err := p.API.CreatePost(post); err != nil { - p.API.LogError("Failed to create post", "error", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - func (p *Plugin) InitApi() { p.router = mux.NewRouter() p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST") @@ -254,42 +67,3 @@ func (p *Plugin) ExecuteCommand( } } - -func (p *Plugin) ensureBot() error { - const botUsername = "sentry" - - user, appErr := p.API.GetUserByUsername(botUsername) - if appErr == nil && user != nil { - if !user.IsBot { - return fmt.Errorf("user @%s exists but is not a bot", botUsername) - } - p.botUserID = user.Id - return nil - } - - bot := &model.Bot{ - Username: botUsername, - DisplayName: "Sentry", - Description: "Sentry notifications bot", - } - - createdBot, appErr := p.API.CreateBot(bot) - if appErr != nil { - p.API.LogError("Failed to create Sentry bot", "error", appErr.Error()) - return appErr - } - - p.botUserID = createdBot.UserId - return nil -} - -func (p *Plugin) getChannelForProject(project string) (string, error) { - key := "sentry:project:" + project - - data, appErr := p.API.KVGet(key) - if appErr != nil || data == nil { - return "", appErr - } - - return string(data), nil -} diff --git a/server/plugin/bot.go b/server/plugin/bot.go new file mode 100644 index 0000000..cee10ee --- /dev/null +++ b/server/plugin/bot.go @@ -0,0 +1,46 @@ +package plugin + +import ( + "fmt" + + "github.com/mattermost/mattermost/server/public/model" +) + +func (p *Plugin) ensureBot() error { + const botUsername = "sentry" + + user, appErr := p.API.GetUserByUsername(botUsername) + if appErr == nil && user != nil { + if !user.IsBot { + return fmt.Errorf("user @%s exists but is not a bot", botUsername) + } + p.botUserID = user.Id + return nil + } + + bot := &model.Bot{ + Username: botUsername, + DisplayName: "Sentry", + Description: "Sentry notifications bot", + } + + createdBot, appErr := p.API.CreateBot(bot) + if appErr != nil { + p.API.LogError("Failed to create Sentry bot", "error", appErr.Error()) + return appErr + } + + p.botUserID = createdBot.UserId + return nil +} + +func (p *Plugin) getChannelForProject(project string) (string, error) { + key := "sentry:project:" + project + + data, appErr := p.API.KVGet(key) + if appErr != nil || data == nil { + return "", appErr + } + + return string(data), nil +} diff --git a/server/plugin/sentry_types.go b/server/plugin/sentry_types.go new file mode 100644 index 0000000..4adab1e --- /dev/null +++ b/server/plugin/sentry_types.go @@ -0,0 +1,56 @@ +package plugin + +// Типы данных для работы с Sentry API + +type httpResponse struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type SentryTag struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type SentryExceptionValue struct { + Type string `json:"type"` + Value string `json:"value"` + Stacktrace struct { + Frames []struct { + Filename string `json:"filename"` + Function string `json:"function"` + Module string `json:"module"` + Lineno int `json:"lineno"` + Colno int `json:"colno"` + AbsPath string `json:"abs_path"` + ContextLine string `json:"context_line"` + } `json:"frames"` + } `json:"stacktrace"` +} + +type SentryException struct { + Values []SentryExceptionValue `json:"values"` +} + +type SentryEvent struct { + Project int `json:"project"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + Level string `json:"level"` + Culprit string `json:"culprit,omitempty"` + Logger string `json:"logger,omitempty"` + Platform string `json:"platform,omitempty"` + WebURL string `json:"web_url"` + IssueID string `json:"issue_id"` + Tags [][]string `json:"tags"` + Exception *SentryException `json:"exception,omitempty"` +} + +type SentryPayload struct { + Action string `json:"action"` + Data struct { + Event SentryEvent `json:"event"` + TriggeredRule string `json:"triggered_rule"` + } `json:"data"` +} diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go new file mode 100644 index 0000000..fc34da6 --- /dev/null +++ b/server/plugin/webhook.go @@ -0,0 +1,138 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/mattermost/mattermost/server/public/model" +) + +func levelToColor(level string) string { + switch strings.ToLower(level) { + case "fatal": + return "#B10DC9" // фиолетовый, критический + case "error": + return "#FF4136" // красный + case "warning": + return "#FF851B" // оранжевый + case "log": + return "#AAAAAA" // серый + case "info": + return "#0074D9" // синий + case "debug": + return "#2ECC40" // зелёный + default: + return "#AAAAAA" // серый для неизвестных + } +} + +func getTagFromArray(tags [][]string, key string) string { + for _, t := range tags { + if len(t) == 2 && t[0] == key { + return t[1] + } + } + return "" +} + +func formatStacktrace(ex *SentryExceptionValue) string { + if ex == nil || len(ex.Stacktrace.Frames) == 0 { + return "" + } + + lines := make([]string, 0, len(ex.Stacktrace.Frames)) + for _, f := range ex.Stacktrace.Frames { + lines = append(lines, fmt.Sprintf("%s:%d %s - %s", f.Filename, f.Lineno, f.Function, f.ContextLine)) + } + return strings.Join(lines, "\n") +} + +func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var payload SentryPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + p.API.LogError("Failed to decode Sentry payload", "error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + event := payload.Data.Event + + channelID, err := p.getChannelForProject(strconv.Itoa(event.Project)) + if err != nil || channelID == "" { + p.API.LogWarn("No channel linked for project", "project", event.Project) + w.WriteHeader(http.StatusOK) + return + } + + environment := getTagFromArray(event.Tags, "environment") + release := getTagFromArray(event.Tags, "release") + user := getTagFromArray(event.Tags, "user") + + attachment := &model.SlackAttachment{ + Color: levelToColor(event.Level), + Title: event.Title, + TitleLink: event.WebURL, + Text: event.Message, + Fields: []*model.SlackAttachmentField{ + {Title: "Project ID", Value: strconv.Itoa(event.Project), Short: true}, + {Title: "Issue ID", Value: event.IssueID, Short: true}, + {Title: "Environment", Value: environment, Short: true}, + {Title: "Level", Value: event.Level, Short: true}, + {Title: "Culprit", Value: event.Culprit, Short: false}, + {Title: "Logger", Value: event.Logger, Short: true}, + {Title: "Platform", Value: event.Platform, Short: true}, + {Title: "Release", Value: release, Short: true}, + {Title: "User", Value: user, Short: true}, + }, + } + + // if event.Exception != nil && len(event.Exception.Values) > 0 { + // for _, ex := range event.Exception.Values { + // attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{ + // Title: "Exception", + // Value: fmt.Sprintf("Type: %s\nValue: %s\nStacktrace:\n%s", ex.Type, ex.Value, formatStacktrace(&ex)), + // Short: true, + // }) + // } + // } + + for _, tag := range event.Tags { + if len(tag) != 2 { + continue + } + + key := tag[0] + value := tag[1] + + if key == "environment" || key == "release" || key == "user" { + continue + } + + attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{ + Title: key, + Value: value, + Short: true, + }) + } + + post := &model.Post{ + UserId: p.botUserID, + ChannelId: channelID, + Props: map[string]interface{}{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + p.API.LogError("Failed to create post", "error", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +}