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 ", }, 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 ", }, 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 — 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 } }