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..23ae71ed 100644 --- a/backend/app/http/endpoints/api/tags/tagcreate.go +++ b/backend/app/http/endpoints/api/tags/tagcreate.go @@ -10,13 +10,13 @@ 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" "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" @@ -48,8 +48,7 @@ func CreateTag(ctx *gin.Context) { // Max of 200 tags count, err := dbclient.Client.Tag.GetTagCount(ctx, guildId) if err != nil { - formatted := fmt.Sprintf("Failed to fetch tag count from database: %v", err) - ctx.JSON(500, utils.ErrorStr("%s", formatted)) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch tag count from database")) return } @@ -79,7 +78,7 @@ func CreateTag(ctx *gin.Context) { if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if ok := errors.As(err, &validationErrors); !ok { - ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "An error occurred while validating the tag")) return } @@ -119,14 +118,14 @@ func CreateTag(ctx *gin.Context) { botContext, err := botcontext.ContextForGuild(guildId) if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to connect to Discord. Please try again later.")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) return } if data.UseGuildCommand { premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botContext.Token, botContext.RateLimiter) if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to verify premium status. Please try again.")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to verify premium status. Please try again.")) return } @@ -147,15 +146,10 @@ 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.")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create tag. Please try again.")) return } @@ -172,7 +166,7 @@ func CreateTag(ctx *gin.Context) { } if err := dbclient.Client.Tag.Set(ctx, wrapped); err != nil { - ctx.JSON(500, utils.ErrorStr("Failed to create tag. Please try again.")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to create tag. Please try again.")) return } diff --git a/backend/app/http/endpoints/api/tags/tagdelete.go b/backend/app/http/endpoints/api/tags/tagdelete.go index 225850c3..436e1aeb 100644 --- a/backend/app/http/endpoints/api/tags/tagdelete.go +++ b/backend/app/http/endpoints/api/tags/tagdelete.go @@ -1,12 +1,12 @@ package api import ( - "fmt" "net/http" "github.com/TicketsBot-cloud/common/featureflags" dbmodel "github.com/TicketsBot-cloud/database" "github.com/gin-gonic/gin" + "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/app/http/audit" "github.com/ticketsbot-cloud/dashboard/backend/botcontext" "github.com/ticketsbot-cloud/dashboard/backend/database" @@ -41,30 +41,33 @@ func DeleteTag(ctx *gin.Context) { // Fetch tag to see if we need to delete a guild command tag, exists, err := database.Client.Tag.Get(ctx, guildId, body.TagId) if err != nil { - ctx.JSON(500, utils.ErrorStr(fmt.Sprintf("Failed to fetch tag from database: %v", err))) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch tag from database")) return } if !exists { - ctx.JSON(404, utils.ErrorStr(fmt.Sprintf("Tag not found: %s", body.TagId))) + ctx.JSON(404, utils.ErrorStr("Tag not found: %s", body.TagId)) return } if tag.ApplicationCommandId != nil { botContext, err := botcontext.ContextForGuild(guildId) if err != nil { - ctx.JSON(500, utils.ErrorStr("Unable to connect to Discord. Please try again later.")) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Unable to connect to Discord. Please try again later.")) return } if err := botContext.DeleteGuildCommand(ctx, guildId, *tag.ApplicationCommandId); err != nil { - ctx.JSON(500, utils.ErrorStr("Failed to delete tag. Please try again.")) - return + // The command may already be gone; that must not strand the tag. + if restError, ok := discordError(err); !ok || restError.StatusCode != http.StatusNotFound { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to delete tag. Please try again.")) + return + } } } - if err := database.Client.Tag.Delete(ctx, guildId, body.TagId); err != nil { - ctx.JSON(500, utils.ErrorStr("Failed to delete tag. Please try again.")) + if err := database.Client.Tag.Delete(ctx, guildId, tag.Id); err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to delete tag. Please try again.")) return } @@ -73,7 +76,7 @@ func DeleteTag(ctx *gin.Context) { UserId: userId, ActionType: dbmodel.AuditActionTagDelete, ResourceType: dbmodel.AuditResourceTag, - ResourceId: audit.StringPtr(body.TagId), + ResourceId: audit.StringPtr(tag.Id), OldData: tag, }) ctx.Status(204) diff --git a/backend/app/http/endpoints/api/tags/tagslist.go b/backend/app/http/endpoints/api/tags/tagslist.go index 5dd4520d..62253055 100644 --- a/backend/app/http/endpoints/api/tags/tagslist.go +++ b/backend/app/http/endpoints/api/tags/tagslist.go @@ -1,11 +1,11 @@ package api import ( - "fmt" + "net/http" "github.com/gin-gonic/gin" + "github.com/ticketsbot-cloud/dashboard/backend/app" "github.com/ticketsbot-cloud/dashboard/backend/database" - "github.com/ticketsbot-cloud/dashboard/backend/utils" "github.com/ticketsbot-cloud/dashboard/backend/utils/types" ) @@ -14,7 +14,7 @@ func TagsListHandler(ctx *gin.Context) { tags, err := database.Client.Tag.GetByGuild(ctx, guildId) if err != nil { - ctx.JSON(500, utils.ErrorStr(fmt.Sprintf("Failed to fetch tag from database: %v", err))) + _ = ctx.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to fetch tags from database")) return } diff --git a/backend/app/http/middleware/errorhandler.go b/backend/app/http/middleware/errorhandler.go index 243a2e4e..948e3d3b 100644 --- a/backend/app/http/middleware/errorhandler.go +++ b/backend/app/http/middleware/errorhandler.go @@ -3,6 +3,8 @@ package middleware import ( "bytes" "errors" + "fmt" + "net/http" "github.com/gin-gonic/gin" "github.com/ticketsbot-cloud/dashboard/backend/app" @@ -24,18 +26,27 @@ func (cw copyWriter) Write(b []byte) (int, error) { return cw.buf.Write(b) } +// Every 503 here is a feature flag holding a subsystem closed, not a fault. +func isServerFault(status int) bool { + return status >= 500 && status != http.StatusServiceUnavailable +} + func ErrorHandler(c *gin.Context) { cw := ©Writer{buf: &bytes.Buffer{}, ResponseWriter: c.Writer} c.Writer = cw c.Next() + status := c.Writer.Status() + if len(c.Errors) > 0 { var message string var internalError *string + err := c.Errors[0].Err + var apiError *app.ApiError - if errors.As(c.Errors[0], &apiError) { + if errors.As(err, &apiError) { message = apiError.ExternalMessage if apiError.InternalError != nil { errStr := apiError.InternalError.Error() @@ -45,6 +56,10 @@ func ErrorHandler(c *gin.Context) { message = "An error occurred processing your request" } + if isServerFault(status) { + logFailure(c, status, zap.Error(err)) + } + c.Writer = cw.ResponseWriter c.JSON(-1, ErrorResponse{ Error: message, @@ -54,14 +69,9 @@ func ErrorHandler(c *gin.Context) { return } - if c.Writer.Status() >= 500 { - // The handler's own message never reaches the client, so keep it in the logs - log.Logger.Error("Request failed", - zap.String("method", c.Request.Method), - zap.String("path", c.Request.URL.Path), - zap.Int("status", c.Writer.Status()), - zap.String("response", cw.buf.String()), - ) + if isServerFault(status) { + // The handler discarded its own error, so the body is the only trace left. + logFailure(c, status, zap.String("response", cw.buf.String())) c.Writer = cw.ResponseWriter @@ -74,3 +84,28 @@ func ErrorHandler(c *gin.Context) { cw.ResponseWriter.Write(cw.buf.Bytes()) } + +// Keyed on the route, not the path, so one fault is not one Sentry issue per guild. +func logFailure(c *gin.Context, status int, cause zap.Field) { + route := c.FullPath() + if route == "" { + route = c.Request.URL.Path + } + + fields := []zap.Field{ + zap.String("method", c.Request.Method), + zap.String("route", route), + zap.String("path", c.Request.URL.Path), + zap.Int("status", status), + } + + if guildId, ok := c.Keys["guildid"]; ok { + fields = append(fields, zap.Uint64("guild_id", guildId.(uint64))) + } + + if userId, ok := c.Keys["userid"]; ok { + fields = append(fields, zap.Uint64("user_id", userId.(uint64))) + } + + log.Logger.Error(fmt.Sprintf("%s %s failed", c.Request.Method, route), append(fields, cause)...) +} diff --git a/backend/app/http/middleware/logging.go b/backend/app/http/middleware/logging.go index 8330060d..c898f997 100644 --- a/backend/app/http/middleware/logging.go +++ b/backend/app/http/middleware/logging.go @@ -20,10 +20,9 @@ func Logging(logger *zap.Logger) gin.HandlerFunc { statusCode := c.Writer.Status() + // ErrorHandler owns the 5xx report; this has the status but never the cause. level := zapcore.InfoLevel - if statusCode >= 500 { - level = zapcore.ErrorLevel - } else if statusCode >= 400 { + if statusCode >= 400 { level = zapcore.WarnLevel } 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..ceaae53d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,8 +13,8 @@ replace github.com/go-playground/validator/v10 => github.com/go-playground/valid require ( github.com/BurntSushi/toml v1.2.1 github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 - github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e - github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f + github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4 + github.com/TicketsBot-cloud/database v0.0.0-20260909063631-6804baaaa52b github.com/TicketsBot-cloud/gdl v0.0.0-20260612070331-a3947b410d3e github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc github.com/TicketsBot-cloud/worker v0.0.0-20260827073646-455b39e53841 @@ -44,7 +44,7 @@ require ( github.com/stretchr/testify v1.12.1 github.com/weppos/publicsuffix-go v0.20.0 go.uber.org/zap v1.28.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 ) require ( @@ -122,12 +122,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.17.0 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/alexcesaro/statsd.v2 v2.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 84457514..b88965db 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -39,10 +39,10 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/ReneKroon/ttlcache v1.6.0/go.mod h1:DG6nbhXKUQhrExfwwLuZUdH7UnRDDRA1IW+nBuCssvs= github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704 h1:liLfvCrzoJ89DXFHzsd1iK3cyP8s4i0CnZPRFEj53zg= github.com/TicketsBot-cloud/archiverclient v0.0.0-20251015181023-f0b66a074704/go.mod h1:Mux1bEPpOHwRw1wo6Fa6qJLJH9Erk9qv1yAIfLi1Wmw= -github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e h1:66mDH3lvotbWSgQXNVbssCvUgy6Yg1mqKl7RmEdr6y4= -github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= -github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f h1:5dcXYEUJKRvB+csN5mbTycftijY9NAIhmV4EtuYoNok= -github.com/TicketsBot-cloud/database v0.0.0-20260827064551-53077b598c5f/go.mod h1:HQXAgmNSm7/FmBYwcsa6qpZqMrDhbLoEl+AyqFQ+RwY= +github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4 h1:zcPcJ+03xW9qalqzqllM6j0ZPdhuL+mrtGw8woLrAMo= +github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g= +github.com/TicketsBot-cloud/database v0.0.0-20260909063631-6804baaaa52b h1:I7gkfG8EKX9DnfpWrSUlNHBsD1H3EDWpvpL6Yr4OanY= +github.com/TicketsBot-cloud/database v0.0.0-20260909063631-6804baaaa52b/go.mod h1:0fKn7nPX6KeljYNQtGQiuZ5aJ/TczgXvBhnbz+4GEc8= github.com/TicketsBot-cloud/gdl v0.0.0-20260612070331-a3947b410d3e h1:qCibZQmO2rrBFrvn6+oAchPEQ7k/0Izr3ruL5P9aVZU= github.com/TicketsBot-cloud/gdl v0.0.0-20260612070331-a3947b410d3e/go.mod h1:CdwBR2egPtxUXjD2CgC9ZwfuB8dz9HPePM8nuG6dt7Y= github.com/TicketsBot-cloud/logarchiver v0.0.0-20251018211319-7a7df5cacbdc h1:qTLNpCvIqM7UwZ6MdWQ9EztcDsIJfHh+VJdG+ULLEaA= @@ -587,8 +587,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -662,8 +662,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -681,8 +681,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -737,8 +737,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -746,8 +746,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -761,8 +761,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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" + > + +