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
|
||||
}
|
121
pkg/services/discord/discord_config.go
Normal file
121
pkg/services/discord/discord_config.go
Normal 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
|
||||
}
|
86
pkg/services/discord/discord_json.go
Normal file
86
pkg/services/discord/discord_json.go
Normal 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
|
||||
}
|
247
pkg/services/discord/discord_playground.http
Normal file
247
pkg/services/discord/discord_playground.http
Normal file
File diff suppressed because one or more lines are too long
332
pkg/services/discord/discord_test.go
Normal file
332
pkg/services/discord/discord_test.go
Normal 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, ""))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue