// Project Makoto // Copyright (C) 2024 Fortunevale // 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 namespace ProjectMakoto.Entities.Guilds; public sealed class CrosspostSettings(Bot bot, Guild parent) : RequiresParent(bot, parent) { [ColumnName("crosspostdelay"), ColumnType(ColumnTypes.Int), Default("5")] public int DelayBeforePosting { get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostdelay", this.Bot.DatabaseClient.mainDatabaseConnection); set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostdelay", value, this.Bot.DatabaseClient.mainDatabaseConnection); } [ColumnName("crosspostexcludebots"), ColumnType(ColumnTypes.TinyInt), Default("0")] public bool ExcludeBots { get => this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostexcludebots", this.Bot.DatabaseClient.mainDatabaseConnection); set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostexcludebots", value, this.Bot.DatabaseClient.mainDatabaseConnection); } [ColumnName("crosspostchannels"), ColumnType(ColumnTypes.LongText), Default("[]")] public ulong[] CrosspostChannels { get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspostchannels", this.Bot.DatabaseClient.mainDatabaseConnection)); set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspostchannels", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); } [ColumnName("crosspost_ratelimits"), ColumnType(ColumnTypes.LongText), Default("[]")] public CrosspostRatelimit[] CrosspostRatelimits { get => JsonConvert.DeserializeObject(this.Bot.DatabaseClient.GetValue("guilds", "serverid", this.Parent.Id, "crosspost_ratelimits", this.Bot.DatabaseClient.mainDatabaseConnection)) .Select(x => { x.Bot = this.Bot; x.Parent = this.Parent; return x; }).ToArray(); set => _ = this.Bot.DatabaseClient.SetValue("guilds", "serverid", this.Parent.Id, "crosspost_ratelimits", JsonConvert.SerializeObject(value), this.Bot.DatabaseClient.mainDatabaseConnection); } private bool QueueInitialized = false; private Dictionary _queue = new(); public async Task CrosspostQueue() { this.QueueInitialized = true; Log.Debug("Initializing crosspost queue for '{Guild}'", this.Parent.Id); while (true) { DiscordChannel channel; DiscordMessage message; try { while (this._queue.Count == 0) await Task.Delay(1000); var keyValuePair = this._queue.First(); channel = keyValuePair.Value; message = keyValuePair.Key; } catch (Exception) { this._queue ??= new(); continue; } try { if (!this.CrosspostRatelimits.Any(x => x.Id == channel.Id)) { Log.Debug("Initialized new crosspost ratelimit for '{Channel}'", channel.Id); this.CrosspostRatelimits = this.CrosspostRatelimits.Add(new() { Id = channel.Id, }); } var r = this.CrosspostRatelimits.First(x => x.Id == channel.Id); Log.Debug("Crosspost Ratelimit '{Channel}': First: {First}; Remaining: {Remaining}", channel.Id, r.FirstPost, r.PostsRemaining); async Task Crosspost() { if (message.Flags?.HasMessageFlag(MessageFlags.Crossposted) ?? false) return; r.PostsRemaining--; var crossPostTask = channel.CrosspostMessageAsync(message); Stopwatch sw = new(); sw.Start(); while (!crossPostTask.IsCompleted && sw.ElapsedMilliseconds < 3000) await Task.Delay(50); sw.Stop(); Log.Debug("It took {Milliseconds}ms to process a crosspost", sw.ElapsedMilliseconds); if (!crossPostTask.IsCompleted) { Log.Warning("Crosspost Ratelimit tripped for '{Channel}': {Message}", channel.Id, message.Id); r.FirstPost = DateTime.UtcNow; r.PostsRemaining = 0; } _ = await crossPostTask; _ = this._queue.Remove(message); Log.Debug("Crossposted message in '{Channel}': {Message}", channel.Id, message.Id); } void ResetLimits() { r.PostsRemaining = 10; r.FirstPost = DateTime.UtcNow; } if (r.FirstPost.AddHours(1).GetTotalSecondsUntil() <= 0) { Log.Debug("First crosspost for '{Channel}' was at {FirstPost}, resetting crosspost availability", channel.Id, r.FirstPost.AddHours(1)); ResetLimits(); } if (r.PostsRemaining > 0) { Log.Debug("{Remaining} crossposts available for '{Channel}', allowing request", r.PostsRemaining, channel.Id); await Crosspost(); continue; } if (r.FirstPost.AddHours(1).GetTotalSecondsUntil() > 0) { Log.Debug("No crossposts available for '{Channel}', waiting until {WaitUntil} ({WaitUntilSec} seconds)", channel.Id, r.FirstPost.AddHours(1), r.FirstPost.AddHours(1).GetTotalSecondsUntil()); await Task.Delay(r.FirstPost.AddHours(1).GetTimespanUntil()); } ResetLimits(); Log.Debug("Crossposts for '{Channel}' available again, allowing request. {Remaining} requests remaining, first post at {First}.", channel.Id, r.PostsRemaining, r.FirstPost); await Crosspost(); continue; } catch (Exception ex) { _ = this._queue.Remove(message); Log.Error(ex, "Failed to process crosspost queue"); } } } public async Task CrosspostWithRatelimit(DiscordClient client, DiscordMessage message) { if (message.Reference is not null || message.MessageType is not MessageType.Default) return; if (this.Parent.Crosspost.ExcludeBots) if (message.WebhookMessage || message.Author.IsBot) return; var ReactionAdded = false; if (!this.QueueInitialized) _ = this.CrosspostQueue(); this._queue.Add(message, message.Channel); await Task.Delay(5000); if (this._queue.ContainsKey(message)) { if (!ReactionAdded) { await message.CreateReactionAsync(DiscordEmoji.FromGuildEmote(client, 974029756355977216)); ReactionAdded = true; } } while (this._queue.ContainsKey(message)) { await Task.Delay(1000); } if (ReactionAdded) _ = message.DeleteReactionsEmojiAsync(DiscordEmoji.FromGuildEmote(client, 974029756355977216)); } }