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") } func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() resource := r.Header.Get("Sentry-Hook-Resource") switch resource { case "event_alert": p.handleEventAlert(w, r) case "comment": p.handleComment(w, r) case "metric_alert": p.handleMetricAlert(w, r) case "issue": p.handleIssue(w, r) default: p.API.LogWarn("Unsupported Sentry hook resource", "resource", resource) 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) }