From d9d371e44082dd0467430b2cbee1ed7b5a922857 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:01:10 +0200 Subject: [PATCH 1/3] Add tag alias resync workflow and status API Introduces a full tag-alias reconciliation flow: new backend resync/status endpoints, background processing with Redis lock/progress/cooldown tracking, Discord error handling, and audit logging for alias resync runs. It also extracts shared alias command/planning/storage logic into a new internal `tagalias` package and reuses it from tag creation and admin command recreation. On the frontend, adds API bindings and a tags-page UI action to start resyncs, poll progress, surface cooldown/conflict responses, and show completion/warning/error toasts, plus new `TagAliasResyncStatus` typing and audit label support. --- .../api/admin/utilities/recreatemain.go | 28 +- .../http/endpoints/api/tags/tagaliasresync.go | 392 ++++++++++++++++++ .../app/http/endpoints/api/tags/tagcreate.go | 10 +- backend/app/http/server.go | 2 + backend/go.mod | 2 +- backend/internal/tagalias/plan.go | 165 ++++++++ backend/internal/tagalias/store.go | 19 + backend/internal/tagalias/tagalias.go | 58 +++ frontend/src/lib/api.ts | 11 + frontend/src/lib/auditlog.ts | 1 + frontend/src/pages/manage/tags/_index.tsx | 207 ++++++++- frontend/src/types.d.ts | 17 + 12 files changed, 876 insertions(+), 36 deletions(-) create mode 100644 backend/app/http/endpoints/api/tags/tagaliasresync.go create mode 100644 backend/internal/tagalias/plan.go create mode 100644 backend/internal/tagalias/store.go create mode 100644 backend/internal/tagalias/tagalias.go diff --git a/backend/app/http/endpoints/api/admin/utilities/recreatemain.go b/backend/app/http/endpoints/api/admin/utilities/recreatemain.go index 84eff9c1..2305d248 100644 --- a/backend/app/http/endpoints/api/admin/utilities/recreatemain.go +++ b/backend/app/http/endpoints/api/admin/utilities/recreatemain.go @@ -2,7 +2,6 @@ package utilities import ( "context" - "fmt" "net/http" "strings" @@ -14,6 +13,7 @@ import ( "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/config" "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/internal/tagalias" "github.com/ticketsbot-cloud/dashboard/backend/utils" ) @@ -66,7 +66,7 @@ func RecreateMainCommands() func(*gin.Context) { return } - adminCommands = append(adminCommands, tagAliasCommands(tags)...) + adminCommands = append(adminCommands, tagalias.Commands(tags)...) existing, err := rest.GetGuildCommands(context.Background(), config.Conf.Bot.Token, botCtx.RateLimiter, config.Conf.Bot.Id, adminGuildId) if err != nil { @@ -101,25 +101,6 @@ func RecreateMainCommands() func(*gin.Context) { } } -// Must match what CreateTag registers when an alias is first enabled. -func tagAliasCommands(tags map[string]dbmodel.Tag) []rest.CreateCommandData { - commands := make([]rest.CreateCommandData, 0, len(tags)) - for _, tag := range tags { - if tag.ApplicationCommandId == nil { - continue - } - - commands = append(commands, rest.CreateCommandData{ - Name: tag.Id, - Description: fmt.Sprintf("Alias for /tag %s", tag.Id), - Options: nil, - Type: interaction.ApplicationCommandTypeChatInput, - }) - } - - return commands -} - // Carries over commands this endpoint did not build, which the overwrite would otherwise delete. func mergeExisting(existing []interaction.ApplicationCommand, target []rest.CreateCommandData) []rest.CreateCommandData { for _, cmd := range existing { @@ -158,15 +139,14 @@ func reconcileTagAliasIds( tags map[string]dbmodel.Tag, registered []interaction.ApplicationCommand, ) error { - query := `UPDATE tags SET "application_command_id" = $1 WHERE "guild_id" = $2 AND LOWER("tag_id") = LOWER($3);` - for _, cmd := range registered { tag, ok := tags[strings.ToLower(cmd.Name)] if !ok || tag.ApplicationCommandId == nil || *tag.ApplicationCommandId == cmd.Id { continue } - if _, err := database.Client.Tag.Exec(ctx, query, cmd.Id, guildId, tag.Id); err != nil { + commandId := cmd.Id + if _, err := tagalias.SetCommandId(ctx, guildId, tag.Id, &commandId); err != nil { return err } } diff --git a/backend/app/http/endpoints/api/tags/tagaliasresync.go b/backend/app/http/endpoints/api/tags/tagaliasresync.go new file mode 100644 index 00000000..2a120d59 --- /dev/null +++ b/backend/app/http/endpoints/api/tags/tagaliasresync.go @@ -0,0 +1,392 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/TicketsBot-cloud/common/featureflags" + "github.com/TicketsBot-cloud/common/premium" + dbmodel "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/rest" + "github.com/TicketsBot-cloud/gdl/rest/request" + "github.com/gin-gonic/gin" + goredis "github.com/go-redis/redis/v8" + "github.com/ticketsbot-cloud/dashboard/backend/app" + "github.com/ticketsbot-cloud/dashboard/backend/app/http/audit" + "github.com/ticketsbot-cloud/dashboard/backend/botcontext" + dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/internal/tagalias" + "github.com/ticketsbot-cloud/dashboard/backend/log" + "github.com/ticketsbot-cloud/dashboard/backend/redis" + "github.com/ticketsbot-cloud/dashboard/backend/rpc" + "github.com/ticketsbot-cloud/dashboard/backend/utils" + "go.uber.org/zap" +) + +const ( + // Backstop if the job dies; normally the lock is deleted when it finishes + aliasResyncLockTTL = 15 * time.Minute + aliasResyncStatusTTL = time.Hour + aliasResyncMaxErrors = 50 + + discordMaxGuildCommands = 30032 + discordMaxDailyCommands = 30034 + discordUnknownCommand = 10063 + + discordCooldownFallback = 5 * time.Minute + + // action_type is an unconstrained INT2, so this inserts fine until go.mod picks up + // AuditActionTagAliasResync. + auditActionTagAliasResync = dbmodel.AuditActionType(52) +) + +func aliasResyncStatusKey(guildId uint64) string { + return fmt.Sprintf("tickets:tags:alias-resync:%d:status", guildId) +} + +func aliasResyncLockKey(guildId uint64) string { + return fmt.Sprintf("tickets:tags:alias-resync:%d:lock", guildId) +} + +func aliasResyncCooldownKey(guildId uint64) string { + return fmt.Sprintf("tickets:tags:alias-resync:%d:cooldown", guildId) +} + +// retry_after comes back in the body, not a header. +func retryAfter(raw []byte) time.Duration { + var body struct { + RetryAfter float64 `json:"retry_after"` + } + + if err := json.Unmarshal(raw, &body); err != nil || body.RetryAfter <= 0 { + return 0 + } + + return time.Duration(body.RetryAfter * float64(time.Second)) +} + +func aliasResyncCooldown(guildId uint64) time.Duration { + ttl, err := redis.Client.TTL(redis.DefaultContext(), aliasResyncCooldownKey(guildId)).Result() + if err != nil || ttl <= 0 { + return 0 + } + + return ttl +} + +type aliasResyncError struct { + TagId string `json:"tag_id"` + Error string `json:"error"` +} + +type AliasResyncStatus struct { + Status string `json:"status"` // "idle" | "running" | "completed" + Total int `json:"total"` + Processed int `json:"processed"` + Recreated int `json:"recreated"` + Removed int `json:"removed"` + Rebound int `json:"rebound"` + InSync int `json:"in_sync"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + CooldownUntil string `json:"cooldown_until,omitempty"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + Warnings []string `json:"warnings"` + Errors []aliasResyncError `json:"errors"` +} + +func writeAliasResyncStatus(guildId uint64, status AliasResyncStatus) { + raw, err := json.Marshal(status) + if err != nil { + log.Logger.Error("Failed to marshal alias resync status", zap.Error(err)) + return + } + + if err := redis.Client.Set(redis.DefaultContext(), aliasResyncStatusKey(guildId), raw, aliasResyncStatusTTL).Err(); err != nil { + log.Logger.Error("Failed to persist alias resync status", zap.Uint64("guild_id", guildId), zap.Error(err)) + } +} + +func releaseAliasResyncLock(guildId uint64) { + if err := redis.Client.Del(redis.DefaultContext(), aliasResyncLockKey(guildId)).Err(); err != nil { + log.Logger.Error("Failed to release alias resync lock", zap.Uint64("guild_id", guildId), zap.Error(err)) + } +} + +func discordError(err error) (request.RestError, bool) { + var restError request.RestError + if errors.As(err, &restError) { + return restError, true + } + + return request.RestError{}, false +} + +// ResyncTagAliases re-registers missing alias commands, repairs drifted IDs and removes aliases +// with no owning tag. Runs in the background, with progress in Redis. +func ResyncTagAliases(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + userId := ctx.Keys["userid"].(uint64) + + if !utils.FeatureFlags.IsEnabled(ctx, "202608_FEATURE_TAGS", featureflags.ForDashboardUser(userId).WithGuild(guildId)) { + ctx.JSON(http.StatusServiceUnavailable, utils.ErrorStr("Tag management is temporarily unavailable. Please try again shortly.")) + return + } + + botContext, err := botcontext.ContextForGuild(guildId) + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) + return + } + + // Same check as creating an alias + premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botContext.Token, botContext.RateLimiter) + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to verify premium status. Please try again.")) + return + } + + if premiumTier < premium.Premium { + ctx.JSON(http.StatusPaymentRequired, utils.ErrorStr("Premium is required to use custom commands")) + return + } + + if cooldown := aliasResyncCooldown(guildId); cooldown > 0 { + ctx.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "error": "Discord is rate limiting this server's commands. Try again shortly.", + "retry_after": int(cooldown.Seconds()), + }) + return + } + + wasSet, err := redis.Client.SetNX(redis.DefaultContext(), aliasResyncLockKey(guildId), 1, aliasResyncLockTTL).Result() + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to process request. Please try again.")) + return + } + + if !wasSet { + ctx.JSON(http.StatusConflict, utils.ErrorStr("An alias resync is already running for this server.")) + return + } + + tags, err := dbclient.Client.Tag.GetByGuild(ctx, guildId) + if err != nil { + releaseAliasResyncLock(guildId) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load this server's tags")) + return + } + + existing, err := rest.GetGuildCommands(ctx, botContext.Token, botContext.RateLimiter, botContext.BotId, guildId) + if err != nil { + releaseAliasResyncLock(guildId) + + if restError, ok := discordError(err); ok && (restError.StatusCode == http.StatusForbidden || restError.StatusCode == http.StatusNotFound) { + ctx.JSON(http.StatusBadRequest, utils.ErrorStr("The bot is not in this server, or is missing access to its commands.")) + return + } + + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to read this server's existing slash commands")) + return + } + + plan := tagalias.BuildPlan(tags, existing) + + warnings := tagalias.SkipWarnings(plan) + + // Tag IDs differing only in case collapse into one entry + if count, err := dbclient.Client.Tag.GetTagCount(ctx, guildId); err == nil && count > len(tags) { + warnings = append(warnings, "This server has tags whose IDs differ only by capitalisation. Their aliases cannot be told apart and may not resync correctly.") + } + + audit.Log(audit.LogEntry{ + GuildId: audit.Uint64Ptr(guildId), + UserId: userId, + ActionType: auditActionTagAliasResync, + ResourceType: dbmodel.AuditResourceTag, + Metadata: gin.H{ + "recreate": len(plan.Create), + "rebind": len(plan.Rebind), + "remove": len(plan.Remove), + "skipped": len(plan.Skipped), + "in_sync": plan.InSync, + }, + }) + + status := AliasResyncStatus{ + Status: "running", + Total: plan.Total(), + StartedAt: time.Now().UTC().Format(time.RFC3339), + Warnings: warnings, + Errors: []aliasResyncError{}, + } + writeAliasResyncStatus(guildId, status) + + go runAliasResync(guildId, botContext, plan, status) + + ctx.JSON(http.StatusAccepted, gin.H{"started": true, "total": plan.Total()}) +} + +// Not the gin context: it is recycled once the response is written. +func runAliasResync(guildId uint64, botContext *botcontext.BotContext, plan tagalias.Plan, status AliasResyncStatus) { + ctx := context.Background() + + defer func() { + status.Status = "completed" + status.FinishedAt = time.Now().UTC().Format(time.RFC3339) + writeAliasResyncStatus(guildId, status) + releaseAliasResyncLock(guildId) + }() + + status.Skipped = len(plan.Skipped) + status.InSync = plan.InSync + + fail := func(tagId string, err error) { + status.Failed++ + if len(status.Errors) < aliasResyncMaxErrors { + status.Errors = append(status.Errors, aliasResyncError{TagId: tagId, Error: err.Error()}) + } + + log.Logger.Warn("Failed to reconcile tag command alias", + zap.Uint64("guild_id", guildId), zap.String("tag_id", tagId), zap.Error(err)) + } + + // Stop the run: the rest would fail the same way + halt := func(err error) bool { + restError, ok := discordError(err) + if !ok { + return false + } + + if restError.ApiError.Code == discordMaxGuildCommands { + status.Warnings = append(status.Warnings, + "This server is at Discord's limit of 100 commands, so some aliases could not be registered.") + return true + } + + if restError.StatusCode != http.StatusTooManyRequests { + return false + } + + cooldown := retryAfter(restError.Raw) + if cooldown <= 0 { + cooldown = discordCooldownFallback + } + + if err := redis.Client.Set(redis.DefaultContext(), aliasResyncCooldownKey(guildId), 1, cooldown).Err(); err != nil { + log.Logger.Error("Failed to persist alias resync cooldown", zap.Uint64("guild_id", guildId), zap.Error(err)) + } + + reason := "Discord rate limited this server" + if restError.ApiError.Code == discordMaxDailyCommands { + reason = "This server has used up Discord's daily allowance for creating commands" + } + + status.CooldownUntil = time.Now().UTC().Add(cooldown).Format(time.RFC3339) + status.Warnings = append(status.Warnings, fmt.Sprintf( + "%s. The remaining aliases were left alone — try again in %s.", + reason, cooldown.Round(time.Second))) + return true + } + + // Cheapest first: no API call needed + for _, rebind := range plan.Rebind { + commandId := rebind.CommandId + if _, err := tagalias.SetCommandId(ctx, guildId, rebind.TagId, &commandId); err != nil { + fail(rebind.TagId, err) + continue + } + + status.Rebound++ + } + writeAliasResyncStatus(guildId, status) + + // Removals first, to free names and command budget + for _, removal := range plan.Remove { + err := botContext.DeleteGuildCommand(ctx, guildId, removal.Id) + restError, isRest := discordError(err) + if err != nil && !(isRest && restError.ApiError.Code == discordUnknownCommand) { + fail(removal.Name, err) + } else { + status.Removed++ + } + + status.Processed++ + if halt(err) { + return + } + + writeAliasResyncStatus(guildId, status) + } + + for _, tagId := range plan.Create { + cmd, err := botContext.CreateGuildCommand(ctx, guildId, tagalias.Command(tagId)) + if err != nil { + fail(tagId, err) + status.Processed++ + if halt(err) { + return + } + + writeAliasResyncStatus(guildId, status) + continue + } + + rows, err := tagalias.SetCommandId(ctx, guildId, tagId, &cmd.Id) + if err != nil { + fail(tagId, err) + } else if rows == 0 { + // Tag was deleted mid-run + if err := botContext.DeleteGuildCommand(ctx, guildId, cmd.Id); err != nil { + fail(tagId, err) + } + } else { + status.Recreated++ + } + + status.Processed++ + writeAliasResyncStatus(guildId, status) + } +} + +func TagAliasResyncStatusHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + raw, err := redis.Client.Get(redis.DefaultContext(), aliasResyncStatusKey(guildId)).Bytes() + if err != nil { + if errors.Is(err, goredis.Nil) { + ctx.JSON(http.StatusOK, withCooldown(guildId, AliasResyncStatus{ + Status: "idle", Warnings: []string{}, Errors: []aliasResyncError{}, + })) + return + } + + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to load status")) + return + } + + var status AliasResyncStatus + if err := json.Unmarshal(raw, &status); err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to parse status")) + return + } + + ctx.JSON(http.StatusOK, withCooldown(guildId, status)) +} + +// The stored status is a snapshot; the cooldown has to be read live. +func withCooldown(guildId uint64, status AliasResyncStatus) AliasResyncStatus { + if cooldown := aliasResyncCooldown(guildId); cooldown > 0 { + status.CooldownUntil = time.Now().UTC().Add(cooldown).Format(time.RFC3339) + } else { + status.CooldownUntil = "" + } + + return status +} diff --git a/backend/app/http/endpoints/api/tags/tagcreate.go b/backend/app/http/endpoints/api/tags/tagcreate.go index 4777afee..ee82e25f 100644 --- a/backend/app/http/endpoints/api/tags/tagcreate.go +++ b/backend/app/http/endpoints/api/tags/tagcreate.go @@ -10,13 +10,12 @@ import ( "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/common/premium" "github.com/TicketsBot-cloud/database" - "github.com/TicketsBot-cloud/gdl/objects/interaction" - "github.com/TicketsBot-cloud/gdl/rest" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/ticketsbot-cloud/dashboard/backend/app/http/audit" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" + "github.com/ticketsbot-cloud/dashboard/backend/internal/tagalias" "github.com/ticketsbot-cloud/dashboard/backend/rpc" "github.com/ticketsbot-cloud/dashboard/backend/utils" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" @@ -147,12 +146,7 @@ func CreateTag(ctx *gin.Context) { var applicationCommandId *uint64 if data.UseGuildCommand { - cmd, err := botContext.CreateGuildCommand(ctx, guildId, rest.CreateCommandData{ - Name: data.Id, - Description: fmt.Sprintf("Alias for /tag %s", data.Id), - Options: nil, - Type: interaction.ApplicationCommandTypeChatInput, - }) + cmd, err := botContext.CreateGuildCommand(ctx, guildId, tagalias.Command(data.Id)) if err != nil { ctx.JSON(500, utils.ErrorStr("Failed to create tag. Please try again.")) diff --git a/backend/app/http/server.go b/backend/app/http/server.go index 5ac0b06f..5006cd8d 100644 --- a/backend/app/http/server.go +++ b/backend/app/http/server.go @@ -293,6 +293,8 @@ func StartServer(logger *zap.Logger, sm *livechat.SocketManager) *nethttp.Server guildAuthApiSupport.GET("/tags", api_tags.TagsListHandler) guildAuthApiSupport.PUT("/tags", api_tags.CreateTag) guildAuthApiSupport.DELETE("/tags", api_tags.DeleteTag) + guildAuthApiSupport.POST("/tags/aliases/resync", rl(middleware.RateLimitTypeGuild, 10, time.Minute), api_tags.ResyncTagAliases) + guildAuthApiSupport.GET("/tags/aliases/resync/status", api_tags.TagAliasResyncStatusHandler) guildAuthApiAdmin.GET("/team", api_team.GetTeams) guildAuthApiAdmin.GET("/team/:teamid", rl(middleware.RateLimitTypeUser, 10, time.Second*30), api_team.GetMembers) diff --git a/backend/go.mod b/backend/go.mod index f95e4759..eae40286 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,7 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database // replace github.com/TicketsBot-cloud/common => ../../common diff --git a/backend/internal/tagalias/plan.go b/backend/internal/tagalias/plan.go new file mode 100644 index 00000000..a91d4bde --- /dev/null +++ b/backend/internal/tagalias/plan.go @@ -0,0 +1,165 @@ +package tagalias + +import ( + "fmt" + "sort" + "strings" + + dbmodel "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/interaction" +) + +// The tag limit is 200, so a guild can want more aliases than Discord allows. +const guildCommandLimit = 100 + +const ( + skipInvalidName = "invalid_name" + skipNameConflict = "name_conflict" + skipCommandLimit = "command_limit" +) + +type Removal struct { + Id uint64 + Name string +} + +type Rebind struct { + TagId string + CommandId uint64 +} + +type Skip struct { + TagId string + Reason string +} + +type Plan struct { + Create []string + // Aliases whose stored id no longer matches Discord + Rebind []Rebind + Remove []Removal + Skipped []Skip + InSync int +} + +func (p Plan) Total() int { + return len(p.Create) + len(p.Remove) +} + +// BuildPlan diffs the guild's alias tags against what Discord has registered. A command counts as +// ours if it looks like an alias or its id is one we stored. +func BuildPlan(tags map[string]dbmodel.Tag, existing []interaction.ApplicationCommand) Plan { + aliases := aliasTags(tags) + + storedIds := make(map[uint64]struct{}, len(aliases)) + for _, tag := range aliases { + storedIds[*tag.ApplicationCommandId] = struct{}{} + } + + liveAliases := make(map[string]interaction.ApplicationCommand, len(existing)) + foreignNames := make(map[string]struct{}, len(existing)) + foreignCount := 0 + + for _, cmd := range existing { + name := strings.ToLower(cmd.Name) + if _, stored := storedIds[cmd.Id]; stored || isAlias(cmd) { + liveAliases[name] = cmd + continue + } + + foreignNames[name] = struct{}{} + foreignCount++ + } + + var plan Plan + + // Sorted so the same aliases get dropped when over the limit + ids := make([]string, 0, len(aliases)) + for id := range aliases { + ids = append(ids, id) + } + sort.Strings(ids) + + kept := 0 + var pending []string + + for _, id := range ids { + tag := aliases[id] + + if !isValidCommandName(id) { + plan.Skipped = append(plan.Skipped, Skip{TagId: tag.Id, Reason: skipInvalidName}) + continue + } + + if live, ok := liveAliases[id]; ok { + kept++ + if live.Id != *tag.ApplicationCommandId { + plan.Rebind = append(plan.Rebind, Rebind{TagId: tag.Id, CommandId: live.Id}) + } else { + plan.InSync++ + } + continue + } + + // Creating would overwrite a command we did not register + if _, taken := foreignNames[id]; taken { + plan.Skipped = append(plan.Skipped, Skip{TagId: tag.Id, Reason: skipNameConflict}) + continue + } + + pending = append(pending, tag.Id) + } + + for name, cmd := range liveAliases { + if _, wanted := aliases[name]; !wanted { + plan.Remove = append(plan.Remove, Removal{Id: cmd.Id, Name: cmd.Name}) + } + } + sort.Slice(plan.Remove, func(i, j int) bool { return plan.Remove[i].Name < plan.Remove[j].Name }) + + budget := guildCommandLimit - foreignCount - kept + for i, id := range pending { + if i >= budget { + plan.Skipped = append(plan.Skipped, Skip{TagId: id, Reason: skipCommandLimit}) + continue + } + + plan.Create = append(plan.Create, id) + } + + return plan +} + +var skipMessages = map[string]string{ + skipInvalidName: "cannot be used as a slash command name", + skipNameConflict: "share a name with a command the bot already has", + skipCommandLimit: fmt.Sprintf("exceed Discord's limit of %d commands per server", guildCommandLimit), +} + +// SkipWarnings turns the skip reasons into messages for the user. +func SkipWarnings(plan Plan) []string { + byReason := make(map[string][]string) + for _, skip := range plan.Skipped { + byReason[skip.Reason] = append(byReason[skip.Reason], skip.TagId) + } + + warnings := make([]string, 0, len(byReason)) + for _, reason := range []string{skipInvalidName, skipNameConflict, skipCommandLimit} { + ids, ok := byReason[reason] + if !ok { + continue + } + + sort.Strings(ids) + listed := ids + suffix := "" + if len(listed) > 5 { + listed, suffix = listed[:5], fmt.Sprintf(" and %d more", len(ids)-5) + } + + warnings = append(warnings, fmt.Sprintf("Skipped %d tag(s) that %s: %s%s.", + len(ids), skipMessages[reason], strings.Join(listed, ", "), suffix)) + } + + return warnings +} diff --git a/backend/internal/tagalias/store.go b/backend/internal/tagalias/store.go new file mode 100644 index 00000000..d61e8f5a --- /dev/null +++ b/backend/internal/tagalias/store.go @@ -0,0 +1,19 @@ +package tagalias + +import ( + "context" + + dbclient "github.com/ticketsbot-cloud/dashboard/backend/database" +) + +// Only updates rows that already have an alias, so a tag differing only in case is left alone. +func SetCommandId(ctx context.Context, guildId uint64, tagId string, commandId *uint64) (int64, error) { + query := `UPDATE tags SET "application_command_id" = $1 WHERE "guild_id" = $2 AND LOWER("tag_id") = LOWER($3) AND "application_command_id" IS NOT NULL;` + + res, err := dbclient.Client.Tag.Exec(ctx, query, commandId, guildId, tagId) + if err != nil { + return 0, err + } + + return res.RowsAffected(), nil +} diff --git a/backend/internal/tagalias/tagalias.go b/backend/internal/tagalias/tagalias.go new file mode 100644 index 00000000..d1807578 --- /dev/null +++ b/backend/internal/tagalias/tagalias.go @@ -0,0 +1,58 @@ +package tagalias + +import ( + "fmt" + "regexp" + "strings" + + dbmodel "github.com/TicketsBot-cloud/database" + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/gdl/rest" +) + +const descriptionPrefix = "Alias for /tag " + +var commandNameRegex = regexp.MustCompile(`^[-_a-z0-9]{1,32}$`) + +func description(tagId string) string { + return fmt.Sprintf("%s%s", descriptionPrefix, tagId) +} + +// Must match what CreateTag registers. +func Command(tagId string) rest.CreateCommandData { + return rest.CreateCommandData{ + Name: tagId, + Description: description(tagId), + Options: nil, + Type: interaction.ApplicationCommandTypeChatInput, + } +} + +// Only commands matching this are ever deleted. +func isAlias(cmd interaction.ApplicationCommand) bool { + return len(cmd.Options) == 0 && strings.HasPrefix(cmd.Description, descriptionPrefix) +} + +func isValidCommandName(tagId string) bool { + return commandNameRegex.MatchString(tagId) +} + +func aliasTags(tags map[string]dbmodel.Tag) map[string]dbmodel.Tag { + aliases := make(map[string]dbmodel.Tag, len(tags)) + for id, tag := range tags { + if tag.ApplicationCommandId != nil { + aliases[strings.ToLower(id)] = tag + } + } + + return aliases +} + +func Commands(tags map[string]dbmodel.Tag) []rest.CreateCommandData { + commands := make([]rest.CreateCommandData, 0, len(tags)) + for _, tag := range aliasTags(tags) { + commands = append(commands, Command(tag.Id)) + } + + return commands +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 28a493ec..cb709d76 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -73,6 +73,7 @@ import type { NotificationPreference, UserSettings, WhitelabelRecreateStatus, + TagAliasResyncStatus, } from "@/types"; type FormInputMutation = Omit, "options"> & { @@ -87,6 +88,8 @@ const DEFAULT_TIMEOUT_MS = 10_000; const DISCORD_HEAVY_TIMEOUT_MS = 20_000; /** Above the analytics handlers' own 10s deadline, so theirs is what fires. */ const ANALYTICS_TIMEOUT_MS = 20_000; +/** Kick-offs that read from Discord before returning. The job they start is polled, not awaited. */ +const LONG_TIMEOUT_MS = 60_000; const MAX_CONCURRENT_REQUESTS = 4; let activeRequests = 0; @@ -404,6 +407,14 @@ export const apiClient = { api.put(`/api/${guildId}/tags`, tag, config), delete: (guildId: string, tagId: string, config?: AxiosRequestConfig) => api.delete(`/api/${guildId}/tags`, { ...config, data: { tag_id: tagId } }), + resyncAliases: (guildId: string) => + api.post<{ started?: boolean; error?: string; retry_after?: number }>( + `/api/${guildId}/tags/aliases/resync`, + {}, + { timeout: LONG_TIMEOUT_MS, validateStatus: (s) => s === 202 || s === 409 || s === 429 }, + ), + aliasResyncStatus: (guildId: string) => + api.get(`/api/${guildId}/tags/aliases/resync/status`, SKIP_ERROR_TOAST), }, blacklist: { diff --git a/frontend/src/lib/auditlog.ts b/frontend/src/lib/auditlog.ts index adb7ee9e..e833dc70 100644 --- a/frontend/src/lib/auditlog.ts +++ b/frontend/src/lib/auditlog.ts @@ -23,6 +23,7 @@ const ACTION_TYPE_LABELS: Record = { 50: "Tag Create", 51: "Tag Delete", + 52: "Tag Alias Resync", 60: "Team Create", 61: "Team Delete", diff --git a/frontend/src/pages/manage/tags/_index.tsx b/frontend/src/pages/manage/tags/_index.tsx index e748e679..362aa4d1 100644 --- a/frontend/src/pages/manage/tags/_index.tsx +++ b/frontend/src/pages/manage/tags/_index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type FC } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type FC } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { apiClient, SKIP_ERROR_TOAST } from "@/lib/api"; import { guildKeys, useGuildPremium, useGuildTags } from "@/hooks/queries/useGuild"; @@ -14,8 +14,10 @@ import TagEditorModal from "@/components/modals/TagEditorModal"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCopy, + faCrown, faEdit, faPlus, + faRotateRight, faShareNodes, faTag, faTrash, @@ -30,9 +32,42 @@ import TableSkeleton from "@/components/skeletons/TableSkeleton"; import GallerySubmitModal from "@/components/modals/GallerySubmitModal"; import { useFeatureLock } from "@/hooks/useFeatureLock"; import { FEATURE_TAGS } from "@/lib/feature-flags"; -import type { Tag } from "@/types"; +import { HoverTooltip } from "@/components/HoverTooltip"; +import type { Tag, TagAliasResyncStatus } from "@/types"; import { useApiErrorHandler } from "@/hooks/useApiErrorHandler"; +const RESYNC_TOOLTIP = + "Use this if a tag's slash command is missing in Discord, still showing after you deleted it, or not responding."; + +function resyncButtonLabel(cooldownLeft: number): string { + if (cooldownLeft <= 0) return "Resync Command Aliases"; + + const mins = Math.floor(cooldownLeft / 60); + return `Discord cooldown — ${mins > 0 ? `${mins}m` : `${cooldownLeft}s`}`; +} + +function resyncSummary(status: TagAliasResyncStatus): string { + const changes = [ + [status.recreated, "recreated"], + [status.rebound, "relinked"], + [status.removed, "removed"], + [status.skipped, "skipped"], + [status.failed, "failed"], + ] as const; + + const parts = changes.filter(([count]) => count > 0).map(([count, label]) => `${count} ${label}`); + if (parts.length === 0) { + return status.in_sync > 0 + ? `All ${status.in_sync} command aliases are already in sync.` + : "There are no command aliases to resync."; + } + + const first = status.errors[0]; + const cause = first ? ` First failure: ${first.tag_id} — ${first.error}.` : ""; + + return `Command aliases resynced: ${parts.join(", ")}.${cause} Discord may take a few minutes to show the changes.`; +} + const TAG_SORT_COLUMNS: Record<"id" | "type", SortColumn> = { id: { value: (t) => t.id, defaultDir: "asc" }, type: { value: (t) => (t.use_embed ? "Embed" : "Text"), defaultDir: "asc" }, @@ -46,11 +81,15 @@ const TagsPage: FC = () => { const queryClient = useQueryClient(); const { data: tags = {}, isLoading: loading } = useGuildTags(guildId); const { data: premiumState = null } = useGuildPremium(guildId, false); + const { data: premiumWithVoting = null } = useGuildPremium(guildId, true); const [editorOpen, setEditorOpen] = useState(false); const [editingTag, setEditingTag] = useState(null); const [cloningTag, setCloningTag] = useState(false); const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; tagId: string } | null>(null); const [gallerySubmitTag, setGallerySubmitTag] = useState(null); + const [resyncStatus, setResyncStatus] = useState(null); + const [isStartingResync, setIsStartingResync] = useState(false); + const resyncToastRef = useRef(null); const canPublishToGallery = (getGuildById(guildId)?.permission_level ?? 0) >= 2; @@ -94,6 +133,137 @@ const TagsPage: FC = () => { setForcedLock, ); + const canResync = premiumWithVoting?.premium ?? false; + const isResyncRunning = resyncStatus?.status === "running"; + const isResyncing = isResyncRunning || isStartingResync; + + // Ticked locally so the button re-enables without polling + const [cooldownLeft, setCooldownLeft] = useState(0); + const cooldownUntil = resyncStatus?.cooldown_until; + + useEffect(() => { + if (!cooldownUntil) { + setCooldownLeft(0); + return; + } + + const tick = () => { + const left = Math.max(0, Math.ceil((Date.parse(cooldownUntil) - Date.now()) / 1000)); + setCooldownLeft(left); + return left; + }; + + if (tick() === 0) return; + const id = setInterval(() => { + if (tick() === 0) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }, [cooldownUntil]); + + const pollResync = useCallback(async () => { + try { + const { data } = await apiClient.tags.aliasResyncStatus(guildId); + setResyncStatus(data); + return data; + } catch { + return null; // the next tick retries + } + }, [guildId]); + + // Re-attach to a job left running by an earlier visit + useEffect(() => { + if (!canResync) return; + + let cancelled = false; + void pollResync().then((status) => { + if (!cancelled && status?.status === "running" && resyncToastRef.current === null) { + resyncToastRef.current = toast.loading("Resyncing command aliases…", { + duration: Infinity, + }); + } + }); + return () => { + cancelled = true; + }; + }, [canResync, pollResync]); + + useEffect(() => { + if (!isResyncRunning) return; + const id = setInterval(() => void pollResync(), 2000); + return () => clearInterval(id); + }, [isResyncRunning, pollResync]); + + // Reusing the id replaces the toast in place + useEffect(() => { + const toastId = resyncToastRef.current; + if (!resyncStatus || toastId === null) return; + + if (resyncStatus.status === "running") { + const progress = + resyncStatus.total > 0 ? ` ${resyncStatus.processed}/${resyncStatus.total}` : ""; + toast.loading(`Resyncing command aliases…${progress}`, { + id: toastId, + duration: Infinity, + }); + return; + } + + if (resyncStatus.status === "completed") { + const summary = resyncSummary(resyncStatus); + if (resyncStatus.failed > 0) { + toast.error(summary, { id: toastId, duration: 8000 }); + } else { + toast.success(summary, { id: toastId, duration: 6000 }); + } + + resyncStatus.warnings.forEach((warning) => toast.warning(warning, { duration: 10000 })); + resyncToastRef.current = null; + } + }, [resyncStatus]); + + // Never leave a spinner behind on a page the user has left. + useEffect( + () => () => { + if (resyncToastRef.current !== null) { + toast.dismiss(resyncToastRef.current); + resyncToastRef.current = null; + } + }, + [], + ); + + const handleResync = async () => { + setIsStartingResync(true); + try { + const { status, data } = await apiClient.tags.resyncAliases(guildId); + if (status === 429) { + toast.warning(data.error ?? "Please wait before resyncing again."); + if (data.retry_after) { + setCooldownLeft(data.retry_after); + } + return; + } + + if (status === 409) { + toast.info("An alias resync is already running for this server."); + } + + // Track the run even on 409, when something else started it + if (resyncToastRef.current === null) { + resyncToastRef.current = toast.loading("Resyncing command aliases…", { + duration: Infinity, + }); + } + + await pollResync(); + } catch (error) { + console.error("Failed to start alias resync:", error); + handleLockableError(error, "Failed to start the resync. Please try again."); + } finally { + setIsStartingResync(false); + } + }; + const handleSave = async (tag: Tag, originalId?: string) => { try { // If the ID was renamed, delete the old one first @@ -163,7 +333,38 @@ const TagsPage: FC = () => { existingLabel="tags" />
-
+
+ + {cooldownLeft > 0 + ? "Discord is rate limiting this server's commands. Run this again once it clears and it picks up where it stopped." + : RESYNC_TOOLTIP} + {!canResync && " Requires Premium."} + + } + placement="bottom" + className="flex" + > + +