Add Discord event announcement functionality to backend
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
2026-07-19 11:21:11 +03:00
parent b7a78b4384
commit ae19c03542
31 changed files with 1632 additions and 88 deletions

View File

@@ -0,0 +1,203 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"mixmaker/backend/internal/domain"
)
const defaultAPIBaseURL = "https://discord.com/api/v10"
type Config struct {
BotToken string
ChannelID string
PublicURL string
Locale string
APIBaseURL string
HTTPClient *http.Client
}
type Announcer struct {
botToken string
channelID string
publicURL string
locale string
apiBaseURL string
httpClient *http.Client
}
type messagePayload struct {
Content string `json:"content"`
Embeds []embed `json:"embeds"`
Components []actionRow `json:"components"`
AllowedMentions allowedMentions `json:"allowed_mentions"`
}
type embed struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
URL string `json:"url"`
Color int `json:"color"`
Fields []embedField `json:"fields"`
}
type embedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
type actionRow struct {
Type int `json:"type"`
Components []button `json:"components"`
}
type button struct {
Type int `json:"type"`
Style int `json:"style"`
Label string `json:"label"`
URL string `json:"url"`
}
type allowedMentions struct {
Parse []string `json:"parse"`
}
type translations struct {
titlePrefix string
startsAt string
deadline string
registerButton string
}
func New(config Config) (*Announcer, error) {
if strings.TrimSpace(config.BotToken) == "" {
return nil, errors.New("discord bot token is required")
}
if strings.TrimSpace(config.ChannelID) == "" {
return nil, errors.New("discord announcement channel ID is required")
}
publicURL, err := url.Parse(strings.TrimSpace(config.PublicURL))
if err != nil || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || publicURL.Host == "" {
return nil, errors.New("discord announcement public URL must be an absolute HTTP(S) URL")
}
locale := strings.ToLower(strings.TrimSpace(config.Locale))
if locale == "" {
locale = "ru"
}
if locale != "ru" && locale != "en" {
return nil, fmt.Errorf("unsupported discord announcement locale %q", locale)
}
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Announcer{
botToken: config.BotToken,
channelID: config.ChannelID,
publicURL: strings.TrimRight(strings.TrimSpace(config.PublicURL), "/"),
locale: locale,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
}, nil
}
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
err := a.announceEventCreated(ctx, event)
if err != nil {
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
}
return err
}
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
payload, err := json.Marshal(a.createdMessage(event))
if err != nil {
return fmt.Errorf("encode Discord message: %w", err)
}
endpoint := fmt.Sprintf("%s/channels/%s/messages", a.apiBaseURL, url.PathEscape(a.channelID))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create Discord request: %w", err)
}
request.Header.Set("Authorization", "Bot "+a.botToken)
request.Header.Set("Content-Type", "application/json")
response, err := a.httpClient.Do(request)
if err != nil {
return fmt.Errorf("send Discord message: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("Discord returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return nil
}
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
text := announcementTranslations(a.locale)
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
return messagePayload{
Content: "@everyone",
Embeds: []embed{{
Title: truncate(text.titlePrefix+event.Name, 256),
Description: truncate(event.Description, 4096),
URL: eventURL,
Color: 0xe9358f,
Fields: []embedField{
{Name: text.startsAt, Value: fmt.Sprintf("<t:%d:F> · <t:%d:R>", event.StartsAt.Unix(), event.StartsAt.Unix()), Inline: true},
{Name: text.deadline, Value: fmt.Sprintf("<t:%d:F>", event.RegistrationDeadline.Unix()), Inline: true},
},
}},
Components: []actionRow{{
Type: 1,
Components: []button{{
Type: 2,
Style: 5,
Label: text.registerButton,
URL: eventURL,
}},
}},
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
}
}
func announcementTranslations(locale string) translations {
if locale == "en" {
return translations{
titlePrefix: "New mix: ",
startsAt: "Starts",
deadline: "Registration deadline",
registerButton: "Register",
}
}
return translations{
titlePrefix: "Новый микс: ",
startsAt: "Начало",
deadline: "Регистрация до",
registerButton: "Зарегистрироваться",
}
}
func truncate(value string, maxRunes int) string {
if utf8.RuneCountInString(value) <= maxRunes {
return value
}
runes := []rune(value)
return string(runes[:maxRunes-1]) + "…"
}

View File

@@ -0,0 +1,97 @@
package discord
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"mixmaker/backend/internal/domain"
)
func TestAnnounceEventCreated(t *testing.T) {
var received messagePayload
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/channels/channel-123/messages" {
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
}
if authorization := request.Header.Get("Authorization"); authorization != "Bot secret-token" {
t.Errorf("unexpected Authorization header: %q", authorization)
}
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
t.Errorf("decode payload: %v", err)
}
response.WriteHeader(http.StatusCreated)
}))
defer server.Close()
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
Locale: "ru",
APIBaseURL: server.URL,
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, time.July, 20, 18, 0, 0, 0, time.UTC)
event := domain.Event{
ID: "event-456",
Name: "Воскресный микс",
Description: "Собираемся на Bo3",
StartsAt: start,
RegistrationDeadline: start.Add(-2 * time.Hour),
}
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
t.Fatal(err)
}
if received.Content != "@everyone" {
t.Fatalf("unexpected content: %q", received.Content)
}
if len(received.AllowedMentions.Parse) != 1 || received.AllowedMentions.Parse[0] != "everyone" {
t.Fatalf("unexpected allowed mentions: %+v", received.AllowedMentions)
}
if len(received.Embeds) != 1 {
t.Fatalf("expected one embed, got %d", len(received.Embeds))
}
eventURL := "https://mix.example.com/events/event-456"
if received.Embeds[0].Title != "Новый микс: Воскресный микс" ||
received.Embeds[0].Description != event.Description ||
received.Embeds[0].URL != eventURL ||
len(received.Embeds[0].Fields) != 2 {
t.Fatalf("unexpected embed: %+v", received.Embeds[0])
}
if len(received.Components) != 1 || len(received.Components[0].Components) != 1 {
t.Fatalf("unexpected components: %+v", received.Components)
}
linkButton := received.Components[0].Components[0]
if linkButton.Style != 5 || linkButton.Label != "Зарегистрироваться" || linkButton.URL != eventURL {
t.Fatalf("unexpected registration button: %+v", linkButton)
}
}
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
}))
defer server.Close()
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
APIBaseURL: server.URL,
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
t.Fatal("expected Discord error")
}
}