diff --git a/RegexBot-Modules/VoiceRoleSync/ModuleConfig.cs b/RegexBot-Modules/VoiceRoleSync/ModuleConfig.cs
new file mode 100644
index 0000000..abd5f25
--- /dev/null
+++ b/RegexBot-Modules/VoiceRoleSync/ModuleConfig.cs
@@ -0,0 +1,42 @@
+using System.Collections.ObjectModel;
+
+namespace RegexBot.Modules.VoiceRoleSync;
+///
+/// Dictionary wrapper. Key = voice channel ID, Value = role.
+///
+class ModuleConfig {
+ private readonly ReadOnlyDictionary _values;
+
+ public ModuleConfig(JObject config) {
+ // Configuration format is expected to be an object that contains other objects.
+ // The objects themselves should have their name be the voice channel,
+ // and the value be the role to be applied.
+
+ // TODO Make it accept names; currently only accepts ulongs
+
+ var values = new Dictionary();
+
+ foreach (var item in config.Properties()) {
+ if (!ulong.TryParse(item.Name, out var voice)) throw new ModuleLoadException($"{item.Name} is not a voice channel ID.");
+ var valstr = item.Value.Value();
+ if (!ulong.TryParse(valstr, out var role)) throw new ModuleLoadException($"{valstr} is not a role ID.");
+
+ values[voice] = role;
+ }
+
+ _values = new ReadOnlyDictionary(values);
+ }
+
+ public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) {
+ if (voiceChannel == null) return null;
+ if (_values.TryGetValue(voiceChannel.Id, out var roleId)) return voiceChannel.Guild.GetRole(roleId);
+ return null;
+ }
+
+ public IEnumerable GetTrackedRoles(SocketGuild guild) {
+ foreach (var pair in _values) {
+ var r = guild.GetRole(pair.Value);
+ if (r != null) yield return r;
+ }
+ }
+}
diff --git a/RegexBot-Modules/VoiceRoleSync/VoiceRoleSync.cs b/RegexBot-Modules/VoiceRoleSync/VoiceRoleSync.cs
new file mode 100644
index 0000000..6856c81
--- /dev/null
+++ b/RegexBot-Modules/VoiceRoleSync/VoiceRoleSync.cs
@@ -0,0 +1,53 @@
+namespace RegexBot.Modules.VoiceRoleSync;
+///
+/// Synchronizes a user's state in a voice channel with a role.
+/// In other words: applies a role to a user entering a voice channel. Removes the role when exiting.
+///
+[RegexbotModule]
+public class VoiceRoleSync : RegexbotModule {
+ // TODO wishlist? specify multiple definitions - multiple channels associated with multiple roles.
+
+ public VoiceRoleSync(RegexbotClient bot) : base(bot) {
+ DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated;
+ }
+
+ private async Task Client_UserVoiceStateUpdated(SocketUser argUser, SocketVoiceState before, SocketVoiceState after) {
+ // Gather data.
+ if (argUser is not SocketGuildUser user) return; // not a guild user
+ var settings = GetGuildState(user.Guild.Id);
+ if (settings == null) return; // not enabled here
+
+ async Task RemoveAllAssociatedRoles()
+ => await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles));
+
+ if (after.VoiceChannel == null) {
+ // Not in any voice channel. Remove all roles being tracked by this instance. Clear.
+ await RemoveAllAssociatedRoles();
+ } else {
+ // In a voice channel, and...
+ if (after.IsDeafened || after.IsSelfDeafened) {
+ // Is defeaned, which is like not being in a voice channel for our purposes. Clear.
+ await RemoveAllAssociatedRoles();
+ } else {
+ var targetRole = settings.GetAssociatedRoleFor(after.VoiceChannel);
+ if (targetRole == null) {
+ // In an untracked voice channel. Clear.
+ await RemoveAllAssociatedRoles();
+ } else {
+ // In a tracked voice channel: Clear all except target, add target if needed.
+ await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild)
+ .Where(role => role.Id != targetRole.Id)
+ .Intersect(user.Roles));
+ if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole);
+ }
+ }
+ }
+ }
+
+ public override Task