214 lines
5.7 KiB
Go
214 lines
5.7 KiB
Go
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
|
|
}
|