1
0
Fork 0
golang-github-nicholas-fedo.../pkg/services/discord/discord.go
Daniel Baumann c0c4addb85
Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-05-22 10:16:14 +02:00

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
}