From edfd8d67f089c77d9c6b6eba8426f3cb5319b9fd Mon Sep 17 00:00:00 2001 From: Michael Flaherty Date: Mon, 3 Sep 2018 23:38:18 -0700 Subject: [PATCH] 1.3 Release Rewrite (#17) Fixes #13 --- IRC-Relay/Discord.cs | 402 +++++++++++++++++++++++++++++++++++++ IRC-Relay/Helpers.cs | 207 ------------------- IRC-Relay/IRC-Relay.csproj | 9 +- IRC-Relay/IRC.cs | 174 +++++++--------- IRC-Relay/LogManager.cs | 45 +++-- IRC-Relay/Program.cs | 202 +++---------------- IRC-Relay/Session.cs | 77 +++++++ IRC-Relay/icon.ico | Bin 0 -> 39427 bytes 8 files changed, 619 insertions(+), 497 deletions(-) create mode 100644 IRC-Relay/Discord.cs delete mode 100644 IRC-Relay/Helpers.cs create mode 100644 IRC-Relay/Session.cs create mode 100644 IRC-Relay/icon.ico diff --git a/IRC-Relay/Discord.cs b/IRC-Relay/Discord.cs new file mode 100644 index 0000000..55fab93 --- /dev/null +++ b/IRC-Relay/Discord.cs @@ -0,0 +1,402 @@ +/* Discord IRC Relay - A Discord & IRC bot that relays messages + * + * Copyright (C) 2018 Michael Flaherty // michaelwflaherty.com // michaelwflaherty@me.com + * + * This program 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, either version 3 of the License, or (at your option) + * any later version. + * + * This program 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 + * this program. If not, see http://www.gnu.org/licenses/. + */ + +using System; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; + +using Discord; +using Discord.Commands; +using Discord.Net.Providers.WS4Net; +using Discord.WebSocket; + +using IRCRelay.Logs; +using System.Net; +using Newtonsoft.Json.Linq; +using System.Text.RegularExpressions; +using System.Text; +using System.Collections.Generic; + +namespace IRCRelay +{ + class Discord : IDisposable + { + private Session session; + + private DiscordSocketClient client; + private CommandService commands; + private IServiceProvider services; + private dynamic config; + + public DiscordSocketClient Client { get => client; } + + public Discord(dynamic config, Session session) + { + this.config = config; + this.session = session; + + var socketConfig = new DiscordSocketConfig + { + WebSocketProvider = WS4NetProvider.Instance, + LogLevel = LogSeverity.Critical + }; + + client = new DiscordSocketClient(socketConfig); + commands = new CommandService(); + + client.Log += Log; + + services = new ServiceCollection().BuildServiceProvider(); + + client.MessageReceived += OnDiscordMessage; + client.Connected += OnDiscordConnected; + client.Disconnected += OnDiscordDisconnect; + } + + public async Task SpawnBot() + { + await client.LoginAsync(TokenType.Bot, config.DiscordBotToken); + await client.StartAsync(); + } + + public async Task OnDiscordConnected() + { + await session.Discord.Log(new LogMessage(LogSeverity.Critical, "DiscSpawn", "Discord bot initalized.")); + } + + /* When we disconnect from discord (we got booted off), we'll remake */ + public async Task OnDiscordDisconnect(Exception ex) + { + /* Create a new thread to kill the session. We cannot block + * this Disconnect call */ + new System.Threading.Thread(() => { session.Kill(); }).Start(); + + await Log(new LogMessage(LogSeverity.Critical, "OnDiscordDisconnect", ex.Message)); + } + + public async Task OnDiscordMessage(SocketMessage messageParam) + { + string url = ""; + if (!(messageParam is SocketUserMessage message)) return; + + if (message.Author.Id == client.CurrentUser.Id) return; // block self + + if (!messageParam.Channel.Name.Contains(config.DiscordChannelName)) return; // only relay trough specified channels + + if (config.DiscordUserIDBlacklist != null) //bcompat support + { + /** + * We'll loop blacklisted user ids. If the user ID is found, + * then we return out and prevent the call + */ + foreach (string id in config.DiscordUserIDBlacklist) + { + if (message.Author.Id == ulong.Parse(id)) + { + return; + } + } + } + + string formatted = messageParam.Content; + string text = "```"; + if (formatted.Contains(text)) + { + int start = formatted.IndexOf(text, StringComparison.CurrentCulture); + int end = formatted.IndexOf(text, start + text.Length, StringComparison.CurrentCulture); + + string code = formatted.Substring(start + text.Length, (end - start) - text.Length); + + url = UploadMarkDown(code); + + formatted = formatted.Remove(start, (end - start) + text.Length); + } + + /* Santize discord-specific notation to human readable things */ + formatted = MentionToUsername(formatted, message); + formatted = EmojiToName(formatted, message); + formatted = ChannelMentionToName(formatted, message); + formatted = Unescape(formatted); + + if (config.SpamFilter != null) //bcompat for older configurations + { + foreach (string badstr in config.SpamFilter) + { + if (formatted.ToLower().Contains(badstr.ToLower())) + { + await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": Message with blacklisted input will not be relayed!"); + await messageParam.DeleteAsync(); + return; + } + } + } + + // Send IRC Message + if (formatted.Length > 1000) + { + await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": messages > 1000 characters cannot be successfully transmitted to IRC!"); + await messageParam.DeleteAsync(); + return; + } + + string[] parts = formatted.Split('\n'); + + if (parts.Length > 3) // don't spam IRC, please. + { + await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": Too many lines! If you're meaning to post" + + " code blocks, please use \\`\\`\\` to open & close the codeblock." + + "\nYour message has been deleted and was not relayed to IRC. Please try again."); + await messageParam.DeleteAsync(); + + await messageParam.Author.SendMessageAsync("To prevent you from having to re-type your message," + + " here's what you tried to send: \n ```" + + messageParam.Content + + "```"); + + return; + } + + if (config.IRCLogMessages) + LogManager.WriteLog(MsgSendType.DiscordToIRC, messageParam.Author.Username, formatted, "log.txt"); + + foreach (var attachment in message.Attachments) + { + session.SendMessage(Session.MessageDestination.IRC, attachment.Url, messageParam.Author.Username); + } + + foreach (String part in parts) // we're going to send each line indpependently instead of letting irc clients handle it. + { + if (part.Replace(" ", "").Replace("\n", "").Replace("\t", "").Length != 0) // if the string is not empty or just spaces + { + session.SendMessage(Session.MessageDestination.IRC, part, messageParam.Author.Username); + } + } + + if (!url.Equals("")) // hastebin upload is succesfuly if url contains any data + { + if (config.IRCLogMessages) + LogManager.WriteLog(MsgSendType.DiscordToIRC, messageParam.Author.Username, url, "log.txt"); + + session.SendMessage(Session.MessageDestination.IRC, url, messageParam.Author.Username); + } + } + + public Task Log(LogMessage msg) + { + return Task.Run(() => Console.WriteLine(msg.ToString())); + } + + public void Dispose() + { + client.Dispose(); + } + + /** Helper methods **/ + + public static string UploadMarkDown(string input) + { + using (var client = new WebClient()) + { + client.Headers[HttpRequestHeader.ContentType] = "text/plain"; + + var response = client.UploadString("https://hastebin.com/documents", input); + JObject obj = JObject.Parse(response); + + if (!obj.HasValues) + { + return ""; + } + + string key = (string)obj["key"]; + string hasteUrl = "https://hastebin.com/" + key + ".cs"; + + return hasteUrl; + } + } + public static string MentionToUsername(string input, SocketUserMessage message) + { + Regex regex = new Regex("<@!?([0-9]+)>"); // create patern + + var m = regex.Matches(input); // find all matches + var itRegex = m.GetEnumerator(); // lets iterate matches + var itUsers = message.MentionedUsers.GetEnumerator(); // iterate mentions, too + int difference = 0; // will explain later + while (itUsers.MoveNext() && itRegex.MoveNext()) // we'll loop iterators together + { + var match = (Match)itRegex.Current; // C# makes us cast here.. gross + var user = itUsers.Current; + int len = match.Length; + int start = match.Index; + string removal = input.Substring(start - difference, len); // seperate what we're trying to replace + + /** + * Since we're replacing `input` after every iteration, we have to + * store the difference in length after our edits. This is because that + * the Match object is going to use lengths from before the replacments + * occured. Thus, we add the length and then subtract after the replace + */ + difference += input.Length; + input = ReplaceFirst(input, removal, user.Username); + difference -= input.Length; + } + + return input; + } + + public static string Unescape(string input) + { + /* Main StringBuilder for messages that aren't in '`' */ + StringBuilder sb = new StringBuilder(); + + /* + * locations - List of indices where the first '`' lies + * peices - List of strings which live inbetween the '`'s + */ + List locations = new List(); + List peices = new List(); + for (int i = 0; i < input.Length; i++) + { + if (input[i] == '`') // we hit a '`' + { + int j; + + StringBuilder slice = new StringBuilder(); // used for capturing the str inbetween '`' + slice.Append('`'); // append the '`' for insertion later + + /* we'll loop from here until we encounter the next '`', + * appending as we go. + */ + for (j = i + 1; j < input.Length && input[j] != '`'; j++) + { + slice.Append(input[j]); + } + + if (j < input.Length) + slice.Append('`'); // append the '`' for insertion later + + locations.Add(i); // push the index of the first '`' + peices.Add(slice); // push the captured string + + i = j; // advance the outer loop to where our inner one stopped + } + else // we didn't hit a '`', so just append :) + { + sb.Append(input[i]); + } + } + + // From here we prep the return string by doing our regex on the input that's not in '`' + string retstr = Regex.Replace(sb.ToString(), @"\\([^A-Za-z0-9])", "$1"); + + // Now we'll just loop the peices, inserting @ the locations we saved earlier + for (int i = 0; i < peices.Count; i++) + { + retstr = retstr.Insert(locations[i], peices[i].ToString()); + } + + return retstr; // thank fuck we're done + } + + public static string ChannelMentionToName(string input, SocketUserMessage message) + { + Regex regex = new Regex("<#([0-9]+)>"); // create patern + + var m = regex.Matches(input); // find all matches + var itRegex = m.GetEnumerator(); // lets iterate matches + var itChan = message.MentionedChannels.GetEnumerator(); // iterate mentions, too + int difference = 0; // will explain later + while (itChan.MoveNext() && itRegex.MoveNext()) // we'll loop iterators together + { + var match = (Match)itRegex.Current; // C# makes us cast here.. gross + var channel = itChan.Current; + int len = match.Length; + int start = match.Index; + string removal = input.Substring(start - difference, len); // seperate what we're trying to replace + + /** + * Since we're replacing `input` after every iteration, we have to + * store the difference in length after our edits. This is because that + * the Match object is going to use lengths from before the replacments + * occured. Thus, we add the length and then subtract after the replace + */ + difference += input.Length; + input = ReplaceFirst(input, removal, "#" + channel.Name); + difference -= input.Length; + } + + return input; + } + + public static string ReplaceFirst(string text, string search, string replace) + { + int pos = text.IndexOf(search); + if (pos < 0) + { + return text; + } + return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); + } + + // Converts <:emoji:23598052306> to :emoji: + public static string EmojiToName(string input, SocketUserMessage message) + { + string returnString = input; + + Regex regex = new Regex("<[A-Za-z0-9-_]?:[A-Za-z0-9-_]+:[0-9]+>"); + Match match = regex.Match(input); + if (match.Success) // contains a emoji + { + string substring = input.Substring(match.Index, match.Length); + string[] sections = substring.Split(':'); + + returnString = input.Replace(substring, ":" + sections[1] + ":"); + } + + return returnString; + } + + public void SendMessageAllToTarget(string targetGuild, string message, string targetChannel) + { + foreach (SocketGuild guild in Client.Guilds) // loop through each discord guild + { + if (guild.Name.ToLower().Contains(targetGuild.ToLower())) // find target + { + SocketTextChannel channel = FindChannel(guild, targetChannel); // find desired channel + + if (channel != null) // target exists + { + channel.SendMessageAsync(message); + } + } + } + } + + public static SocketTextChannel FindChannel(SocketGuild guild, string text) + { + foreach (SocketTextChannel channel in guild.TextChannels) + { + if (channel.Name.Contains(text)) + { + return channel; + } + } + + return null; + } + } +} diff --git a/IRC-Relay/Helpers.cs b/IRC-Relay/Helpers.cs deleted file mode 100644 index 11ce3bc..0000000 --- a/IRC-Relay/Helpers.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Net; -using System.Linq; -using Newtonsoft.Json.Linq; - -using System.Text.RegularExpressions; -using System.Collections.Generic; - -using Discord.WebSocket; -using System.Text; - -namespace IRCRelay -{ - class Helpers - { - public static string UploadMarkDown(string input) - { - using (var client = new WebClient()) - { - client.Headers[HttpRequestHeader.ContentType] = "text/plain"; - - var response = client.UploadString("https://hastebin.com/documents", input); - JObject obj = JObject.Parse(response); - - if (!obj.HasValues) - { - return ""; - } - - string key = (string)obj["key"]; - string hasteUrl = "https://hastebin.com/" + key + ".cs"; - - return hasteUrl; - } - } - - public static string MentionToUsername(string input, SocketUserMessage message) - { - Regex regex = new Regex("<@!?([0-9]+)>"); // create patern - - var m = regex.Matches(input); // find all matches - var itRegex = m.GetEnumerator(); // lets iterate matches - var itUsers = message.MentionedUsers.GetEnumerator(); // iterate mentions, too - int difference = 0; // will explain later - while (itUsers.MoveNext() && itRegex.MoveNext()) // we'll loop iterators together - { - var match = (Match)itRegex.Current; // C# makes us cast here.. gross - var user = itUsers.Current; - int len = match.Length; - int start = match.Index; - string removal = input.Substring(start - difference, len); // seperate what we're trying to replace - - /** - * Since we're replacing `input` after every iteration, we have to - * store the difference in length after our edits. This is because that - * the Match object is going to use lengths from before the replacments - * occured. Thus, we add the length and then subtract after the replace - */ - difference += input.Length; - input = ReplaceFirst(input, removal, user.Username); - difference -= input.Length; - } - - return input; - } - - public static string Unescape(string input) - { - /* Main StringBuilder for messages that aren't in '`' */ - StringBuilder sb = new StringBuilder(); - - /* - * locations - List of indices where the first '`' lies - * peices - List of strings which live inbetween the '`'s - */ - List locations = new List(); - List peices = new List(); - for (int i = 0; i < input.Length; i++) - { - if (input[i] == '`') // we hit a '`' - { - int j; - - StringBuilder slice = new StringBuilder(); // used for capturing the str inbetween '`' - slice.Append('`'); // append the '`' for insertion later - - /* we'll loop from here until we encounter the next '`', - * appending as we go. - */ - for (j = i+1; j < input.Length && input[j] != '`'; j++) { - slice.Append(input[j]); - } - - if (j < input.Length) - slice.Append('`'); // append the '`' for insertion later - - locations.Add(i); // push the index of the first '`' - peices.Add(slice); // push the captured string - - i = j; // advance the outer loop to where our inner one stopped - } - else // we didn't hit a '`', so just append :) - { - sb.Append(input[i]); - } - } - - // From here we prep the return string by doing our regex on the input that's not in '`' - string retstr = Regex.Replace(sb.ToString(), @"\\([^A-Za-z0-9])", "$1"); - - // Now we'll just loop the peices, inserting @ the locations we saved earlier - for (int i = 0; i < peices.Count; i++) - { - retstr = retstr.Insert(locations[i], peices[i].ToString()); - } - - return retstr; // thank fuck we're done - } - - public static string ChannelMentionToName(string input, SocketUserMessage message) - { - Regex regex = new Regex("<#([0-9]+)>"); // create patern - - var m = regex.Matches(input); // find all matches - var itRegex = m.GetEnumerator(); // lets iterate matches - var itChan = message.MentionedChannels.GetEnumerator(); // iterate mentions, too - int difference = 0; // will explain later - while (itChan.MoveNext() && itRegex.MoveNext()) // we'll loop iterators together - { - var match = (Match)itRegex.Current; // C# makes us cast here.. gross - var channel = itChan.Current; - int len = match.Length; - int start = match.Index; - string removal = input.Substring(start - difference, len); // seperate what we're trying to replace - - /** - * Since we're replacing `input` after every iteration, we have to - * store the difference in length after our edits. This is because that - * the Match object is going to use lengths from before the replacments - * occured. Thus, we add the length and then subtract after the replace - */ - difference += input.Length; - input = ReplaceFirst(input, removal, "#" + channel.Name); - difference -= input.Length; - } - - return input; - } - - public static string ReplaceFirst(string text, string search, string replace) - { - int pos = text.IndexOf(search); - if (pos < 0) - { - return text; - } - return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); - } - - // Converts <:emoji:23598052306> to :emoji: - public static string EmojiToName(string input, SocketUserMessage message) - { - string returnString = input; - - Regex regex = new Regex("<[A-Za-z0-9-_]?:[A-Za-z0-9-_]+:[0-9]+>"); - Match match = regex.Match(input); - if (match.Success) // contains a emoji - { - string substring = input.Substring(match.Index, match.Length); - string[] sections = substring.Split(':'); - - returnString = input.Replace(substring, ":" + sections[1] + ":"); - } - - return returnString; - } - - public static void SendMessageAllToTarget(string targetGuild, string message, string targetChannel) - { - foreach (SocketGuild guild in Program.Instance.client.Guilds) // loop through each discord guild - { - - if (guild.Name.ToLower().Contains(targetGuild.ToLower())) // find target - { - SocketTextChannel channel = FindChannel(guild, targetChannel); // find desired channel - - if (channel != null) // target exists - { - channel.SendMessageAsync(message); - } - } - } - } - - public static SocketTextChannel FindChannel(SocketGuild guild, string text) - { - foreach (SocketTextChannel channel in guild.TextChannels) - { - if (channel.Name.Contains(text)) - { - return channel; - } - } - - return null; - } - } -} diff --git a/IRC-Relay/IRC-Relay.csproj b/IRC-Relay/IRC-Relay.csproj index 378460d..e121de9 100644 --- a/IRC-Relay/IRC-Relay.csproj +++ b/IRC-Relay/IRC-Relay.csproj @@ -31,6 +31,9 @@ prompt 4 + + icon.ico + ..\packages\Discord.Net.Commands.1.0.2\lib\netstandard1.1\Discord.Net.Commands.dll @@ -96,17 +99,21 @@ - + + + + + diff --git a/IRC-Relay/IRC.cs b/IRC-Relay/IRC.cs index 1d6606c..77cd08b 100644 --- a/IRC-Relay/IRC.cs +++ b/IRC-Relay/IRC.cs @@ -1,144 +1,116 @@ -using System; +/* Discord IRC Relay - A Discord & IRC bot that relays messages + * + * Copyright (C) 2018 Michael Flaherty // michaelwflaherty.com // michaelwflaherty@me.com + * + * This program 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, either version 3 of the License, or (at your option) + * any later version. + * + * This program 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 + * this program. If not, see http://www.gnu.org/licenses/. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Text.RegularExpressions; using Meebey.SmartIrc4net; -using System.Threading; -using System.Timers; + using IRCRelay.Logs; -using System.Text.RegularExpressions; -using System.Collections.Generic; +using Discord; namespace IRCRelay { public class IRC { + private Session session; + private dynamic config; private IrcClient ircClient; - private System.Timers.Timer timer = null; - - private string server; - private int port; - private string nick; - private string channel; - private string loginName; - private string authstring; - private string authuser; - private string targetGuild; - private string targetChannel; - - private bool logMessages; - private Object[] blacklistNames; - - public IRC(string server, int port, string nick, string channel, string loginName, - string authstring, string authuser, string targetGuild, string targetChannel, bool logMessages, Object[] blacklistNames) + public bool shouldrun; + + public IrcClient Client { get => ircClient; set => ircClient = value; } + + public IRC(dynamic config, Session session) { - ircClient = new IrcClient(); + this.config = config; + this.session = session; + + ircClient = new IrcClient + { + Encoding = System.Text.Encoding.UTF8, + SendDelay = 50, - ircClient.Encoding = System.Text.Encoding.UTF8; - ircClient.SendDelay = 200; + ActiveChannelSyncing = true, - ircClient.ActiveChannelSyncing = true; - - ircClient.AutoRetry = true; - ircClient.AutoRejoin = true; - ircClient.AutoRelogin = true; - ircClient.AutoRejoinOnKick = true; + AutoRetry = true, + AutoRejoin = true, + AutoRelogin = true, + AutoRejoinOnKick = true + }; + ircClient.OnConnected += OnConnected; ircClient.OnError += this.OnError; ircClient.OnChannelMessage += this.OnChannelMessage; - ircClient.OnDisconnected += this.OnDisconnected; - - timer = new System.Timers.Timer(); - - timer.Elapsed += Timer_Callback; - - timer.Enabled = true; - timer.AutoReset = true; - timer.Interval = TimeSpan.FromSeconds(30.0).TotalMilliseconds; - - /* Connection Info */ - this.server = server; - this.port = port; - this.nick = nick; - this.channel = channel; - this.loginName = loginName; - this.authstring = authstring; - this.authuser = authuser; - this.targetGuild = targetGuild; - this.targetChannel = targetChannel; - this.logMessages = logMessages; - this.blacklistNames = blacklistNames; } public void SendMessage(string username, string message) { - ircClient.SendMessage(SendType.Message, channel, "<" + username + "> " + message); + ircClient.SendMessage(SendType.Message, config.IRCChannel, "<" + username + "> " + message); } - public void SpawnBot() + public async Task SpawnBot() { - new Thread(() => + shouldrun = true; + await Task.Run(() => { - try - { - ircClient.Connect(server, port); - - ircClient.Login(nick, loginName); - - if (authstring.Length != 0) - { - ircClient.SendMessage(SendType.Message, authuser, authstring); - - Thread.Sleep(1000); // login delay - } + ircClient.Connect(config.IRCServer, config.IRCPort); - ircClient.RfcJoin(channel); + ircClient.Login(config.IRCNick, config.IRCLoginName); - ircClient.Listen(); - - timer.Start(); - } - catch (Exception ex) + if (config.IRCAuthString.Length != 0) { - Console.WriteLine(ex.Message); - return; + ircClient.SendMessage(SendType.Message, config.IRCAuthUser, config.IRCAuthString); + + Thread.Sleep(1000); // login delay } - }).Start(); + ircClient.RfcJoin(config.IRCChannel); + ircClient.Listen(); + }); } - private void Timer_Callback(Object source, ElapsedEventArgs e) + private void OnConnected(object sender, EventArgs e) { - if (ircClient.IsConnected) - { - return; - } - - Console.WriteLine("Bot disconnected! Retrying..."); - this.SpawnBot(); + session.Discord.Log(new LogMessage(LogSeverity.Critical, "IRCSpawn", "IRC bot initalized.")); } - private void OnDisconnected(object sender, EventArgs e) + private void OnError(object sender, ErrorEventArgs e) { - Console.WriteLine("Disconnecting"); - } + /* Create a new thread to kill the session. We cannot block + * this Disconnect call */ + new System.Threading.Thread(() => { session.Kill(); }).Start(); - private void OnError(object sender, Meebey.SmartIrc4net.ErrorEventArgs e) - { - Console.WriteLine("Error: " + e.ErrorMessage); - Environment.Exit(0); + session.Discord.Log(new LogMessage(LogSeverity.Critical, "IRCOnError", e.ErrorMessage)); } private void OnChannelMessage(object sender, IrcEventArgs e) { - if (e.Data.Nick.Equals(this.nick)) + if (e.Data.Nick.Equals(this.config.IRCNick)) return; - if (blacklistNames != null) // bcompat support + if (config.IRCNameBlacklist != null) // bcompat support { /** * We'll loop all blacklisted names, if the sender * has a blacklisted name, we won't relay and ret out */ - foreach (string name in blacklistNames) + foreach (string name in config.IRCNameBlacklist) { if (e.Data.Nick.Equals(name)) { @@ -147,7 +119,7 @@ private void OnChannelMessage(object sender, IrcEventArgs e) } } - if (logMessages) + if (config.IRCLogMessages) LogManager.WriteLog(MsgSendType.IRCToDiscord, e.Data.Nick, e.Data.Message, "log.txt"); string msg = e.Data.Message; @@ -158,7 +130,7 @@ private void OnChannelMessage(object sender, IrcEventArgs e) string prefix = ""; - var usr = e.Data.Irc.GetChannelUser(channel, e.Data.Nick); + var usr = e.Data.Irc.GetChannelUser(config.IRCChannel, e.Data.Nick); if (usr.IsOp) { prefix = "@"; @@ -168,19 +140,19 @@ private void OnChannelMessage(object sender, IrcEventArgs e) prefix = "+"; } - if (Program.config.SpamFilter != null) //bcompat for older configurations + if (config.SpamFilter != null) //bcompat for older configurations { - foreach (string badstr in Program.config.SpamFilter) + foreach (string badstr in config.SpamFilter) { if (msg.ToLower().Contains(badstr.ToLower())) { - ircClient.SendMessage(SendType.Message, channel, "Message with blacklisted input will not be relayed!"); + ircClient.SendMessage(SendType.Message, config.IRCChannel, "Message with blacklisted input will not be relayed!"); return; } } } - Helpers.SendMessageAllToTarget(targetGuild, "**<" + prefix + Regex.Escape(e.Data.Nick) + ">** " + msg, targetChannel); + session.SendMessage(Session.MessageDestination.Discord, "**<" + prefix + Regex.Escape(e.Data.Nick) + ">** " + msg); } } } diff --git a/IRC-Relay/LogManager.cs b/IRC-Relay/LogManager.cs index d86bedc..650fc2c 100644 --- a/IRC-Relay/LogManager.cs +++ b/IRC-Relay/LogManager.cs @@ -1,11 +1,23 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; +/* Discord IRC Relay - A Discord & IRC bot that relays messages + * + * Copyright (C) 2018 Michael Flaherty // michaelwflaherty.com // michaelwflaherty@me.com + * + * This program 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, either version 3 of the License, or (at your option) + * any later version. + * + * This program 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 + * this program. If not, see http://www.gnu.org/licenses/. + */ + +using System; using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Globalization; namespace IRCRelay.Logs { @@ -22,32 +34,33 @@ public static void WriteLog(MsgSendType type, string name, string message, strin string prefix; if (type == MsgSendType.DiscordToIRC) { - Console.ForegroundColor = ConsoleColor.Cyan; - prefix = "Discord -> IRC"; + prefix = "[Discord]"; } else { - Console.ForegroundColor = ConsoleColor.Green; - prefix = "IRC -> Discord"; + prefix = "[IRC]"; } + Console.ForegroundColor = ConsoleColor.Green; + Console.Write(prefix); + Console.ForegroundColor = ConsoleColor.White; + Console.Write(" <{0}>", name); + Console.ForegroundColor = ConsoleColor.Gray; + Console.WriteLine(" {0}", message); + try { string date = "[" + DateTime.Now.ToString(new CultureInfo("en-US")) + "]"; - string logMessage = string.Format("{0} {1} <{2}> {3}", date, prefix, name, message); using (StreamWriter stream = new StreamWriter(AppDomain.CurrentDomain.BaseDirectory + filename, true)) { stream.WriteLine(logMessage); } - - Console.WriteLine(logMessage); } catch (Exception ex) { - Console.WriteLine("Error Writing To File: ", ex.Message); + Console.WriteLine("Error Writing To File: {0}", ex.Message); } - Console.ForegroundColor = ConsoleColor.White; } } } diff --git a/IRC-Relay/Program.cs b/IRC-Relay/Program.cs index 969d366..06787eb 100644 --- a/IRC-Relay/Program.cs +++ b/IRC-Relay/Program.cs @@ -1,188 +1,46 @@ -using System; -using System.Threading.Tasks; - -using Discord; -using Discord.WebSocket; -using Discord.Commands; - -using Microsoft.Extensions.DependencyInjection; +/* Discord IRC Relay - A Discord & IRC bot that relays messages + * + * Copyright (C) 2018 Michael Flaherty // michaelwflaherty.com // michaelwflaherty@me.com + * + * This program 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, either version 3 of the License, or (at your option) + * any later version. + * + * This program 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 + * this program. If not, see http://www.gnu.org/licenses/. + */ + +using System; +using System.IO; -using IRCRelay.Logs; -using Discord.Net.Providers.WS4Net; +using System.Threading.Tasks; using JsonConfig; -using System.IO; namespace IRCRelay { - class Program + public class Program { - public static Program Instance; //Entry to access DiscordSocketClient for Helpers.cs - public DiscordSocketClient client; - - /* Instance Vars */ - private IRC irc; - private CommandService commands; - private IServiceProvider services; - public static dynamic config; - public static void Main(string[] args) { - var stream = new StreamReader("settings.json"); - config = Config.ApplyJson(stream.ReadToEnd(), new ConfigObject()); - - Instance = new Program(); - - Instance.MainAsync().GetAwaiter().GetResult(); - } - - private async Task MainAsync() - { - var socketConfig = new DiscordSocketConfig - { - WebSocketProvider = WS4NetProvider.Instance, - LogLevel = LogSeverity.Verbose - }; - - client = new DiscordSocketClient(socketConfig); - commands = new CommandService(); - - client.Log += Log; - - services = new ServiceCollection().BuildServiceProvider(); - - client.MessageReceived += OnDiscordMessage; - - await client.LoginAsync(TokenType.Bot, config.DiscordBotToken); - await client.StartAsync(); + Console.Title = "Discord IRC Relay (c) Michael Flaherty 2018"; + var config = Config.ApplyJson(new StreamReader("settings.json").ReadToEnd(), new ConfigObject()); - irc = new IRC(config.IRCServer, - config.IRCPort, - config.IRCNick, - config.IRCChannel, - config.IRCLoginName, - config.IRCAuthString, - config.IRCAuthUser, - config.DiscordGuildName, - config.DiscordChannelName, - config.IRCLogMessages, - config.IRCNameBlacklist); - - irc.SpawnBot(); - - await Task.Delay(-1); + StartSessions(config).GetAwaiter().GetResult(); } - public async Task OnDiscordMessage(SocketMessage messageParam) + private static async Task StartSessions(dynamic config) { - string url = ""; - var message = messageParam as SocketUserMessage; - if (message == null) return; - - if (message.Author.Id == client.CurrentUser.Id) return; // block self - - if (!messageParam.Channel.Name.Contains(config.DiscordChannelName)) return; // only relay trough specified channels - - if (config.DiscordUserIDBlacklist != null) //bcompat support - { - /** - * We'll loop blacklisted user ids. If the user ID is found, - * then we return out and prevent the call - */ - foreach (string id in config.DiscordUserIDBlacklist) - { - if (message.Author.Id == ulong.Parse(id)) - { - return; - } - } - } - - string formatted = messageParam.Content; - string text = "```"; - if (formatted.Contains(text)) - { - int start = formatted.IndexOf(text, StringComparison.CurrentCulture); - int end = formatted.IndexOf(text, start + text.Length, StringComparison.CurrentCulture); - - string code = formatted.Substring(start + text.Length, (end - start) - text.Length); - - url = Helpers.UploadMarkDown(code); - - formatted = formatted.Remove(start, (end - start) + text.Length); - } - - /* Santize discord-specific notation to human readable things */ - formatted = Helpers.MentionToUsername(formatted, message); - formatted = Helpers.EmojiToName(formatted, message); - formatted = Helpers.ChannelMentionToName(formatted, message); - formatted = Helpers.Unescape(formatted); - - if (config.SpamFilter != null) //bcompat for older configurations - { - foreach (string badstr in config.SpamFilter) - { - if (formatted.ToLower().Contains(badstr.ToLower())) - { - await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": Message with blacklisted input will not be relayed!"); - await messageParam.DeleteAsync(); - return; - } - } - } - - // Send IRC Message - if (formatted.Length > 1000) - { - await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": messages > 1000 characters cannot be successfully transmitted to IRC!"); - await messageParam.DeleteAsync(); - return; - } - - string[] parts = formatted.Split('\n'); - - if (parts.Length > 3) // don't spam IRC, please. + Session session = new Session(config); + do { - await messageParam.Channel.SendMessageAsync(messageParam.Author.Mention + ": Too many lines! If you're meaning to post" + - " code blocks, please use \\`\\`\\` to open & close the codeblock." + - "\nYour message has been deleted and was not relayed to IRC. Please try again."); - await messageParam.DeleteAsync(); - - await messageParam.Author.SendMessageAsync("To prevent you from having to re-type your message," - + " here's what you tried to send: \n ```" - + messageParam.Content - + "```"); - - return; - } - - if (config.IRCLogMessages) - LogManager.WriteLog(MsgSendType.DiscordToIRC, messageParam.Author.Username, formatted, "log.txt"); - - foreach (var attachment in message.Attachments) - { - irc.SendMessage(messageParam.Author.Username, attachment.Url); - } - - foreach (String part in parts) // we're going to send each line indpependently instead of letting irc clients handle it. - { - if (part.Replace(" ", "").Replace("\n", "").Replace("\t", "").Length != 0) // if the string is not empty or just spaces - { - irc.SendMessage(messageParam.Author.Username, part); - } - } - - if (!url.Equals("")) // hastebin upload is succesfuly if url contains any data - { - if (config.IRCLogMessages) - LogManager.WriteLog(MsgSendType.DiscordToIRC, messageParam.Author.Username, url, "log.txt"); - - irc.SendMessage(messageParam.Author.Username, url); - } - } - - public Task Log(LogMessage msg) - { - return Task.Run(() => Console.WriteLine(msg.ToString())); + await session.StartSession(); + Console.WriteLine("Session failure... New session starting."); + } while (!session.IsAlive); } } } diff --git a/IRC-Relay/Session.cs b/IRC-Relay/Session.cs new file mode 100644 index 0000000..558a4c9 --- /dev/null +++ b/IRC-Relay/Session.cs @@ -0,0 +1,77 @@ +/* Discord IRC Relay - A Discord & IRC bot that relays messages + * + * Copyright (C) 2018 Michael Flaherty // michaelwflaherty.com // michaelwflaherty@me.com + * + * This program 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, either version 3 of the License, or (at your option) + * any later version. + * + * This program 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 + * this program. If not, see http://www.gnu.org/licenses/. + */ + +using System.Threading.Tasks; +using Discord; + +namespace IRCRelay +{ + public class Session + { + public enum MessageDestination { + Discord, + IRC + }; + + private Discord discord; + private IRC irc; + private dynamic config; + private bool alive; + + public bool IsAlive { get => alive; } + public IRC Irc { get => irc; } + internal Discord Discord { get => discord; } + + public Session(dynamic config) + { + this.config = config; + alive = true; + } + + public void Kill() + { + discord.Dispose(); + irc.Client.RfcQuit(); + + Discord.Log(new LogMessage(LogSeverity.Critical, "KillSesh", "Session killed.")); + this.alive = false; + } + + public async Task StartSession() + { + this.discord = new Discord(config, this); + this.irc = new IRC(config, this); + + await Discord.Log(new LogMessage(LogSeverity.Critical, "StartSesh", "Session started.")); + await discord.SpawnBot(); + await irc.SpawnBot(); + } + + public void SendMessage(MessageDestination dest, string message, string username = "") + { + switch (dest) + { + case MessageDestination.Discord: + discord.SendMessageAllToTarget(config.DiscordGuildName, message, config.DiscordChannelName); + break; + case MessageDestination.IRC: + irc.SendMessage(username, message); + break; + } + } + } +} diff --git a/IRC-Relay/icon.ico b/IRC-Relay/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1ed8ea73f3b02d3368c1454968458dbb8f310a78 GIT binary patch literal 39427 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 literal 0 HcmV?d00001