1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View 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
}

View file

@ -0,0 +1,121 @@
package discord
import (
"errors"
"fmt"
"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"
)
// Scheme defines the protocol identifier for this service's configuration URL.
const Scheme = "discord"
// Static error definitions.
var (
ErrIllegalURLArgument = errors.New("illegal argument in config URL")
ErrMissingWebhookID = errors.New("webhook ID missing from config URL")
ErrMissingToken = errors.New("token missing from config URL")
)
// Config holds the settings required for sending Discord notifications.
type Config struct {
standard.EnumlessConfig
WebhookID string `url:"host"`
Token string `url:"user"`
Title string ` default:"" key:"title"`
Username string ` default:"" key:"username" desc:"Override the webhook default username"`
Avatar string ` default:"" key:"avatar,avatarurl" desc:"Override the webhook default avatar with specified URL"`
Color uint ` default:"0x50D9ff" key:"color" desc:"The color of the left border for plain messages" base:"16"`
ColorError uint ` default:"0xd60510" key:"colorError" desc:"The color of the left border for error messages" base:"16"`
ColorWarn uint ` default:"0xffc441" key:"colorWarn" desc:"The color of the left border for warning messages" base:"16"`
ColorInfo uint ` default:"0x2488ff" key:"colorInfo" desc:"The color of the left border for info messages" base:"16"`
ColorDebug uint ` default:"0x7b00ab" key:"colorDebug" desc:"The color of the left border for debug messages" base:"16"`
SplitLines bool ` default:"Yes" key:"splitLines" desc:"Whether to send each line as a separate embedded item"`
JSON bool ` default:"No" key:"json" desc:"Whether to send the whole message as the JSON payload instead of using it as the 'content' field"`
ThreadID string ` default:"" key:"thread_id" desc:"The thread ID to send the message to"`
}
// LevelColors returns an array of colors indexed by MessageLevel.
func (config *Config) LevelColors() [types.MessageLevelCount]uint {
var colors [types.MessageLevelCount]uint
colors[types.Unknown] = config.Color
colors[types.Error] = config.ColorError
colors[types.Warning] = config.ColorWarn
colors[types.Info] = config.ColorInfo
colors[types.Debug] = config.ColorDebug
return colors
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from configuration using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
url := &url.URL{
User: url.User(config.Token),
Host: config.WebhookID,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
ForceQuery: false,
}
if config.JSON {
url.Path = "/raw"
}
return url
}
// setURL updates the configuration from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
config.WebhookID = url.Host
config.Token = url.User.Username()
if len(url.Path) > 0 {
switch url.Path {
case "/raw":
config.JSON = true
default:
return ErrIllegalURLArgument
}
}
if config.WebhookID == "" {
return ErrMissingWebhookID
}
if len(config.Token) < 1 {
return ErrMissingToken
}
for key, vals := range url.Query() {
if key == "thread_id" {
// Trim whitespace from thread_id
config.ThreadID = strings.TrimSpace(vals[0])
continue
}
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config value for key %s: %w", key, err)
}
}
return nil
}

View file

