diff --git a/HacVersionListBot/HacVersionListBot.sln b/HacVersionListBot/HacVersionListBot.sln new file mode 100644 index 0000000..83b226b --- /dev/null +++ b/HacVersionListBot/HacVersionListBot.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25123.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HacVersionListBot", "HacVersionListBot\HacVersionListBot.csproj", "{BCFB87CC-64A3-4C2D-B472-18354CC57358}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BCFB87CC-64A3-4C2D-B472-18354CC57358}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCFB87CC-64A3-4C2D-B472-18354CC57358}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCFB87CC-64A3-4C2D-B472-18354CC57358}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCFB87CC-64A3-4C2D-B472-18354CC57358}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/HacVersionListBot/HacVersionListBot/App.config b/HacVersionListBot/HacVersionListBot/App.config new file mode 100644 index 0000000..88fa402 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/HacVersionListBot/HacVersionListBot/DiscordUtils.cs b/HacVersionListBot/HacVersionListBot/DiscordUtils.cs new file mode 100644 index 0000000..5779f88 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/DiscordUtils.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using HacVersionListBot.Properties; + +namespace HacVersionListBot +{ + + + class DiscordUtils + { + private static readonly string discord_webhook_url = Resources.DiscordWebhook; + + public static void SendMessage(string message, byte[] version_list, string filename) + { + if (!discord_webhook_url.StartsWith("http")) + { + Program.Log($"Discord webhook not set up."); + return; + } + try + { + using (var form = new MultipartFormDataContent()) + using (var httpC = new HttpClient()) + { + + form.Add(new StringContent(message), "content"); + form.Add(new StringContent(filename), "Filename"); + form.Add(new ByteArrayContent(version_list, 0, version_list.Length), "hac_versionlist", filename); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var response = httpC.PostAsync(new Uri(discord_webhook_url), form, cts.Token).Result; + Program.Log(response.ToString()); + } + } + catch (WebException wex) + { + Program.Log($"Failed to post to discord: {wex.Message}"); + } + } + } +} diff --git a/HacVersionListBot/HacVersionListBot/HacVersionListBot.csproj b/HacVersionListBot/HacVersionListBot/HacVersionListBot.csproj new file mode 100644 index 0000000..7f8f161 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/HacVersionListBot.csproj @@ -0,0 +1,86 @@ + + + + + Debug + AnyCPU + {BCFB87CC-64A3-4C2D-B472-18354CC57358} + Exe + Properties + HacVersionListBot + HacVersionListBot + v4.5.2 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\DSharpPlus.2.5.4\lib\net45\DSharpPlus.dll + True + + + ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + True + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + Designer + + + + + \ No newline at end of file diff --git a/HacVersionListBot/HacVersionListBot/NetworkUtils.cs b/HacVersionListBot/HacVersionListBot/NetworkUtils.cs new file mode 100644 index 0000000..5dbfe08 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/NetworkUtils.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using HacVersionListBot.Properties; + + +namespace HacVersionListBot +{ + class NetworkUtils + { + private static X509Certificate ShopNCert = new X509Certificate(Resources.ShopN, Resources.ShopN_Password); + private static X509Certificate ConsoleCert = new X509Certificate(Resources.ConsoleCert, Resources.ConsoleCert_Password); + + public static byte[] TryDownload(string file) + { + try + { + return new WebClient().DownloadData(file); + } + catch (WebException) + { + Program.Log($"Failed to download {file}."); + return null; + } + } + + public static byte[] TryCertifiedDownload(string file, X509Certificate x509, string userAgent = "") + { + try + { + return new CertificateWebClient(x509, userAgent).DownloadData(file); + } + catch (WebException) + { + Program.Log($"Failed to download {file}."); + return null; + } + } + + public static byte[] TryConsoleDownload(string file) + { + return TryCertifiedDownload(file, ConsoleCert); + } + + public static string TryMakeCertifiedRequest(string URL, X509Certificate clientCert, bool json = true, string userAgent = "") + { + var wr = WebRequest.Create(new Uri(URL)) as HttpWebRequest; + wr.UserAgent = userAgent; + if (json) + wr.Accept = "application/json"; + wr.Method = WebRequestMethods.Http.Get; + wr.ClientCertificates.Clear(); + wr.ClientCertificates.Add(clientCert); + try + { + using (var resp = wr.GetResponse() as HttpWebResponse) + { + return new StreamReader(resp.GetResponseStream()).ReadToEnd().Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t"); + } + } + catch (WebException ex) + { + Program.Log($"Error: Failed to make request to {URL} -- WebException {ex.Message}."); + } + catch (NullReferenceException nex) + { + Program.Log($"Error: Failed to make request to {URL} -- NullReferenceException {nex.Message}."); + } + return null; + } + + public static string TryMakeShogunRequest(string URL) + { + return TryMakeCertifiedRequest(URL, ShopNCert); + } + + public static string TryMakeConsoleRequest(string URL) + { + return TryMakeCertifiedRequest(URL, ConsoleCert); + } + + private class CertificateWebClient : WebClient + { + private X509Certificate client_cert; + private readonly string user_agent; + + public CertificateWebClient(X509Certificate cert, string ua = "") : base() + { + client_cert = cert; + user_agent = ua; + } + + protected override WebRequest GetWebRequest(Uri address) + { + var request = (HttpWebRequest)WebRequest.Create(address); + request.ClientCertificates.Clear(); + request.ClientCertificates.Add(client_cert); + request.UserAgent = user_agent; + return request; + } + } + } +} diff --git a/HacVersionListBot/HacVersionListBot/Program.cs b/HacVersionListBot/HacVersionListBot/Program.cs new file mode 100644 index 0000000..16da141 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/Program.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; + +using Newtonsoft.Json; + +namespace HacVersionListBot +{ + class Program + { + private static DateTime now = DateTime.Now; + private static bool keep_log; + private static StreamWriter log; + + private const string version_url = "https://tagaya.hac.lp1.eshop.nintendo.net/tagaya/hac_versionlist"; + + private const int format_version = 1; + + private static Dictionary title_names = new Dictionary(); + + public static string TryGetTitleName(string title_id) + { + if (title_names.ContainsKey(title_id)) + return title_names[title_id]; + var server_name = ShogunUtils.GetTitleName(title_id); + if (server_name == null) return "?"; + // Cache the new name. + title_names[title_id] = server_name; + return server_name; + } + + public static void LoadTitleNames() + { + try + { + var cache = File.ReadAllLines("known.txt"); + for (var i = 0; i < cache.Length; i += 2) + { + var title_id = cache[i]; + var title_name = cache[i + 1]; + title_names[title_id] = title_name; + } + } + catch (Exception ex) + { + Log($"Failed to read title name cache: {ex.Message}"); + } + } + + public static void SaveTitleNames() + { + try + { + var cache = new List(); + foreach (var title_id in title_names.Keys) + { + cache.Add(title_id); + cache.Add(title_names[title_id]); + } + File.WriteAllLines("known.txt", cache); + } + catch (Exception ex) + { + Log($"Failed to save title name cache: {ex.Message}"); + } + } + + public static void Log(string msg) + { + Console.WriteLine(msg); + log.WriteLine(msg); + } + + public static void CreateDirectoryIfNull(string dir) + { + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + } + + + + static void Main(string[] args) + { + CreateDirectoryIfNull("logs"); + CreateDirectoryIfNull("data"); + + var log_file = $"logs/{now.ToString("MMMM dd, yyyy - HH-mm-ss")}.log"; + log = new StreamWriter(log_file, false, Encoding.UTF8); + + + Log("HacVersionListBot v1.1 - SciresM"); + Log($"{now.ToString("MMMM dd, yyyy - HH-mm-ss")}"); + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls; + ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; + Log("Installed certificate bypass."); + + LoadTitleNames(); + + try + { + UpdateVersionList(); + } + catch (Exception ex) + { + keep_log = true; + Log($"An exception occurred: {ex.Message}"); + } + + SaveTitleNames(); + + log.Close(); + if (!keep_log) + File.Delete(log_file); + + } + + private static void UpdateVersionList() + { + var old_json = File.ReadAllText("hac_versionlist"); + dynamic old_list = JsonConvert.DeserializeObject(old_json); + + var old_format = old_list["format_version"]; + var do_comparison = true; + + if (old_format != format_version) + { + Log($"The most recently-saved versionlist was a newer format than expected {old_format} != {format_version}."); + Log("Will not do comparison."); + do_comparison = false; + } + + var new_data = NetworkUtils.TryConsoleDownload(version_url); + if (new_data == null) + { + Log($"Failed to download a new versionlist."); + return; + } + + File.WriteAllBytes("tmp", new_data); + var new_json = File.ReadAllText("tmp"); + File.Delete("tmp"); + dynamic new_list = JsonConvert.DeserializeObject(new_json); + + + var new_path = $"data/hac_versionlist - {new_list["last_modified"]}.json"; + + File.WriteAllBytes(new_path, new_data); + + + + if (new_list["format_version"] != format_version) + { + Log($"The most recently-saved versionlist was a newer format than expected {old_format} != {format_version}."); + Log("Will not do comparison."); + do_comparison = false; + } + + var Lines = new List(); + + if (do_comparison) + { + if (new_list["last_modified"] != old_list["last_modified"]) + { + var first_line = $"Comparing newly updated list {new_list["last_modified"]} != {old_list["last_modified"]}."; + Log(first_line); + Lines.Add(first_line); + var old_vers = JsonConvert.DeserializeObject(old_json); + var new_vers = JsonConvert.DeserializeObject(new_json); + var only_old = old_vers.titles.Where(t => new_vers.titles.All(t2 => t2.id != t.id)).ToList(); + var only_new = new_vers.titles.Where(t => old_vers.titles.All(t2 => t2.id != t.id)).ToList(); + var shared_old = old_vers.titles.Where(t => new_vers.titles.Any(t2 => t2.id == t.id)).OrderBy(t => t.id).ToList(); + var shared_new = new_vers.titles.Where(t => old_vers.titles.Any(t2 => t2.id == t.id)).OrderBy(t => t.id).ToList(); + foreach (var removed_title in only_old) + { + var msg = ($"Title {removed_title.id} is no longer in the versionlist! ({TryGetTitleName(removed_title.id)})"); + Lines.Add(msg); + Log(msg); + } + foreach (var added_title in only_new) + { + var msg = ($"New Title {added_title.id} was added, version 0x{added_title.version:X5}, required version 0x{added_title.required_version:X5} ({TryGetTitleName(added_title.id)})"); + Lines.Add(msg); + Log(msg); + } + for (var i = 0; i < shared_old.Count; i++) + { + var old_title = shared_old[i]; + var new_title = shared_new[i]; + if (old_title.version != new_title.version && + old_title.required_version != new_title.required_version) + { + var msg = ($"Title {old_title.id} changed: Version 0x{old_title.version:X5} => 0x{new_title.version:X5}, Required 0x{old_title.required_version:X5} => 0x{new_title.required_version:X5} ({TryGetTitleName(old_title.id)})"); + Lines.Add(msg); + Log(msg); + + } + else if (old_title.version != new_title.version) + { + var msg = ($"Title {old_title.id} changed: Version 0x{old_title.version:X5} => 0x{new_title.version:X5} ({TryGetTitleName(old_title.id)})"); + Lines.Add(msg); + Log(msg); + + } + else if (old_title.required_version != new_title.required_version) + { + var msg = ($"Title {old_title.id} changed: Required 0x{old_title.required_version:X5} => 0x{new_title.required_version:X5} ({TryGetTitleName(old_title.id)})"); + Lines.Add(msg); + Log(msg); + } + } + } + } + else + { + if (new_list["last_modified"] != old_list["last_modified"]) + { + var msg = $"A new versionlist was uploaded, but it has a new format version ({new_list["format_version"]}) that can't currently be analyzed."; + Log(msg); + Lines.Add(msg); + } + } + + if (new_list["last_modified"] != old_list["last_modified"]) + { + File.WriteAllBytes("hac_versionlist", new_data); + } + + if (Lines.Count > 0) + { + var max_len = 2000 - 3 - 3 - 1 - 3; + var msg = string.Join("\n", Lines); + if (msg.Length >= max_len) msg = msg.Substring(0, max_len) + "\n..."; + DiscordUtils.SendMessage($"```{msg}```", new_data, $"hac_versionlist - {new_list["last_modified"]}.json"); + } + } + + internal class VersionList + { + public List titles { get; set; } + public int format_version { get; set; } + public ulong last_modified { get; set; } + } + + internal class TitleVersion + { + public string id { get; set; } + public int version { get; set; } + public int required_version { get; set; } + } + } +} diff --git a/HacVersionListBot/HacVersionListBot/Properties/AssemblyInfo.cs b/HacVersionListBot/HacVersionListBot/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..09a73b8 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("HacVersionListBot")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("HacVersionListBot")] +[assembly: AssemblyCopyright("Copyright © 2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("bcfb87cc-64a3-4c2d-b472-18354cc57358")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/HacVersionListBot/HacVersionListBot/Properties/Resources.Designer.cs b/HacVersionListBot/HacVersionListBot/Properties/Resources.Designer.cs new file mode 100644 index 0000000..4cbf73b --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/Properties/Resources.Designer.cs @@ -0,0 +1,110 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace HacVersionListBot.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HacVersionListBot.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] ConsoleCert { + get { + object obj = ResourceManager.GetObject("ConsoleCert", resourceCulture); + return ((byte[])(obj)); + } + } + + /// + /// Looks up a localized string similar to switch. + /// + internal static string ConsoleCert_Password { + get { + return ResourceManager.GetString("ConsoleCert_Password", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to [CENSORED]. + /// + internal static string DiscordWebhook { + get { + return ResourceManager.GetString("DiscordWebhook", resourceCulture); + } + } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] ShopN { + get { + object obj = ResourceManager.GetObject("ShopN", resourceCulture); + return ((byte[])(obj)); + } + } + + /// + /// Looks up a localized string similar to shop. + /// + internal static string ShopN_Password { + get { + return ResourceManager.GetString("ShopN_Password", resourceCulture); + } + } + } +} diff --git a/HacVersionListBot/HacVersionListBot/Properties/Resources.resx b/HacVersionListBot/HacVersionListBot/Properties/Resources.resx new file mode 100644 index 0000000..dd5ffc2 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/Properties/Resources.resx @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\resources\consolecert.p12;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + switch + + + [CENSORED] + + + ..\resources\shopn.p12;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + shop + + \ No newline at end of file diff --git a/HacVersionListBot/HacVersionListBot/Resources/ConsoleCert.p12 b/HacVersionListBot/HacVersionListBot/Resources/ConsoleCert.p12 new file mode 100644 index 0000000..e69de29 diff --git a/HacVersionListBot/HacVersionListBot/Resources/ShopN.p12 b/HacVersionListBot/HacVersionListBot/Resources/ShopN.p12 new file mode 100644 index 0000000..23f9af5 Binary files /dev/null and b/HacVersionListBot/HacVersionListBot/Resources/ShopN.p12 differ diff --git a/HacVersionListBot/HacVersionListBot/ShogunUtils.cs b/HacVersionListBot/HacVersionListBot/ShogunUtils.cs new file mode 100644 index 0000000..bf41bf7 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/ShogunUtils.cs @@ -0,0 +1,85 @@ +using System; +using Newtonsoft.Json; + +namespace HacVersionListBot +{ + class ShogunUtils + { + // Shogun is awful. So, so awful. + private static string url_prefix = "https://bugyo.hac.lp1.eshop.nintendo.net/shogun/v1"; + + public static string GetNsUid(string title_id, string country, string type) + { + var url = $"{url_prefix}/contents/ids?shop_id=4&lang=en&country={country}&type={type}&title_ids={title_id}"; + var id_pair_res = NetworkUtils.TryMakeShogunRequest(url); + if (id_pair_res == null) + { + return null; + } + + try + { + dynamic id_pairs = JsonConvert.DeserializeObject(id_pair_res); + var pairs = id_pairs["id_pairs"]; + foreach (var pair in pairs) + { + if (pair["title_id"] == title_id.ToUpper()) + { + return pair["id"].ToString(); + } + } + } + catch (Exception ex) + { + Program.Log($"Error ({ex.Message}) on attempt to get {url} -- response {id_pair_res}."); + return null; + } + return null; + } + + public static string GetNameFromNsUid(string ns_uid, string country) + { + var url = $"{url_prefix}/titles/{ns_uid}?shop_id=4&lang=en&country={country}"; + var title_res = NetworkUtils.TryMakeShogunRequest(url); + if (title_res == null) + { + return null; + } + + try + { + dynamic title_meta = JsonConvert.DeserializeObject(title_res); + return title_meta["formal_name"]; + } + catch (Exception ex) + { + Program.Log($"Error ({ex.Message}) on attempt to get {url} -- response {title_res}."); + return null; + } + } + + public static string GetBaseTitleId(string title_id) + { + return title_id.Substring(0, 13) + "000"; + } + + public static string GetTitleName(string title_id) + { + var base_title_id = GetBaseTitleId(title_id); + foreach (var country in new[] {"US", "GB", "JP", "AU", "TW", "KR"}) + { + var ns_uid = GetNsUid(base_title_id, country, "title"); + if (ns_uid == null) continue; + var is_demo = GetNsUid(base_title_id, country, "demo") != null; + + var title_name = GetNameFromNsUid(ns_uid, country); + if (title_name == null) continue; + if (is_demo) title_name += " (Demo)"; + if (title_id != base_title_id) title_name += " (Update)"; + return title_name.Replace("\r", "\\r").Replace("\n", "\\n"); + } + return null; + } + + } +} diff --git a/HacVersionListBot/HacVersionListBot/packages.config b/HacVersionListBot/HacVersionListBot/packages.config new file mode 100644 index 0000000..810e559 --- /dev/null +++ b/HacVersionListBot/HacVersionListBot/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file