Files
mixmaker/backend/internal/adapter/discord/rolesync.go
lemintare e5646ba33d
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Enhance Discord role management by adding hoist functionality
This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
2026-07-19 11:52:57 +03:00

702 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
type RoleSyncConfig struct {
BotToken string
GuildID string
Locale string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
JobTimeout time.Duration
}
type RoleWorker struct {
store application.DiscordRoleStore
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
locale string
now func() time.Time
}
type RoleManager struct {
botToken string
guildID string
apiBaseURL string
httpClient *http.Client
}
type discordRoleResponse struct {
ID string `json:"id"`
Position int `json:"position"`
}
type discordHTTPError struct {
StatusCode int
Status string
Body string
RetryAfter time.Duration
}
func (e *discordHTTPError) Error() string {
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
}
func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*RoleWorker, error) {
if store == nil {
return nil, errors.New("Discord role store is required")
}
manager, err := newRoleManager(config)
if err != nil {
return nil, err
}
pollInterval := config.PollInterval
if pollInterval <= 0 {
pollInterval = 2 * time.Second
}
jobTimeout := config.JobTimeout
if jobTimeout <= 0 {
jobTimeout = 30 * time.Second
}
locale := strings.ToLower(strings.TrimSpace(config.Locale))
if locale == "" {
locale = "ru"
}
if locale != "ru" && locale != "en" {
return nil, fmt.Errorf("unsupported Discord role locale %q", locale)
}
return &RoleWorker{
store: store,
manager: manager,
pollInterval: pollInterval,
jobTimeout: jobTimeout,
locale: locale,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
func newRoleManager(config RoleSyncConfig) (*RoleManager, error) {
if strings.TrimSpace(config.BotToken) == "" {
return nil, errors.New("discord bot token is required for role sync")
}
if strings.TrimSpace(config.GuildID) == "" {
return nil, errors.New("discord guild ID is required for role sync")
}
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &RoleManager{
botToken: config.BotToken,
guildID: config.GuildID,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
}, nil
}
func (w *RoleWorker) Run(ctx context.Context) {
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
slog.Error("Could not seed Discord role sync jobs", "error", err)
}
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
for {
for {
processed, err := w.ProcessNext(ctx)
if err != nil {
slog.Error("Discord role sync worker failed", "error", err)
}
if !processed {
break
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout)
defer cancel()
var warnings []string
switch job.Action {
case application.DiscordRoleActionTeardown:
warnings, err = w.teardown(jobCtx, job)
case application.DiscordRoleActionRSVP:
warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
default:
warnings, err = w.reconcile(jobCtx, job.EventID)
}
if err == nil {
return true, w.store.CompleteDiscordRoleSyncJob(ctx, job.ID, strings.Join(warnings, "; "))
}
delay := retryDelay(job.Attempts, err)
if retryErr := w.store.RetryDiscordRoleSyncJob(ctx, job.ID, err.Error(), w.now().Add(delay)); retryErr != nil {
return true, fmt.Errorf("role sync failed: %v; schedule retry: %w", err, retryErr)
}
return true, nil
}
func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, error) {
current, err := w.store.GetDiscordRoleRoster(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) {
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
}
if err != nil {
return nil, err
}
roles, err := w.store.ListDiscordManagedRoles(ctx, eventID)
if err != nil {
return nil, err
}
registeredRole := existingRole(roles, application.DiscordRoleKindRegistered)
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
if err != nil {
return nil, err
}
if err = w.ensureTeamsAboveRegistered(ctx, roles, registeredRole); err != nil {
return nil, err
}
desired, warnings, err := w.desiredAssignments(ctx, current, roles)
if err != nil {
return warnings, err
}
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
continue
}
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return warnings, nil
}
func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) {
state, err := w.store.GetDiscordRoleRegistration(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) {
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
}
if err != nil {
return nil, err
}
if state.Event.State == domain.Completed || state.Event.State == domain.Cancelled {
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
}
existing, err := w.store.ListDiscordManagedRoles(ctx, eventID)
if err != nil {
return nil, err
}
registrationKinds := map[string]bool{
application.DiscordRoleKindRegistered: true,
application.DiscordRoleKindGoing: true,
application.DiscordRoleKindMaybe: true,
application.DiscordRoleKindNotGoing: true,
}
if len(state.Registrations) == 0 {
hasRegistrationRoles := false
for _, role := range existing {
hasRegistrationRoles = hasRegistrationRoles || registrationKinds[role.Kind]
}
if !hasRegistrationRoles {
return nil, nil
}
}
names := rsvpRoleNames(w.locale, state.Event.Name)
desiredRoles := []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered], Hoist: true},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]},
}
roles, err := w.ensureRoles(ctx, desiredRoles, existing, registrationKinds)
if err != nil {
return nil, err
}
byKind := make(map[string]application.DiscordManagedRole, len(roles))
desiredAssignments := make(map[string]map[string]bool, len(roles))
for _, role := range roles {
byKind[role.Kind] = role
desiredAssignments[role.DiscordRoleID] = make(map[string]bool)
}
warnings := make([]string, 0)
for _, registration := range state.Registrations {
discordID := state.PlayerDiscordIDs[registration.PlayerID]
if discordID == "" {
warnings = append(warnings, "player "+registration.PlayerID+" has no linked Discord account")
continue
}
desiredAssignments[byKind[application.DiscordRoleKindRegistered].DiscordRoleID][discordID] = true
kind := rsvpStatusKind(registration.Status)
if role := byKind[kind]; role.DiscordRoleID != "" {
desiredAssignments[role.DiscordRoleID][discordID] = true
}
}
for _, role := range roles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.RosterDraft, existing []application.DiscordManagedRole) ([]application.DiscordManagedRole, error) {
desired := []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindTank, RoleName: "Tank"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindDamage, RoleName: "Damage"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindSupport, RoleName: "Support"},
}
for _, team := range roster.Teams {
teamName := truncate(strings.TrimSpace(team.Name), 100)
desired = append(desired,
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName, Hoist: true},
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
)
}
return w.ensureRoles(ctx, desired, existing, map[string]bool{
application.DiscordRoleKindTeam: true, application.DiscordRoleKindCaptain: true,
})
}
func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []application.DiscordManagedRole, removableKinds map[string]bool) ([]application.DiscordManagedRole, error) {
existingByKey := make(map[string]application.DiscordManagedRole, len(existing))
for _, role := range existing {
existingByKey[managedRoleKey(role)] = role
}
desiredKeys := make(map[string]bool, len(desired))
result := make([]application.DiscordManagedRole, 0, len(desired))
for _, role := range desired {
key := managedRoleKey(role)
desiredKeys[key] = true
if saved, ok := existingByKey[key]; ok {
role.DiscordRoleID = saved.DiscordRoleID
if saved.RoleName != role.RoleName || saved.Hoist != role.Hoist {
if err := w.manager.UpdateRole(ctx, saved.DiscordRoleID, role.RoleName, role.Hoist); err != nil {
return nil, err
}
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
return nil, err
}
}
} else {
roleID, err := w.manager.CreateRole(ctx, role.RoleName, role.Hoist)
if err != nil {
return nil, err
}
role.DiscordRoleID = roleID
if err = w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
_ = w.manager.DeleteRole(ctx, roleID)
return nil, err
}
}
result = append(result, role)
}
for _, role := range existing {
if !removableKinds[role.Kind] || desiredKeys[managedRoleKey(role)] {
continue
}
if err := w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
}
return result, nil
}
func (w *RoleWorker) desiredAssignments(ctx context.Context, current application.DiscordRoleRoster, roles []application.DiscordManagedRole) (map[string]map[string]bool, []string, error) {
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole, len(roles))
for _, role := range roles {
byKey[managedRoleKey(role)] = role
desired[role.DiscordRoleID] = make(map[string]bool)
}
warnings := appendRosterAssignments(desired, byKey, current, true)
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, warnings, err
}
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
return desired, uniqueStrings(warnings), nil
}
func appendRosterAssignments(desired map[string]map[string]bool, roles map[string]application.DiscordManagedRole, item application.DiscordRoleRoster, includeEventRoles bool) []string {
warnings := make([]string, 0)
for _, team := range item.Roster.Teams {
for _, slot := range team.Slots {
if slot.PlayerID == "" {
continue
}
discordID := item.PlayerDiscordIDs[slot.PlayerID]
if discordID == "" {
warnings = append(warnings, "player "+slot.PlayerID+" has no linked Discord account")
continue
}
globalRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeGlobal, Kind: roleKind(slot.Role)})]
if globalRole.DiscordRoleID != "" {
desired[globalRole.DiscordRoleID][discordID] = true
}
if !includeEventRoles {
continue
}
teamRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam})]
if teamRole.DiscordRoleID != "" {
desired[teamRole.DiscordRoleID][discordID] = true
}
if slot.PlayerID == team.CaptainPlayerID {
captainRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain})]
if captainRole.DiscordRoleID != "" {
desired[captainRole.DiscordRoleID][discordID] = true
}
}
}
}
return warnings
}
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
if err != nil {
return nil, err
}
currentSet := make(map[string]bool, len(current))
for _, userID := range current {
currentSet[userID] = true
}
warnings := make([]string, 0)
for userID := range desired {
if currentSet[userID] {
continue
}
if err = w.manager.AddMemberRole(ctx, userID, roleID); err != nil {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
continue
}
return warnings, err
}
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
for userID := range currentSet {
if desired[userID] {
continue
}
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
return warnings, err
}
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
return warnings, nil
}
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
roles, err := w.store.ListDiscordManagedRoles(ctx, job.EventID)
if err != nil {
return nil, err
}
deleted := make(map[string]bool)
for _, role := range roles {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != job.EventID {
continue
}
if err = w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
deleted[role.DiscordRoleID] = true
}
for _, roleID := range job.RoleSnapshot {
if deleted[roleID] {
continue
}
if err = w.manager.DeleteRole(ctx, roleID); err != nil && !isDiscordNotFound(err) {
return nil, err
}
}
globalRoles := make([]application.DiscordManagedRole, 0)
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeGlobal {
globalRoles = append(globalRoles, role)
}
}
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, err
}
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole)
for _, role := range globalRoles {
desired[role.DiscordRoleID] = make(map[string]bool)
byKey[managedRoleKey(role)] = role
}
warnings := make([]string, 0)
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
for _, role := range globalRoles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
if err := w.manager.DeleteRole(ctx, role.DiscordRoleID); err != nil && !isDiscordNotFound(err) {
return err
}
return w.store.DeleteDiscordManagedRole(ctx, role)
}
func (w *RoleWorker) ensureTeamsAboveRegistered(ctx context.Context, roles []application.DiscordManagedRole, registered application.DiscordManagedRole) error {
if registered.DiscordRoleID == "" {
return nil
}
teamRoleIDs := make([]string, 0)
for _, role := range roles {
if role.Kind == application.DiscordRoleKindTeam {
teamRoleIDs = append(teamRoleIDs, role.DiscordRoleID)
}
}
if len(teamRoleIDs) == 0 {
return nil
}
return w.manager.MoveRolesAbove(ctx, teamRoleIDs, registered.DiscordRoleID)
}
func (m *RoleManager) CreateRole(ctx context.Context, name string, hoist bool) (string, error) {
var response discordRoleResponse
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
"name": name, "permissions": "0", "hoist": hoist, "mentionable": false,
}, &response); err != nil {
return "", err
}
if response.ID == "" {
return "", errors.New("Discord create role response has no ID")
}
return response.ID, nil
}
func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist bool) error {
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]any{"name": name, "hoist": hoist}, nil)
}
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
var roles []discordRoleResponse
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil {
return err
}
positions := make(map[string]int, len(roles))
for _, role := range roles {
positions[role.ID] = role.Position
}
anchorPosition, ok := positions[anchorRoleID]
if !ok {
return nil
}
needsMove := false
for _, roleID := range roleIDs {
if position, found := positions[roleID]; !found || position <= anchorPosition {
needsMove = true
break
}
}
if !needsMove {
return nil
}
payload := make([]map[string]any, 0, len(roleIDs))
for index, roleID := range roleIDs {
payload = append(payload, map[string]any{"id": roleID, "position": anchorPosition + index + 1})
}
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
}
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
}
func (m *RoleManager) AddMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodPut, m.guildPath(path), nil, nil)
}
func (m *RoleManager) RemoveMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodDelete, m.guildPath(path), nil, nil)
}
func (m *RoleManager) guildPath(path string) string {
return m.apiBaseURL + "/guilds/" + url.PathEscape(m.guildID) + path
}
func (m *RoleManager) request(ctx context.Context, method, endpoint string, payload any, result any) error {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bot "+m.botToken)
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := m.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
httpErr := &discordHTTPError{StatusCode: response.StatusCode, Status: response.Status, Body: strings.TrimSpace(string(responseBody))}
if seconds, parseErr := strconv.ParseFloat(response.Header.Get("Retry-After"), 64); parseErr == nil {
httpErr.RetryAfter = time.Duration(seconds * float64(time.Second))
}
return httpErr
}
if result != nil {
return json.NewDecoder(response.Body).Decode(result)
}
return nil
}
func retryDelay(attempts int, err error) time.Duration {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter
}
if attempts < 1 {
attempts = 1
}
if attempts > 6 {
attempts = 6
}
return time.Duration(1<<uint(attempts-1)) * time.Second
}
func managedRoleKey(role application.DiscordManagedRole) string {
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
}
func existingRole(roles []application.DiscordManagedRole, kind string) application.DiscordManagedRole {
for _, role := range roles {
if role.Kind == kind {
return role
}
}
return application.DiscordManagedRole{}
}
func roleKind(role domain.Role) string {
switch role {
case domain.Tank:
return application.DiscordRoleKindTank
case domain.Damage:
return application.DiscordRoleKindDamage
case domain.Support:
return application.DiscordRoleKindSupport
default:
return ""
}
}
func rsvpStatusKind(status domain.RSVPStatus) string {
switch status {
case domain.Going:
return application.DiscordRoleKindGoing
case domain.Maybe:
return application.DiscordRoleKindMaybe
case domain.NotGoing:
return application.DiscordRoleKindNotGoing
default:
return ""
}
}
func rsvpRoleNames(locale, eventName string) map[string]string {
prefixes := map[string]string{
application.DiscordRoleKindRegistered: "Зарегистрирован: ",
application.DiscordRoleKindGoing: "Идёт: ",
application.DiscordRoleKindMaybe: "Возможно: ",
application.DiscordRoleKindNotGoing: "Не идёт: ",
}
if locale == "en" {
prefixes = map[string]string{
application.DiscordRoleKindRegistered: "Registered: ",
application.DiscordRoleKindGoing: "Going: ",
application.DiscordRoleKindMaybe: "Maybe: ",
application.DiscordRoleKindNotGoing: "Not going: ",
}
}
for kind, prefix := range prefixes {
prefixes[kind] = truncate(prefix+strings.TrimSpace(eventName), 100)
}
return prefixes
}
func isDiscordNotFound(err error) bool {
var httpErr *discordHTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}