diff --git a/sources/Users/ACommandUserGroups.uc b/sources/Users/ACommandUserGroups.uc
new file mode 100644
index 0000000..77dcc0d
--- /dev/null
+++ b/sources/Users/ACommandUserGroups.uc
@@ -0,0 +1,347 @@
+/**
+ * Command for displaying help information about registered Acedia's commands.
+ * Copyright 2022 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class ACommandUserGroups extends Command
+ dependson(Users_Feature);
+
+protected function BuildData(CommandDataBuilder builder)
+{
+ builder.Name(P("usergroups"))
+ .Group(P("admin"))
+ .Summary(P("User groups management."))
+ .Describe(P("Allows to add/remove user groups and users to these:"
+ @ "groups. Changes made by it will always affect current session,"
+ @ "but might fail to be saved in case user groups are stored in"
+ @ "a database that is either corrupted or in read-only mode."));
+ builder.SubCommand(P("show"))
+ .Describe(P("Shows all groups along with users that belong to them."));
+ builder.SubCommand(P("add"))
+ .Describe(P("Adds a new group"))
+ .ParamText(P("group_name"));
+ builder.SubCommand(P("remove"))
+ .Describe(P("Removes a group"))
+ .ParamText(P("group_name"));
+ builder.SubCommand(P("adduser"))
+ .Describe(P("Adds new user to the group"))
+ .ParamText(P("group_name"))
+ .ParamTextList(P("user_names"));
+ builder.SubCommand(P("removeuser"))
+ .Describe(P("Removes user from the group"))
+ .ParamText(P("group_name"))
+ .ParamTextList(P("user_names"));
+}
+
+protected function Executed(CallData arguments, EPlayer instigator)
+{
+ local Text groupName;
+ local ArrayList userNames;
+
+ groupName = arguments.parameters.GetText(P("group_name"));
+ userNames = arguments.parameters.GetArrayList(P("user_names"));
+ if (arguments.subCommandName.IsEmpty()) {
+ DisplayUserGroups();
+ }
+ else if (arguments.subCommandName.Compare(P("show"), SCASE_SENSITIVE)) {
+ DisplayUserGroupsWithUsers();
+ }
+ else if (arguments.subCommandName.Compare(P("add"), SCASE_SENSITIVE)) {
+ AddGroup(groupName);
+ }
+ else if (arguments.subCommandName.Compare(P("remove"), SCASE_SENSITIVE)) {
+ RemoveGroup(groupName);
+ }
+ else if (arguments.subCommandName.Compare(P("adduser"), SCASE_SENSITIVE)) {
+ AddUser(groupName, userNames);
+ }
+ else if (arguments.subCommandName.Compare(P("removeuser"), SCASE_SENSITIVE))
+ {
+ RemoveUser(groupName, userNames);
+ }
+ _.memory.Free(groupName);
+ _.memory.Free(userNames);
+}
+
+private function AddUser(BaseText groupName, ArrayList userNames)
+{
+ local int i;
+ local Text nextTextID;
+ local UserID nextID;
+
+ if (groupName == none) return;
+ if (userNames == none) return;
+
+ for (i = 0; i < userNames.GetLength(); i += 1)
+ {
+ nextTextID = userNames.GetText(i);
+ if (nextTextID == none) {
+ continue;
+ }
+ nextID = UserID(_.memory.Allocate(class'UserID'));
+ nextID.Initialize(nextTextID);
+ if (_.users.IsUserIDInGroup(nextID, groupName))
+ {
+ callerConsole
+ .Write(P("User "))
+ .UseColorOnce(_.color.Gray)
+ .Write(nextTextID)
+ .UseColorOnce(_.color.TextFailure)
+ .Write(P(" is already in the group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P("!"));
+ }
+ else
+ {
+ _.users.AddUserIDToGroup(nextID, groupName);
+ callerConsole
+ .Write(F("{$TextPositive Added} user "))
+ .UseColorOnce(_.color.Gray)
+ .Write(nextTextID)
+ .Write(P(" to the group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P("!"));
+ }
+ }
+}
+
+private function RemoveUser(BaseText groupName, ArrayList userNames)
+{
+ local int i;
+ local Text nextTextID;
+ local UserID nextID;
+
+ if (groupName == none) return;
+ if (userNames == none) return;
+
+ for (i = 0; i < userNames.GetLength(); i += 1)
+ {
+ nextTextID = userNames.GetText(i);
+ if (nextTextID == none) {
+ continue;
+ }
+ nextID = UserID(_.memory.Allocate(class'UserID'));
+ nextID.Initialize(nextTextID);
+ if (!_.users.IsUserIDInGroup(nextID, groupName))
+ {
+ callerConsole
+ .Write(P("User "))
+ .UseColorOnce(_.color.Gray)
+ .Write(nextTextID)
+ .UseColorOnce(_.color.TextFailure)
+ .Write(P(" doesn't belong to the group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P("!"));
+ }
+ else
+ {
+ _.users.RemoveUserIDFromGroup(nextID, groupName);
+ callerConsole
+ .Write(F("{$TextNegative Removed} user "))
+ .UseColorOnce(_.color.Gray)
+ .Write(nextTextID)
+ .Write(P(" from the group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P("!"));
+ }
+ }
+}
+
+private function AddGroup(BaseText groupName)
+{
+ if (_.users.IsGroupExisting(groupName))
+ {
+ callerConsole
+ .Write(P("Group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .UseColorOnce(_.color.TextNegative)
+ .Write(P(" already exists"))
+ .WriteLine(P("!"));
+ return;
+ }
+ if (_.users.AddGroup(groupName))
+ {
+ callerConsole
+ .Write(P("Group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .UseColorOnce(_.color.TextPositive)
+ .Write(P(" was added"))
+ .WriteLine(P("!"));
+ }
+ else
+ {
+ callerConsole
+ .UseColorOnce(_.color.TextFailure)
+ .Write(P("Cannot add"))
+ .Write(P(" group with a name "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P(" for unknown reason."));
+ }
+}
+
+private function RemoveGroup(BaseText groupName)
+{
+ if (!_.users.IsGroupExisting(groupName))
+ {
+ callerConsole
+ .Write(P("Group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .UseColorOnce(_.color.TextNegative)
+ .Write(P(" doesn't exists"))
+ .WriteLine(P("!"));
+ return;
+ }
+ if (_.users.RemoveGroup(groupName))
+ {
+ callerConsole
+ .Write(P("Group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .UseColorOnce(_.color.TextPositive)
+ .Write(P(" was removed"))
+ .WriteLine(P("!"));
+ }
+ else
+ {
+ callerConsole
+ .UseColorOnce(_.color.TextFailure)
+ .Write(P("Cannot remove"))
+ .Write(P(" group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(groupName)
+ .WriteLine(P(" for unknown reason."));
+ }
+}
+
+private function DisplayUserGroups()
+{
+ local int i;
+ local array availableGroups;
+
+ if (!ValidateUsersFeature()) {
+ return;
+ }
+ availableGroups = _.users.GetAvailableGroups();
+ if (availableGroups.length <= 0)
+ {
+ callerConsole.WriteLine(F("{$TextNegative No user groups}"
+ @ "currently available."));
+ return;
+ }
+ callerConsole
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(P("Available user groups"))
+ .Write(P(": "));
+ for (i = 0; i < availableGroups.length; i += 1)
+ {
+ if (i > 0) {
+ callerConsole.Write(P(", "));
+ }
+ callerConsole.Write(availableGroups[i]);
+ }
+ callerConsole.Flush();
+ _.memory.FreeMany(availableGroups);
+}
+
+private function bool ValidateUsersFeature()
+{
+ if (class'Users_Feature'.static.IsEnabled()) {
+ return true;
+ }
+ callerConsole
+ .UseColorOnce(_.color.TextFailure)
+ .WriteLine(P("`Users_Feature` is currently disabled."));
+ return false;
+}
+
+private function DisplayUserGroupsWithUsers()
+{
+ local int i;
+ local array availableGroups;
+
+ if (!ValidateUsersFeature()) {
+ return;
+ }
+ availableGroups = _.users.GetAvailableGroups();
+ if (availableGroups.length <= 0)
+ {
+ callerConsole.WriteLine(F("{$TextNegative No user groups}"
+ @ "currently available."));
+ return;
+ }
+ for (i = 0; i < availableGroups.length; i += 1)
+ {
+ callerConsole
+ .Write(P("User group "))
+ .UseColorOnce(_.color.TextEmphasis)
+ .Write(availableGroups[i])
+ .WriteLine(P(":"));
+ DisplayUsersFor(availableGroups[i]);
+ }
+ callerConsole.Flush();
+ _.memory.FreeMany(availableGroups);
+}
+
+private function DisplayUsersFor(Text groupName)
+{
+ local int i;
+ local Text nextID;
+ local array annotatedUsers;
+
+ annotatedUsers = _.users.GetAnnotatedGroupMembers(groupName);
+ if (annotatedUsers.length <= 0)
+ {
+ callerConsole.WriteBlock(P("No users"));
+ return;
+ }
+ for (i = 0; i < annotatedUsers.length; i += 1)
+ {
+ if (annotatedUsers[i].id == none) {
+ continue;
+ }
+ nextID = annotatedUsers[i].id.GetUniqueID();
+ if (annotatedUsers[i].annotation != none)
+ {
+ callerConsole
+ .Write(nextID)
+ .UseColorOnce(_.color.TextNeutral)
+ .Write(P(" aka "))
+ .WriteBlock(annotatedUsers[i].annotation);
+ }
+ else {
+ callerConsole.WriteBlock(nextID);
+ }
+ _.memory.Free(nextID);
+ }
+ for (i = 0; i < annotatedUsers.length; i += 1)
+ {
+ _.memory.Free(annotatedUsers[i].id);
+ _.memory.Free(annotatedUsers[i].annotation);
+ }
+}
+
+defaultproperties
+{
+}
\ No newline at end of file