some refactoring

This commit is contained in:
Кирилл Моос 2025-12-26 17:56:42 +03:00
parent 627d4b821b
commit e04079cb0b
4 changed files with 240 additions and 226 deletions

View File

@ -1,10 +1,7 @@
package plugin package plugin
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -12,190 +9,6 @@ import (
"github.com/mattermost/mattermost/server/public/plugin" "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() { func (p *Plugin) InitApi() {
p.router = mux.NewRouter() p.router = mux.NewRouter()
p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST") p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST")
@ -254,42 +67,3 @@ func (p *Plugin) ExecuteCommand(
} }
} }
func (p *Plugin) ensureBot() error {
const botUsername = "sentry"
user, appErr := p.API.GetUserByUsername(botUsername)
if appErr == nil && user != nil {
if !user.IsBot {
return fmt.Errorf("user @%s exists but is not a bot", botUsername)
}
p.botUserID = user.Id
return nil
}
bot := &model.Bot{
Username: botUsername,
DisplayName: "Sentry",
Description: "Sentry notifications bot",
}
createdBot, appErr := p.API.CreateBot(bot)
if appErr != nil {
p.API.LogError("Failed to create Sentry bot", "error", appErr.Error())
return appErr
}
p.botUserID = createdBot.UserId
return nil
}
func (p *Plugin) getChannelForProject(project string) (string, error) {
key := "sentry:project:" + project
data, appErr := p.API.KVGet(key)
if appErr != nil || data == nil {
return "", appErr
}
return string(data), nil
}

46
server/plugin/bot.go Normal file
View File

@ -0,0 +1,46 @@
package plugin
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
func (p *Plugin) ensureBot() error {
const botUsername = "sentry"
user, appErr := p.API.GetUserByUsername(botUsername)
if appErr == nil && user != nil {
if !user.IsBot {
return fmt.Errorf("user @%s exists but is not a bot", botUsername)
}
p.botUserID = user.Id
return nil
}
bot := &model.Bot{
Username: botUsername,
DisplayName: "Sentry",
Description: "Sentry notifications bot",
}
createdBot, appErr := p.API.CreateBot(bot)
if appErr != nil {
p.API.LogError("Failed to create Sentry bot", "error", appErr.Error())
return appErr
}
p.botUserID = createdBot.UserId
return nil
}
func (p *Plugin) getChannelForProject(project string) (string, error) {
key := "sentry:project:" + project
data, appErr := p.API.KVGet(key)
if appErr != nil || data == nil {
return "", appErr
}
return string(data), nil
}

View File

@ -0,0 +1,56 @@
package plugin
// Типы данных для работы с Sentry API
type httpResponse struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
}
type SentryTag struct {
Key string `json:"key"`
Value string `json:"value"`
}
type SentryExceptionValue struct {
Type string `json:"type"`
Value string `json:"value"`
Stacktrace struct {
Frames []struct {
Filename string `json:"filename"`
Function string `json:"function"`
Module string `json:"module"`
Lineno int `json:"lineno"`
Colno int `json:"colno"`
AbsPath string `json:"abs_path"`
ContextLine string `json:"context_line"`
} `json:"frames"`
} `json:"stacktrace"`
}
type SentryException struct {
Values []SentryExceptionValue `json:"values"`
}
type SentryEvent struct {
Project int `json:"project"`
Title string `json:"title"`
Message string `json:"message,omitempty"`
Level string `json:"level"`
Culprit string `json:"culprit,omitempty"`
Logger string `json:"logger,omitempty"`
Platform string `json:"platform,omitempty"`
WebURL string `json:"web_url"`
IssueID string `json:"issue_id"`
Tags [][]string `json:"tags"`
Exception *SentryException `json:"exception,omitempty"`
}
type SentryPayload struct {
Action string `json:"action"`
Data struct {
Event SentryEvent `json:"event"`
TriggeredRule string `json:"triggered_rule"`
} `json:"data"`
}

138
server/plugin/webhook.go Normal file
View File

@ -0,0 +1,138 @@
package plugin
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
)
func levelToColor(level string) string {
switch strings.ToLower(level) {
case "fatal":
return "#B10DC9" // фиолетовый, критический
case "error":
return "#FF4136" // красный
case "warning":
return "#FF851B" // оранжевый
case "log":
return "#AAAAAA" // серый
case "info":
return "#0074D9" // синий
case "debug":
return "#2ECC40" // зелёный
default:
return "#AAAAAA" // серый для неизвестных
}
}
func getTagFromArray(tags [][]string, key string) string {
for _, t := range tags {
if len(t) == 2 && t[0] == key {
return t[1]
}
}
return ""
}
func formatStacktrace(ex *SentryExceptionValue) string {
if ex == nil || len(ex.Stacktrace.Frames) == 0 {
return ""
}
lines := make([]string, 0, len(ex.Stacktrace.Frames))
for _, f := range ex.Stacktrace.Frames {
lines = append(lines, fmt.Sprintf("%s:%d %s - %s", f.Filename, f.Lineno, f.Function, f.ContextLine))
}
return strings.Join(lines, "\n")
}
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var payload SentryPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
p.API.LogError("Failed to decode Sentry payload", "error", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
event := payload.Data.Event
channelID, err := p.getChannelForProject(strconv.Itoa(event.Project))
if err != nil || channelID == "" {
p.API.LogWarn("No channel linked for project", "project", event.Project)
w.WriteHeader(http.StatusOK)
return
}
environment := getTagFromArray(event.Tags, "environment")
release := getTagFromArray(event.Tags, "release")
user := getTagFromArray(event.Tags, "user")
attachment := &model.SlackAttachment{
Color: levelToColor(event.Level),
Title: event.Title,
TitleLink: event.WebURL,
Text: event.Message,
Fields: []*model.SlackAttachmentField{
{Title: "Project ID", Value: strconv.Itoa(event.Project), Short: true},
{Title: "Issue ID", Value: event.IssueID, Short: true},
{Title: "Environment", Value: environment, Short: true},
{Title: "Level", Value: event.Level, Short: true},
{Title: "Culprit", Value: event.Culprit, Short: false},
{Title: "Logger", Value: event.Logger, Short: true},
{Title: "Platform", Value: event.Platform, Short: true},
{Title: "Release", Value: release, Short: true},
{Title: "User", Value: user, Short: true},
},
}
// if event.Exception != nil && len(event.Exception.Values) > 0 {
// for _, ex := range event.Exception.Values {
// attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
// Title: "Exception",
// Value: fmt.Sprintf("Type: %s\nValue: %s\nStacktrace:\n%s", ex.Type, ex.Value, formatStacktrace(&ex)),
// Short: true,
// })
// }
// }
for _, tag := range event.Tags {
if len(tag) != 2 {
continue
}
key := tag[0]
value := tag[1]
if key == "environment" || key == "release" || key == "user" {
continue
}
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
Title: key,
Value: value,
Short: true,
})
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{attachment},
},
}
if _, err := p.API.CreatePost(post); err != nil {
p.API.LogError("Failed to create post", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}