refactoring, add set bot icon
This commit is contained in:
parent
1a056be4c6
commit
419ff0b5a1
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="-0.05 27.99 512.05 456.01"><path d="M505.4 460.4c8.8-14.8 8.8-33.2 0-48L296.8 51c-8.4-14.6-24.1-23.4-40.9-23-16.8-.2-32.4 8.5-40.9 23l-67.2 117.1 16.6 10.2c50.3 28.9 92 70.6 120.9 120.9 25.8 44.3 40.7 94 43.5 145.2h-47.3c-3-42.8-15.7-84.4-37.1-121.5-24.6-43.4-60.4-79.4-103.6-104.3l-17.3-9.6-62.7 108.7L77.4 328c42.4 24.8 70.9 67.8 77.4 116.4H46.1c-2.7-.1-5.1-1.5-6.4-3.8-1.6-2.5-1.6-5.8 0-8.3l30.7-51.2c-10.2-8.8-21.9-15.8-34.5-20.5L6.4 413c-8.6 14.6-8.6 32.7 0 47.3 8.3 14.7 24 23.8 40.9 23.7h148.4v-20.5c0-34.3-8.8-68-25.6-97.9-14.1-24.1-33-45-55.6-61.4l23.7-40.9c29.6 20.5 54.3 47.4 72.3 78.7 21.3 37 32.6 78.9 32.6 121.5v19.2h126v-19.2c-.1-64.7-17.3-128.3-49.9-184.2-28.7-50.7-69-93.9-117.7-126l48-83.2c1.3-2.3 3.7-3.8 6.4-3.8 2.7.1 5.1 1.5 6.4 3.8l209.2 361.4c1.4 2.4 1.4 5.3 0 7.7a6.88 6.88 0 0 1-6.4 3.8h-49.3v39.7h49.3c16.8.2 32.3-8.9 40.3-23.7z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 933 B |
@ -1,12 +1,14 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ensureBot создает или получает бота для отправки сообщений
|
||||||
func (p *Plugin) ensureBot() error {
|
func (p *Plugin) ensureBot() error {
|
||||||
const botUsername = "sentry"
|
const botUsername = "sentry"
|
||||||
|
|
||||||
@ -32,24 +34,40 @@ func (p *Plugin) ensureBot() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.botUserID = createdBot.UserId
|
p.botUserID = createdBot.UserId
|
||||||
|
|
||||||
|
// Устанавливаем иконку после создания бота
|
||||||
|
if err := p.setBotIcon(); err != nil {
|
||||||
|
p.API.LogWarn("Failed to set bot icon", "error", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) getChannelForProject(projectID string) (string, error) {
|
func (p *Plugin) setBotIcon() error {
|
||||||
key := "ru.loop.plugin.sentry:project:" + projectID
|
if p.botUserID == "" {
|
||||||
|
return fmt.Errorf("bot user ID is not set")
|
||||||
data, appErr := p.API.KVGet(key)
|
|
||||||
if appErr != nil {
|
|
||||||
return "", appErr
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var project LinkedProject
|
bundlePath := p.bundlePath
|
||||||
if err := json.Unmarshal(data, &project); err != nil {
|
if bundlePath == "" {
|
||||||
return "", err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,72 +1,12 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"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) {
|
func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.CommandResponse, *model.AppError) {
|
||||||
if len(split) < 3 {
|
if len(split) < 3 {
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
@ -112,38 +52,26 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model.
|
|||||||
|
|
||||||
slug := split[2]
|
slug := split[2]
|
||||||
|
|
||||||
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
projects, err := p.getAllProjects()
|
||||||
if data == nil {
|
if err != nil {
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
Text: "ℹ️ No linked projects",
|
Text: "ℹ️ No linked projects",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var projects []string
|
var removed *LinkedProject
|
||||||
_ = json.Unmarshal(data, &projects)
|
for _, project := range projects {
|
||||||
|
if project.Slug == slug {
|
||||||
var (
|
removed = project
|
||||||
newProjects []string
|
if err := p.deleteProject(project.ID); err != nil {
|
||||||
removed *LinkedProject
|
return &model.CommandResponse{
|
||||||
)
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
|
Text: "❌ Failed to unlink project: " + err.Error(),
|
||||||
for _, id := range projects {
|
}, nil
|
||||||
pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id)
|
}
|
||||||
if pData == nil {
|
break
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if removed == nil {
|
||||||
@ -153,9 +81,6 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model.
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes, _ := json.Marshal(newProjects)
|
|
||||||
_ = p.API.KVSet("ru.loop.plugin.sentry:projects", bytes)
|
|
||||||
|
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
Text: fmt.Sprintf(
|
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) {
|
func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
|
||||||
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
projects, err := p.getAllProjects()
|
||||||
if data == nil {
|
if err != nil || len(projects) == 0 {
|
||||||
return &model.CommandResponse{
|
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
|
||||||
Text: "_No linked Sentry projects_",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var projects []string
|
|
||||||
_ = json.Unmarshal(data, &projects)
|
|
||||||
|
|
||||||
if len(projects) == 0 {
|
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
Text: "_No linked Sentry projects_",
|
Text: "_No linked Sentry projects_",
|
||||||
@ -186,16 +101,7 @@ func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
|
for _, project := range projects {
|
||||||
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)
|
|
||||||
|
|
||||||
channelName := project.ChannelID
|
channelName := project.ChannelID
|
||||||
if ch, err := p.API.GetChannel(project.ChannelID); err == nil {
|
if ch, err := p.API.GetChannel(project.ChannelID); err == nil {
|
||||||
channelName = "~" + ch.Name
|
channelName = "~" + ch.Name
|
||||||
@ -297,46 +203,6 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
|
|||||||
return &model.CommandResponse{}, nil
|
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(
|
func (p *Plugin) commandSetupHook(
|
||||||
args *model.CommandArgs,
|
args *model.CommandArgs,
|
||||||
|
|||||||
26
server/plugin/hooks.go
Normal file
26
server/plugin/hooks.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
88
server/plugin/sentry_client.go
Normal file
88
server/plugin/sentry_client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -1,3 +1,181 @@
|
|||||||
package plugin
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,21 +1,10 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HookType string
|
// getBool извлекает булево значение из map[string]interface{}
|
||||||
|
|
||||||
const (
|
|
||||||
HookEventAlert HookType = "event_alert"
|
|
||||||
HookMetricAlert HookType = "metric_alert"
|
|
||||||
HookIssue HookType = "issue"
|
|
||||||
HookComment HookType = "comment"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getBool(sub map[string]interface{}, key string, def bool) bool {
|
func getBool(sub map[string]interface{}, key string, def bool) bool {
|
||||||
if v, ok := sub[key]; ok {
|
if v, ok := sub[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
@ -25,6 +14,7 @@ func getBool(sub map[string]interface{}, key string, def bool) bool {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// boolToStr конвертирует булево значение в строку
|
||||||
func boolToStr(v bool) string {
|
func boolToStr(v bool) string {
|
||||||
if v {
|
if v {
|
||||||
return "true"
|
return "true"
|
||||||
@ -32,47 +22,7 @@ func boolToStr(v bool) string {
|
|||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHookEnabled(p *LinkedProject, hook HookType) bool {
|
// ephemeral создает эфемерный ответ команды
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ephemeral(text string) *model.CommandResponse {
|
func ephemeral(text string) *model.CommandResponse {
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
|
|||||||
@ -1,71 +1,10 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func levelToColor(level string) string {
|
// handleWebhook обрабатывает входящие вебхуки от Sentry
|
||||||
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) {
|
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
@ -85,336 +24,3 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
49
server/plugin/webhook_formatters.go
Normal file
49
server/plugin/webhook_formatters.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
327
server/plugin/webhook_handlers.go
Normal file
327
server/plugin/webhook_handlers.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user