@ -0,0 +1,86 @@
package discord
import (
"errors"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
const (
MaxEmbeds = 9
)
// Static error definition.
var ErrEmptyMessage = errors.New("message is empty")
// WebhookPayload is the webhook endpoint payload.
type WebhookPayload struct {
Embeds []embedItem `json:"embeds"`
Username string `json:"username,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// JSON is the actual notification payload.
type embedItem struct {
Title string `json:"title,omitempty"`
Content string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Color uint `json:"color,omitempty"`
Footer *embedFooter `json:"footer,omitempty"`
}
type embedFooter struct {
Text string `json:"text"`
IconURL string `json:"icon_url,omitempty"`
}
// CreatePayloadFromItems creates a JSON payload to be sent to the discord webhook API.
func CreatePayloadFromItems(
items []types.MessageItem,
title string,
colors [types.MessageLevelCount]uint,
) (WebhookPayload, error) {
if len(items) < 1 {
return WebhookPayload{}, ErrEmptyMessage
}
itemCount := util.Min(MaxEmbeds, len(items))
embeds := make([]embedItem, 0, itemCount)
for _, item := range items {
color := uint(0)
if item.Level >= types.Unknown && int(item.Level) < len(colors) {
color = colors[item.Level]
}
embeddedItem := embedItem{
Content: item.Text,
Color: color,
}
if item.Level != types.Unknown {
embeddedItem.Footer = &embedFooter{
Text: item.Level.String(),
}
}
if !item.Timestamp.IsZero() {
embeddedItem.Timestamp = item.Timestamp.UTC().Format(time.RFC3339)
}
embeds = append(embeds, embeddedItem)
}
// This should not happen, but it's better to leave the index check before dereferencing the array
if len(embeds) > 0 {
embeds[0].Title = title
}
return WebhookPayload{
Embeds: embeds,
}, nil
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,332 @@
package discord_test
import (
"fmt"
"log"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/discord"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestDiscord runs the Discord service test suite using Ginkgo.
func TestDiscord(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Discord Suite")
}
var (
dummyColors = [types.MessageLevelCount]uint{}
service *discord.Service
envDiscordURL *url.URL
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
service = &discord.Service{}
envDiscordURL, _ = url.Parse(os.Getenv("SHOUTRRR_DISCORD_URL"))
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
)
var _ = ginkgo.Describe("the discord service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should work without errors", func() {
if envDiscordURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envDiscordURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the service", func() {
ginkgo.It("should implement Service interface", func() {
var impl types.Service = service
gomega.Expect(impl).ToNot(gomega.BeNil())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("discord"))
})
})
ginkgo.Describe("creating a config", func() {
ginkgo.When("given a URL and a message", func() {
ginkgo.It("should return an error if no arguments are supplied", func() {
serviceURL, _ := url.Parse("discord://")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should not return an error if exactly two arguments are given", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should not return an error when given the raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set the JSON flag when given the raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw")
config := discord.Config{}
err := config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.JSON).To(gomega.BeTrue())
})
ginkgo.It("should not set the JSON flag when not provided raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel")
config := discord.Config{}
err := config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.JSON).NotTo(gomega.BeTrue())
})
ginkgo.It("should return an error if more than two arguments are given", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/illegal-argument")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "discord://token@channel?avatar=TestBot.jpg&color=0x112233&colordebug=0x223344&colorerror=0x334455&colorinfo=0x445566&colorwarn=0x556677&splitlines=No&title=Test+Title&username=TestBot"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
ginkgo.It("should include thread_id in URL after de-/serialization", func() {
testURL := "discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err = resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
gomega.Expect(config.ThreadID).To(gomega.Equal("123456789"))
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
ginkgo.It("should handle thread_id with whitespace correctly", func() {
testURL := "discord://token@channel?color=0x50d9ff&thread_id=%20%20123456789%20%20&title=Test+Title"
expectedThreadID := "123456789"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err = resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
gomega.Expect(config.ThreadID).To(gomega.Equal(expectedThreadID))
gomega.Expect(config.GetURL().Query().Get("thread_id")).
To(gomega.Equal(expectedThreadID))
gomega.Expect(config.GetURL().String()).
To(gomega.Equal("discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title"))
})
ginkgo.It("should not include thread_id in URL when empty", func() {
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err := resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
serviceURL, _ := url.Parse("discord://token@channel?title=Test+Title")
err = config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting URL")
outputURL := config.GetURL()
gomega.Expect(outputURL.Query().Get("thread_id")).To(gomega.BeEmpty())
gomega.Expect(outputURL.String()).
To(gomega.Equal("discord://token@channel?color=0x50d9ff&title=Test+Title"))
})
})
})
ginkgo.Describe("creating a json payload", func() {
ginkgo.When("given a blank message", func() {
ginkgo.When("split lines is enabled", func() {
ginkgo.It("should return an error", func() {
items := []types.MessageItem{}
gomega.Expect(items).To(gomega.BeEmpty())
_, err := discord.CreatePayloadFromItems(items, "title", dummyColors)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("split lines is disabled", func() {
ginkgo.It("should return an error", func() {
batches := discord.CreateItemsFromPlain("", false)
items := batches[0]
gomega.Expect(items).To(gomega.BeEmpty())
_, err := discord.CreatePayloadFromItems(items, "title", dummyColors)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
ginkgo.When("given a message that exceeds the max length", func() {
ginkgo.It("should return a payload with chunked messages", func() {
payload, err := buildPayloadFromHundreds(42, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
items := payload.Embeds
gomega.Expect(items).To(gomega.HaveLen(3))
gomega.Expect(items[0].Content).To(gomega.HaveLen(1994))
gomega.Expect(items[1].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[2].Content).To(gomega.HaveLen(205))
})
ginkgo.It("omit characters above total max", func() {
payload, err := buildPayloadFromHundreds(62, "", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
items := payload.Embeds
gomega.Expect(items).To(gomega.HaveLen(4))
gomega.Expect(items[0].Content).To(gomega.HaveLen(1994))
gomega.Expect(items[1].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[2].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[3].Content).To(gomega.HaveLen(5))
})
ginkgo.When("no title is supplied and content fits", func() {
ginkgo.It("should return a payload without a meta chunk", func() {
payload, err := buildPayloadFromHundreds(42, "", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(payload.Embeds[0].Footer).To(gomega.BeNil())
gomega.Expect(payload.Embeds[0].Title).To(gomega.BeEmpty())
})
})
ginkgo.When("title is supplied, but content fits", func() {
ginkgo.It("should return a payload with a meta chunk", func() {
payload, err := buildPayloadFromHundreds(42, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(payload.Embeds[0].Title).ToNot(gomega.BeEmpty())
})
})
ginkgo.It("rich test 1", func() {
testTime, _ := time.Parse(time.RFC3339, time.RFC3339)
items := []types.MessageItem{
{
Text: "Message",
Timestamp: testTime,
Level: types.Warning,
},
}
payload, err := discord.CreatePayloadFromItems(items, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
item := payload.Embeds[0]
gomega.Expect(payload.Embeds).To(gomega.HaveLen(1))
gomega.Expect(item.Footer.Text).To(gomega.Equal(types.Warning.String()))
gomega.Expect(item.Title).To(gomega.Equal("Title"))
gomega.Expect(item.Color).To(gomega.Equal(dummyColors[types.Warning]))
})
})
})
ginkgo.Describe("sending the payload", func() {
dummyConfig := discord.Config{
WebhookID: "1",
Token: "dummyToken",
}
var service discord.Service
ginkgo.BeforeEach(func() {
httpmock.Activate()
service = discord.Service{}
if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil {
panic(fmt.Errorf("service initialization failed: %w", err))
}
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
setupResponder(&dummyConfig, 204)
gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed())
})
ginkgo.It("should report an error if the server response is not OK", func() {
setupResponder(&dummyConfig, 400)
gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed())
})
ginkgo.It("should report an error if the message is empty", func() {
setupResponder(&dummyConfig, 204)
gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("", nil)).NotTo(gomega.Succeed())
})
ginkgo.When("using a custom json payload", func() {
ginkgo.It("should report an error if the server response is not OK", func() {
config := dummyConfig
config.JSON = true
setupResponder(&config, 400)
gomega.Expect(service.Initialize(config.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed())
})
})
ginkgo.It("should trim whitespace from thread_id in API URL", func() {
config := discord.Config{
WebhookID: "1",
Token: "dummyToken",
ThreadID: " 123456789 ",
}
service := discord.Service{}
err := service.Initialize(config.GetURL(), logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
setupResponder(&config, 204)
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
// Verify the API URL used in the HTTP request
targetURL := discord.CreateAPIURLFromConfig(&config)
gomega.Expect(targetURL).
To(gomega.Equal("https://discord.com/api/webhooks/1/dummyToken?thread_id=123456789"))
})
})
})
// buildPayloadFromHundreds creates a Discord webhook payload from a repeated 100-character string.
func buildPayloadFromHundreds(
hundreds int,
title string,
colors [types.MessageLevelCount]uint,
) (discord.WebhookPayload, error) {
hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error"
builder := strings.Builder{}
for range hundreds {
builder.WriteString(hundredChars)
}
batches := discord.CreateItemsFromPlain(
builder.String(),
false,
) // SplitLines is always false in these tests
items := batches[0]
return discord.CreatePayloadFromItems(items, title, colors)
}
// setupResponder configures an HTTP mock responder for a Discord webhook URL with the given status code.
func setupResponder(config *discord.Config, code int) {
targetURL := discord.CreateAPIURLFromConfig(config)
httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(code, ""))
}