Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
214
pkg/services/discord/discord.go
Normal file
214
pkg/services/discord/discord.go
Normal file
|
@ -0,0 +1,214 @@
|
|||
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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue