From 419ff0b5a1af90eb6160ab90496434713b763a2f Mon Sep 17 00:00:00 2001 From: "kirill.moos" Date: Fri, 23 Jan 2026 21:37:33 +0300 Subject: [PATCH] refactoring, add set bot icon --- assets/icon.png | Bin 0 -> 11124 bytes assets/icon.svg | 1 - server/plugin/bot.go | 46 +++- server/plugin/commands.go | 166 ++---------- server/plugin/hooks.go | 26 ++ server/plugin/sentry_client.go | 88 +++++++ server/plugin/store.go | 180 ++++++++++++- server/plugin/utils.go | 56 +--- server/plugin/webhook.go | 396 +--------------------------- server/plugin/webhook_formatters.go | 49 ++++ server/plugin/webhook_handlers.go | 327 +++++++++++++++++++++++ 11 files changed, 721 insertions(+), 614 deletions(-) create mode 100644 assets/icon.png delete mode 100644 assets/icon.svg create mode 100644 server/plugin/hooks.go create mode 100644 server/plugin/sentry_client.go create mode 100644 server/plugin/webhook_formatters.go create mode 100644 server/plugin/webhook_handlers.go diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5efbfb2efadf539a4205d3a0b6c3664f3c220716 GIT binary patch literal 11124 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 \ No newline at end of file diff --git a/server/plugin/bot.go b/server/plugin/bot.go index f77133f..cc0463d 100644 --- a/server/plugin/bot.go +++ b/server/plugin/bot.go @@ -1,12 +1,14 @@ package plugin import ( - "encoding/json" "fmt" + "os" + "path/filepath" "github.com/mattermost/mattermost/server/public/model" ) +// ensureBot создает или получает бота для отправки сообщений func (p *Plugin) ensureBot() error { const botUsername = "sentry" @@ -32,24 +34,40 @@ func (p *Plugin) ensureBot() error { } p.botUserID = createdBot.UserId + + // Устанавливаем иконку после создания бота + if err := p.setBotIcon(); err != nil { + p.API.LogWarn("Failed to set bot icon", "error", err.Error()) + } + return nil } -func (p *Plugin) getChannelForProject(projectID string) (string, error) { - key := "ru.loop.plugin.sentry:project:" + projectID - - data, appErr := p.API.KVGet(key) - if appErr != nil { - return "", appErr - } - if data == nil { - return "", nil +func (p *Plugin) setBotIcon() error { + if p.botUserID == "" { + return fmt.Errorf("bot user ID is not set") } - var project LinkedProject - if err := json.Unmarshal(data, &project); err != nil { - return "", err + bundlePath := p.bundlePath + if bundlePath == "" { + var err error + bundlePath, err = p.API.GetBundlePath() + if err != nil { + return fmt.Errorf("failed to get bundle path: %w", err) + } } - return project.ChannelID, nil + iconPath := filepath.Join(bundlePath, "assets", "icon.png") + + iconData, err := os.ReadFile(iconPath) + if err != nil { + return fmt.Errorf("failed to read icon file: %w", err) + } + + if appErr := p.API.SetProfileImage(p.botUserID, iconData); appErr != nil { + return fmt.Errorf("failed to set profile image: %s", appErr.Error()) + } + + p.API.LogInfo("Bot icon set successfully") + return nil } diff --git a/server/plugin/commands.go b/server/plugin/commands.go index 08db583..d6895ba 100644 --- a/server/plugin/commands.go +++ b/server/plugin/commands.go @@ -1,72 +1,12 @@ package plugin import ( - "encoding/json" - "errors" "fmt" - "io" - "net/http" "strings" - "time" "github.com/mattermost/mattermost/server/public/model" ) -func (p *Plugin) fetchSentryProject(projectSlug string) (*LinkedProject, error) { - cfg := p.GetConfiguration() - - if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { - return nil, errors.New("sentry is not configured") - } - - url := fmt.Sprintf( - "%s/api/0/projects/%s/%s/", - strings.TrimRight(cfg.SentryUrl, "/"), - cfg.SentryOrganisationName, - projectSlug, - ) - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+cfg.SentryAuthToken) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sentry api error (%d): %s", resp.StatusCode, body) - } - - var result struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - if result.ID == "" { - return nil, errors.New("project id not found") - } - - return &LinkedProject{ - ID: result.ID, - Slug: result.Slug, - Name: result.Name, - }, nil -} - func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.CommandResponse, *model.AppError) { if len(split) < 3 { return &model.CommandResponse{ @@ -112,38 +52,26 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model. slug := split[2] - data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") - if data == nil { + projects, err := p.getAllProjects() + if err != nil { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "ℹ️ No linked projects", }, nil } - var projects []string - _ = json.Unmarshal(data, &projects) - - var ( - newProjects []string - removed *LinkedProject - ) - - for _, id := range projects { - pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id) - if pData == nil { - continue + var removed *LinkedProject + for _, project := range projects { + if project.Slug == slug { + removed = project + if err := p.deleteProject(project.ID); err != nil { + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "❌ Failed to unlink project: " + err.Error(), + }, nil + } + break } - - var proj LinkedProject - _ = json.Unmarshal(pData, &proj) - - if proj.Slug == slug { - removed = &proj - _ = p.API.KVDelete("ru.loop.plugin.sentry:project:" + id) - continue - } - - newProjects = append(newProjects, id) } if removed == nil { @@ -153,9 +81,6 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model. }, nil } - bytes, _ := json.Marshal(newProjects) - _ = p.API.KVSet("ru.loop.plugin.sentry:projects", bytes) - return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: fmt.Sprintf( @@ -167,18 +92,8 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model. } func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { - data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") - if data == nil { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: "_No linked Sentry projects_", - }, nil - } - - var projects []string - _ = json.Unmarshal(data, &projects) - - if len(projects) == 0 { + projects, err := p.getAllProjects() + if err != nil || len(projects) == 0 { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "_No linked Sentry projects_", @@ -186,16 +101,7 @@ func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, * } var lines []string - - for _, id := range projects { - pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id) - if pData == nil { - continue - } - - var project LinkedProject - _ = json.Unmarshal(pData, &project) - + for _, project := range projects { channelName := project.ChannelID if ch, err := p.API.GetChannel(project.ChannelID); err == nil { channelName = "~" + ch.Name @@ -297,46 +203,6 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse, return &model.CommandResponse{}, nil } -func (p *Plugin) linkProjectToChannel( - projectSlug string, - channelID string, - hooks HookSettings, -) (*LinkedProject, error) { - project, err := p.fetchSentryProject(projectSlug) - if err != nil { - return nil, err - } - - project.ChannelID = channelID - project.Hooks = hooks - - bytes, _ := json.Marshal(project) - if err := p.API.KVSet("ru.loop.plugin.sentry:project:"+project.ID, bytes); err != nil { - return nil, errors.New("failed to save project") - } - - data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") - var projects []string - if data != nil { - _ = json.Unmarshal(data, &projects) - } - - exists := false - for _, id := range projects { - if id == project.ID { - exists = true - break - } - } - if !exists { - projects = append(projects, project.ID) - } - - updated, _ := json.Marshal(projects) - _ = p.API.KVSet("ru.loop.plugin.sentry:projects", updated) - - return project, nil -} func (p *Plugin) commandSetupHook( args *model.CommandArgs, diff --git a/server/plugin/hooks.go b/server/plugin/hooks.go new file mode 100644 index 0000000..b09f52a --- /dev/null +++ b/server/plugin/hooks.go @@ -0,0 +1,26 @@ +package plugin + +type HookType string + +const ( + HookEventAlert HookType = "event_alert" + HookMetricAlert HookType = "metric_alert" + HookIssue HookType = "issue" + HookComment HookType = "comment" +) + +// isHookEnabled проверяет, включен ли хук для проекта +func isHookEnabled(p *LinkedProject, hook HookType) bool { + switch hook { + case HookEventAlert: + return p.Hooks.EventAlert + case HookMetricAlert: + return p.Hooks.MetricAlert + case HookIssue: + return p.Hooks.Issue + case HookComment: + return p.Hooks.Comment + default: + return false + } +} diff --git a/server/plugin/sentry_client.go b/server/plugin/sentry_client.go new file mode 100644 index 0000000..40032c5 --- /dev/null +++ b/server/plugin/sentry_client.go @@ -0,0 +1,88 @@ +package plugin + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// fetchSentryProject получает информацию о проекте из Sentry API +func (p *Plugin) fetchSentryProject(projectSlug string) (*LinkedProject, error) { + cfg := p.GetConfiguration() + + if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { + return nil, errors.New("sentry is not configured") + } + + url := fmt.Sprintf( + "%s/api/0/projects/%s/%s/", + strings.TrimRight(cfg.SentryUrl, "/"), + cfg.SentryOrganisationName, + projectSlug, + ) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+cfg.SentryAuthToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("sentry api error (%d): %s", resp.StatusCode, body) + } + + var result struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + if result.ID == "" { + return nil, errors.New("project id not found") + } + + return &LinkedProject{ + ID: result.ID, + Slug: result.Slug, + Name: result.Name, + }, nil +} + +// linkProjectToChannel связывает проект Sentry с каналом Mattermost +func (p *Plugin) linkProjectToChannel( + projectSlug string, + channelID string, + hooks HookSettings, +) (*LinkedProject, error) { + project, err := p.fetchSentryProject(projectSlug) + if err != nil { + return nil, err + } + + project.ChannelID = channelID + project.Hooks = hooks + + if err := p.saveProject(project); err != nil { + return nil, err + } + + return project, nil +} diff --git a/server/plugin/store.go b/server/plugin/store.go index 2bc2eea..21329e5 100644 --- a/server/plugin/store.go +++ b/server/plugin/store.go @@ -1,3 +1,181 @@ package plugin -// Add your store utility functions here +import ( + "encoding/json" + "errors" + "strings" +) + +const ( + kvProjectsKey = "ru.loop.plugin.sentry:projects" + kvProjectPrefix = "ru.loop.plugin.sentry:project:" +) + +// getProject получает проект по ID из хранилища +func (p *Plugin) getProject(projectID string) (*LinkedProject, error) { + key := kvProjectPrefix + projectID + + data, appErr := p.API.KVGet(key) + if appErr != nil || data == nil { + return nil, appErr + } + + var project LinkedProject + if err := json.Unmarshal(data, &project); err != nil { + return nil, err + } + + return &project, nil +} + +// getProjectBySlug получает проект по slug из хранилища +func (p *Plugin) getProjectBySlug(slug string) (*LinkedProject, error) { + keys, appErr := p.API.KVList(0, 1000) + if appErr != nil { + return nil, appErr + } + + for _, key := range keys { + if !strings.HasPrefix(key, kvProjectPrefix) { + continue + } + + data, appErr := p.API.KVGet(key) + if appErr != nil || data == nil { + continue + } + + var project LinkedProject + if err := json.Unmarshal(data, &project); err != nil { + continue + } + + if project.Slug == slug { + return &project, nil + } + } + + return nil, errors.New("project not found") +} + +// getProjectByChannel получает проект, привязанный к каналу +func (p *Plugin) getProjectByChannel(channelID string) (*LinkedProject, error) { + data, _ := p.API.KVGet(kvProjectsKey) + if data == nil { + return nil, errors.New("no projects linked") + } + + var ids []string + _ = json.Unmarshal(data, &ids) + + for _, id := range ids { + raw, _ := p.API.KVGet(kvProjectPrefix + id) + if raw == nil { + continue + } + + var project LinkedProject + _ = json.Unmarshal(raw, &project) + + if project.ChannelID == channelID { + return &project, nil + } + } + + return nil, errors.New("no Sentry project linked to this channel") +} + +// getChannelForProject получает ID канала для проекта +func (p *Plugin) getChannelForProject(projectID string) (string, error) { + project, err := p.getProject(projectID) + if err != nil { + return "", err + } + return project.ChannelID, nil +} + +// getAllProjects получает все связанные проекты +func (p *Plugin) getAllProjects() ([]*LinkedProject, error) { + data, _ := p.API.KVGet(kvProjectsKey) + if data == nil { + return []*LinkedProject{}, nil + } + + var ids []string + _ = json.Unmarshal(data, &ids) + + var projects []*LinkedProject + for _, id := range ids { + project, err := p.getProject(id) + if err != nil { + continue + } + projects = append(projects, project) + } + + return projects, nil +} + +// saveProject сохраняет проект в хранилище +func (p *Plugin) saveProject(project *LinkedProject) error { + bytes, err := json.Marshal(project) + if err != nil { + return err + } + + key := kvProjectPrefix + project.ID + if err := p.API.KVSet(key, bytes); err != nil { + return errors.New("failed to save project") + } + + // Добавляем ID в список проектов + data, _ := p.API.KVGet(kvProjectsKey) + var projects []string + if data != nil { + _ = json.Unmarshal(data, &projects) + } + + exists := false + for _, id := range projects { + if id == project.ID { + exists = true + break + } + } + if !exists { + projects = append(projects, project.ID) + updated, _ := json.Marshal(projects) + _ = p.API.KVSet(kvProjectsKey, updated) + } + + return nil +} + +// deleteProject удаляет проект из хранилища +func (p *Plugin) deleteProject(projectID string) error { + key := kvProjectPrefix + projectID + if err := p.API.KVDelete(key); err != nil { + return err + } + + // Удаляем ID из списка проектов + data, _ := p.API.KVGet(kvProjectsKey) + if data == nil { + return nil + } + + var projects []string + _ = json.Unmarshal(data, &projects) + + var newProjects []string + for _, id := range projects { + if id != projectID { + newProjects = append(newProjects, id) + } + } + + bytes, _ := json.Marshal(newProjects) + _ = p.API.KVSet(kvProjectsKey, bytes) + + return nil +} diff --git a/server/plugin/utils.go b/server/plugin/utils.go index 13e96b8..145c92f 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -1,21 +1,10 @@ package plugin import ( - "encoding/json" - "errors" - "github.com/mattermost/mattermost/server/public/model" ) -type HookType string - -const ( - HookEventAlert HookType = "event_alert" - HookMetricAlert HookType = "metric_alert" - HookIssue HookType = "issue" - HookComment HookType = "comment" -) - +// getBool извлекает булево значение из map[string]interface{} func getBool(sub map[string]interface{}, key string, def bool) bool { if v, ok := sub[key]; ok { if s, ok := v.(string); ok { @@ -25,6 +14,7 @@ func getBool(sub map[string]interface{}, key string, def bool) bool { return def } +// boolToStr конвертирует булево значение в строку func boolToStr(v bool) string { if v { return "true" @@ -32,47 +22,7 @@ func boolToStr(v bool) string { return "false" } -func isHookEnabled(p *LinkedProject, hook HookType) bool { - switch hook { - case HookEventAlert: - return p.Hooks.EventAlert - case HookMetricAlert: - return p.Hooks.MetricAlert - case HookIssue: - return p.Hooks.Issue - case HookComment: - return p.Hooks.Comment - default: - return false - } -} - -func (p *Plugin) getProjectByChannel(channelID string) (*LinkedProject, error) { - data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") - if data == nil { - return nil, errors.New("no projects linked") - } - - var ids []string - _ = json.Unmarshal(data, &ids) - - for _, id := range ids { - raw, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id) - if raw == nil { - continue - } - - var project LinkedProject - _ = json.Unmarshal(raw, &project) - - if project.ChannelID == channelID { - return &project, nil - } - } - - return nil, errors.New("no Sentry project linked to this channel") -} - +// ephemeral создает эфемерный ответ команды func ephemeral(text string) *model.CommandResponse { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index 53371a7..8df35ad 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -1,71 +1,10 @@ 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 (p *Plugin) getProject(projectID string) (*LinkedProject, error) { - key := "ru.loop.plugin.sentry:project:" + projectID - - data, appErr := p.API.KVGet(key) - if appErr != nil || data == nil { - return nil, appErr - } - - var project LinkedProject - if err := json.Unmarshal(data, &project); err != nil { - return nil, err - } - - return &project, nil -} - -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") -} - +// handleWebhook обрабатывает входящие вебхуки от Sentry func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() @@ -85,336 +24,3 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } } - -func (p *Plugin) handleEventAlert(w http.ResponseWriter, r *http.Request) { - var payload SentryPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - p.API.LogError("Failed to decode Sentry event alert 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 - } - - project, err := p.getProject(strconv.Itoa(event.Project)) - if err != nil || project == nil || project.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", - Value: fmt.Sprintf("%s (`%s`)", project.Name, project.Slug), - Short: true, - }, - {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) handleComment(w http.ResponseWriter, r *http.Request) { - var payload SentryCommentPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - p.API.LogError("Failed to decode Sentry comment payload", "error", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - project, err := p.getProjectBySlug(payload.Data.ProjectSlug) - if err != nil || project == nil || project.ChannelID == "" { - p.API.LogWarn("No channel linked for project", "slug", payload.Data.ProjectSlug) - w.WriteHeader(http.StatusOK) - return - } - - attachment := &model.SlackAttachment{ - Color: "#439FE0", - Title: "💬 New Sentry comment", - Text: fmt.Sprintf( - "*%s* commented on issue `%d`\n\n>%s", - payload.Actor.Name, - payload.Data.IssueID, - payload.Data.Comment, - ), - Fields: []*model.SlackAttachmentField{ - {Title: "Project", Value: project.Name, Short: true}, - {Title: "Action", Value: payload.Action, Short: true}, - {Title: "Comment ID", Value: strconv.Itoa(payload.Data.CommentID), Short: true}, - }, - } - - post := &model.Post{ - UserId: p.botUserID, - ChannelId: project.ChannelID, - Props: map[string]interface{}{ - "attachments": []*model.SlackAttachment{attachment}, - }, - } - - if _, err := p.API.CreatePost(post); err != nil { - p.API.LogError("Failed to create comment post", "error", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (p *Plugin) getProjectBySlug(slug string) (*LinkedProject, error) { - // Получаем все ключи плагина - keys, appErr := p.API.KVList(0, 1000) - if appErr != nil { - return nil, appErr - } - - for _, key := range keys { - // интересуют только наши проекты - if !strings.HasPrefix(key, "ru.loop.plugin.sentry:project:") { - continue - } - - data, appErr := p.API.KVGet(key) - if appErr != nil || data == nil { - continue - } - - var project LinkedProject - if err := json.Unmarshal(data, &project); err != nil { - continue - } - - if project.Slug == slug { - return &project, nil - } - } - - return nil, nil -} - -func (p *Plugin) handleMetricAlert(w http.ResponseWriter, r *http.Request) { - var payload SentryMetricAlertPayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - p.API.LogError("Failed to decode metric alert payload", "error", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - alert := payload.Data.MetricAlert - rule := alert.AlertRule - - // Обычно metric alerts привязаны к проекту по slug - var project *LinkedProject - if len(alert.Projects) > 0 { - project, _ = p.getProjectBySlug(alert.Projects[0]) - } - - channelID := "" - if project != nil { - channelID = project.ChannelID - } - - if channelID == "" { - p.API.LogWarn("No channel linked for metric alert", "projects", alert.Projects) - w.WriteHeader(http.StatusOK) - return - } - - color := "#E01E5A" // default: critical - switch payload.Action { - case "resolved": - color = "#2EB67D" - case "warning": - color = "#ECB22E" - } - - attachment := &model.SlackAttachment{ - Color: color, - Title: payload.Data.DescriptionTitle, - TitleLink: payload.Data.WebURL, - Text: payload.Data.DescriptionText, - Fields: []*model.SlackAttachmentField{ - { - Title: "Rule", - Value: rule.Name, - Short: true, - }, - { - Title: "Aggregate", - Value: rule.Aggregate, - Short: true, - }, - { - Title: "Query", - Value: rule.Query, - Short: false, - }, - { - Title: "Window (min)", - Value: strconv.Itoa(rule.TimeWindow), - Short: true, - }, - { - Title: "Action", - Value: payload.Action, - 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 metric alert post", "error", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -func (p *Plugin) handleIssue(w http.ResponseWriter, r *http.Request) { - var payload SentryIssuePayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - p.API.LogError("Failed to decode issue payload", "error", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - issue := payload.Data.Issue - - project, err := p.getProjectBySlug(issue.Project.Slug) - if err != nil || project == nil || project.ChannelID == "" { - p.API.LogWarn("No channel linked for issue", "project", issue.Project.Slug) - w.WriteHeader(http.StatusOK) - return - } - - color := "#E01E5A" // default: red - switch payload.Action { - case "resolved": - color = "#2EB67D" - case "archived": - color = "#6B7280" - case "unresolved": - color = "#F97316" - } - - attachment := &model.SlackAttachment{ - Color: color, - Title: fmt.Sprintf("[%s] %s", issue.ShortID, issue.Title), - TitleLink: issue.WebURL, - Text: fmt.Sprintf( - "**Action:** `%s`\n**Status:** `%s`\n**Level:** `%s`\n**Priority:** `%s`", - payload.Action, - issue.Status, - issue.Level, - issue.Priority, - ), - Fields: []*model.SlackAttachmentField{ - {Title: "Project", Value: issue.Project.Name, Short: true}, - {Title: "Platform", Value: issue.Platform, Short: true}, - {Title: "Type", Value: issue.IssueType, Short: true}, - {Title: "Category", Value: issue.IssueCategory, Short: true}, - {Title: "Events", Value: issue.Count, Short: true}, - {Title: "Users", Value: strconv.Itoa(issue.UserCount), Short: true}, - }, - } - - post := &model.Post{ - UserId: p.botUserID, - ChannelId: project.ChannelID, - Props: map[string]interface{}{ - "attachments": []*model.SlackAttachment{attachment}, - }, - } - - if _, err := p.API.CreatePost(post); err != nil { - p.API.LogError("Failed to create issue post", "error", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} diff --git a/server/plugin/webhook_formatters.go b/server/plugin/webhook_formatters.go new file mode 100644 index 0000000..cb17ce4 --- /dev/null +++ b/server/plugin/webhook_formatters.go @@ -0,0 +1,49 @@ +package plugin + +import ( + "fmt" + "strings" +) + +// levelToColor возвращает цвет для уровня события Sentry +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" // серый для неизвестных + } +} + +// getTagFromArray извлекает значение тега из массива тегов +func getTagFromArray(tags [][]string, key string) string { + for _, t := range tags { + if len(t) == 2 && t[0] == key { + return t[1] + } + } + return "" +} + +// formatStacktrace форматирует стектрейс для отображения +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") +} diff --git a/server/plugin/webhook_handlers.go b/server/plugin/webhook_handlers.go new file mode 100644 index 0000000..419c112 --- /dev/null +++ b/server/plugin/webhook_handlers.go @@ -0,0 +1,327 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/mattermost/mattermost/server/public/model" +) + +// handleEventAlert обрабатывает вебхук события Sentry +func (p *Plugin) handleEventAlert(w http.ResponseWriter, r *http.Request) { + var payload SentryPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + p.API.LogError("Failed to decode Sentry event alert payload", "error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + event := payload.Data.Event + + project, err := p.getProject(strconv.Itoa(event.Project)) + if err != nil || project == nil || project.ChannelID == "" { + p.API.LogWarn("No channel linked for project", "project", event.Project) + w.WriteHeader(http.StatusOK) + return + } + + if !project.Hooks.EventAlert { + p.API.LogDebug("Event alert hook disabled for project", "project", project.Slug) + 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", + Value: fmt.Sprintf("%s (`%s`)", project.Name, project.Slug), + Short: true, + }, + {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: project.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) +} + +// handleComment обрабатывает вебхук комментария Sentry +func (p *Plugin) handleComment(w http.ResponseWriter, r *http.Request) { + var payload SentryCommentPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + p.API.LogError("Failed to decode Sentry comment payload", "error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + project, err := p.getProjectBySlug(payload.Data.ProjectSlug) + if err != nil || project == nil || project.ChannelID == "" { + p.API.LogWarn("No channel linked for project", "slug", payload.Data.ProjectSlug) + w.WriteHeader(http.StatusOK) + return + } + + if !project.Hooks.Comment { + p.API.LogDebug("Comment hook disabled for project", "project", project.Slug) + w.WriteHeader(http.StatusOK) + return + } + + attachment := &model.SlackAttachment{ + Color: "#439FE0", + Title: "💬 New Sentry comment", + Text: fmt.Sprintf( + "*%s* commented on issue `%d`\n\n>%s", + payload.Actor.Name, + payload.Data.IssueID, + payload.Data.Comment, + ), + Fields: []*model.SlackAttachmentField{ + {Title: "Project", Value: project.Name, Short: true}, + {Title: "Action", Value: payload.Action, Short: true}, + {Title: "Comment ID", Value: strconv.Itoa(payload.Data.CommentID), Short: true}, + }, + } + + post := &model.Post{ + UserId: p.botUserID, + ChannelId: project.ChannelID, + Props: map[string]interface{}{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + p.API.LogError("Failed to create comment post", "error", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// handleMetricAlert обрабатывает вебхук метрического алерта Sentry +func (p *Plugin) handleMetricAlert(w http.ResponseWriter, r *http.Request) { + var payload SentryMetricAlertPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + p.API.LogError("Failed to decode metric alert payload", "error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + alert := payload.Data.MetricAlert + rule := alert.AlertRule + + var project *LinkedProject + if len(alert.Projects) > 0 { + project, _ = p.getProjectBySlug(alert.Projects[0]) + } + + if project == nil || project.ChannelID == "" { + p.API.LogWarn("No channel linked for metric alert", "projects", alert.Projects) + w.WriteHeader(http.StatusOK) + return + } + + if !project.Hooks.MetricAlert { + p.API.LogDebug("Metric alert hook disabled for project", "project", project.Slug) + w.WriteHeader(http.StatusOK) + return + } + + color := "#E01E5A" // default: critical + switch payload.Action { + case "resolved": + color = "#2EB67D" + case "warning": + color = "#ECB22E" + } + + attachment := &model.SlackAttachment{ + Color: color, + Title: payload.Data.DescriptionTitle, + TitleLink: payload.Data.WebURL, + Text: payload.Data.DescriptionText, + Fields: []*model.SlackAttachmentField{ + { + Title: "Rule", + Value: rule.Name, + Short: true, + }, + { + Title: "Aggregate", + Value: rule.Aggregate, + Short: true, + }, + { + Title: "Query", + Value: rule.Query, + Short: false, + }, + { + Title: "Window (min)", + Value: strconv.Itoa(rule.TimeWindow), + Short: true, + }, + { + Title: "Action", + Value: payload.Action, + Short: true, + }, + }, + } + + post := &model.Post{ + UserId: p.botUserID, + ChannelId: project.ChannelID, + Props: map[string]interface{}{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + p.API.LogError("Failed to create metric alert post", "error", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// handleIssue обрабатывает вебхук issue Sentry +func (p *Plugin) handleIssue(w http.ResponseWriter, r *http.Request) { + var payload SentryIssuePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + p.API.LogError("Failed to decode issue payload", "error", err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + issue := payload.Data.Issue + + project, err := p.getProjectBySlug(issue.Project.Slug) + if err != nil || project == nil || project.ChannelID == "" { + p.API.LogWarn("No channel linked for issue", "project", issue.Project.Slug) + w.WriteHeader(http.StatusOK) + return + } + + if !project.Hooks.Issue { + p.API.LogDebug("Issue hook disabled for project", "project", project.Slug) + w.WriteHeader(http.StatusOK) + return + } + + color := "#E01E5A" // default: red + switch payload.Action { + case "resolved": + color = "#2EB67D" + case "archived": + color = "#6B7280" + case "unresolved": + color = "#F97316" + } + + attachment := &model.SlackAttachment{ + Color: color, + Title: fmt.Sprintf("[%s] %s", issue.ShortID, issue.Title), + TitleLink: issue.WebURL, + Text: fmt.Sprintf( + "**Action:** `%s`\n**Status:** `%s`\n**Level:** `%s`\n**Priority:** `%s`", + payload.Action, + issue.Status, + issue.Level, + issue.Priority, + ), + Fields: []*model.SlackAttachmentField{ + {Title: "Project", Value: issue.Project.Name, Short: true}, + {Title: "Platform", Value: issue.Platform, Short: true}, + {Title: "Type", Value: issue.IssueType, Short: true}, + {Title: "Category", Value: issue.IssueCategory, Short: true}, + {Title: "Events", Value: issue.Count, Short: true}, + {Title: "Users", Value: strconv.Itoa(issue.UserCount), Short: true}, + }, + } + + post := &model.Post{ + UserId: p.botUserID, + ChannelId: project.ChannelID, + Props: map[string]interface{}{ + "attachments": []*model.SlackAttachment{attachment}, + }, + } + + if _, err := p.API.CreatePost(post); err != nil { + p.API.LogError("Failed to create issue post", "error", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +}