adding test containers
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled
This commit is contained in:
281
haven-notify/discord.go
Normal file
281
haven-notify/discord.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
deliveryTimeout = 10 * time.Second
|
||||
attemptTimeout = 5 * time.Second
|
||||
maximumAttempts = 3
|
||||
maximumRetryDelay = 2 * time.Second
|
||||
truncationMarker = "… [truncated]"
|
||||
)
|
||||
|
||||
var (
|
||||
errWebhookDelivery = errors.New("webhook delivery failed")
|
||||
errWebhookTimeout = errors.New("webhook delivery timed out")
|
||||
)
|
||||
|
||||
type sleepFunc func(context.Context, time.Duration) error
|
||||
|
||||
type discordClient struct {
|
||||
webhookURL string
|
||||
httpClient *http.Client
|
||||
sleep sleepFunc
|
||||
}
|
||||
|
||||
type discordPayload struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Embeds []discordEmbed `json:"embeds,omitempty"`
|
||||
AllowedMentions discordAllowedMentions `json:"allowed_mentions"`
|
||||
}
|
||||
|
||||
type discordAllowedMentions struct {
|
||||
Parse []string `json:"parse"`
|
||||
}
|
||||
|
||||
type discordEmbed struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Fields []discordEmbedField `json:"fields,omitempty"`
|
||||
Footer *discordEmbedFooter `json:"footer,omitempty"`
|
||||
}
|
||||
|
||||
type discordEmbedField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline"`
|
||||
}
|
||||
|
||||
type discordEmbedFooter struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func newDiscordClient(webhookURL string, client *http.Client, sleep sleepFunc) (*discordClient, error) {
|
||||
parsed, err := url.Parse(webhookURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, errors.New("WEBHOOK_URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: attemptTimeout}
|
||||
}
|
||||
if sleep == nil {
|
||||
sleep = sleepContext
|
||||
}
|
||||
return &discordClient{webhookURL: webhookURL, httpClient: client, sleep: sleep}, nil
|
||||
}
|
||||
|
||||
func (c *discordClient) send(ctx context.Context, payload discordPayload) error {
|
||||
normalizeDiscordPayload(&payload)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: marshal payload", errWebhookDelivery)
|
||||
}
|
||||
|
||||
deliveryCtx, cancel := context.WithTimeout(ctx, deliveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maximumAttempts; attempt++ {
|
||||
attemptCtx, attemptCancel := context.WithTimeout(deliveryCtx, attemptTimeout)
|
||||
request, requestErr := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.webhookURL, bytes.NewReader(body))
|
||||
if requestErr != nil {
|
||||
attemptCancel()
|
||||
return fmt.Errorf("%w: create request", errWebhookDelivery)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, requestErr := c.httpClient.Do(request)
|
||||
if requestErr == nil {
|
||||
delay := retryDelay(response, attempt)
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
_ = response.Body.Close()
|
||||
attemptCancel()
|
||||
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("%w: HTTP %d", errWebhookDelivery, response.StatusCode)
|
||||
if !retryableStatus(response.StatusCode) || attempt == maximumAttempts-1 {
|
||||
return lastErr
|
||||
}
|
||||
if err := c.sleep(deliveryCtx, delay); err != nil {
|
||||
return errWebhookTimeout
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
attemptCancel()
|
||||
lastErr = requestErr
|
||||
if deliveryCtx.Err() != nil {
|
||||
return errWebhookTimeout
|
||||
}
|
||||
if attempt == maximumAttempts-1 {
|
||||
if isTimeout(requestErr) {
|
||||
return fmt.Errorf("%w: %v", errWebhookTimeout, requestErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errWebhookDelivery, requestErr)
|
||||
}
|
||||
if err := c.sleep(deliveryCtx, fallbackRetryDelay(attempt)); err != nil {
|
||||
return errWebhookTimeout
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %v", errWebhookDelivery, lastErr)
|
||||
}
|
||||
|
||||
func retryableStatus(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
|
||||
func retryDelay(response *http.Response, attempt int) time.Duration {
|
||||
if response.StatusCode == http.StatusTooManyRequests {
|
||||
for _, header := range []string{"Retry-After", "X-RateLimit-Reset-After"} {
|
||||
if delay, ok := parseRetryDelay(response.Header.Get(header)); ok {
|
||||
return min(delay, maximumRetryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackRetryDelay(attempt)
|
||||
}
|
||||
|
||||
func parseRetryDelay(value string) (time.Duration, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
if seconds, err := strconv.ParseFloat(value, 64); err == nil && seconds >= 0 {
|
||||
return time.Duration(seconds * float64(time.Second)), true
|
||||
}
|
||||
if timestamp, err := http.ParseTime(value); err == nil {
|
||||
return max(time.Until(timestamp), 0), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func fallbackRetryDelay(attempt int) time.Duration {
|
||||
return 250 * time.Millisecond * time.Duration(1<<attempt)
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
func normalizeDiscordPayload(payload *discordPayload) {
|
||||
payload.AllowedMentions.Parse = []string{}
|
||||
payload.Content = truncateRunes(payload.Content, 2000)
|
||||
if len(payload.Embeds) > 10 {
|
||||
payload.Embeds = payload.Embeds[:10]
|
||||
}
|
||||
|
||||
for embedIndex := range payload.Embeds {
|
||||
embed := &payload.Embeds[embedIndex]
|
||||
embed.Title = truncateRunes(embed.Title, 256)
|
||||
embed.Description = truncateRunes(embed.Description, 4096)
|
||||
if len(embed.Fields) > 25 {
|
||||
embed.Fields = embed.Fields[:25]
|
||||
}
|
||||
for fieldIndex := range embed.Fields {
|
||||
embed.Fields[fieldIndex].Name = truncateRunes(embed.Fields[fieldIndex].Name, 256)
|
||||
embed.Fields[fieldIndex].Value = truncateRunes(embed.Fields[fieldIndex].Value, 1024)
|
||||
}
|
||||
if embed.Footer != nil {
|
||||
embed.Footer.Text = truncateRunes(embed.Footer.Text, 2048)
|
||||
}
|
||||
}
|
||||
|
||||
for discordEmbedCharacters(payload.Embeds) > 6000 {
|
||||
removed := false
|
||||
for embedIndex := len(payload.Embeds) - 1; embedIndex >= 0; embedIndex-- {
|
||||
embed := &payload.Embeds[embedIndex]
|
||||
if len(embed.Fields) > 0 {
|
||||
embed.Fields = embed.Fields[:len(embed.Fields)-1]
|
||||
removed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
remainingOverflow := discordEmbedCharacters(payload.Embeds) - 6000
|
||||
for embedIndex := len(payload.Embeds) - 1; remainingOverflow > 0 && embedIndex >= 0; embedIndex-- {
|
||||
embed := &payload.Embeds[embedIndex]
|
||||
if embed.Footer != nil && embed.Footer.Text != "" {
|
||||
newLimit := max(utf8.RuneCountInString(embed.Footer.Text)-remainingOverflow, 0)
|
||||
embed.Footer.Text = truncateRunes(embed.Footer.Text, newLimit)
|
||||
}
|
||||
remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000
|
||||
if remainingOverflow > 0 && embed.Description != "" {
|
||||
newLimit := max(utf8.RuneCountInString(embed.Description)-remainingOverflow, 0)
|
||||
embed.Description = truncateRunes(embed.Description, newLimit)
|
||||
}
|
||||
remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000
|
||||
if remainingOverflow > 0 && embed.Title != "" {
|
||||
newLimit := max(utf8.RuneCountInString(embed.Title)-remainingOverflow, 0)
|
||||
embed.Title = truncateRunes(embed.Title, newLimit)
|
||||
}
|
||||
remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000
|
||||
}
|
||||
}
|
||||
|
||||
func discordEmbedCharacters(embeds []discordEmbed) int {
|
||||
total := 0
|
||||
for _, embed := range embeds {
|
||||
total += utf8.RuneCountInString(embed.Title)
|
||||
total += utf8.RuneCountInString(embed.Description)
|
||||
if embed.Footer != nil {
|
||||
total += utf8.RuneCountInString(embed.Footer.Text)
|
||||
}
|
||||
for _, field := range embed.Fields {
|
||||
total += utf8.RuneCountInString(field.Name)
|
||||
total += utf8.RuneCountInString(field.Value)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func truncateRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
marker := []rune(truncationMarker)
|
||||
if limit <= len(marker) {
|
||||
return string(marker[:limit])
|
||||
}
|
||||
return string(runes[:limit-len(marker)]) + truncationMarker
|
||||
}
|
||||
Reference in New Issue
Block a user