package discord import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "github.com/nicholas-fedor/shoutrrr/pkg/format" "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" "github.com/nicholas-fedor/shoutrrr/pkg/types" "github.com/nicholas-fedor/shoutrrr/pkg/util" ) const ( ChunkSize = 2000 // Maximum size of a single message chunk TotalChunkSize = 6000 // Maximum total size of all chunks ChunkCount = 10 // Maximum number of chunks allowed MaxSearchRunes = 100 // Maximum number of runes to search for split position HooksBaseURL = "https://discord.com/api/webhooks" ) var ( ErrUnknownAPIError = errors.New("unknown error from Discord API") ErrUnexpectedStatus = errors.New("unexpected response status code") ErrInvalidURLPrefix = errors.New("URL must start with Discord webhook base URL") ErrInvalidWebhookID = errors.New("invalid webhook ID") ErrInvalidToken = errors.New("invalid token") ErrEmptyURL = errors.New("empty URL provided") ErrMalformedURL = errors.New("malformed URL: missing webhook ID or token") ) var limits = types.MessageLimit{ ChunkSize: ChunkSize, TotalChunkSize: TotalChunkSize, ChunkCount: ChunkCount, } // Service implements a Discord notification service. type Service struct { standard.Standard Config *Config pkr format.PropKeyResolver } // Send delivers a notification message to Discord. func (service *Service) Send(message string, params *types.Params) error { var firstErr error if service.Config.JSON { postURL := CreateAPIURLFromConfig(service.Config) if err := doSend([]byte(message), postURL); err != nil { return fmt.Errorf("sending JSON message: %w", err) } } else { batches := CreateItemsFromPlain(message, service.Config.SplitLines) for _, items := range batches { if err := service.sendItems(items, params); err != nil { service.Log(err) if firstErr == nil { firstErr = err } } } } if firstErr != nil { return fmt.Errorf("failed to send discord notification: %w", firstErr) } return nil } // SendItems delivers message items with enhanced metadata and formatting to Discord. func (service *Service) SendItems(items []types.MessageItem, params *types.Params) error { return service.sendItems(items, params) } func (service *Service) sendItems(items []types.MessageItem, params *types.Params) error { config := *service.Config if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { return fmt.Errorf("updating config from params: %w", err) } payload, err := CreatePayloadFromItems(items, config.Title, config.LevelColors()) if err != nil { return fmt.Errorf("creating payload: %w", err) } payload.Username = config.Username payload.AvatarURL = config.Avatar payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshaling payload to JSON: %w", err) } postURL := CreateAPIURLFromConfig(&config) return doSend(payloadBytes, postURL) } // CreateItemsFromPlain converts plain text into MessageItems suitable for Discord's webhook payload. func CreateItemsFromPlain(plain string, splitLines bool) [][]types.MessageItem { var batches [][]types.MessageItem if splitLines { return util.MessageItemsFromLines(plain, limits) } for { items, omitted := util.PartitionMessage(plain, limits, MaxSearchRunes) batches = append(batches, items) if omitted == 0 { break } plain = plain[len(plain)-omitted:] } return batches } // Initialize configures the service with a URL and logger. func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { service.SetLogger(logger) service.Config = &Config{} service.pkr = format.NewPropKeyResolver(service.Config) if err := service.pkr.SetDefaultProps(service.Config); err != nil { return fmt.Errorf("setting default properties: %w", err) } if err := service.Config.SetURL(configURL); err != nil { return fmt.Errorf("setting config URL: %w", err) } return nil } // GetID provides the identifier for this service. func (service *Service) GetID() string { return Scheme } // CreateAPIURLFromConfig builds a POST URL from the Discord configuration. func CreateAPIURLFromConfig(config *Config) string { if config.WebhookID == "" || config.Token == "" { return "" // Invalid cases are caught in doSend } // Trim whitespace to prevent malformed URLs webhookID := strings.TrimSpace(config.WebhookID) token := strings.TrimSpace(config.Token) baseURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token) if config.ThreadID != "" { // Append thread_id as a query parameter query := url.Values{} query.Set("thread_id", strings.TrimSpace(config.ThreadID)) return baseURL + "?" + query.Encode() } return baseURL } // doSend executes an HTTP POST request to deliver the payload to Discord. // //nolint:gosec,noctx func doSend(payload []byte, postURL string) error { if postURL == "" { return ErrEmptyURL } parsedURL, err := url.ParseRequestURI(postURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } if !strings.HasPrefix(parsedURL.String(), HooksBaseURL) { return ErrInvalidURLPrefix } parts := strings.Split(strings.TrimPrefix(postURL, HooksBaseURL+"/"), "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return ErrMalformedURL } webhookID := strings.TrimSpace(parts[0]) token := strings.TrimSpace(parts[1]) safeURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token) res, err := http.Post(safeURL, "application/json", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("making HTTP POST request: %w", err) } if res == nil { return ErrUnknownAPIError } defer res.Body.Close() if res.StatusCode != http.StatusNoContent { return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status) } return nil }