using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Threading; namespace IdleMafiaBot { enum Ping { Test, Problem, Rarity, Property, Summary, Level, Boss, GlobalBoss, Shop } sealed class Discord { sealed class Msg { public Ping Kind; public string Title, Text, Link; public byte[] Jpg; public Func Shot; public int Color; public bool Mention; } sealed class Batch { public readonly List Lines = new List(); public string Title; public Func Shot; public int Color; public DateTime Last = DateTime.MinValue; } static readonly TimeSpan Gap = TimeSpan.FromMinutes(10); static readonly Ping[] Batched = { Ping.Rarity, Ping.Property, Ping.Boss }; public const int Gold = 0xD4AF37, Red = 0xC84B3F, Green = 0x7EBE5A, Purple = 0x8B6ABB; public static readonly int[] RarityColors = { 0xB4B4B4, 0x7CBC7C, 0x6C9CDC, 0xAC7CDC, 0xD4AC34, 0xEC547C, 0x5CDCD4, 0xDC2424 }; readonly Settings s; readonly Action log; readonly object gate = new object(), sendLock = new object(); readonly Queue queue = new Queue(); readonly Dictionary batches = new Dictionary(); readonly Dictionary problemAt = new Dictionary(); readonly List problemTimes = new List(); readonly AutoResetEvent wake = new AutoResetEvent(false); Thread thread; bool sending, failing, fullSaid; string deadLink; DateTime deadAt; static readonly TimeSpan DeadFor = TimeSpan.FromHours(1); volatile string trouble, troubleLink; DateTime lastSend = DateTime.MinValue; public event Action Changed; public string Trouble { get { string t = trouble; return t != null && troubleLink == Link ? t : null; } } void SetTrouble(string link, string why) { if (trouble == why && troubleLink == link) return; trouble = why; troubleLink = link; var h = Changed; if (h != null) h(); } bool Dead(string link) { return link == deadLink && DateTime.UtcNow - deadAt < DeadFor; } public Discord(Settings settings, Action log) { s = settings; this.log = log; foreach (var k in Batched) batches[k] = new Batch(); } public static bool IsLink(string link) { return link != null && Regex.IsMatch(link.Trim(), @"^https://(?:(?:ptb|canary)\.)?discord(?:app)?\.com/api/(?:v\d+/)?webhooks/\d{5,30}/[A-Za-z0-9_\-]{20,100}/?$"); } public Settings Pc; Settings P { get { return Pc ?? s; } } string Link { get { return (P.DiscordLink ?? "").Trim(); } } public static bool IsUserId(string id) { return id != null && Regex.IsMatch(id.Trim(), @"^\d{17,20}$"); } string UserId { get { string id = (P.DiscordUserId ?? "").Trim(); return IsUserId(id) ? id : null; } } public bool On(Ping k) { string link = Link; if (!IsLink(link) || Dead(link)) return false; var p = P; switch (k) { case Ping.Problem: return p.DiscordProblems; case Ping.Rarity: return p.DiscordRarity; case Ping.Property: return p.DiscordProperty; case Ping.Summary: return p.DiscordSummary; case Ping.Level: return p.DiscordLevel; case Ping.Boss: return p.DiscordBoss; case Ping.GlobalBoss: return p.DiscordGlobalBoss; case Ping.Shop: return p.ShopPing; default: return true; } } public event Action Moment; public void Post(Ping k, string title, string text, Func shot, int color = Gold, bool mention = false) { if (k == Ping.Rarity || k == Ping.Property || k == Ping.Level || k == Ping.Boss || k == Ping.GlobalBoss) { var m = Moment; if (m != null) try { m(k, title, text); } catch (Exception) { } } if (!On(k)) return; var now = DateTime.UtcNow; lock (gate) { Batch b; if (batches.TryGetValue(k, out b) && !closing) { if (b.Lines.Count > 0 || now - b.Last < Gap) { b.Lines.Add(text); b.Title = title; b.Color = color; if (shot != null) b.Shot = shot; return; } b.Last = now; } } byte[] jpg = Take(shot); lock (gate) Enqueue(new Msg { Kind = k, Title = title, Text = text, Jpg = jpg, Color = color, Mention = mention }); } public void Problem(string key, string title, string text, Func shot, bool small = false, int minutes = 60, int color = Red, bool mention = false) { if (!On(Ping.Problem)) return; var now = DateTime.UtcNow; lock (gate) { DateTime at; if (problemAt.TryGetValue(key, out at) && now - at < TimeSpan.FromMinutes(minutes)) return; problemTimes.RemoveAll(t => now - t > TimeSpan.FromHours(1)); if (small && problemTimes.Count >= 3) return; problemAt[key] = now; if (small) problemTimes.Add(now); } Post(Ping.Problem, title, text, shot, color, mention); } public const string StatsLinkHere = "That's your stats link (Stats in our Discord, below). Paste a webhook link of your own channel here."; public void Test(string link, Func shot, Action done) { link = (link ?? "").Trim(); if (!IsLink(link)) { done(link.Length == 0 ? "Paste your webhook link first." : "That isn't a Discord webhook link. It starts with https://discord.com/api/webhooks/"); return; } if (link == (P.StatsLink ?? "").Trim()) { done(StatsLinkHere); return; } var t = new Thread(() => { var m = new Msg { Kind = Ping.Test, Link = link, Color = Gold, Jpg = Take(shot), Mention = true, Title = "✅ Idle Mafia Bot is connected", Text = "Its messages will show up here. It sends them only to this channel.", }; done(Send(m)); }) { IsBackground = true, Name = "discord test" }; t.Start(); } public void Flush(int ms) { lock (gate) { if (thread == null) return; Due(DateTime.UtcNow, true); } wake.Set(); int end = Environment.TickCount + ms; while (end - Environment.TickCount > 0) { lock (gate) if (queue.Count == 0 && !sending) return; Thread.Sleep(100); } } volatile bool closing; public void Close() { lock (gate) { closing = true; if (thread != null) Due(DateTime.UtcNow, true); } wake.Set(); } internal bool Sending { get { lock (gate) return thread != null; } } void Enqueue(Msg m) { if (queue.Count >= 10) { if (!fullSaid) { fullSaid = true; log("Discord: too many messages waiting - left one out (\"" + m.Title + "\")"); } return; } fullSaid = false; queue.Enqueue(m); if (thread == null) { thread = new Thread(Loop) { IsBackground = true, Name = "discord" }; thread.Start(); } wake.Set(); } void Due(DateTime now, bool all) { foreach (var kv in batches) { var b = kv.Value; if (b.Lines.Count == 0 || (!all && now - b.Last < Gap)) continue; string title = b.Lines.Count == 1 ? b.Title : Many(kv.Key, b.Lines.Count) ?? b.Title; string text = string.Join("\n", b.Lines.Take(10)) + (b.Lines.Count > 10 ? "\nand " + (b.Lines.Count - 10) + " more" : ""); Enqueue(new Msg { Kind = kv.Key, Title = title, Text = text, Shot = b.Shot, Color = b.Color }); b.Lines.Clear(); b.Shot = null; b.Last = now; } } static string Many(Ping k, int n) { switch (k) { case Ping.Rarity: return "\U0001F3B2 " + n + " new henchmen"; case Ping.Property: return "\U0001F3E0 " + n + " new properties"; case Ping.Boss: return "\U0001F44A " + n + " bosses beaten"; default: return null; } } void Loop() { while (true) { Msg m = null; lock (gate) { if (queue.Count == 0) Due(DateTime.UtcNow, closing); if (queue.Count > 0) { m = queue.Dequeue(); sending = true; } else if (closing) { thread = null; return; } } if (m == null) { wake.WaitOne(1000); continue; } try { Send(m); } catch (Exception) { } finally { lock (gate) sending = false; } } } string Send(Msg m) { string link = m.Link ?? Link; if (m.Kind != Ping.Test && Dead(link)) return "The link doesn't work any more."; if (m.Kind != Ping.Test && !On(m.Kind)) return "Switched off."; if (!IsLink(link)) return "No webhook link."; if (m.Jpg == null && m.Shot != null) { m.Jpg = Take(m.Shot); m.Shot = null; } lock (sendLock) { string why = null; for (int tries = 0; tries < 4; tries++) { int wait = 2000 - (int)(DateTime.UtcNow - lastSend).TotalMilliseconds; if (wait > 0) Thread.Sleep(wait); int retryMs; why = Post(link, m, out retryMs); lastSend = DateTime.UtcNow; if (why == null) { if (m.Kind != Ping.Test) log("Discord: sent \"" + Plain(m.Title) + "\""); if (deadLink == link) deadLink = null; if (failing) { failing = false; if (m.Kind != Ping.Test) log("Discord: sending works again"); } SetTrouble(link, null); return ""; } if (retryMs < 0) break; if (tries >= 1 && retryMs == 15000) break; if (tries == 3) break; Thread.Sleep(retryMs); } if (m.Kind != Ping.Test) { if (!failing) log("Discord: couldn't send \"" + Plain(m.Title) + "\" - " + why); failing = true; SetTrouble(link, Dead(link) ? "Discord turned this link down (deleted, or not copied whole). Paste a new one, or send a test." : "The last message didn't go out: " + why.TrimEnd('.') + ". It tries again with the next one."); } return why; } } string Post(string link, Msg m, out int retryMs) { retryMs = 15000; try { ServicePointManager.SecurityProtocol |= (SecurityProtocolType)3072; string boundary = "----IdleMafiaBot" + DateTime.UtcNow.Ticks.ToString("x", CultureInfo.InvariantCulture); var body = new MemoryStream(); Action write = t => { var b = Encoding.UTF8.GetBytes(t); body.Write(b, 0, b.Length); }; write("--" + boundary + "\r\nContent-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n" + Payload(m) + "\r\n"); if (m.Jpg != null) { write("--" + boundary + "\r\nContent-Disposition: form-data; name=\"files[0]\"; filename=\"game.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n"); body.Write(m.Jpg, 0, m.Jpg.Length); write("\r\n"); } write("--" + boundary + "--\r\n"); var req = (HttpWebRequest)WebRequest.Create(link); req.Method = "POST"; req.ContentType = "multipart/form-data; boundary=" + boundary; req.UserAgent = "IdleMafiaBot/" + Program.Version; req.Timeout = 20000; req.ReadWriteTimeout = 20000; req.ContentLength = body.Length; using (var rs = req.GetRequestStream()) body.WriteTo(rs); using (var resp = (HttpWebResponse)req.GetResponse()) { } return null; } catch (WebException e) { var r = e.Response as HttpWebResponse; if (r == null) return "couldn't reach Discord (" + Tidy(e.Message, link) + ")"; using (r) { int code = (int)r.StatusCode; if (code == 429) { double sec; string after = r.Headers["Retry-After"]; retryMs = after != null && double.TryParse(after, NumberStyles.Float, CultureInfo.InvariantCulture, out sec) ? (int)Math.Min(60000, sec * 1000 + 250) : 5000; return "Discord asked to slow down"; } if (code == 401 || code == 404) { retryMs = -1; if (m.Kind != Ping.Test && !Dead(link)) { deadLink = link; deadAt = DateTime.UtcNow; log("Discord: the webhook link doesn't work any more (deleted in Discord?) - no messages for an hour, or until it's changed or a test goes through"); } return "Discord says this link doesn't work (deleted, or not copied whole)."; } if (code == 407) { retryMs = -1; return "a proxy on this PC blocked it (407)"; } if (code == 403) { retryMs = -1; return "Discord refused it (403), which a VPN or a school or work network can cause"; } if (code >= 400 && code < 500) { retryMs = -1; return "Discord refused the message (" + code + ")"; } return "Discord had a problem (" + code + ")"; } } catch (Exception e) { return "couldn't send (" + Tidy(e.Message, link) + ")"; } } string Payload(Msg m) { var sb = new StringBuilder(); string id = m.Mention ? UserId : null; if (id != null) sb.Append("{\"username\":\"Idle Mafia Bot\",\"content\":\"<@").Append(id).Append(">\",\"allowed_mentions\":{\"users\":[\"").Append(id).Append("\"]},\"embeds\":[{"); else sb.Append("{\"username\":\"Idle Mafia Bot\",\"allowed_mentions\":{\"parse\":[]},\"embeds\":[{"); sb.Append("\"title\":").Append(Json(Cut(m.Title, 250))); if (!string.IsNullOrEmpty(m.Text)) sb.Append(",\"description\":").Append(Json(Cut(m.Text, 3500))); sb.Append(",\"color\":").Append(m.Color.ToString(CultureInfo.InvariantCulture)); if (m.Jpg != null) sb.Append(",\"image\":{\"url\":\"attachment://game.jpg\"}"); string who = (s.Account ?? "").Trim(); sb.Append(",\"footer\":{\"text\":").Append(Json("Idle Mafia Bot " + Program.Version + (who.Length > 0 ? " - " + who : ""))).Append("}"); sb.Append(",\"timestamp\":\"").Append(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)).Append("\""); sb.Append("}]"); if (m.Jpg != null) sb.Append(",\"attachments\":[{\"id\":0,\"filename\":\"game.jpg\"}]"); sb.Append("}"); return sb.ToString(); } static string Json(string t) { var sb = new StringBuilder("\""); foreach (char c in t ?? "") { if (c == '"') sb.Append("\\\""); else if (c == '\\') sb.Append("\\\\"); else if (c == '\n') sb.Append("\\n"); else if (c == '\r') { } else if (c == '\t') sb.Append("\\t"); else if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); else sb.Append(c); } return sb.Append('"').ToString(); } static string Cut(string t, int max) { t = t ?? ""; return t.Length <= max ? t : t.Substring(0, max - 3) + "..."; } static string Plain(string t) { return Regex.Replace(t ?? "", @"^[^A-Za-z0-9$]+", ""); } static string Tidy(string msg, string link) { return (msg ?? "").Replace(link, "the link").Trim().TrimEnd('.'); } static byte[] Take(Func shot) { if (shot == null) return null; try { return shot(); } catch (Exception) { return null; } } public static byte[] Jpg(Bitmap b) { if (b == null || b.Width < 1 || b.Height < 1 || b.Tag == Frame.NotSendable) return null; double k = Math.Min(1.0, 1280.0 / b.Width); int w = Math.Max(1, (int)Math.Round(b.Width * k)), h = Math.Max(1, (int)Math.Round(b.Height * k)); using (var small = new Bitmap(w, h, PixelFormat.Format24bppRgb)) { using (var g = Graphics.FromImage(small)) { g.InterpolationMode = InterpolationMode.HighQualityBicubic; g.PixelOffsetMode = PixelOffsetMode.HighQuality; g.DrawImage(b, 0, 0, w, h); } var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid); using (var ps = new EncoderParameters(1)) using (var ms = new MemoryStream()) { ps.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 85L); small.Save(ms, codec, ps); return ms.ToArray(); } } } public static byte[] JpgOf(string file) { using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) using (var b = new Bitmap(fs)) return Jpg(b); } public static byte[] ShotOfRoblox() { string why; return ShotOfRoblox(IntPtr.Zero, out why); } public static byte[] ShotOfRoblox(IntPtr pinned, out string why) { var before = Native.RealPixels(); try { var w = new RobloxWindow { Pinned = pinned }; why = "Roblox isn't open on the PC, so there's nothing to show."; if (!w.Find()) return null; why = "Roblox is minimized on the PC, so there's nothing to show."; if (w.Minimized) return null; using (var f = w.Capture()) { var jpg = Jpg(f.Bitmap); why = (RobloxWindow.CoveredBy ?? "Another window") + " is over Roblox on the PC, so no picture was sent: it could show something private."; return jpg; } } finally { Native.RestorePixels(before); } } } }