Discord.Net: [Bug]: AddRoleAsync() and AddRolesAsync() removes roles after adding them

Check The Docs

  • I double checked the docs and couldn’t find any useful information.

Verify Issue Source

  • I verified the issue was caused by Discord.Net.

Check your intents

  • I double checked that I have the required intents.

Description

AddRoleAsync() and AddRolesAsync() removes roles after adding them when using Discord.Net 3.10.0.

After downgrading to Discord.Net 3.9.0, AddRoleAsync() and AddRolesAsync() functions as intended.

Version

3.10.0

Working Version

3.9.0

Logs

n/a

Sample

public async Task HandleMenuInteraction(SocketMessageComponent messageComponent)
{
    switch(messageComponent.Data.CustomId)
    {
        case "role-selection":
            Console.WriteLine($"{messageComponent.User.Username} has changed their role subscriptions.");

            var values = messageComponent.Data.Values.ToList<string>();

            Console.WriteLine($"{messageComponent.User.Username} has selected the following role subscriptions:");

            foreach(var v in values) {
                Console.WriteLine(v);
            }
            
            var user = (IGuildUser) messageComponent.User;

            if(user != null)
            {
                List<string> allowedRoles = new List<string>()
                {
                    "Games",
                    "Technology",
                    "Programming",
                    "Art",
                    "Music"
                };

                List<ulong> addedRoles = new List<ulong>();
                List<ulong> removedRoles = new List<ulong>();

                string roleSubscriptions = "";

                foreach(var i in allowedRoles) {
                    try {
                        var role = user.Guild.Roles.First(x => x.Name == i);

                        if(values.Contains(role.Name)) {
                            addedRoles.Add(role.Id);
                            roleSubscriptions += $@"
:bell: {role.Name}";
                            Console.WriteLine($"{role.Name} ({role.Id}) was added to {messageComponent.User.Username}.");
                        }
                        else {
                            removedRoles.Add(role.Id);
                            roleSubscriptions += $@"
:no_entry_sign: {role.Name}";
                            Console.WriteLine($"{role.Name} ({role.Id}) was removed from {messageComponent.User.Username}.");
                        }
                    }
                    catch(Exception ex) {
                        Console.WriteLine(ex);
                    }
                }

                await user.AddRolesAsync(addedRoles);
                await user.RemoveRolesAsync(removedRoles);

                await messageComponent.DeferAsync();
                await messageComponent.ModifyOriginalResponseAsync(x => x.Content = $"{user.Mention} is now subscribed to the following channels: {roleSubscriptions}");
            }
            break;
    }
}

Packages

Discord.Net V3.9.0

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Comments: 27 (4 by maintainers)

Most upvoted comments

@cjbonn can you please explain how it works just fine for all previous versions but the latest is breaking? I am not sure how that is a user implementation issue.

Previous versions implemented AddRolesAsync by locally foreaching over the list and calling AddRoleAsync once for each role. That means if you want to add 5 roles, it would make 5 separate API calls (and accumulate 5 hits against your rate limit).

Discord has an endpoint for bulk-editing roles, and naturally the bulk-edit methods should use that endpoint. But that endpoint sets the user’s roles to whatever list is received, rather than adding or removing. Which means AddRolesAsync now locally needs to grab the user’s current role list and update it to add any new IDs, then send the full new list to the server.

Take this example:

// local user.RoleIds == [1, 2]; server-side user has roles 1, 2
await user.AddRolesAsync(new[] {3, 4}); // local [1, 2] + [3, 4] sends [1, 2, 3, 4] to server
// local user.RoleIds == [1, 2]; server-side user has roles 1, 2, 3, 4
await user.AddRolesAsync(new[] {5, 6}); // local [1, 2] + [5, 6] == sends [1, 2, 5, 6] to server
// local user.RoleIds == [1, 2]; server-side user has roles 1, 2, 5, 6

That first AddRolesAsync will calculate the new role list as [1, 2, 3, 4] and send that to the server. That’s fine, the user’s roles are now [1, 2, 3, 4]. But the local cache isn’t updated until we receive a gateway message from the server telling us about the modified User.

The second AddRolesAsync will calculate the new role list as [1, 2, 5, 6] and send that to the server. The user’s roles are now [1, 2, 5, 6]. Because we haven’t yet processed the gateway message, so the local cache is stale, so it’s not possible to calculate the correct set of roles.


So why aren’t we processing the gateway messages in between the two API calls? Well, that depends. If your handler is configured as RunMode.Sync (the default), that’ll be one reason: the handler blocks the gateway thread and no gateway messages can be processed until the entire handler is finished. Using RunMode.Async could help, but that can cause several other issues (you can then have multiple handlers run in parallel, so you need to make sure your code has no race conditions. Also the post-execution flow and error handling are completely different, see docs and docs.

But even using RunMode.Async wouldn’t definitively solve this: there’s no guarantee that you’ll receive the gateway message and process it before the next bulk-edit command is run, since multiple async threads are non-deterministically ordered by nature. And without hooking the user updated event there wouldn’t be any way to be sure that the cache is updated before the second call.

The simple solution is: don’t use multiple bulk (AddRoles/RemoveRoles) role editing methods in a single handler. Do the merge operations yourself and make a single request; Misha provided example code: https://github.com/discord-net/Discord.Net/issues/2655#issuecomment-1494670584. Alternatively do a foreach loop over the roles you want to add/remove and call the singular methods, which is exactly what the old behaviour was - just be aware that this is not ratelimit-friendly.

e: I wonder if there’s any reason the bulk-edit methods can’t update the local cached copy directly? Maybe it’s just hard to do with how it’s currently implemented :\

This is most likely a race condition error because AddRoles modifies the guild user directly and AddRole DeleteRole does not so if you add role and modify guild user at the same time it will overwrite the role list of the user.

Ah, yup, that’s most likely the issue

@douglasparker You could use user.ModifyAsync instead in this particular case the code would look like

await user.ModifyAsync(x => x.RoleIds = user.Roles.Select(r => r.Id)  // take roles user currently has
            .Except(new [] {user.Guild.Id} ) // @everyone role cannot be added or removed
            .Except(removedRoles) // remove roles from the user
            .Concat(addedRoles) // add roles
            .Distinct().ToList());

This adds & removes roles in a single API request, so it’s more ratelimit friendly.