2026-01-23 20:54:16 +03:00

370 lines
8.8 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Usage: /sentry link <project_slug>",
}, nil
}
projectSlug := split[2]
channelID := args.ChannelId
hooks := HookSettings{
EventAlert: true,
MetricAlert: true,
Issue: true,
Comment: true,
}
project, err := p.linkProjectToChannel(projectSlug, channelID, hooks)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ " + err.Error(),
}, nil
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(
"✅ Linked **%s** (`%s`) to this channel",
project.Name,
project.Slug,
),
}, nil
}
func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model.CommandResponse, *model.AppError) {
if len(split) < 3 {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Usage: /sentry unlink <project_slug>",
}, nil
}
slug := split[2]
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
if data == 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 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 {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Project not found",
}, nil
}
bytes, _ := json.Marshal(newProjects)
_ = p.API.KVSet("ru.loop.plugin.sentry:projects", bytes)
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf(
"✅ Unlinked **%s** (`%s`)",
removed.Name,
removed.Slug,
),
}, nil
}
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 {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "_No linked Sentry projects_",
}, nil
}
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)
channelName := project.ChannelID
if ch, err := p.API.GetChannel(project.ChannelID); err == nil {
channelName = "~" + ch.Name
}
lines = append(
lines,
fmt.Sprintf(
"• **%s** (`%s`) → %s",
project.Name,
project.Slug,
channelName,
),
)
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n"),
}, nil
}
func (p *Plugin) commandHelp() *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: `**Sentry plugin commands**
• /sentry link <project_slug> — link project to channel
• /sentry help — show help`,
}
}
func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
if args.TriggerId == "" {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "This command must be run from Mattermost UI",
}, nil
}
// Получаем каналы пользователя
channels, appErr := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false)
if appErr != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Failed to load channels",
}, nil
}
options := []*model.PostActionOptions{}
for _, ch := range channels {
options = append(options, &model.PostActionOptions{
Text: ch.DisplayName,
Value: ch.Id,
})
}
modal := &model.Dialog{
Title: "Sentry Setup",
CallbackId: "sentry_setup",
SubmitLabel: "Save",
Elements: []model.DialogElement{
// ───── General ─────
{
DisplayName: "Default channel",
Name: "default_channel_id",
Type: "select",
Options: options,
Default: args.ChannelId,
},
{
DisplayName: "Sentry project slug",
Name: "project_slug",
Type: "text",
Placeholder: "frontend-app",
},
// ───── Webhook types ─────
{DisplayName: "Event alerts", Name: "hook_event_alert", Type: "bool", Default: "true"},
{DisplayName: "Metric alerts", Name: "hook_metric_alert", Type: "bool", Default: "true"},
{DisplayName: "Issues", Name: "hook_issue", Type: "bool", Default: "true"},
{DisplayName: "Comments", Name: "hook_comment", Type: "bool", Default: "true"},
},
}
req := model.OpenDialogRequest{
TriggerId: args.TriggerId,
URL: "/plugins/ru.loop.plugin.sentry/dialog/submit",
Dialog: *modal,
}
if appErr := p.API.OpenInteractiveDialog(req); appErr != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Failed to open setup dialog: " + appErr.Error(),
}, 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(
args *model.CommandArgs,
hook string,
) (*model.CommandResponse, *model.AppError) {
project, err := p.getProjectByChannel(args.ChannelId)
if err != nil {
return ephemeral("❌ " + err.Error()), nil
}
hookType := HookType(hook)
if !isHookEnabled(project, hookType) {
return ephemeral("⚠️ This webhook is disabled in setup"), nil
}
switch hookType {
case HookEventAlert:
return p.openEventAlertSetup(args, project)
case HookMetricAlert:
return p.openMetricAlertSetup(args, project)
case HookIssue:
return p.openIssueSetup(args, project)
case HookComment:
return p.openCommentSetup(args, project)
default:
return ephemeral("❌ Unknown webhook type"), nil
}
}