From f56a0fc809d37e980b89801c979bdeb6afc38274 Mon Sep 17 00:00:00 2001 From: Michael McCarty Date: Mon, 12 Oct 2026 08:29:16 -0700 Subject: [PATCH 1/2] feat: add alt-account autocomplete & helpers --- main.go | 7 +- src/farmerstate/farmerstate.go | 11 + src/farmerstate/privacy.go | 303 +++++++++++++++++++--- src/farmerstate/privacy_test.go | 439 ++++++++++++++++++++++++++++++++ 4 files changed, 724 insertions(+), 36 deletions(-) create mode 100644 src/farmerstate/privacy_test.go diff --git a/main.go b/main.go index bddb7e4c..3907fc93 100644 --- a/main.go +++ b/main.go @@ -745,9 +745,10 @@ func setupCommands() { }, }, { - AppCmd: farmerstate.GetSlashPrivacyCommand(slashPrivacy), - Category: CmdCategoryStandard, - Handler: farmerstate.HandlePrivacy, + AppCmd: farmerstate.GetSlashPrivacyCommand(slashPrivacy), + Category: CmdCategoryStandard, + Handler: farmerstate.HandlePrivacy, + Autocomplete: farmerstate.HandlePrivacyAutocomplete, }, { AppCmd: boost.GetSlashBumpCommand(slashBump), diff --git a/src/farmerstate/farmerstate.go b/src/farmerstate/farmerstate.go index 299bbb23..549f5829 100644 --- a/src/farmerstate/farmerstate.go +++ b/src/farmerstate/farmerstate.go @@ -236,6 +236,7 @@ func getFarmer(userID string) *Farmer { // DeleteFarmer deletes a Farmer from the map and SQLite database tables func DeleteFarmer(userID string) { stateMutex.Lock() + f := farmerstate[userID] delete(farmerstate, userID) stateMutex.Unlock() @@ -250,6 +251,16 @@ func DeleteFarmer(userID string) { _ = queries.DeleteUserTimers(ctx, userID) _ = queries.DeleteUserSuspectMissions(ctx, userID) _ = queries.DeleteUserLeaderboardStats(ctx, userID) + if f != nil { + if f.EggIncName != "" && f.EggIncName != userID { + _ = queries.DeleteUserLeaderboardStats(ctx, f.EggIncName) + } + if f.MiscSettingsString != nil { + if ign := f.MiscSettingsString["ei_ign"]; ign != "" && ign != userID && ign != f.EggIncName { + _ = queries.DeleteUserLeaderboardStats(ctx, ign) + } + } + } _ = queries.DeleteUserLeaderboardOptIns(ctx, userID) _ = queries.DeleteUserLeaderboardExclusions(ctx, userID) _ = queries.DeleteUserWatches(ctx, userID) diff --git a/src/farmerstate/privacy.go b/src/farmerstate/privacy.go index 77e0eba8..e3688f5d 100644 --- a/src/farmerstate/privacy.go +++ b/src/farmerstate/privacy.go @@ -3,6 +3,7 @@ package farmerstate import ( "bytes" "encoding/json" + "fmt" "log" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( "time" "github.com/mkmccarty/TokenTimeBoostBot/src/dc" + "github.com/mkmccarty/TokenTimeBoostBot/src/ei" ) // GetSlashPrivacyCommand creates a new slash command for setting Egg, Inc name @@ -51,30 +53,233 @@ func GetSlashPrivacyCommand(cmd string) *dc.Command { Description: "Confirm privacy setting change or data removal.", Required: false, }, + dc.StringOption{ + Name: "alt", + Description: "Manage privacy settings or purge data for a linked alternate account.", + Required: false, + Autocomplete: true, + }, }, } return &command } +// HandlePrivacyAutocomplete provides autocomplete suggestions for the alt option on /privacy. +func HandlePrivacyAutocomplete(e *dc.AutocompleteEvent) { + name, value := e.FocusedOption() + if name != "alt" { + _ = e.RespondChoices(nil) + return + } + + callerID := e.UserID() + alts := GetLinkedAltIDs(callerID) + if len(alts) == 0 { + _ = e.RespondChoices(nil) + return + } + + value = strings.ToLower(strings.TrimSpace(value)) + var choices []dc.Choice[string] + for _, altID := range alts { + ign := strings.TrimSpace(GetMiscSettingString(altID, "ei_ign")) + if ign == "" { + ign = strings.TrimSpace(GetEggIncName(altID)) + } + displayName := altID + if dc.IsSnowflake(altID) { + if ign != "" { + displayName = fmt.Sprintf("%s (%s)", ei.NormalizePlayerNameForDisplay(ign), altID) + } + } else { + if ign != "" && !strings.EqualFold(altID, ign) { + displayName = fmt.Sprintf("%s (%s)", altID, ei.NormalizePlayerNameForDisplay(ign)) + } else if ign != "" { + displayName = ei.NormalizePlayerNameForDisplay(ign) + } + } + + if value != "" && !strings.Contains(strings.ToLower(displayName), value) && !strings.Contains(strings.ToLower(altID), value) { + continue + } + + if len(displayName) > 100 { + displayName = displayName[:100] + } + choices = append(choices, dc.Choice[string]{ + Name: displayName, + Value: altID, + }) + if len(choices) >= 25 { + break + } + } + _ = e.RespondChoices(choices) +} + +// GetLinkedAltIDs returns all unique alternate account IDs linked to callerID. +func GetLinkedAltIDs(callerID string) []string { + seen := make(map[string]bool) + var result []string + + add := func(id string) { + id = strings.TrimSpace(id) + if id == "" || id == callerID || seen[id] { + return + } + seen[id] = true + result = append(result, id) + } + + for _, id := range GetAltControllerByMiscString("AltController", callerID) { + add(id) + } + for _, id := range GetAllActAsLinkedUsers(callerID) { + add(id) + } + + return result +} + +func resolveLinkedAlt(callerID, altParam string) (string, bool) { + altParam = strings.TrimSpace(altParam) + if altParam == "" { + return "", false + } + + cleanParam := altParam + if strings.HasPrefix(cleanParam, "<@") && strings.HasSuffix(cleanParam, ">") { + cleanParam = strings.TrimPrefix(cleanParam, "<@") + cleanParam = strings.TrimPrefix(cleanParam, "!") + cleanParam = strings.TrimSuffix(cleanParam, ">") + cleanParam = strings.TrimSpace(cleanParam) + } + + linkedAlts := GetLinkedAltIDs(callerID) + // 1. Direct or case-insensitive ID match + for _, id := range linkedAlts { + if id == cleanParam || strings.EqualFold(id, cleanParam) { + return id, true + } + } + + // 2. Match on IGN, EggIncName, or composite display name + for _, id := range linkedAlts { + ign := strings.TrimSpace(GetMiscSettingString(id, "ei_ign")) + eggName := strings.TrimSpace(GetEggIncName(id)) + if (ign != "" && strings.EqualFold(ign, cleanParam)) || + (eggName != "" && strings.EqualFold(eggName, cleanParam)) { + return id, true + } + + var dispName string + if dc.IsSnowflake(id) { + if ign != "" { + dispName = fmt.Sprintf("%s (%s)", ei.NormalizePlayerNameForDisplay(ign), id) + } + } else { + if ign != "" && !strings.EqualFold(id, ign) { + dispName = fmt.Sprintf("%s (%s)", id, ei.NormalizePlayerNameForDisplay(ign)) + } else if ign != "" { + dispName = ei.NormalizePlayerNameForDisplay(ign) + } + } + if dispName != "" && strings.EqualFold(dispName, cleanParam) { + return id, true + } + } + + return "", false +} + +// PurgeAltLinks removes all act-as links, switches, and alt-controller associations for altUserID. +func PurgeAltLinks(mainUserID, altUserID string) { + _ = RemoveActAsLink(mainUserID, altUserID) + _ = DeleteAllActAsLinks(altUserID) + SetMiscSettingString(altUserID, "AltController", "") + for _, a := range GetAltControllerByMiscString("AltController", altUserID) { + SetMiscSettingString(a, "AltController", "") + } + ClearPendingActAsRegistration(mainUserID) + ClearPendingActAsRegistration(altUserID) + + ephemeralSwitchesMutex.Lock() + for msgID, info := range ephemeralMessageSwitches { + if info.MainUserID == altUserID || info.AltUserID == altUserID || + (info.MainUserID == mainUserID && info.AltUserID == altUserID) { + delete(ephemeralMessageSwitches, msgID) + } + } + ephemeralSwitchesMutex.Unlock() +} + +func purgeUserDataFiles(userID string) { + userFilesPattern := "ttbb-data/eiuserdata/*" + userID + "*" + matches, err := filepath.Glob(userFilesPattern) + if err != nil { + log.Printf("Error finding user data files for deletion: %v", err) + } else { + for _, file := range matches { + if err := os.Remove(file); err != nil { + log.Printf("Error deleting file %s: %v", file, err) + } + } + } +} + // HandlePrivacy will handle the /privacy command through the dc facade. func HandlePrivacy(e *dc.CommandEvent) { - userID := e.UserID() + callerID := e.UserID() + targetUserID := callerID + isAlt := false _ = e.Defer(true) + + if altParam, ok := e.OptString("alt"); ok && strings.TrimSpace(altParam) != "" { + altParam = strings.TrimSpace(altParam) + resolvedID, isLinked := resolveLinkedAlt(callerID, altParam) + if !isLinked { + _ = e.Followup(dc.Message{ + Content: fmt.Sprintf("Account \"%s\" is not linked to you as an alternate account.", altParam), + }) + return + } + targetUserID = resolvedID + isAlt = true + } + var builder strings.Builder - builder.WriteString("# Privacy information for user: <@") - builder.WriteString(userID) - builder.WriteString(">\n") - builder.WriteString("Boost Bot stores some information about your usage to provide you with a better experience and to improve the bot.\n") - builder.WriteString("Your Discord User ID is used as a key to this saved information.\n") - builder.WriteString("**This information is never sold.** It will only be shared with other Boost Bot developers or testers within the Bot's development Discord server.\n") - builder.WriteString("You can view this information at any time using the **get-settings-data** option.\n") - builder.WriteString("\n") + if isAlt { + if dc.IsSnowflake(targetUserID) { + builder.WriteString("# Privacy information for alternate account: <@") + builder.WriteString(targetUserID) + builder.WriteString(">\n") + } else { + builder.WriteString("# Privacy information for alternate account: ") + builder.WriteString(targetUserID) + builder.WriteString("\n") + } + builder.WriteString("Boost Bot stores some information about alternate account usage to provide you with a better experience.\n") + builder.WriteString("The alternate account ID is used as a key to this saved information.\n") + builder.WriteString("**This information is never sold.** It will only be shared with other Boost Bot developers or testers within the Bot's development Discord server.\n") + builder.WriteString("You can view this information at any time using the **get-settings-data** option.\n") + builder.WriteString("\n") + } else { + builder.WriteString("# Privacy information for user: <@") + builder.WriteString(callerID) + builder.WriteString(">\n") + builder.WriteString("Boost Bot stores some information about your usage to provide you with a better experience and to improve the bot.\n") + builder.WriteString("Your Discord User ID is used as a key to this saved information.\n") + builder.WriteString("**This information is never sold.** It will only be shared with other Boost Bot developers or testers within the Bot's development Discord server.\n") + builder.WriteString("You can view this information at any time using the **get-settings-data** option.\n") + builder.WriteString("\n") + } + var filename string var reader *bytes.Reader - userPrivacy := getDataPrivacy(userID) + userPrivacy := getDataPrivacy(targetUserID) removeData := false confirmOption := false @@ -86,15 +291,25 @@ func HandlePrivacy(e *dc.CommandEvent) { if opt, ok := e.OptInt("enable-data-privacy"); ok { userPrivacy = opt == 1 if userPrivacy && confirmOption { - builder.WriteString("Boost Bot wil no longer store any persistent data about you.") - builder.WriteString("If you wish to store data again, you will need to re-enable it.") - builder.WriteString("If you interact with the bot for contracts and token tracking it will be stored temporarily and removed within a week of the last interaction of a contract or tracker.") + if isAlt { + builder.WriteString("Boost Bot will no longer store any persistent data about this alternate account. ") + builder.WriteString("If you wish to store data again, you will need to re-enable it. ") + builder.WriteString("If it interacts with the bot for contracts and token tracking it will be stored temporarily and removed within a week of the last interaction of a contract or tracker.\n") + } else { + builder.WriteString("Boost Bot wil no longer store any persistent data about you.") + builder.WriteString("If you wish to store data again, you will need to re-enable it.") + builder.WriteString("If you interact with the bot for contracts and token tracking it will be stored temporarily and removed within a week of the last interaction of a contract or tracker.") + } removeData = true } else if userPrivacy && !confirmOption { builder.WriteString("You have not confirmed the privacy setting change, use the **confirm-request** option.") } else { - setDataPrivacy(userID, userPrivacy) - builder.WriteString("Boost Bot will store save a small amount of data about you. You can download this data at any time.") + setDataPrivacy(targetUserID, userPrivacy) + if isAlt { + builder.WriteString("Boost Bot will save a small amount of data about this alternate account. You can download this data at any time.") + } else { + builder.WriteString("Boost Bot will store save a small amount of data about you. You can download this data at any time.") + } } } @@ -107,34 +322,44 @@ func HandlePrivacy(e *dc.CommandEvent) { } if removeData { - builder.WriteString("Your settings data have been removed from the Boost Bot database.") - builder.WriteString("A default set of settings is now used along with your preference not to store data.") - - // Want to remove all files from within ttbb-data/eiuserdata/ which contain this userID in the filename - userFilesPattern := "ttbb-data/eiuserdata/*" + userID + "*" - matches, err := filepath.Glob(userFilesPattern) - if err != nil { - log.Printf("Error finding user data files for deletion: %v", err) + if isAlt { + builder.WriteString("Alternate account settings data and alt links have been removed from the Boost Bot database.\n") + builder.WriteString("A default set of settings is now used along with the preference not to store data.\n") + + purgeUserDataFiles(targetUserID) + DeleteFarmer(targetUserID) + PurgeAltLinks(callerID, targetUserID) + setDataPrivacy(targetUserID, userPrivacy) } else { - for _, file := range matches { - if err := os.Remove(file); err != nil { - log.Printf("Error deleting file %s: %v", file, err) - } + linkedAlts := GetLinkedAltIDs(callerID) + for _, altID := range linkedAlts { + purgeUserDataFiles(altID) + DeleteFarmer(altID) + PurgeAltLinks(callerID, altID) + setDataPrivacy(altID, userPrivacy) } - } - DeleteFarmer(userID) + purgeUserDataFiles(callerID) + DeleteFarmer(callerID) + _ = DeleteAllActAsLinks(callerID) + setDataPrivacy(callerID, userPrivacy) - setDataPrivacy(userID, userPrivacy) + if len(linkedAlts) > 0 { + builder.WriteString("Your settings data and all linked alternate accounts' data have been removed from the Boost Bot database.\n") + } else { + builder.WriteString("Your settings data have been removed from the Boost Bot database.\n") + } + builder.WriteString("A default set of settings is now used along with your preference not to store data.\n") + } } if opt, ok := e.OptBool("get-settings-data"); ok { // Return the users settings data in a JSON file to the user getData := opt if getData { - userData := GetFullUserData(userID) + userData := GetFullUserData(targetUserID) - filename = "boostbot-data-" + userID + ".json" + filename = "boostbot-data-" + targetUserID + ".json" buf := &bytes.Buffer{} jsonData, err := json.Marshal(userData) if err != nil { @@ -156,7 +381,11 @@ func HandlePrivacy(e *dc.CommandEvent) { } } if reader != nil { - builder.WriteString("Your settings data has been saved to a JSON file. You can view and download it.") + if isAlt { + builder.WriteString("Alternate account settings data has been saved to a JSON file. You can view and download it.") + } else { + builder.WriteString("Your settings data has been saved to a JSON file. You can view and download it.") + } _ = e.Followup(dc.Message{ Content: builder.String(), Files: []dc.File{{Name: filename, Reader: reader}}, @@ -189,6 +418,8 @@ type UserPrivacyData struct { LeaderboardOptins []GetLeaderboardOptInsForUserRow `json:"leaderboard_optins,omitempty"` LeaderboardExclusions []GetLeaderboardExclusionsForUserRow `json:"leaderboard_exclusions,omitempty"` Watches []Watch `json:"watches,omitempty"` + ActAsLinks []string `json:"act_as_links,omitempty"` + ActAsMains []string `json:"act_as_mains,omitempty"` } func GetFullUserData(userID string) *UserPrivacyData { @@ -221,5 +452,11 @@ func GetFullUserData(userID string) *UserPrivacyData { data.Watches = watches } } + if links := GetActAsLinks(userID); len(links) > 0 { + data.ActAsLinks = links + } + if mains := GetActAsMains(userID); len(mains) > 0 { + data.ActAsMains = mains + } return data } diff --git a/src/farmerstate/privacy_test.go b/src/farmerstate/privacy_test.go new file mode 100644 index 00000000..79196ae8 --- /dev/null +++ b/src/farmerstate/privacy_test.go @@ -0,0 +1,439 @@ +package farmerstate + +import ( + "database/sql" + "fmt" + "strings" + "testing" + "time" + + "github.com/mkmccarty/TokenTimeBoostBot/src/dc" + "github.com/mkmccarty/TokenTimeBoostBot/src/dc/dctest" +) + +func TestGetSlashPrivacyCommand(t *testing.T) { + cmd := GetSlashPrivacyCommand("privacy") + if cmd.Name != "privacy" { + t.Errorf("cmd.Name = %q; want privacy", cmd.Name) + } + + var foundAlt bool + for _, opt := range cmd.Options { + if sOpt, ok := opt.(dc.StringOption); ok && sOpt.Name == "alt" { + foundAlt = true + if sOpt.Required { + t.Errorf("alt option should be optional (Required=false)") + } + if !sOpt.Autocomplete { + t.Errorf("alt option should have Autocomplete=true") + } + } + } + if !foundAlt { + t.Fatalf("alt option not found in /privacy command options") + } +} + +func TestHandlePrivacyAutocomplete(t *testing.T) { + mainUser := "100000000000000001" + alt1 := "100000000000000002" + alt2 := "100000000000000003" + + SetMiscSettingString(alt1, "AltController", mainUser) + SetMiscSettingString(alt1, "ei_ign", "AlphaFarmer") + + _ = AddActAsLink(mainUser, alt2) + SetMiscSettingString(alt2, "ei_ign", "BetaFarmer") + + // Autocomplete for "alt" option with empty search string + acEmpty := dctest.AutocompleteEventWithUser("privacy", "alt", "", mainUser) + HandlePrivacyAutocomplete(acEmpty) + if len(acEmpty.LastChoices) < 2 { + t.Fatalf("expected at least 2 choices, got %d", len(acEmpty.LastChoices)) + } + + // Autocomplete with filter "alpha" + acFilter := dctest.AutocompleteEventWithUser("privacy", "alt", "alpha", mainUser) + HandlePrivacyAutocomplete(acFilter) + if len(acFilter.LastChoices) != 1 || acFilter.LastChoices[0].Value != alt1 { + t.Errorf("expected 1 choice for 'alpha' matching %s, got %+v", alt1, acFilter.LastChoices) + } + + // Autocomplete with non-matching option name + acOther := dctest.AutocompleteEventWithUser("privacy", "other", "", mainUser) + HandlePrivacyAutocomplete(acOther) + if len(acOther.LastChoices) != 0 { + t.Errorf("expected 0 choices for non-alt option, got %d", len(acOther.LastChoices)) + } + + // Autocomplete for user with no alts + acNoAlts := dctest.AutocompleteEventWithUser("privacy", "alt", "", "100000000000000099") + HandlePrivacyAutocomplete(acNoAlts) + if len(acNoAlts.LastChoices) != 0 { + t.Errorf("expected 0 choices for user with no alts, got %d", len(acNoAlts.LastChoices)) + } + + // Clean up + PurgeAltLinks(mainUser, alt1) + PurgeAltLinks(mainUser, alt2) +} + +func TestResolveLinkedAlt(t *testing.T) { + mainUser := "100000000000000010" + alt1 := "100000000000000011" + alt2 := "test-named-alt" + + SetMiscSettingString(alt1, "AltController", mainUser) + SetMiscSettingString(alt1, "ei_ign", "TargetIGN") + + _ = AddActAsLink(mainUser, alt2) + + // Direct ID match + resolved, ok := resolveLinkedAlt(mainUser, alt1) + if !ok || resolved != alt1 { + t.Errorf("resolveLinkedAlt by ID failed: got %q, %v", resolved, ok) + } + + // Discord mention match + resolved, ok = resolveLinkedAlt(mainUser, "<@"+alt1+">") + if !ok || resolved != alt1 { + t.Errorf("resolveLinkedAlt by mention failed: got %q, %v", resolved, ok) + } + + // IGN match + resolved, ok = resolveLinkedAlt(mainUser, "TargetIGN") + if !ok || resolved != alt1 { + t.Errorf("resolveLinkedAlt by IGN failed: got %q, %v", resolved, ok) + } + + // Named alt match + resolved, ok = resolveLinkedAlt(mainUser, alt2) + if !ok || resolved != alt2 { + t.Errorf("resolveLinkedAlt for named alt failed: got %q, %v", resolved, ok) + } + + // Unlinked account + _, ok = resolveLinkedAlt(mainUser, "100000000000000099") + if ok { + t.Errorf("resolveLinkedAlt for unlinked user should fail") + } + + // Clean up + PurgeAltLinks(mainUser, alt1) + PurgeAltLinks(mainUser, alt2) +} + +func commandEventFromPayload(payload string) *dc.CommandEvent { + event, err := dc.NewCommandEventFromPayload([]byte(payload)) + if err != nil { + panic(fmt.Sprintf("building command event: %v", err)) + } + return event +} + +func TestHandlePrivacy_UnlinkedAlt(t *testing.T) { + mainUser := "100000000000000020" + + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "alt", "type": 3, "value": "100000000000000099"} + ]} + }`, mainUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if !strings.Contains(event.LastFollowup.Content, "not linked to you as an alternate account") { + t.Errorf("expected unlinked account warning, got: %s", event.LastFollowup.Content) + } +} + +func TestHandlePrivacy_AltView(t *testing.T) { + mainUser := "100000000000000030" + altUser := "100000000000000031" + + SetMiscSettingString(altUser, "AltController", mainUser) + + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "alt", "type": 3, "value": %q} + ]} + }`, mainUser, altUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if !strings.Contains(event.LastFollowup.Content, "Privacy information for alternate account") { + t.Errorf("expected alternate account header, got: %s", event.LastFollowup.Content) + } + + PurgeAltLinks(mainUser, altUser) +} + +func TestHandlePrivacy_AltGetSettingsData(t *testing.T) { + mainUser := "100000000000000040" + altUser := "100000000000000041" + + SetMiscSettingString(altUser, "AltController", mainUser) + SetEggIncName(altUser, "AltFarmerData") + + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "alt", "type": 3, "value": %q}, + {"name": "get-settings-data", "type": 5, "value": true} + ]} + }`, mainUser, altUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if len(event.LastFollowup.Files) != 1 { + t.Fatalf("expected 1 file attached, got %d", len(event.LastFollowup.Files)) + } + expectedFilename := "boostbot-data-" + altUser + ".json" + if event.LastFollowup.Files[0].Name != expectedFilename { + t.Errorf("filename = %q, want %q", event.LastFollowup.Files[0].Name, expectedFilename) + } + + PurgeAltLinks(mainUser, altUser) +} + +func TestHandlePrivacy_AltRemoveData_Unconfirmed(t *testing.T) { + mainUser := "100000000000000050" + altUser := "100000000000000051" + + SetMiscSettingString(altUser, "AltController", mainUser) + + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "alt", "type": 3, "value": %q}, + {"name": "remove-data", "type": 5, "value": true} + ]} + }`, mainUser, altUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if !strings.Contains(event.LastFollowup.Content, "You have not confirmed your data removal") { + t.Errorf("expected confirmation warning, got: %s", event.LastFollowup.Content) + } + + // Alt should still be linked + if GetMiscSettingString(altUser, "AltController") != mainUser { + t.Errorf("AltController should still be set") + } + + PurgeAltLinks(mainUser, altUser) +} + +func TestHandlePrivacy_AltRemoveData_Confirmed(t *testing.T) { + mainUser := "100000000000000060" + altUser := "100000000000000061" + + // Set up alt data and links in both AltController and act_as_links + SetMiscSettingString(altUser, "AltController", mainUser) + SetEggIncName(altUser, "PurgeAltFarmer") + _ = AddActAsLink(mainUser, altUser) + _ = SetActAsSwitch(mainUser, "test-channel", altUser, time.Now().Add(1*time.Hour)) + + if !IsActAsLinked(mainUser, altUser) { + t.Fatalf("setup failed: altUser should be linked in act_as_links") + } + if GetMiscSettingString(altUser, "AltController") != mainUser { + t.Fatalf("setup failed: altUser should have AltController set") + } + + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "alt", "type": 3, "value": %q}, + {"name": "remove-data", "type": 5, "value": true}, + {"name": "confirm-request", "type": 5, "value": true} + ]} + }`, mainUser, altUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if !strings.Contains(event.LastFollowup.Content, "Alternate account settings data and alt links have been removed") { + t.Errorf("expected purge confirmation message, got: %s", event.LastFollowup.Content) + } + + // Verify alt links and switches are purged + if IsActAsLinked(mainUser, altUser) { + t.Errorf("IsActAsLinked should be false after purge") + } + if GetMiscSettingString(altUser, "AltController") != "" { + t.Errorf("AltController should be empty after purge, got %q", GetMiscSettingString(altUser, "AltController")) + } + if _, _, active := GetActAsSwitch(mainUser, "test-channel"); active { + t.Errorf("active act-as switch should be removed after purge") + } + + // Verify alt is no longer in GetLinkedAltIDs for mainUser + linked := GetLinkedAltIDs(mainUser) + for _, id := range linked { + if id == altUser { + t.Errorf("altUser should no longer be returned by GetLinkedAltIDs") + } + } +} + +func TestPurgeAltLinks(t *testing.T) { + mainUser := "100000000000000070" + altUser := "100000000000000071" + + SetMiscSettingString(altUser, "AltController", mainUser) + _ = AddActAsLink(mainUser, altUser) + _ = SetActAsSwitch(mainUser, "chan-1", altUser, time.Now().Add(1*time.Hour)) + StorePendingActAsRegistration(mainUser, altUser, "12345678", 5*time.Minute) + + PurgeAltLinks(mainUser, altUser) + + if IsActAsLinked(mainUser, altUser) { + t.Errorf("IsActAsLinked should be false") + } + if GetMiscSettingString(altUser, "AltController") != "" { + t.Errorf("AltController should be empty") + } + if _, ok := GetPendingActAsRegistration(mainUser); ok { + t.Errorf("pending registration should be cleared") + } +} + +func TestHandlePrivacy_MainUserPurgesLinkedAlts(t *testing.T) { + mainUser := "100000000000000080" + alt1 := "100000000000000081" + alt2 := "100000000000000082" + + // Set up main and alts with data + SetEggIncName(mainUser, "MainFarmer") + SetMiscSettingString(mainUser, "ei_ign", "MainIGN") + + SetEggIncName(alt1, "AltOneFarmer") + SetMiscSettingString(alt1, "ei_ign", "AltOneIGN") + SetMiscSettingString(alt1, "AltController", mainUser) + + SetEggIncName(alt2, "AltTwoFarmer") + SetMiscSettingString(alt2, "ei_ign", "AltTwoIGN") + _ = AddActAsLink(mainUser, alt2) + _ = SetActAsSwitch(mainUser, "chan-main", alt2, time.Now().Add(1*time.Hour)) + + // Populate leaderboard stats and opt-ins for main and alt + _ = UpsertLeaderboardStat("cxp", mainUser, "MainIGN", "2026-10-10", 100, sql.NullString{}) + _ = UpsertLeaderboardStat("cxp", alt1, "AltOneIGN", "2026-10-10", 50, sql.NullString{}) + _ = queries.UpsertLeaderboardOptIn(ctx, UpsertLeaderboardOptInParams{ + GuildID: "guild-1", + UserID: mainUser, + LbType: "cxp", + }) + _ = queries.UpsertLeaderboardOptIn(ctx, UpsertLeaderboardOptInParams{ + GuildID: "guild-1", + UserID: alt1, + LbType: "cxp", + }) + + // Ensure alts are linked + linkedBefore := GetLinkedAltIDs(mainUser) + if len(linkedBefore) != 2 { + t.Fatalf("expected 2 linked alts before purge, got %d", len(linkedBefore)) + } + + // Main user runs /privacy with remove-data and confirm-request (no alt param) + payload := fmt.Sprintf(`{ + "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, + "channel": {"id": "3", "type": 0}, + "user": {"id": %q, "username": "tester", "discriminator": "0"}, + "data": {"id": "5", "name": "privacy", "type": 1, "options": [ + {"name": "remove-data", "type": 5, "value": true}, + {"name": "confirm-request", "type": 5, "value": true} + ]} + }`, mainUser) + + event := commandEventFromPayload(payload) + HandlePrivacy(event) + + if event.LastFollowup == nil { + t.Fatalf("expected a followup message") + } + if !strings.Contains(event.LastFollowup.Content, "all linked alternate accounts' data have been removed") { + t.Errorf("expected mention of linked alts removal in: %s", event.LastFollowup.Content) + } + + // Verify main data is purged + if GetEggIncName(mainUser) != "" { + t.Errorf("main EggIncName should be cleared, got %q", GetEggIncName(mainUser)) + } + if GetMiscSettingString(mainUser, "ei_ign") != "" { + t.Errorf("main ei_ign should be cleared, got %q", GetMiscSettingString(mainUser, "ei_ign")) + } + + // Verify alt1 data and links are purged + if GetEggIncName(alt1) != "" { + t.Errorf("alt1 EggIncName should be cleared, got %q", GetEggIncName(alt1)) + } + if GetMiscSettingString(alt1, "AltController") != "" { + t.Errorf("alt1 AltController should be empty, got %q", GetMiscSettingString(alt1, "AltController")) + } + + // Verify alt2 data and links are purged + if GetEggIncName(alt2) != "" { + t.Errorf("alt2 EggIncName should be cleared, got %q", GetEggIncName(alt2)) + } + if IsActAsLinked(mainUser, alt2) { + t.Errorf("act-as link between main and alt2 should be deleted") + } + + // Verify no alts remain linked to main + linkedAfter := GetLinkedAltIDs(mainUser) + if len(linkedAfter) != 0 { + t.Errorf("expected 0 linked alts after main purge, got %d", len(linkedAfter)) + } + + // Verify leaderboard stats and opt-ins are deleted + mainStats, _ := queries.GetStatsForPlayer(ctx, mainUser) + if len(mainStats) != 0 { + t.Errorf("main leaderboard stats should be deleted, got %d", len(mainStats)) + } + altStats, _ := queries.GetStatsForPlayer(ctx, alt1) + if len(altStats) != 0 { + t.Errorf("alt1 leaderboard stats should be deleted, got %d", len(altStats)) + } + mainOptIns, _ := queries.GetLeaderboardOptInsForUser(ctx, mainUser) + if len(mainOptIns) != 0 { + t.Errorf("main leaderboard optins should be deleted, got %d", len(mainOptIns)) + } + altOptIns, _ := queries.GetLeaderboardOptInsForUser(ctx, alt1) + if len(altOptIns) != 0 { + t.Errorf("alt1 leaderboard optins should be deleted, got %d", len(altOptIns)) + } +} From c9e2460306d568c4257485e6bec6a0856cf2090d Mon Sep 17 00:00:00 2001 From: Michael McCarty Date: Mon, 12 Oct 2026 09:29:38 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8feat:=20add=20privacy=20button=20t?= =?UTF-8?q?ests=20and=20helpers,=20remove=20confirm=20opt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 1 + src/dc/event.go | 2 + src/farmerstate/privacy.go | 296 ++++++++++++++++++++++---------- src/farmerstate/privacy_test.go | 175 ++++++++++++++++--- 4 files changed, 358 insertions(+), 116 deletions(-) diff --git a/main.go b/main.go index 3907fc93..76f35b2d 100644 --- a/main.go +++ b/main.go @@ -313,6 +313,7 @@ var ( "active-contracts": func(e *dc.ComponentEvent) { boost.HandleActiveContractsPage(botClient(), e) }, "admin-contract-list": func(e *dc.ComponentEvent) { boost.HandleAdminContractListComponent(botClient(), e) }, "admin_exit": func(e *dc.ComponentEvent) { boost.HandleAdminExitButton(botClient(), e) }, + "privacy_btn": farmerstate.HandlePrivacyButton, "fd_signupStart": func(e *dc.ComponentEvent) { boost.HandleSignupStart(botClient(), e) }, "fd_signupFarmer": func(e *dc.ComponentEvent) { boost.HandleSignupFarmer(botClient(), e) }, "fd_signupBell": func(e *dc.ComponentEvent) { boost.HandleSignupBell(botClient(), e) }, diff --git a/src/dc/event.go b/src/dc/event.go index d04f5afd..659a9ec9 100644 --- a/src/dc/event.go +++ b/src/dc/event.go @@ -525,6 +525,8 @@ func (e *ComponentEvent) Update(m Message) (err error) { err = nil } }() + msg := m + e.LastResponse = &msg if e.event == nil { return nil } diff --git a/src/farmerstate/privacy.go b/src/farmerstate/privacy.go index e3688f5d..acfb93db 100644 --- a/src/farmerstate/privacy.go +++ b/src/farmerstate/privacy.go @@ -48,11 +48,6 @@ func GetSlashPrivacyCommand(cmd string) *dc.Command { Description: "Remove my data from the boost bot database.", Required: false, }, - dc.BoolOption{ - Name: "confirm-request", - Description: "Confirm privacy setting change or data removal.", - Required: false, - }, dc.StringOption{ Name: "alt", Description: "Manage privacy settings or purge data for a linked alternate account.", @@ -227,6 +222,178 @@ func purgeUserDataFiles(userID string) { } } +func buildPrivacyDeleteSummary(callerID, targetUserID string, isAlt bool, linkedAlts []string) string { + var b strings.Builder + b.WriteString("## ⚠️ Boost Bot Data Deletion Confirmation\n\n") + + if isAlt { + if dc.IsSnowflake(targetUserID) { + fmt.Fprintf(&b, "**Target Alternate Account:** <@%s>\n\n", targetUserID) + } else { + fmt.Fprintf(&b, "**Target Alternate Account:** `%s`\n\n", targetUserID) + } + b.WriteString("This action will **permanently delete** all stored information and databases for this alternate account:\n\n") + } else { + fmt.Fprintf(&b, "**Account:** <@%s>\n\n", callerID) + b.WriteString("This action will **permanently delete** all stored information and databases for your account:\n\n") + } + + b.WriteString("### 📦 Databases & Information to be Removed:\n") + b.WriteString("- **Bot Settings & Farmer State** (`farmer` database table)\n") + b.WriteString("- **Egg, Inc User Data & Cache Files** (`ttbb-data/eiuserdata/`)\n") + b.WriteString("- **Leaderboard Statistics** (`leaderboard_stats` table)\n") + b.WriteString("- **Leaderboard Opt-Ins & Exclusions** (`leaderboard_optin`, `leaderboard_exclusion` tables)\n") + b.WriteString("- **Active Timers & Reminders** (`timers` table)\n") + b.WriteString("- **User Watches** (`watches` table)\n") + b.WriteString("- **Custom Banners** (`custom_banners` table)\n") + b.WriteString("- **Guild Memberships** (`guild_memberships` table)\n") + b.WriteString("- **Alternate Account Links & Switches** (`act_as_links`, `act_as_switches`, `AltController`)\n\n") + + if !isAlt { + b.WriteString("### 👥 Linked Alternate Accounts That Will Also Be Deleted:\n") + if len(linkedAlts) > 0 { + for _, altID := range linkedAlts { + ign := strings.TrimSpace(GetMiscSettingString(altID, "ei_ign")) + if ign == "" { + ign = strings.TrimSpace(GetEggIncName(altID)) + } + if dc.IsSnowflake(altID) { + if ign != "" { + fmt.Fprintf(&b, "- <@%s> (%s)\n", altID, ei.NormalizePlayerNameForDisplay(ign)) + } else { + fmt.Fprintf(&b, "- <@%s>\n", altID) + } + } else { + if ign != "" { + fmt.Fprintf(&b, "- `%s` (%s)\n", altID, ei.NormalizePlayerNameForDisplay(ign)) + } else { + fmt.Fprintf(&b, "- `%s`\n", altID) + } + } + } + b.WriteString("*(All databases, files, and links for these alternate accounts will be permanently removed as well.)*\n\n") + } else { + b.WriteString("*(No linked alternate accounts found.)*\n\n") + } + } + + return b.String() +} + +func buildPrivacyDeleteConfirmation(callerID, targetUserID string, isAlt bool, enablePrivacy bool) []dc.LayoutComponent { + var linkedAlts []string + if !isAlt { + linkedAlts = GetLinkedAltIDs(callerID) + } + summary := buildPrivacyDeleteSummary(callerID, targetUserID, isAlt, linkedAlts) + content := summary + "**Are you sure you want to permanently delete this data?**" + + deleteBtn := dc.Button{ + Label: "DELETE", + Style: dc.ButtonDanger, + CustomID: fmt.Sprintf("privacy_btn#delete#%s#%s#%t", callerID, targetUserID, enablePrivacy), + Emoji: &dc.Emoji{Name: "🗑️"}, + } + keepBtn := dc.Button{ + Label: "KEEP", + Style: dc.ButtonSecondary, + CustomID: fmt.Sprintf("privacy_btn#keep#%s#%s#%t", callerID, targetUserID, enablePrivacy), + Emoji: &dc.Emoji{Name: "🛡️"}, + } + + return []dc.LayoutComponent{ + dc.TextDisplay{Content: content}, + dc.ActionRow{ + Components: []dc.InteractiveComponent{deleteBtn, keepBtn}, + }, + } +} + +// HandlePrivacyButton handles DELETE and KEEP confirmation button clicks for /privacy data removal. +func HandlePrivacyButton(e *dc.ComponentEvent) { + parts := strings.Split(e.CustomID(), "#") + if len(parts) < 5 { + _ = e.Respond(dc.Message{Content: "Invalid action.", Ephemeral: true}) + return + } + + action := parts[1] + callerID := parts[2] + targetUserID := parts[3] + enablePrivacy := parts[4] == "true" + + if e.UserID() != callerID { + _ = e.Respond(dc.Message{ + Content: "You are not authorized to use this button.", + Ephemeral: true, + }) + return + } + + respondAndClose := func(content string) { + err := e.Update(dc.Message{ + Content: content, + Ephemeral: true, + Components: []dc.LayoutComponent{ + dc.TextDisplay{Content: content}, + }, + }) + if err != nil { + log.Println("Error updating privacy confirmation dialog:", err) + } + } + + switch action { + case "keep": + respondAndClose("Data removal cancelled. Your data has been kept.") + return + + case "delete": + isAlt := targetUserID != callerID + var linkedAlts []string + if !isAlt { + linkedAlts = GetLinkedAltIDs(callerID) + } + summary := buildPrivacyDeleteSummary(callerID, targetUserID, isAlt, linkedAlts) + + var msg string + + if isAlt { + purgeUserDataFiles(targetUserID) + DeleteFarmer(targetUserID) + PurgeAltLinks(callerID, targetUserID) + setDataPrivacy(targetUserID, enablePrivacy) + + msg = "Alternate account settings data and alt links have been removed from the Boost Bot database.\nA default set of settings is now used along with the preference not to store data." + } else { + for _, altID := range linkedAlts { + purgeUserDataFiles(altID) + DeleteFarmer(altID) + PurgeAltLinks(callerID, altID) + setDataPrivacy(altID, enablePrivacy) + } + + purgeUserDataFiles(callerID) + DeleteFarmer(callerID) + _ = DeleteAllActAsLinks(callerID) + setDataPrivacy(callerID, enablePrivacy) + + if len(linkedAlts) > 0 { + msg = "Your settings data and all linked alternate accounts' data have been removed from the Boost Bot database.\nA default set of settings is now used along with your preference not to store data." + } else { + msg = "Your settings data have been removed from the Boost Bot database.\nA default set of settings is now used along with your preference not to store data." + } + } + + fullMsg := summary + "### ✅ Deletion Confirmed\n" + msg + respondAndClose(fullMsg) + return + + default: + _ = e.Respond(dc.Message{Content: "Unknown action.", Ephemeral: true}) + } +} + // HandlePrivacy will handle the /privacy command through the dc facade. func HandlePrivacy(e *dc.CommandEvent) { callerID := e.UserID() @@ -281,116 +448,69 @@ func HandlePrivacy(e *dc.CommandEvent) { userPrivacy := getDataPrivacy(targetUserID) removeData := false - confirmOption := false - - // User must confirm data removal and/or privacy setting change - if opt, ok := e.OptBool("confirm-request"); ok { - confirmOption = opt - } if opt, ok := e.OptInt("enable-data-privacy"); ok { userPrivacy = opt == 1 - if userPrivacy && confirmOption { - if isAlt { - builder.WriteString("Boost Bot will no longer store any persistent data about this alternate account. ") - builder.WriteString("If you wish to store data again, you will need to re-enable it. ") - builder.WriteString("If it interacts with the bot for contracts and token tracking it will be stored temporarily and removed within a week of the last interaction of a contract or tracker.\n") - } else { - builder.WriteString("Boost Bot wil no longer store any persistent data about you.") - builder.WriteString("If you wish to store data again, you will need to re-enable it.") - builder.WriteString("If you interact with the bot for contracts and token tracking it will be stored temporarily and removed within a week of the last interaction of a contract or tracker.") - } + if userPrivacy { removeData = true - } else if userPrivacy && !confirmOption { - builder.WriteString("You have not confirmed the privacy setting change, use the **confirm-request** option.") } else { - setDataPrivacy(targetUserID, userPrivacy) + setDataPrivacy(targetUserID, false) if isAlt { - builder.WriteString("Boost Bot will save a small amount of data about this alternate account. You can download this data at any time.") + builder.WriteString("Boost Bot will save a small amount of data about this alternate account. You can download this data at any time.\n") } else { - builder.WriteString("Boost Bot will store save a small amount of data about you. You can download this data at any time.") + builder.WriteString("Boost Bot will store save a small amount of data about you. You can download this data at any time.\n") } } } - if opt, ok := e.OptBool("remove-data"); ok { - removeData = opt - if removeData && !confirmOption { - builder.WriteString("You have not confirmed your data removal, use the **confirm-request** option.") - removeData = false - } + if opt, ok := e.OptBool("remove-data"); ok && opt { + removeData = true } - if removeData { - if isAlt { - builder.WriteString("Alternate account settings data and alt links have been removed from the Boost Bot database.\n") - builder.WriteString("A default set of settings is now used along with the preference not to store data.\n") - - purgeUserDataFiles(targetUserID) - DeleteFarmer(targetUserID) - PurgeAltLinks(callerID, targetUserID) - setDataPrivacy(targetUserID, userPrivacy) - } else { - linkedAlts := GetLinkedAltIDs(callerID) - for _, altID := range linkedAlts { - purgeUserDataFiles(altID) - DeleteFarmer(altID) - PurgeAltLinks(callerID, altID) - setDataPrivacy(altID, userPrivacy) - } - - purgeUserDataFiles(callerID) - DeleteFarmer(callerID) - _ = DeleteAllActAsLinks(callerID) - setDataPrivacy(callerID, userPrivacy) - - if len(linkedAlts) > 0 { - builder.WriteString("Your settings data and all linked alternate accounts' data have been removed from the Boost Bot database.\n") - } else { - builder.WriteString("Your settings data have been removed from the Boost Bot database.\n") - } - builder.WriteString("A default set of settings is now used along with your preference not to store data.\n") - } - } - - if opt, ok := e.OptBool("get-settings-data"); ok { + if opt, ok := e.OptBool("get-settings-data"); ok && opt { // Return the users settings data in a JSON file to the user - getData := opt - if getData { - userData := GetFullUserData(targetUserID) - - filename = "boostbot-data-" + targetUserID + ".json" - buf := &bytes.Buffer{} - jsonData, err := json.Marshal(userData) + userData := GetFullUserData(targetUserID) + + filename = "boostbot-data-" + targetUserID + ".json" + buf := &bytes.Buffer{} + jsonData, err := json.Marshal(userData) + if err != nil { + log.Println(err.Error()) + builder.WriteString("Error formatting JSON data. ") + builder.WriteString(err.Error()) + } else { + err = json.Indent(buf, jsonData, "", " ") if err != nil { - log.Println(err.Error()) builder.WriteString("Error formatting JSON data. ") builder.WriteString(err.Error()) } else { - err = json.Indent(buf, jsonData, "", " ") - if err != nil { - builder.WriteString("Error formatting JSON data. ") - builder.WriteString(err.Error()) - } else { - // Create io.Reader from JSON string - reader = bytes.NewReader(buf.Bytes()) - } + // Create io.Reader from JSON string + reader = bytes.NewReader(buf.Bytes()) } - } else { - builder.WriteString("\nYou have not requested your settings data, use the **get-settings-data** option with a value of true.") } } + if reader != nil { + fileMsg := "Your settings data has been saved to a JSON file. You can view and download it." if isAlt { - builder.WriteString("Alternate account settings data has been saved to a JSON file. You can view and download it.") - } else { - builder.WriteString("Your settings data has been saved to a JSON file. You can view and download it.") + fileMsg = "Alternate account settings data has been saved to a JSON file. You can view and download it." } _ = e.Followup(dc.Message{ - Content: builder.String(), + Content: fileMsg, Files: []dc.File{{Name: filename, Reader: reader}}, }) - } else { + } + + if removeData { + components := buildPrivacyDeleteConfirmation(callerID, targetUserID, isAlt, userPrivacy) + _ = e.Followup(dc.Message{ + Ephemeral: true, + Components: components, + }) + return + } + + if reader == nil { _ = e.Followup(dc.Message{Content: builder.String()}) } } diff --git a/src/farmerstate/privacy_test.go b/src/farmerstate/privacy_test.go index 79196ae8..31db332b 100644 --- a/src/farmerstate/privacy_test.go +++ b/src/farmerstate/privacy_test.go @@ -28,6 +28,9 @@ func TestGetSlashPrivacyCommand(t *testing.T) { t.Errorf("alt option should have Autocomplete=true") } } + if bOpt, ok := opt.(dc.BoolOption); ok && bOpt.Name == "confirm-request" { + t.Errorf("confirm-request option should have been removed from /privacy") + } } if !foundAlt { t.Fatalf("alt option not found in /privacy command options") @@ -216,7 +219,30 @@ func TestHandlePrivacy_AltGetSettingsData(t *testing.T) { PurgeAltLinks(mainUser, altUser) } -func TestHandlePrivacy_AltRemoveData_Unconfirmed(t *testing.T) { +func getComponentText(components []dc.LayoutComponent) string { + for _, c := range components { + if td, ok := c.(dc.TextDisplay); ok { + return td.Content + } + } + return "" +} + +func getComponentButtons(components []dc.LayoutComponent) []dc.Button { + var buttons []dc.Button + for _, c := range components { + if ar, ok := c.(dc.ActionRow); ok { + for _, comp := range ar.Components { + if b, ok := comp.(dc.Button); ok { + buttons = append(buttons, b) + } + } + } + } + return buttons +} + +func TestHandlePrivacy_AltRemoveData_ShowsConfirmationDialog(t *testing.T) { mainUser := "100000000000000050" altUser := "100000000000000051" @@ -238,19 +264,90 @@ func TestHandlePrivacy_AltRemoveData_Unconfirmed(t *testing.T) { if event.LastFollowup == nil { t.Fatalf("expected a followup message") } - if !strings.Contains(event.LastFollowup.Content, "You have not confirmed your data removal") { - t.Errorf("expected confirmation warning, got: %s", event.LastFollowup.Content) + if len(event.LastFollowup.Components) == 0 { + t.Fatalf("expected confirmation dialog components in followup message") + } + + dialogText := getComponentText(event.LastFollowup.Components) + if !strings.Contains(dialogText, "Boost Bot Data Deletion Confirmation") { + t.Errorf("expected confirmation header, got: %s", dialogText) + } + if !strings.Contains(dialogText, altUser) { + t.Errorf("expected target alt user in confirmation text, got: %s", dialogText) + } + if !strings.Contains(dialogText, "Databases & Information to be Removed") { + t.Errorf("expected database summary list in confirmation text, got: %s", dialogText) + } + + buttons := getComponentButtons(event.LastFollowup.Components) + if len(buttons) != 2 { + t.Fatalf("expected 2 buttons (DELETE and KEEP), got %d", len(buttons)) + } + if buttons[0].Label != "DELETE" || buttons[0].Style != dc.ButtonDanger { + t.Errorf("expected first button to be DELETE danger, got %+v", buttons[0]) + } + if buttons[1].Label != "KEEP" || buttons[1].Style != dc.ButtonSecondary { + t.Errorf("expected second button to be KEEP secondary, got %+v", buttons[1]) } - // Alt should still be linked + // Alt data should still be intact if GetMiscSettingString(altUser, "AltController") != mainUser { - t.Errorf("AltController should still be set") + t.Errorf("AltController should still be set before confirmation") } PurgeAltLinks(mainUser, altUser) } -func TestHandlePrivacy_AltRemoveData_Confirmed(t *testing.T) { +func TestHandlePrivacyButton_Keep(t *testing.T) { + mainUser := "100000000000000052" + altUser := "100000000000000053" + + SetMiscSettingString(altUser, "AltController", mainUser) + + keepCustomID := fmt.Sprintf("privacy_btn#keep#%s#%s#false", mainUser, altUser) + btnEvent := dctest.ComponentButtonEventWithUser(keepCustomID, mainUser) + HandlePrivacyButton(btnEvent) + + if btnEvent.LastResponse == nil { + t.Fatalf("expected response from HandlePrivacyButton") + } + respText := btnEvent.LastResponse.Content + if respText == "" { + respText = getComponentText(btnEvent.LastResponse.Components) + } + if !strings.Contains(respText, "Data removal cancelled. Your data has been kept.") { + t.Errorf("expected cancellation message, got: %s", respText) + } + if strings.Contains(respText, "Databases & Information to be Removed") { + t.Errorf("summary should be removed for KEEP option, got: %s", respText) + } + + // Data must be kept + if GetMiscSettingString(altUser, "AltController") != mainUser { + t.Errorf("AltController should still be set after cancel") + } + + PurgeAltLinks(mainUser, altUser) +} + +func TestHandlePrivacyButton_Unauthorized(t *testing.T) { + mainUser := "100000000000000054" + altUser := "100000000000000055" + stranger := "100000000000000099" + + delCustomID := fmt.Sprintf("privacy_btn#delete#%s#%s#false", mainUser, altUser) + btnEvent := dctest.ComponentButtonEventWithUser(delCustomID, stranger) + HandlePrivacyButton(btnEvent) + + if btnEvent.LastResponse == nil { + t.Fatalf("expected response for unauthorized click") + } + if !strings.Contains(btnEvent.LastResponse.Content, "not authorized") { + t.Errorf("expected unauthorized error, got: %s", btnEvent.LastResponse.Content) + } +} + +func TestHandlePrivacyButton_AltDelete_Confirmed(t *testing.T) { mainUser := "100000000000000060" altUser := "100000000000000061" @@ -267,25 +364,22 @@ func TestHandlePrivacy_AltRemoveData_Confirmed(t *testing.T) { t.Fatalf("setup failed: altUser should have AltController set") } - payload := fmt.Sprintf(`{ - "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, - "channel": {"id": "3", "type": 0}, - "user": {"id": %q, "username": "tester", "discriminator": "0"}, - "data": {"id": "5", "name": "privacy", "type": 1, "options": [ - {"name": "alt", "type": 3, "value": %q}, - {"name": "remove-data", "type": 5, "value": true}, - {"name": "confirm-request", "type": 5, "value": true} - ]} - }`, mainUser, altUser) + delCustomID := fmt.Sprintf("privacy_btn#delete#%s#%s#false", mainUser, altUser) + btnEvent := dctest.ComponentButtonEventWithUser(delCustomID, mainUser) + HandlePrivacyButton(btnEvent) - event := commandEventFromPayload(payload) - HandlePrivacy(event) - - if event.LastFollowup == nil { - t.Fatalf("expected a followup message") + if btnEvent.LastResponse == nil { + t.Fatalf("expected response from HandlePrivacyButton") } - if !strings.Contains(event.LastFollowup.Content, "Alternate account settings data and alt links have been removed") { - t.Errorf("expected purge confirmation message, got: %s", event.LastFollowup.Content) + respText := btnEvent.LastResponse.Content + if respText == "" { + respText = getComponentText(btnEvent.LastResponse.Components) + } + if !strings.Contains(respText, "Alternate account settings data and alt links have been removed") { + t.Errorf("expected purge confirmation message, got: %s", respText) + } + if !strings.Contains(respText, "Databases & Information to be Removed") { + t.Errorf("expected summary to be preserved on deletion, got: %s", respText) } // Verify alt links and switches are purged @@ -368,14 +462,13 @@ func TestHandlePrivacy_MainUserPurgesLinkedAlts(t *testing.T) { t.Fatalf("expected 2 linked alts before purge, got %d", len(linkedBefore)) } - // Main user runs /privacy with remove-data and confirm-request (no alt param) + // Main user runs /privacy with remove-data: true (no alt param) payload := fmt.Sprintf(`{ "id": "1", "application_id": "2", "type": 2, "token": "test-token", "version": 1, "channel": {"id": "3", "type": 0}, "user": {"id": %q, "username": "tester", "discriminator": "0"}, "data": {"id": "5", "name": "privacy", "type": 1, "options": [ - {"name": "remove-data", "type": 5, "value": true}, - {"name": "confirm-request", "type": 5, "value": true} + {"name": "remove-data", "type": 5, "value": true} ]} }`, mainUser) @@ -385,8 +478,34 @@ func TestHandlePrivacy_MainUserPurgesLinkedAlts(t *testing.T) { if event.LastFollowup == nil { t.Fatalf("expected a followup message") } - if !strings.Contains(event.LastFollowup.Content, "all linked alternate accounts' data have been removed") { - t.Errorf("expected mention of linked alts removal in: %s", event.LastFollowup.Content) + if len(event.LastFollowup.Components) == 0 { + t.Fatalf("expected confirmation dialog components") + } + dialogText := getComponentText(event.LastFollowup.Components) + if !strings.Contains(dialogText, "Linked Alternate Accounts That Will Also Be Deleted") { + t.Errorf("expected dialog to mention linked alternate accounts, got: %s", dialogText) + } + if !strings.Contains(dialogText, alt1) || !strings.Contains(dialogText, alt2) { + t.Errorf("expected dialog to list alt1 and alt2, got: %s", dialogText) + } + + // Main user confirms via DELETE button + delCustomID := fmt.Sprintf("privacy_btn#delete#%s#%s#false", mainUser, mainUser) + btnEvent := dctest.ComponentButtonEventWithUser(delCustomID, mainUser) + HandlePrivacyButton(btnEvent) + + if btnEvent.LastResponse == nil { + t.Fatalf("expected button response") + } + respText := btnEvent.LastResponse.Content + if respText == "" { + respText = getComponentText(btnEvent.LastResponse.Components) + } + if !strings.Contains(respText, "all linked alternate accounts' data have been removed") { + t.Errorf("expected mention of linked alts removal in: %s", respText) + } + if !strings.Contains(respText, "Databases & Information to be Removed") { + t.Errorf("expected summary to be preserved on deletion, got: %s", respText) } // Verify main data is purged