From 5d9326b65e6363308a9b3fdde4b27b4263e8ef36 Mon Sep 17 00:00:00 2001 From: Toastie <toastie@toastiet0ast.com> Date: Thu, 6 Feb 2025 12:51:47 +1300 Subject: [PATCH] xp almost fully reimplemented --- src/EllieBot/Db/EllieDbService.cs | 15 +- .../Db/Extensions/GuildConfigExtensions.cs | 59 ++++---- src/EllieBot/Modules/Xp/XpRewards.cs | 102 +++++++------ src/EllieBot/Modules/Xp/XpService.cs | 142 ++++++++++++------ src/EllieBot/Services/GrpcApi/XpSvc.cs | 101 +++++++------ 5 files changed, 236 insertions(+), 183 deletions(-) diff --git a/src/EllieBot/Db/EllieDbService.cs b/src/EllieBot/Db/EllieDbService.cs index a351c8c..2ffa59e 100644 --- a/src/EllieBot/Db/EllieDbService.cs +++ b/src/EllieBot/Db/EllieDbService.cs @@ -89,9 +89,9 @@ public sealed class EllieDbService : DbService var applied = await ctx.Database.GetAppliedMigrationsAsync(); // get all .sql file names from the migrations folder - var available = Directory.GetFiles("Migrations/Sqlite", "*_*.sql") - .Select(x => Path.GetFileNameWithoutExtension(x)) - .OrderBy(x => x); + var available = Directory.GetFiles("Migrations/" + GetMigrationDirectory(ctx.Database), "*_*.sql") + .Select(x => Path.GetFileNameWithoutExtension(x)) + .OrderBy(x => x); var lastApplied = applied.Last(); Log.Information("Last applied migration: {LastApplied}", lastApplied); @@ -112,12 +112,17 @@ public sealed class EllieDbService : DbService } private static string GetMigrationPath(DatabaseFacade ctxDatabase, string runnable) + { + return $"Migrations/{GetMigrationDirectory(ctxDatabase)}/{runnable}.sql"; + } + + private static string GetMigrationDirectory(DatabaseFacade ctxDatabase) { if (ctxDatabase.IsSqlite()) - return $"Migrations/Sqlite/{runnable}.sql"; + return "Sqlite"; if (ctxDatabase.IsNpgsql()) - return $"Migrations/PostgreSql/{runnable}.sql"; + return "PostgreSql"; throw new NotSupportedException("This database type is not supported."); } diff --git a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs index 865ae8e..a8f9329 100644 --- a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs +++ b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs @@ -33,8 +33,8 @@ public static class GuildConfigExtensions public static async Task<StreamRoleSettings> GetOrCreateStreamRoleSettings(this DbContext ctx, ulong guildId) { var srs = await ctx.GetTable<StreamRoleSettings>() - .Where(x => x.GuildId == guildId) - .FirstOrDefaultAsyncEF(); + .Where(x => x.GuildId == guildId) + .FirstOrDefaultAsyncEF(); if (srs is not null) return srs; @@ -52,18 +52,18 @@ public static class GuildConfigExtensions public static LogSetting LogSettingsFor(this DbContext ctx, ulong guildId) { var logSetting = ctx.Set<LogSetting>() - .AsQueryable() - .Include(x => x.LogIgnores) - .Where(x => x.GuildId == guildId) - .FirstOrDefault(); + .AsQueryable() + .Include(x => x.LogIgnores) + .Where(x => x.GuildId == guildId) + .FirstOrDefault(); if (logSetting is null) { ctx.Set<LogSetting>() - .Add(logSetting = new() - { - GuildId = guildId - }); + .Add(logSetting = new() + { + GuildId = guildId + }); ctx.SaveChanges(); } @@ -71,7 +71,6 @@ public static class GuildConfigExtensions } - public static IEnumerable<GuildConfig> PermissionsForAll(this DbSet<GuildConfig> configs, List<ulong> include) { var query = configs.AsQueryable().Where(x => include.Contains(x.GuildId)).Include(gc => gc.Permissions); @@ -82,19 +81,19 @@ public static class GuildConfigExtensions public static GuildConfig GcWithPermissionsFor(this DbContext ctx, ulong guildId) { var config = ctx.Set<GuildConfig>() - .AsQueryable() - .Where(gc => gc.GuildId == guildId) - .Include(gc => gc.Permissions) - .FirstOrDefault(); + .AsQueryable() + .Where(gc => gc.GuildId == guildId) + .Include(gc => gc.Permissions) + .FirstOrDefault(); if (config is null) // if there is no guildconfig, create new one { ctx.Set<GuildConfig>() - .Add(config = new() - { - GuildId = guildId, - Permissions = Permissionv2.GetDefaultPermlist - }); + .Add(config = new() + { + GuildId = guildId, + Permissions = Permissionv2.GetDefaultPermlist + }); ctx.SaveChanges(); } else if (config.Permissions is null || !config.Permissions.Any()) // if no perms, add default ones @@ -106,20 +105,24 @@ public static class GuildConfigExtensions return config; } - public static async Task<XpSettings> XpSettingsFor(this DbContext ctx, ulong guildId) + public static async Task<XpSettings> XpSettingsFor(this DbContext ctx, ulong guildId, + Func<IQueryable<XpSettings>, IQueryable<XpSettings>> includes = default) { - var srs = await ctx.GetTable<XpSettings>() - .Where(x => x.GuildId == guildId) - .FirstOrDefaultAsyncLinqToDB(); + includes ??= static set => set; + + var srs = await includes(ctx.GetTable<XpSettings>() + .Where(x => x.GuildId == guildId)) + .FirstOrDefaultAsyncLinqToDB(); if (srs is not null) return srs; srs = await ctx.GetTable<XpSettings>() - .InsertWithOutputAsync(() => new() - { - GuildId = guildId, - }); + .InsertWithOutputAsync(() => new() + { + GuildId = guildId, + ServerExcluded = false, + }); return srs; } diff --git a/src/EllieBot/Modules/Xp/XpRewards.cs b/src/EllieBot/Modules/Xp/XpRewards.cs index f737e4a..2a4ff47 100644 --- a/src/EllieBot/Modules/Xp/XpRewards.cs +++ b/src/EllieBot/Modules/Xp/XpRewards.cs @@ -17,8 +17,8 @@ public partial class Xp public async Task XpRewsReset() { var promptEmbed = CreateEmbed() - .WithPendingColor() - .WithDescription(GetText(strs.xprewsreset_confirm)); + .WithPendingColor() + .WithDescription(GetText(strs.xprewsreset_confirm)); var reply = await PromptUserConfirmAsync(promptEmbed); @@ -38,57 +38,59 @@ public partial class Xp if (page is < 0 or > 100) return; - var rews = await _service.GetRoleRewardsAsync(ctx.Guild.Id); + var xpSettings = await _service.GetFullXpSettingsFor(ctx.Guild.Id); + var rews = xpSettings.RoleRewards; + var allRewards = rews.OrderBy(x => x.Level) - .Select(x => - { - var sign = !x.Remove ? "✅ " : "❌ "; + .Select(x => + { + var sign = !x.Remove ? "✅ " : "❌ "; - var str = ctx.Guild.GetRole(x.RoleId)?.ToString(); + var str = ctx.Guild.GetRole(x.RoleId)?.ToString(); - if (str is null) - { - str = GetText(strs.role_not_found(Format.Code(x.RoleId.ToString()))); - } - else - { - if (!x.Remove) - str = GetText(strs.xp_receive_role(Format.Bold(str))); - else - str = GetText(strs.xp_lose_role(Format.Bold(str))); - } + if (str is null) + { + str = GetText(strs.role_not_found(Format.Code(x.RoleId.ToString()))); + } + else + { + if (!x.Remove) + str = GetText(strs.xp_receive_role(Format.Bold(str))); + else + str = GetText(strs.xp_lose_role(Format.Bold(str))); + } - return (x.Level, Text: sign + str); - }) - .Concat((await _service.GetCurrencyRewardsAsync(ctx.Guild.Id)) - .OrderBy(x => x.Level) - .Select(x => (x.Level, - Format.Bold(x.Amount + _cp.GetCurrencySign())))) - .GroupBy(x => x.Level) - .OrderBy(x => x.Key) - .ToList(); + return (x.Level, Text: sign + str); + }) + .Concat(xpSettings.CurrencyRewards + .OrderBy(x => x.Level) + .Select(x => (x.Level, + Format.Bold(x.Amount + _cp.GetCurrencySign())))) + .GroupBy(x => x.Level) + .OrderBy(x => x.Key) + .ToList(); await Response() - .Paginated() - .Items(allRewards) - .PageSize(9) - .CurrentPage(page) - .Page((items, _) => - { - var embed = CreateEmbed().WithTitle(GetText(strs.level_up_rewards)).WithOkColor(); + .Paginated() + .Items(allRewards) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var embed = CreateEmbed().WithTitle(GetText(strs.level_up_rewards)).WithOkColor(); - if (!items.Any()) - return embed.WithDescription(GetText(strs.no_level_up_rewards)); + if (!items.Any()) + return embed.WithDescription(GetText(strs.no_level_up_rewards)); - foreach (var reward in items) - { - embed.AddField(GetText(strs.level_x(reward.Key)), - string.Join("\n", reward.Select(y => y.Item2))); - } + foreach (var reward in items) + { + embed.AddField(GetText(strs.level_x(reward.Key)), + string.Join("\n", reward.Select(y => y.Item2))); + } - return embed; - }) - .SendAsync(); + return embed; + }) + .SendAsync(); } [Cmd] @@ -120,9 +122,9 @@ public partial class Xp else { await Response() - .Confirm(strs.xp_role_reward_remove_role(Format.Bold(level.ToString()), - Format.Bold(role.ToString()))) - .SendAsync(); + .Confirm(strs.xp_role_reward_remove_role(Format.Bold(level.ToString()), + Format.Bold(role.ToString()))) + .SendAsync(); } } @@ -142,9 +144,9 @@ public partial class Xp else { await Response() - .Confirm(strs.cur_reward_added(level, - Format.Bold(amount + _cp.GetCurrencySign()))) - .SendAsync(); + .Confirm(strs.cur_reward_added(level, + Format.Bold(amount + _cp.GetCurrencySign()))) + .SendAsync(); } } } diff --git a/src/EllieBot/Modules/Xp/XpService.cs b/src/EllieBot/Modules/Xp/XpService.cs index f465cc5..109d9b0 100644 --- a/src/EllieBot/Modules/Xp/XpService.cs +++ b/src/EllieBot/Modules/Xp/XpService.cs @@ -46,11 +46,12 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand private readonly IPatronageService _ps; private readonly IBotCache _c; - private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>(); private readonly INotifySubscriber _notifySub; private readonly ShardData _shardData; + private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 100); + public XpService( DiscordSocketClient client, DbService db, @@ -109,7 +110,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public async Task OnReadyAsync() { // initialize ignored - ArgumentOutOfRangeException.ThrowIfLessThan(_xpConfig.Data.MessageXpCooldown, 1, + ArgumentOutOfRangeException.ThrowIfLessThan(_xpConfig.Data.MessageXpCooldown, + 1, nameof(_xpConfig.Data.MessageXpCooldown)); await using (var ctx = _db.GetDbContext()) @@ -134,27 +136,25 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } } - await Task.WhenAll(UpdateTimer(), PeriodClearTimer()); + await Task.WhenAll(UpdateTimer(), _levelUpQueue.RunAsync()); return; - async Task PeriodClearTimer() - { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_xpConfig.Data.MessageXpCooldown)); - while (true) - { - await timer.WaitForNextTickAsync(); - _usersGainedInPeriod.Clear(); - } - } - async Task UpdateTimer() { // todo a bigger loop that runs once every XpTimer using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); while (await timer.WaitForNextTickAsync()) { - await UpdateXp(); + try + { + await UpdateXp(); + } + catch (Exception ex) + { + Log.Error(ex, "Error updating xp"); + await Task.Delay(30_000); + } } } } @@ -170,33 +170,80 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand var currentBatch = _usersBatch.ToArray(); _usersBatch.Clear(); + if (currentBatch.Length == 0) + return; + + var ids = currentBatch.Select(x => x.Id).ToArray(); + await using var ctx = _db.GetDbContext(); await using var lctx = ctx.CreateLinqToDBConnection(); - await using var batchTable = await lctx.CreateTempTableAsync<UserXpBatch>(); + var tempTableName = "xp_batch_" + _shardData.ShardId; + await using var batchTable = await lctx.CreateTempTableAsync<UserXpBatch>(tempTableName); await batchTable.BulkCopyAsync(currentBatch.Select(x => new UserXpBatch() { GuildId = x.GuildId, UserId = x.Id, - UserName = x.Username, - AvatarId = x.DisplayAvatarId, - + Username = x.Username, + AvatarId = x.DisplayAvatarId })); await lctx.ExecuteAsync( $""" - INSERT INTO ${nameof(DiscordUser)} - SELECT ${nameof(UserXpBatch.GuildId)}, ${nameof(UserXpBatch.UserId)}, ${nameof(UserXpBatch.UserName)}, ${nameof(UserXpBatch.AvatarId)}, ${xpAmount} - FROM ${nameof(UserXpBatch)} - ON CONFLICT(${nameof(DiscordUser.UserId)}, ${nameof(DiscordUser.UserId)}) DO UPDATE SET - ${nameof(DiscordUser.Username)} = ${nameof(UserXpBatch)}.${nameof(UserXpBatch.UserName)} - ${nameof(DiscordUser.AvatarId)} = ${nameof(UserXpBatch)}.${nameof(UserXpBatch.AvatarId)} - ${nameof(DiscordUser.TotalXp)} = ${nameof(DiscordUser.TotalXp)} + ${xpAmount} - RETURNING *; + INSERT INTO UserXpStats (GuildId, UserId, Xp) + SELECT "{tempTableName}"."GuildId", "{tempTableName}"."UserId", {xpAmount} + FROM {tempTableName} + WHERE TRUE + ON CONFLICT (GuildId, UserId) DO UPDATE + SET + Xp = UserXpStats.Xp + EXCLUDED.Xp; """); - // todo send notifications + await lctx.ExecuteAsync( + $""" + INSERT INTO DiscordUser (UserId, AvatarId, Username, TotalXp) + SELECT "{tempTableName}"."UserId", "{tempTableName}"."AvatarId", "{tempTableName}"."Username", {xpAmount} + FROM {tempTableName} + WHERE TRUE + ON CONFLICT (UserId) DO UPDATE + SET + Username = EXCLUDED.Username, + AvatarId = EXCLUDED.AvatarId, + TotalXp = DiscordUser.TotalXp + {xpAmount}; + """); + + foreach (var (guildId, users) in currentBatch.GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => x.AsEnumerable())) + { + var userIds = users.Select(x => x.Id).ToArray(); + + var dbStats = await ctx.GetTable<UserXpStats>() + .Where(x => x.GuildId == guildId && userIds.Contains(x.UserId)) + .OrderByDescending(x => x.Xp) + .ToArrayAsyncLinqToDB(); + + for (var i = 0; i < dbStats.Length; i++) + { + var oldStats = new LevelStats(dbStats[i].Xp - xpAmount); + var newStats = new LevelStats(dbStats[i].Xp); + + Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}", + dbStats[i].UserId, + oldStats.TotalXp, + newStats.TotalXp); + + if (oldStats.Level < newStats.Level) + { + await _levelUpQueue.EnqueueAsync(NotifyUser(guildId, + 0, + dbStats[i].UserId, + true, + oldStats.Level, + newStats.Level)); + } + } + } } private Func<Task> NotifyUser( @@ -222,13 +269,9 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand long oldLevel, long newLevel) { - List<XpRoleReward> rrews; - List<XpCurrencyReward> crews; - await using (var ctx = _db.GetDbContext()) - { - rrews = await ctx.XpSettingsFor(guildId).Fmap(x => x.RoleRewards.ToList()); - crews = await ctx.XpSettingsFor(guildId).Fmap(x => x.CurrencyRewards.ToList()); - } + var settings = await GetFullXpSettingsFor(guildId); + var rrews = settings.RoleRewards; + var crews = settings.CurrencyRewards; //loop through levels since last level up, so if a high amount of xp is gained, reward are still applied. for (var i = oldLevel + 1; i <= newLevel; i++) @@ -369,7 +412,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public async Task SetCurrencyReward(ulong guildId, int level, int amount) { await using var uow = _db.GetDbContext(); - var settings = await uow.XpSettingsFor(guildId); + var settings = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.CurrencyRewards)); if (amount <= 0) { @@ -399,22 +442,19 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand uow.SaveChanges(); } - public async Task<IEnumerable<XpCurrencyReward>> GetCurrencyRewardsAsync(ulong id) + public async Task<XpSettings> GetFullXpSettingsFor(ulong guildId) { await using var uow = _db.GetDbContext(); - return (await uow.XpSettingsFor(id)).CurrencyRewards.ToArray(); - } - - public async Task<IEnumerable<XpRoleReward>> GetRoleRewardsAsync(ulong id) - { - await using var uow = _db.GetDbContext(); - return (await uow.XpSettingsFor(id)).RoleRewards.ToArray(); + return await uow.XpSettingsFor(guildId, + set => set + .LoadWith(x => x.CurrencyRewards) + .LoadWith(x => x.RoleRewards)); } public async Task ResetRoleRewardAsync(ulong guildId, int level) { await using var uow = _db.GetDbContext(); - var settings = await uow.XpSettingsFor(guildId); + var settings = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.RoleRewards)); var toRemove = settings.RoleRewards.FirstOrDefault(x => x.Level == level); if (toRemove is not null) @@ -423,7 +463,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand settings.RoleRewards.Remove(toRemove); } - uow.SaveChanges(); + await uow.SaveChangesAsync(); } public async Task SetRoleRewardAsync( @@ -433,7 +473,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand bool remove) { await using var uow = _db.GetDbContext(); - var settings = await uow.XpSettingsFor(guildId); + var settings = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.RoleRewards)); var rew = settings.RoleRewards.FirstOrDefault(x => x.Level == level); @@ -806,7 +846,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand { var roles = _excludedRoles.GetOrAdd(guildId, _ => new()); await using var uow = _db.GetDbContext(); - var xpSetting = await uow.XpSettingsFor(guildId); + var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList)); var excludeObj = new ExcludedItem { ItemId = rId, @@ -837,7 +877,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand { var channels = _excludedChannels.GetOrAdd(guildId, _ => new()); await using var uow = _db.GetDbContext(); - var xpSetting = await uow.XpSettingsFor(guildId); + var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList)); var excludeObj = new ExcludedItem { ItemId = chId, @@ -1510,8 +1550,10 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public sealed class UserXpBatch { - [Key] public ulong UserId { get; set; } + [Key] + public ulong UserId { get; set; } + public ulong GuildId { get; set; } - public string UserName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; public string AvatarId { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/EllieBot/Services/GrpcApi/XpSvc.cs b/src/EllieBot/Services/GrpcApi/XpSvc.cs index a30d1c9..c403340 100644 --- a/src/EllieBot/Services/GrpcApi/XpSvc.cs +++ b/src/EllieBot/Services/GrpcApi/XpSvc.cs @@ -42,22 +42,23 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService var reply = new GetXpSettingsReply(); reply.Exclusions.AddRange(excludedChannels - .Select(x => new ExclItemReply() - { - Id = x, - Type = "Channel", - Name = guild.GetChannel(x)?.Name ?? "????" - }) - .Concat(excludedRoles - .Select(x => new ExclItemReply() - { - Id = x, - Type = "Role", - Name = guild.GetRole(x)?.Name ?? "????" - }))); + .Select(x => new ExclItemReply() + { + Id = x, + Type = "Channel", + Name = guild.GetChannel(x)?.Name ?? "????" + }) + .Concat(excludedRoles + .Select(x => new ExclItemReply() + { + Id = x, + Type = "Role", + Name = guild.GetRole(x)?.Name ?? "????" + }))); - var curRews = await _xp.GetCurrencyRewardsAsync(request.GuildId); - var roleRews = await _xp.GetRoleRewardsAsync(request.GuildId); + var settings = await _xp.GetFullXpSettingsFor(request.GuildId); + var curRews = settings.CurrencyRewards; + var roleRews = settings.RoleRewards; var rews = curRews.Select(x => new RewItemReply() { @@ -72,7 +73,7 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService Type = x.Remove ? "RemoveRole" : "AddRole", Value = guild.GetRole(x.RoleId)?.ToString() ?? x.RoleId.ToString() })) - .OrderBy(x => x.Level); + .OrderBy(x => x.Level); reply.Rewards.AddRange(rews); @@ -224,43 +225,43 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService }; var users = await data - .Select(async x => - { - var user = guild.GetUser(x.UserId); + .Select(async x => + { + var user = guild.GetUser(x.UserId); - if (user is null) - { - var du = await _duSvc.GetUserAsync(x.UserId); - if (du is null) - return new XpLbUserReply - { - UserId = x.UserId, - Avatar = string.Empty, - Username = string.Empty, - Xp = x.Xp, - Level = new LevelStats(x.Xp).Level - }; + if (user is null) + { + var du = await _duSvc.GetUserAsync(x.UserId); + if (du is null) + return new XpLbUserReply + { + UserId = x.UserId, + Avatar = string.Empty, + Username = string.Empty, + Xp = x.Xp, + Level = new LevelStats(x.Xp).Level + }; - return new XpLbUserReply() - { - UserId = x.UserId, - Avatar = du.RealAvatarUrl()?.ToString() ?? string.Empty, - Username = du.ToString() ?? string.Empty, - Xp = x.Xp, - Level = new LevelStats(x.Xp).Level - }; - } + return new XpLbUserReply() + { + UserId = x.UserId, + Avatar = du.RealAvatarUrl()?.ToString() ?? string.Empty, + Username = du.ToString() ?? string.Empty, + Xp = x.Xp, + Level = new LevelStats(x.Xp).Level + }; + } - return new XpLbUserReply - { - UserId = x.UserId, - Avatar = user?.GetAvatarUrl() ?? string.Empty, - Username = user?.ToString() ?? string.Empty, - Xp = x.Xp, - Level = new LevelStats(x.Xp).Level - }; - }) - .WhenAll(); + return new XpLbUserReply + { + UserId = x.UserId, + Avatar = user?.GetAvatarUrl() ?? string.Empty, + Username = user?.ToString() ?? string.Empty, + Xp = x.Xp, + Level = new LevelStats(x.Xp).Level + }; + }) + .WhenAll(); reply.Users.AddRange(users);