package plugin import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/gorilla/mux" "github.com/mattermost/mattermost/server/public/model" "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") } func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { if !p.IsReady { http.Error(w, "Plugin not ready", http.StatusServiceUnavailable) return } p.router.ServeHTTP(w, r) } func (p *Plugin) registerCommands() error { command := &model.Command{ Trigger: "sentry", DisplayName: "Sentry", Description: "Manage Sentry alerts and integrations", AutoComplete: true, AutoCompleteDesc: "Available commands: help, link, unlink, list", AutoCompleteHint: "help | link | unlink | list", } if err := p.API.RegisterCommand(command); err != nil { p.API.LogError("Failed to register Sentry command", "error", err.Error()) return err } return nil } func (p *Plugin) ExecuteCommand( ctx *plugin.Context, args *model.CommandArgs, ) (*model.CommandResponse, *model.AppError) { split := strings.Fields(args.Command) if len(split) < 2 { return p.commandHelp(), nil } switch split[1] { case "setup": return p.commandSetup(args) case "link": return p.commandLink(args, split) case "unlink": return p.commandUnlink(args, split) case "list": return p.commandList(args) case "help": fallthrough default: return p.commandHelp(), nil } } 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 }