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(" · ", event.StartsAt.Unix(), event.StartsAt.Unix()), Inline: true}, {Name: text.deadline, Value: fmt.Sprintf("", 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]) + "…" }