This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
208 lines
5.7 KiB
Go
208 lines
5.7 KiB
Go
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, pingEveryone bool) error {
|
|
err := a.announceEventCreated(ctx, event, pingEveryone)
|
|
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, pingEveryone bool) error {
|
|
payload, err := json.Marshal(a.createdMessage(event, pingEveryone))
|
|
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, pingEveryone bool) messagePayload {
|
|
text := announcementTranslations(a.locale)
|
|
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
|
|
message := messagePayload{
|
|
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{}},
|
|
}
|
|
if pingEveryone {
|
|
message.Content = "@everyone"
|
|
message.AllowedMentions.Parse = []string{"everyone"}
|
|
}
|
|
return message
|
|
}
|
|
|
|
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]) + "…"
|
|
}
|