diff --git a/src/DevChatter.Bot.Core/Util/CommandParser.cs b/src/DevChatter.Bot.Core/Util/CommandParser.cs new file mode 100644 index 00000000..8ac2f4da --- /dev/null +++ b/src/DevChatter.Bot.Core/Util/CommandParser.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; + +namespace DevChatter.Bot.Core.Util +{ + public static class CommandParser + { + public static (string commandWord, List arguments) Parse(string commandString, int startIndex = 0) + { + if (startIndex < 0 || string.IsNullOrWhiteSpace(commandString)) + { + return (string.Empty, new List()); + } + + int commandWordEndIndex = commandString.IndexOf(' ', startIndex); + if (commandWordEndIndex == -1) + { + commandWordEndIndex = commandString.Length - 1; + } + + int commandWordLength = commandWordEndIndex - startIndex + 1; + string commandWord = commandString.Substring(startIndex, commandWordLength).Trim(); + if (commandWordEndIndex == commandString.Length - 1) + { + // return early because we have no arguments + return (commandWord, new List()); + } + + string remainingCommand = commandString.Substring(commandWordLength).Trim(); + var arguments = SplitArguments(remainingCommand); + + return (commandWord, arguments); + } + + private static List SplitArguments(string arguments) + { + const char argumentDelimiter = ' '; + const char quoteCharacter = '"'; + + if (string.IsNullOrWhiteSpace(arguments)) + return new List(); + + List splitArguments = new List(); + bool wasInQuotedSection = false; + bool inQuotedSection = false; + int argumentStart = -1; + + for (int i = 0; i < arguments.Length; ++i) + { + if (arguments[i] == quoteCharacter) + { + if (!inQuotedSection) + { + if (i == 0 || arguments[i - 1] == argumentDelimiter) + { + inQuotedSection = true; + wasInQuotedSection = false; + } + } + else + { + if (i == arguments.Length - 1 || arguments[i + 1] == argumentDelimiter) + { + wasInQuotedSection = true; + inQuotedSection = false; + } + } + } + else if(arguments[i] == argumentDelimiter && !inQuotedSection) + { + int argumentLength = i - argumentStart; + if (wasInQuotedSection) + { + // If we were previously in a quoted section we + // need to offset our length by -1 otherwise we get the closing quote + argumentLength -= 1; + } + + var argument = arguments.Substring(argumentStart, argumentLength); + splitArguments.Add(argument); + argumentStart = -1; + wasInQuotedSection = false; + inQuotedSection = false; + } + else if(arguments[i] != argumentDelimiter && argumentStart == -1) + { + // we only want to record the start index + // if the current character is NOT the delimiter + argumentStart = i; + } + } + + if(argumentStart != -1) + { + int argumentLength = arguments.Length - argumentStart; + if(wasInQuotedSection) + { + argumentLength -= 1; + } + + var argument = arguments.Substring(argumentStart, argumentLength); + splitArguments.Add(argument); + } + + return splitArguments; + } + } +} diff --git a/src/DevChatter.Bot.Infra.Discord/DevChatter.Bot.Infra.Discord.csproj b/src/DevChatter.Bot.Infra.Discord/DevChatter.Bot.Infra.Discord.csproj new file mode 100644 index 00000000..90594152 --- /dev/null +++ b/src/DevChatter.Bot.Infra.Discord/DevChatter.Bot.Infra.Discord.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + + + + + + + + + + + + diff --git a/src/DevChatter.Bot.Infra.Discord/DevChatterBotDiscordModule.cs b/src/DevChatter.Bot.Infra.Discord/DevChatterBotDiscordModule.cs new file mode 100644 index 00000000..ba9b3bf3 --- /dev/null +++ b/src/DevChatter.Bot.Infra.Discord/DevChatterBotDiscordModule.cs @@ -0,0 +1,12 @@ +using Autofac; + +namespace DevChatter.Bot.Infra.Discord +{ + public class DevChatterBotDiscordModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().AsImplementedInterfaces().SingleInstance(); + } + } +} diff --git a/src/DevChatter.Bot.Infra.Discord/DiscordChatClient.cs b/src/DevChatter.Bot.Infra.Discord/DiscordChatClient.cs new file mode 100644 index 00000000..93c993f7 --- /dev/null +++ b/src/DevChatter.Bot.Infra.Discord/DiscordChatClient.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DevChatter.Bot.Core.Data.Model; +using DevChatter.Bot.Core.Events.Args; +using DevChatter.Bot.Core.Systems.Chat; +using DevChatter.Bot.Core.Util; +using DevChatter.Bot.Infra.Discord.Extensions; +using Discord; +using Discord.Commands; +using Discord.WebSocket; + +namespace DevChatter.Bot.Infra.Discord +{ + public class DiscordChatClient : IChatClient + { + private readonly DiscordClientSettings _settings; + private readonly DiscordSocketClient _discordClient; + private TaskCompletionSource _connectionCompletionTask = new TaskCompletionSource(); + private TaskCompletionSource _disconnectionCompletionTask = new TaskCompletionSource(); + private SocketGuild _guild; + private readonly List _guildChannels = new List(); + private ISocketMessageChannel _textChannel; + private bool _isReady; + + public DiscordChatClient(DiscordClientSettings settings) + { + _settings = settings; + _discordClient = new DiscordSocketClient(); + _discordClient.MessageReceived += DiscordClientMessageReceived; + _discordClient.GuildAvailable += DiscordClientGuildAvailable; + _discordClient.GuildUnavailable += DiscordClientGuildUnavailable; + _discordClient.ChannelCreated += DiscordClientChannelCreated; + _discordClient.ChannelDestroyed += DiscordClientChannelDestroyed; + _discordClient.UserJoined += DiscordClientUserJoined; + _discordClient.UserLeft += DiscordClientUserLeft; + } + + private async Task DiscordClientGuildAvailable(SocketGuild guild) + { + _guild = guild; + _guildChannels.AddRange(_guild.Channels.Select(channel => channel.Id)); + _textChannel = _guild.Channels.FirstOrDefault(channel => channel.Id == _settings.DiscordTextChannelId) as ISocketMessageChannel; + _isReady = true; + } + + private async Task DiscordClientGuildUnavailable(SocketGuild guild) + { + _guild = null; + _guildChannels.Clear(); + _isReady = false; + } + + private async Task DiscordClientChannelCreated(SocketChannel newChannel) + { + _guildChannels.Add(newChannel.Id); + } + + private async Task DiscordClientChannelDestroyed(SocketChannel oldChannel) + { + _guildChannels.Remove(oldChannel.Id); + } + + private async Task DiscordClientMessageReceived(SocketMessage arg) + { + var message = arg as SocketUserMessage; + if (message == null) + { + return; + } + + int commandStartIndex = 0; + if (message.HasCharPrefix(_settings.CommandPrefix, ref commandStartIndex)) + { + if (_guildChannels.Contains(message.Channel.Id)) + { + if (arg.Author is IGuildUser guildUser) + { + GuildCommandReceived(guildUser, commandStartIndex, arg.Content); + } + } + else + { + DirectCommandReceieved(arg.Author, commandStartIndex, arg.Content); + } + } + } + + private void GuildCommandReceived(IGuildUser user, int commandStartIndex, string message) + { + var commandInfo = CommandParser.Parse(message, commandStartIndex); + if(string.IsNullOrWhiteSpace(commandInfo.commandWord)) + { + return; + } + + RaiseOnCommandReceived(user, commandInfo.commandWord, commandInfo.arguments); + } + + private void DirectCommandReceieved(IUser user, int commandStartIndex, string message) + { + var commandInfo = CommandParser.Parse(message, commandStartIndex); + if(string.IsNullOrWhiteSpace(commandInfo.commandWord)) + { + return; + } + + // TODO: Do we want to handle direct message commands? + } + + public async Task Connect() + { + _discordClient.Connected += DiscordClientConnected; + await _discordClient.LoginAsync(TokenType.Bot, _settings.DiscordToken).ConfigureAwait(false); + await _discordClient.StartAsync().ConfigureAwait(false); + + await _connectionCompletionTask.Task; + } + + private async Task DiscordClientConnected() + { + _discordClient.Connected -= DiscordClientConnected; + + _connectionCompletionTask?.SetResult(true); + _disconnectionCompletionTask = new TaskCompletionSource(); + } + + public async Task Disconnect() + { + _discordClient.Disconnected += DiscordClientDisconnected; + await _discordClient.LogoutAsync().ConfigureAwait(false); + await _discordClient.StopAsync().ConfigureAwait(false); + + await _disconnectionCompletionTask.Task; + } + + private async Task DiscordClientDisconnected(Exception arg) + { + _discordClient.Disconnected -= DiscordClientDisconnected; + + _disconnectionCompletionTask.SetResult(true); + _connectionCompletionTask = new TaskCompletionSource(); + } + + private async Task DiscordClientUserJoined(SocketGuildUser arg) + { + RaiseOnUserNoticed(arg); + } + + private async Task DiscordClientUserLeft(SocketGuildUser arg) + { + RaiseOnUserLeft(arg); + } + + /// + /// If not connected to a guild + /// + public IList GetAllChatters() + { + if (!_isReady) + { + return new List(); + } + + var chatUsers = _guild.Users.Select(user => user.ToChatUser(_settings)).ToList(); + return chatUsers; + } + + public void SendMessage(string message) + { + if (!_isReady) + { + return; + } + + _textChannel?.SendMessageAsync($"`{message}`").Wait(); + } + + public void SendDirectMessage(string username, string message) + { + if (!_isReady) + { + return; + } + + var discordUser = _guild.Users.FirstOrDefault(u => u.Username == username); + discordUser?.SendMessageAsync(message); + } + + private void RaiseOnCommandReceived(IGuildUser user, string commandWord, List arguments) + { + var eventArgs = new CommandReceivedEventArgs + { + CommandWord = commandWord, + Arguments = arguments ?? new List(), + ChatUser = user.ToChatUser(_settings) + }; + + OnCommandReceived?.Invoke(this, eventArgs); + } + + private void RaiseOnUserNoticed(SocketGuildUser user) + { + var eventArgs = new UserStatusEventArgs + { + DisplayName = user.Username, + Role = user.ToUserRole(_settings) + }; + + OnUserNoticed?.Invoke(this, eventArgs); + } + + private void RaiseOnUserLeft(SocketGuildUser user) + { + var eventArgs = new UserStatusEventArgs + { + DisplayName = user.Username, + Role = user.ToUserRole(_settings) + }; + + OnUserLeft?.Invoke(this, eventArgs); + } + + public event EventHandler OnCommandReceived; + public event EventHandler OnNewSubscriber; + public event EventHandler OnUserNoticed; + public event EventHandler OnUserLeft; + } +} diff --git a/src/DevChatter.Bot.Infra.Discord/DiscordClientSettings.cs b/src/DevChatter.Bot.Infra.Discord/DiscordClientSettings.cs new file mode 100644 index 00000000..868eb043 --- /dev/null +++ b/src/DevChatter.Bot.Infra.Discord/DiscordClientSettings.cs @@ -0,0 +1,12 @@ +namespace DevChatter.Bot.Infra.Discord +{ + public class DiscordClientSettings + { + public string DiscordToken { get; set; } + public ulong DiscordStreamerRoleId { get; set; } + public ulong DiscordModeratorRoleId { get; set; } + public ulong DiscordSubscriberRoleId { get; set; } + public ulong DiscordTextChannelId { get; set; } + public char CommandPrefix { get; set; } + } +} \ No newline at end of file diff --git a/src/DevChatter.Bot.Infra.Discord/Extensions/ModelExtensions.cs b/src/DevChatter.Bot.Infra.Discord/Extensions/ModelExtensions.cs new file mode 100644 index 00000000..b846afae --- /dev/null +++ b/src/DevChatter.Bot.Infra.Discord/Extensions/ModelExtensions.cs @@ -0,0 +1,40 @@ +using System.Linq; +using DevChatter.Bot.Core.Data.Model; +using Discord; + +namespace DevChatter.Bot.Infra.Discord.Extensions +{ + public static class ModelExtensions + { + public static ChatUser ToChatUser(this IGuildUser discordUser, DiscordClientSettings settings) + { + var chatUser = new ChatUser + { + DisplayName = discordUser.Username, + Role = discordUser.ToUserRole(settings) + }; + + return chatUser; + } + + public static UserRole ToUserRole(this IGuildUser discordUser, DiscordClientSettings settings) + { + if (discordUser.RoleIds.Any(role => role == settings.DiscordStreamerRoleId)) + { + return UserRole.Streamer; + } + + if (discordUser.RoleIds.Any(role => role == settings.DiscordModeratorRoleId)) + { + return UserRole.Mod; + } + + if (discordUser.RoleIds.Any(role => role == settings.DiscordSubscriberRoleId)) + { + return UserRole.Subscriber; + } + + return UserRole.Everyone; + } + } +} diff --git a/src/DevChatter.Bot/BotConfiguration.cs b/src/DevChatter.Bot/BotConfiguration.cs index 52ef454f..35ca6c64 100644 --- a/src/DevChatter.Bot/BotConfiguration.cs +++ b/src/DevChatter.Bot/BotConfiguration.cs @@ -1,4 +1,5 @@ using DevChatter.Bot.Core.Events; +using DevChatter.Bot.Infra.Discord; using DevChatter.Bot.Infra.Twitch; namespace DevChatter.Bot @@ -7,6 +8,7 @@ public class BotConfiguration { public string DatabaseConnectionString { get; set; } public TwitchClientSettings TwitchClientSettings { get; set; } + public DiscordClientSettings DiscordClientSettings { get; set; } public CommandHandlerSettings CommandHandlerSettings { get; set; } } } diff --git a/src/DevChatter.Bot/DevChatter.Bot.csproj b/src/DevChatter.Bot/DevChatter.Bot.csproj index dbbdde89..0988dba9 100644 --- a/src/DevChatter.Bot/DevChatter.Bot.csproj +++ b/src/DevChatter.Bot/DevChatter.Bot.csproj @@ -20,6 +20,7 @@ + diff --git a/src/DevChatter.Bot/Startup/SetUpBot.cs b/src/DevChatter.Bot/Startup/SetUpBot.cs index bc48708c..130498fd 100644 --- a/src/DevChatter.Bot/Startup/SetUpBot.cs +++ b/src/DevChatter.Bot/Startup/SetUpBot.cs @@ -2,8 +2,8 @@ using DevChatter.Bot.Core; using DevChatter.Bot.Core.Commands; using DevChatter.Bot.Core.Commands.Trackers; -using DevChatter.Bot.Core.Data; using DevChatter.Bot.Infra.Twitch; +using DevChatter.Bot.Infra.Discord; using System.Linq; namespace DevChatter.Bot.Startup @@ -18,13 +18,11 @@ public static IContainer NewBotDependencyContainer(BotConfiguration botConfigura builder.RegisterModule(); builder.RegisterModule(); + builder.RegisterModule(); builder.Register(ctx => botConfiguration.CommandHandlerSettings).AsSelf().SingleInstance(); builder.Register(ctx => botConfiguration.TwitchClientSettings).AsSelf().SingleInstance(); - - builder.Register(ctx => repository) - .As().SingleInstance(); - + builder.Register(ctx => botConfiguration.DiscordClientSettings).AsSelf().SingleInstance(); var simpleCommands = repository.List(); foreach (var command in simpleCommands) diff --git a/src/DevChatter.Bot/appsettings.json b/src/DevChatter.Bot/appsettings.json index 2f8c6be6..67f2ebf7 100644 --- a/src/DevChatter.Bot/appsettings.json +++ b/src/DevChatter.Bot/appsettings.json @@ -10,6 +10,15 @@ "TwitchClientId": "secret" }, + "DiscordClientSettings": { + "DiscordToken": "secret", + "DiscordStreamerRoleId": "0", + "DiscordModeratorRoleId": "0", + "DiscordSubscriberRoleId": "0", + "DiscordTextChannelId": "0", + "CommandPrefix": "!" + }, + "CommandHandlerSettings": { "GlobalCommandCooldown": "0.5" } diff --git a/src/DevChatterBot.sln b/src/DevChatterBot.sln index 7dba3b56..f5709bd7 100644 --- a/src/DevChatterBot.sln +++ b/src/DevChatterBot.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevChatter.Bot.Infra.Discord", "DevChatter.Bot.Infra.Discord\DevChatter.Bot.Infra.Discord.csproj", "{F76F8BD9-3CFB-47EC-8115-29186E4165D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +46,10 @@ Global {EB61DAF6-52D8-4203-AACA-A896D6DB02CB}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB61DAF6-52D8-4203-AACA-A896D6DB02CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB61DAF6-52D8-4203-AACA-A896D6DB02CB}.Release|Any CPU.Build.0 = Release|Any CPU + {F76F8BD9-3CFB-47EC-8115-29186E4165D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F76F8BD9-3CFB-47EC-8115-29186E4165D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F76F8BD9-3CFB-47EC-8115-29186E4165D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F76F8BD9-3CFB-47EC-8115-29186E4165D9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE