diff --git a/bot/button/handlers/gdprconfirm.go b/bot/button/handlers/gdprconfirm.go index c42d1eb..2662711 100644 --- a/bot/button/handlers/gdprconfirm.go +++ b/bot/button/handlers/gdprconfirm.go @@ -356,3 +356,133 @@ func (h *GDPRConfirmMessagesHandler) Execute(ctx *cmdcontext.ButtonContext) { container := utils.BuildContainerWithComponents(ctx, customisation.Orange, title, components) ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) } + +type GDPRConfirmExportGuildHandler struct{} + +func (h *GDPRConfirmExportGuildHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "gdpr_confirm_export_guild_") + }) +} + +func (h *GDPRConfirmExportGuildHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.DMsAllowed), + Timeout: constants.TimeoutGDPR, + } +} + +func (h *GDPRConfirmExportGuildHandler) Execute(ctx *cmdcontext.ButtonContext) { + locale := utils.ExtractLanguageFromCustomId(ctx.InteractionData.CustomId) + + if !gdprrelay.IsWorkerAlive(redis.Client) { + container := utils.BuildGDPRWorkerOfflineView(ctx, locale) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) + return + } + + guildIds := utils.ParseGuildIds(ctx.InteractionData.CustomId) + if len(guildIds) == 0 { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorInvalidServerId)) + return + } + + guildNames := utils.FetchGuildNames(ctx, guildIds) + + request := gdprrelay.GDPRRequest{ + Type: gdprrelay.RequestTypeExportGuild, + UserId: ctx.UserId(), + GuildIds: guildIds, + GuildNames: guildNames, + Language: locale.IsoLongCode, + InteractionToken: ctx.Interaction.Token, + InteractionGuildId: ctx.GuildId(), + ApplicationId: ctx.Worker().BotId, + } + + scrambledId := sha256.New() + fmt.Fprintf(scrambledId, "%d", ctx.UserId()) + id, err := dbclient.Client.GdprLogs.InsertLog(fmt.Sprintf("%x", scrambledId.Sum(nil)), "ExportGuild", "Queued") + if err != nil { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorQueueFailed)) + return + } + + if err := gdprrelay.Publish(redis.Client, request, id); err != nil { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorQueueFailed)) + return + } + + guildDisplays := make([]string, len(guildIds)) + for i, guildId := range guildIds { + guildDisplays[i] = utils.FormatGuildDisplay(guildId, guildNames) + } + + var queuedContent string + if len(guildIds) == 1 { + queuedContent = i18n.GetMessage(locale, i18n.GdprQueuedExportGuild, guildDisplays[0]) + } else { + queuedContent = i18n.GetMessage(locale, i18n.GdprQueuedExportGuildMulti, strings.Join(guildDisplays, "\n* ")) + } + queuedContent += i18n.GetMessage(locale, i18n.GdprQueuedFooter) + + queuedComponents := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: queuedContent})} + queuedTitle := i18n.GetMessage(locale, i18n.GdprQueuedTitle) + queuedContainer := utils.BuildContainerWithComponents(ctx, customisation.Green, queuedTitle, queuedComponents) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{queuedContainer})) +} + +type GDPRConfirmExportUserHandler struct{} + +func (h *GDPRConfirmExportUserHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "gdpr_confirm_export_user_") + }) +} + +func (h *GDPRConfirmExportUserHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.DMsAllowed), + Timeout: constants.TimeoutGDPR, + } +} + +func (h *GDPRConfirmExportUserHandler) Execute(ctx *cmdcontext.ButtonContext) { + locale := utils.ExtractLanguageFromCustomId(ctx.InteractionData.CustomId) + + if !gdprrelay.IsWorkerAlive(redis.Client) { + container := utils.BuildGDPRWorkerOfflineView(ctx, locale) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) + return + } + + request := gdprrelay.GDPRRequest{ + Type: gdprrelay.RequestTypeExportUser, + UserId: ctx.UserId(), + Language: locale.IsoLongCode, + InteractionToken: ctx.Interaction.Token, + InteractionGuildId: ctx.GuildId(), + ApplicationId: ctx.Worker().BotId, + } + + scrambledId := sha256.New() + fmt.Fprintf(scrambledId, "%d", ctx.UserId()) + id, err := dbclient.Client.GdprLogs.InsertLog(fmt.Sprintf("%x", scrambledId.Sum(nil)), "ExportUser", "Queued") + if err != nil { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorQueueFailed)) + return + } + + if err := gdprrelay.Publish(redis.Client, request, id); err != nil { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorQueueFailed)) + return + } + + exportContent := i18n.GetMessage(locale, i18n.GdprQueuedExportUser) + exportContent += i18n.GetMessage(locale, i18n.GdprQueuedFooter) + + exportComponents := []component.Component{component.BuildTextDisplay(component.TextDisplay{Content: exportContent})} + exportTitle := i18n.GetMessage(locale, i18n.GdprQueuedTitle) + exportContainer := utils.BuildContainerWithComponents(ctx, customisation.Green, exportTitle, exportComponents) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{exportContainer})) +} diff --git a/bot/button/handlers/gdprconfirmview.go b/bot/button/handlers/gdprconfirmview.go index cabe7d8..deb0eee 100644 --- a/bot/button/handlers/gdprconfirmview.go +++ b/bot/button/handlers/gdprconfirmview.go @@ -19,6 +19,8 @@ const ( GDPRSpecificTranscripts GDPRAllMessages GDPRSpecificMessages + GDPRExportGuild + GDPRExportUser ) type GDPRConfirmationData struct { @@ -57,9 +59,33 @@ func buildGDPRConfirmationView(ctx interface{}, locale *i18n.Locale, data GDPRCo case GDPRSpecificMessages: content = i18n.GetMessage(locale, i18n.GdprConfirmSpecificMessages, data.GuildNames[0], data.TicketIdsStr) + + case GDPRExportGuild: + if len(data.GuildIds) == 1 { + content = i18n.GetMessage(locale, i18n.GdprConfirmExportGuild, data.GuildNames[0]) + } else { + serversList := strings.Join(data.GuildNames, "\n* ") + content = i18n.GetMessage(locale, i18n.GdprConfirmExportGuildMulti, serversList) + } + + case GDPRExportUser: + content = i18n.GetMessage(locale, i18n.GdprConfirmExportUser) } - content += i18n.GetMessage(locale, i18n.GdprConfirmWarning) + isExport := data.RequestType == GDPRExportGuild || data.RequestType == GDPRExportUser + + if !isExport { + content += i18n.GetMessage(locale, i18n.GdprConfirmWarning) + } + + buttonStyle := component.ButtonStyleDanger + buttonLabel := i18n.GetMessage(locale, i18n.GdprConfirmButton) + buttonEmoji := utils.BuildEmoji("⚠️") + if isExport { + buttonStyle = component.ButtonStylePrimary + buttonLabel = i18n.GetMessage(locale, i18n.GdprConfirmExportButton) + buttonEmoji = utils.BuildEmoji("📦") + } innerComponents := []component.Component{ component.BuildTextDisplay(component.TextDisplay{ @@ -68,10 +94,10 @@ func buildGDPRConfirmationView(ctx interface{}, locale *i18n.Locale, data GDPRCo component.BuildSeparator(component.Separator{}), component.BuildActionRow( component.BuildButton(component.Button{ - Label: i18n.GetMessage(locale, i18n.GdprConfirmButton), + Label: buttonLabel, CustomId: data.ConfirmButtonId, - Style: component.ButtonStyleDanger, - Emoji: utils.BuildEmoji("⚠️"), + Style: buttonStyle, + Emoji: buttonEmoji, }), ), } diff --git a/bot/button/handlers/gdprexportbutton.go b/bot/button/handlers/gdprexportbutton.go new file mode 100644 index 0000000..2124237 --- /dev/null +++ b/bot/button/handlers/gdprexportbutton.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "fmt" + "strings" + + "github.com/TicketsBot-cloud/gdl/objects/interaction" + "github.com/TicketsBot-cloud/gdl/objects/interaction/component" + "github.com/TicketsBot-cloud/worker/bot/button" + "github.com/TicketsBot-cloud/worker/bot/button/registry" + "github.com/TicketsBot-cloud/worker/bot/button/registry/matcher" + "github.com/TicketsBot-cloud/worker/bot/command" + cmdcontext "github.com/TicketsBot-cloud/worker/bot/command/context" + "github.com/TicketsBot-cloud/worker/bot/customisation" + "github.com/TicketsBot-cloud/worker/bot/gdprrelay" + "github.com/TicketsBot-cloud/worker/bot/redis" + "github.com/TicketsBot-cloud/worker/bot/utils" + "github.com/TicketsBot-cloud/worker/i18n" +) + +// GDPRExportGuildHandler handles the "Export Guild Data" button click. +// Shows a guild selection modal (same pattern as transcript deletion). +type GDPRExportGuildHandler struct{} + +func (h *GDPRExportGuildHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "gdpr_export_guild_") + }) +} + +func (h *GDPRExportGuildHandler) Properties() registry.Properties { + return gdprProperties() +} + +func (h *GDPRExportGuildHandler) Execute(ctx *cmdcontext.ButtonContext) { + locale := utils.ExtractLanguageFromCustomId(ctx.InteractionData.CustomId) + + if !gdprrelay.IsWorkerAlive(redis.Client) { + container := utils.BuildGDPRWorkerOfflineView(ctx, locale) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) + return + } + + guilds, err := getOwnedGuildsWithTranscripts(ctx, ctx.UserId()) + if err != nil { + ctx.HandleError(err) + return + } + + if len(guilds) == 0 { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorNoServers)) + return + } + + var modal interaction.ModalResponseData + if len(guilds) > 25 { + modal = buildExportGuildTextModal(locale) + } else { + modal = buildExportGuildModal(locale, guilds) + } + + ctx.Modal(button.ResponseModal{Data: modal}) +} + +func buildExportGuildModal(locale *i18n.Locale, guilds []guildInfo) interaction.ModalResponseData { + options := buildGuildSelectOptions(guilds) + minVal, maxVal := 1, len(options) + if maxVal > 25 { + maxVal = 25 + } + + return interaction.ModalResponseData{ + CustomId: fmt.Sprintf("gdpr_modal_export_guild_%s", locale.IsoShortCode), + Title: i18n.GetMessage(locale, i18n.GdprModalExportGuildTitle), + Components: []component.Component{ + component.BuildLabel(component.Label{ + Label: i18n.GetMessage(locale, i18n.GdprModalSelectServers), + Component: component.BuildSelectMenu(component.SelectMenu{ + CustomId: "server_ids", + MinValues: &minVal, + MaxValues: &maxVal, + Options: options, + }), + }), + }, + } +} + +func buildExportGuildTextModal(locale *i18n.Locale) interaction.ModalResponseData { + return interaction.ModalResponseData{ + CustomId: fmt.Sprintf("gdpr_modal_export_guild_%s", locale.IsoShortCode), + Title: i18n.GetMessage(locale, i18n.GdprModalExportGuildTitle), + Components: []component.Component{ + component.BuildLabel(component.Label{ + Label: i18n.GetMessage(locale, i18n.GdprModalServerIdsLabel), + Component: component.BuildInputText(component.InputText{ + CustomId: "server_ids", + Style: component.TextStyleParagraph, + Placeholder: utils.Ptr(i18n.GetMessage(locale, i18n.GdprModalServerIdsPlaceholder)), + Required: utils.Ptr(true), + MinLength: utils.Ptr(uint32(17)), + MaxLength: utils.Ptr(uint32(2000)), + }), + }), + }, + } +} + +// GDPRExportUserHandler handles the "Export My Data" button click. +// Skips guild selection and goes directly to confirmation. +type GDPRExportUserHandler struct{} + +func (h *GDPRExportUserHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "gdpr_export_user_") + }) +} + +func (h *GDPRExportUserHandler) Properties() registry.Properties { + return gdprProperties() +} + +func (h *GDPRExportUserHandler) Execute(ctx *cmdcontext.ButtonContext) { + locale := utils.ExtractLanguageFromCustomId(ctx.InteractionData.CustomId) + + if !gdprrelay.IsWorkerAlive(redis.Client) { + container := utils.BuildGDPRWorkerOfflineView(ctx, locale) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) + return + } + + data := GDPRConfirmationData{ + RequestType: GDPRExportUser, + UserId: ctx.UserId(), + Locale: locale, + ConfirmButtonId: fmt.Sprintf("gdpr_confirm_export_user_%s", locale.IsoShortCode), + } + + components := buildGDPRConfirmationView(ctx, locale, data) + ctx.Edit(command.NewMessageResponseWithComponents(components)) +} diff --git a/bot/button/handlers/gdprmodal.go b/bot/button/handlers/gdprmodal.go index a73d0d8..aff56b0 100644 --- a/bot/button/handlers/gdprmodal.go +++ b/bot/button/handlers/gdprmodal.go @@ -867,3 +867,102 @@ func (h *GDPRModalSpecificMessagesHandler) Execute(ctx *context.ModalContext) { ctx.HandleError(err) } } + +type GDPRModalExportGuildHandler struct{} + +func (h *GDPRModalExportGuildHandler) Matcher() matcher.Matcher { + return matcher.NewFuncMatcher(func(customId string) bool { + return strings.HasPrefix(customId, "gdpr_modal_export_guild_") + }) +} + +func (h *GDPRModalExportGuildHandler) Properties() registry.Properties { + return registry.Properties{ + Flags: registry.SumFlags(registry.DMsAllowed), + Timeout: constants.TimeoutGDPR, + } +} + +func (h *GDPRModalExportGuildHandler) Execute(ctx *context.ModalContext) { + locale := utils.ExtractLanguageFromCustomId(ctx.Interaction.Data.CustomId) + userId := ctx.UserId() + + if !gdprrelay.IsWorkerAlive(redis.Client) { + container := utils.BuildGDPRWorkerOfflineView(ctx, locale) + ctx.Edit(command.NewMessageResponseWithComponents([]component.Component{container})) + return + } + + var guildIds []uint64 + + for _, actionRow := range ctx.Interaction.Data.Components { + if actionRow.Component != nil { + switch actionRow.Component.CustomId { + case "server_ids": + if actionRow.Component.Values != nil { + for _, val := range actionRow.Component.Values { + if id, err := strconv.ParseUint(val, 10, 64); err == nil { + guildIds = append(guildIds, id) + } + } + } else if actionRow.Component.Value != "" { + guildIds = utils.ParseGuildIdsFromInput(actionRow.Component.Value) + } + } + } else { + for _, comp := range actionRow.Components { + switch comp.CustomId { + case "server_ids": + if comp.Values != nil { + for _, val := range comp.Values { + if id, err := strconv.ParseUint(val, 10, 64); err == nil { + guildIds = append(guildIds, id) + } + } + } else if comp.Value != "" { + guildIds = utils.ParseGuildIdsFromInput(comp.Value) + } + } + } + } + } + + if len(guildIds) == 0 { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorInvalidServerId)) + return + } + + var serverNames []string + var validGuildIds []uint64 + + for _, guildId := range guildIds { + guild, err := ctx.Worker().GetGuild(guildId) + if err != nil || guild.OwnerId != userId { + continue + } + + serverNames = append(serverNames, fmt.Sprintf("%s (ID: %d)", guild.Name, guildId)) + validGuildIds = append(validGuildIds, guildId) + } + + if len(validGuildIds) == 0 { + ctx.ReplyRaw(customisation.Red, "Error", i18n.GetMessage(locale, i18n.GdprErrorNotOwner)) + return + } + + guildIdsStr := strings.Trim(strings.ReplaceAll(fmt.Sprint(validGuildIds), " ", ","), "[]") + + exportGuildData := GDPRConfirmationData{ + RequestType: GDPRExportGuild, + UserId: userId, + GuildIds: validGuildIds, + GuildNames: serverNames, + Locale: locale, + ConfirmButtonId: fmt.Sprintf("gdpr_confirm_export_guild_%s_%s", guildIdsStr, locale.IsoShortCode), + } + + exportComponents := buildGDPRConfirmationView(ctx, locale, exportGuildData) + if _, err := ctx.ReplyWith(command.NewMessageResponseWithComponents(exportComponents)); err != nil { + ctx.HandleError(err) + } +} diff --git a/bot/button/manager/manager.go b/bot/button/manager/manager.go index 498c8f5..4d6abaa 100644 --- a/bot/button/manager/manager.go +++ b/bot/button/manager/manager.go @@ -70,6 +70,10 @@ func (m *ComponentInteractionManager) RegisterCommands() { new(handlers.GDPRConfirmSpecificTranscriptsHandler), new(handlers.GDPRConfirmAllMessagesHandler), new(handlers.GDPRConfirmMessagesHandler), + new(handlers.GDPRExportGuildHandler), + new(handlers.GDPRExportUserHandler), + new(handlers.GDPRConfirmExportGuildHandler), + new(handlers.GDPRConfirmExportUserHandler), new(handlers.JoinThreadHandler), new(handlers.OpenSurveyHandler), new(handlers.PanelHandler), @@ -105,6 +109,7 @@ func (m *ComponentInteractionManager) RegisterCommands() { new(handlers.GDPRModalSpecificTranscriptsHandler), new(handlers.GDPRModalAllMessagesHandler), new(handlers.GDPRModalSpecificMessagesHandler), + new(handlers.GDPRModalExportGuildHandler), new(handlers.PremiumKeySubmitHandler), new(edit.LabelChangeSubmitHandler), new(modals.AdminDebugServerPanelSettingsModalHandler), diff --git a/bot/command/impl/general/gdpr.go b/bot/command/impl/general/gdpr.go index d82a1d9..6e9434d 100644 --- a/bot/command/impl/general/gdpr.go +++ b/bot/command/impl/general/gdpr.go @@ -31,6 +31,11 @@ var ( {i18n.GdprButtonAllMessages, "gdpr_all_messages"}, {i18n.GdprButtonSpecificMessages, "gdpr_specific_messages"}, } + + exportButtons = []gdprButton{ + {i18n.GdprButtonExportGuild, "gdpr_export_guild"}, + {i18n.GdprButtonExportUser, "gdpr_export_user"}, + } ) type GDPRCommand struct{} @@ -86,6 +91,9 @@ func buildGDPRComponents(ctx registry.CommandContext, locale *i18n.Locale) []com buildTextSection(i18n.GetMessage(locale, i18n.GdprMessageSectionTitle)), buildButtonRow(ctx, locale, messageButtons), component.BuildSeparator(component.Separator{}), + buildTextSection(i18n.GetMessage(locale, i18n.GdprExportSectionTitle)), + buildButtonRow(ctx, locale, exportButtons), + component.BuildSeparator(component.Separator{}), buildTextSection(i18n.GetMessage(locale, i18n.GdprWarningText)), component.BuildSeparator(component.Separator{}), buildTextSection(i18n.GetMessage(locale, i18n.GdprResources, "https://gdpr.eu/what-is-gdpr/", "https://gdpr-info.eu/art-17-gdpr/", "https://gdpr-info.eu/art-15-gdpr/")), diff --git a/bot/gdprrelay/gdprrelay.go b/bot/gdprrelay/gdprrelay.go index b38b0d8..3106c0f 100644 --- a/bot/gdprrelay/gdprrelay.go +++ b/bot/gdprrelay/gdprrelay.go @@ -16,6 +16,8 @@ const ( RequestTypeSpecificTranscripts RequestTypeAllMessages RequestTypeSpecificMessages + RequestTypeExportGuild + RequestTypeExportUser ) type GDPRRequest struct { diff --git a/i18n/messages.go b/i18n/messages.go index b35302f..2c3ffc3 100644 --- a/i18n/messages.go +++ b/i18n/messages.go @@ -419,6 +419,18 @@ var ( GdprErrorQueueFailed MessageId = "gdpr.error.queue_failed" GdprErrorWorkerOffline MessageId = "gdpr.error.worker_offline" + GdprExportSectionTitle MessageId = "gdpr.section.export" + GdprButtonExportGuild MessageId = "gdpr.button.export_guild" + GdprButtonExportUser MessageId = "gdpr.button.export_user" + GdprModalExportGuildTitle MessageId = "gdpr.modal.export_guild" + GdprConfirmExportGuild MessageId = "gdpr.confirm.export_guild" + GdprConfirmExportGuildMulti MessageId = "gdpr.confirm.export_guild_multi" + GdprConfirmExportUser MessageId = "gdpr.confirm.export_user" + GdprConfirmExportButton MessageId = "gdpr.confirm.export_button" + GdprQueuedExportGuild MessageId = "gdpr.queued.export_guild" + GdprQueuedExportGuildMulti MessageId = "gdpr.queued.export_guild_multi" + GdprQueuedExportUser MessageId = "gdpr.queued.export_user" + MessageEditTitle MessageId = "commands.edit.title" MessageEditDescription MessageId = "commands.edit.description" MessageEditLabelsTitle MessageId = "commands.edit.labels.title"