package notify import "bytes" import "context" import "encoding/json" import "fmt" import "io" import "net/http" import "strings" import "codit/internal/models" // severityHexColor maps an event severity to a hex accent (Teams themeColor, // Slack attachment color). func severityHexColor(sev string) string { switch sev { case models.EventSeverityCritical: return "D93025" case models.EventSeverityWarning: return "F9AB00" } return "1A73E8" } // eventText is the human one-liner body shared by the chat transports. func eventText(event models.Event) string { var text string text = event.Title if strings.TrimSpace(event.Body) != "" { text = text + "\n" + event.Body } return text } // postJSON POSTs a JSON payload to url with the shared (system-roots, egress- // guarded) client and treats any >=400 status as a delivery failure. func (d *Dispatcher) postJSON(ctx context.Context, channelName string, url string, payload any) error { var body []byte var req *http.Request var resp *http.Response var cancel context.CancelFunc var err error ctx, cancel = context.WithTimeout(ctx, d.webhookTimeout()) defer cancel() if strings.TrimSpace(url) == "" { return fmt.Errorf("channel %q has no webhook URL", channelName) } body, err = json.Marshal(payload) if err != nil { return err } req, err = http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(url), bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err = d.client.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 400 { return fmt.Errorf("channel %q returned status %d", channelName, resp.StatusCode) } return nil } // teamsMessageCard is the legacy Office 365 connector card format accepted by // Microsoft Teams incoming webhooks. type teamsMessageCard struct { Type string `json:"@type"` Context string `json:"@context"` ThemeColor string `json:"themeColor"` Summary string `json:"summary"` Title string `json:"title"` Text string `json:"text"` } func buildTeamsPayload(event models.Event) teamsMessageCard { return teamsMessageCard{ Type: "MessageCard", Context: "http://schema.org/extensions", ThemeColor: severityHexColor(event.Severity), Summary: event.Title, Title: event.Title, Text: event.Body, } } func (d *Dispatcher) deliverTeams(ctx context.Context, target models.DeliveryTarget, event models.Event) error { return d.postJSON(ctx, target.ChannelName, target.Address, buildTeamsPayload(d.withOriginTitle(event))) } // slackMessage uses the attachment form so the severity color renders as a bar. type slackAttachment struct { Color string `json:"color"` Title string `json:"title"` Text string `json:"text"` Footer string `json:"footer,omitempty"` TS int64 `json:"ts,omitempty"` } type slackMessage struct { Text string `json:"text"` Attachments []slackAttachment `json:"attachments,omitempty"` } func buildSlackPayload(event models.Event) slackMessage { return slackMessage{ Text: eventText(event), Attachments: []slackAttachment{{ Color: "#" + severityHexColor(event.Severity), Title: event.Title, Text: event.Body, Footer: event.SourceType + ":" + event.SourceID, TS: event.CreatedAt, }}, } } func (d *Dispatcher) deliverSlack(ctx context.Context, target models.DeliveryTarget, event models.Event) error { return d.postJSON(ctx, target.ChannelName, target.Address, buildSlackPayload(d.withOriginTitle(event))) }