package plugin import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/mattermost/mattermost/server/public/model" ) var availableFields = []*model.PostActionOptions{ {Text: "Project", Value: "project"}, {Text: "Project ID", Value: "project_id"}, {Text: "Issue ID", Value: "issue_id"}, {Text: "Environment", Value: "environment"}, {Text: "Level", Value: "level"}, {Text: "Culprit", Value: "culprit"}, {Text: "Logger", Value: "logger"}, {Text: "Platform", Value: "platform"}, {Text: "Release", Value: "release"}, {Text: "User", Value: "user"}, {Text: "Exception", Value: "exception"}, {Text: "Tags", Value: "tags"}, } 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 project, err := p.fetchSentryProject(projectSlug) if err != nil { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "❌ " + err.Error(), }, nil } project.ChannelID = channelID // сохраняем проект bytes, _ := json.Marshal(project) if err := p.API.KVSet("ru.loop.plugin.sentry:project:"+project.ID, bytes); err != nil { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "❌ Failed to save project", }, nil } // обновляем список IDs 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 &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, _ := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false) options := []*model.PostActionOptions{} for _, ch := range channels { options = append(options, &model.PostActionOptions{ Text: ch.DisplayName, Value: ch.Id, }) } // Поля webhook (для CSV ввода) fieldList := "project,project_id,issue_id,environment,level,culprit,logger,platform,release,user,exception,tags" modal := &model.Dialog{ Title: "Sentry Setup", CallbackId: "sentry_setup", SubmitLabel: "Save", Elements: []model.DialogElement{ { DisplayName: "Channel", Name: "channel_id", Type: "select", Options: options, Default: args.ChannelId, }, { DisplayName: "Sentry Project Slug", Name: "project_slug", Type: "text", Placeholder: "Enter project slug", }, { DisplayName: "Webhook fields", Name: "fields", Type: "text", Default: fieldList, Placeholder: "Comma-separated list of fields to show in webhook", HelpText: "Example: project,issue_id,environment,level,exception", }, {DisplayName: "Fatal color", Name: "color_fatal", Type: "text", Default: "#B10DC9"}, {DisplayName: "Error color", Name: "color_error", Type: "text", Default: "#FF4136"}, {DisplayName: "Warning color", Name: "color_warning", Type: "text", Default: "#FF851B"}, {DisplayName: "Info color", Name: "color_info", Type: "text", Default: "#0074D9"}, {DisplayName: "Debug color", Name: "color_debug", Type: "text", Default: "#2ECC40"}, {DisplayName: "Log color", Name: "color_log", Type: "text", Default: "#AAAAAA"}, }, } 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 }