diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..5efbfb2 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg deleted file mode 100644 index 87d2de5..0000000 --- a/assets/icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ 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) +}