diff --git a/.gitignore b/.gitignore index add57be707d1a627fd960286263733b8f2df2dcb..2063d74d04ff5c7291033d240d137c49ad0112ab 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ bin/ obj/ /packages/ riderModule.iml -/_ReSharper.Caches/ \ No newline at end of file +/_ReSharper.Caches/ +local.build \ No newline at end of file diff --git a/.idea/.idea.Quarkcord/.idea/.gitignore b/.idea/.idea.Quarkcord/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4d045d0d6e11c9000350f9073eac3209a1729d56 --- /dev/null +++ b/.idea/.idea.Quarkcord/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/.idea.Quarkcord.iml +/modules.xml +/contentModel.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.Quarkcord/.idea/encodings.xml b/.idea/.idea.Quarkcord/.idea/encodings.xml new file mode 100644 index 0000000000000000000000000000000000000000..df87cf951fb4858ab7a76b68dd479c98b2df2404 --- /dev/null +++ b/.idea/.idea.Quarkcord/.idea/encodings.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" /> +</project> \ No newline at end of file diff --git a/.idea/.idea.Quarkcord/.idea/indexLayout.xml b/.idea/.idea.Quarkcord/.idea/indexLayout.xml new file mode 100644 index 0000000000000000000000000000000000000000..7b08163cebc50fb3e777eea4881b68fcebc10590 --- /dev/null +++ b/.idea/.idea.Quarkcord/.idea/indexLayout.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="UserContentModel"> + <attachedFolders /> + <explicitIncludes /> + <explicitExcludes /> + </component> +</project> \ No newline at end of file diff --git a/.idea/.idea.Quarkcord/.idea/vcs.xml b/.idea/.idea.Quarkcord/.idea/vcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..94a25f7f4cb416c083d265558da75d457237d671 --- /dev/null +++ b/.idea/.idea.Quarkcord/.idea/vcs.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="$PROJECT_DIR$" vcs="Git" /> + </component> +</project> \ No newline at end of file diff --git a/Quarkcord/HumanReadable.cs b/Quarkcord/HumanReadable.cs index ac86d46b9d40e5549eca52cfb8967f9ae6fb4302..b547fbd3063acb6fbbf777cfa2ddd72cb3cb10aa 100644 --- a/Quarkcord/HumanReadable.cs +++ b/Quarkcord/HumanReadable.cs @@ -1,6 +1,43 @@ -namespace Quarkcord; +using System.Globalization; + +namespace Quarkcord; public class HumanReadable { - + public static string BytesToString(long value) + { + string suffix; + double readable; + switch (Math.Abs(value)) + { + case >= 0x1000000000000000: + suffix = "EiB"; + readable = value >> 50; + break; + case >= 0x4000000000000: + suffix = "PiB"; + readable = value >> 40; + break; + case >= 0x10000000000: + suffix = "TiB"; + readable = value >> 30; + break; + case >= 0x40000000: + suffix = "GiB"; + readable = value >> 20; + break; + case >= 0x100000: + suffix = "MiB"; + readable = value >> 10; + break; + case >= 0x400: + suffix = "KiB"; + readable = value; + break; + default: + return value.ToString("0 B"); + } + + return (readable / 1024).ToString("0.## ", CultureInfo.InvariantCulture) + suffix; + } } \ No newline at end of file diff --git a/Quarkcord/Objects/ChannelPair.cs b/Quarkcord/Objects/ChannelPair.cs index 96792915fcc6f275ee73f35de8e52cff7ac08a15..f01427322eb8a130742e911f0886dc00e14331f8 100644 --- a/Quarkcord/Objects/ChannelPair.cs +++ b/Quarkcord/Objects/ChannelPair.cs @@ -3,7 +3,7 @@ using MongoDB.Bson.Serialization.Attributes; namespace Quarkcord.Objects; -public class MessagePair +public class ChannelPair { [BsonId] public ObjectId Id; public ulong DiscordId; diff --git a/Quarkcord/Objects/LqUser.cs b/Quarkcord/Objects/LqUser.cs index edd9c6f5c78962951afdb9841daa293ae9d609b5..7baf3c880c709cae6fc7dd7b39ee22735eed6c7e 100644 --- a/Quarkcord/Objects/LqUser.cs +++ b/Quarkcord/Objects/LqUser.cs @@ -1,6 +1,46 @@ -namespace Quarkcord.Objects; +using Lightquark.Types.Mongo; +using MongoDB.Bson; -public class LqUser +namespace Quarkcord.Objects; + +public class LqUser : IUser { + public ObjectId Id { get; set; } + + public required byte[] PasswordHash { get; init; } + + public required string Email { get; set; } + + public required byte[] Salt { get; init; } + + public required string Username { get; set; } + + public bool Admin { get; set; } + + public bool IsBot { get; set; } + + public bool SecretThirdThing { get; set; } + + public string? Pronouns { get; set; } + + public IStatus? Status => null; + + public required Uri AvatarUri { get; set; } + + public Uri AvatarUriGetter => AvatarUri; + + public IUser Safe => new LqUser + { + Id = Id, + Username = Username, + AvatarUri = AvatarUri, + IsBot = IsBot, + Admin = Admin, + SecretThirdThing = SecretThirdThing, + Pronouns = Pronouns, + PasswordHash = null!, + Email = null!, + Salt = null! + }; } \ No newline at end of file diff --git a/Quarkcord/Objects/MessagePair.cs b/Quarkcord/Objects/MessagePair.cs index 744afee48da9b22e635db05f770ce3ea0a166f27..96792915fcc6f275ee73f35de8e52cff7ac08a15 100644 --- a/Quarkcord/Objects/MessagePair.cs +++ b/Quarkcord/Objects/MessagePair.cs @@ -1,6 +1,11 @@ -namespace Quarkcord.Objects; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Quarkcord.Objects; public class MessagePair { - + [BsonId] public ObjectId Id; + public ulong DiscordId; + public ObjectId LqId; } \ No newline at end of file diff --git a/Quarkcord/Program.cs b/Quarkcord/Program.cs index 5b65f86fd06bddbb8cdbcf90d86de9eb9f1e56a9..d8ddd2a94d41c8aba91f646f5e6d43688ef5cc79 100644 --- a/Quarkcord/Program.cs +++ b/Quarkcord/Program.cs @@ -1,12 +1,372 @@ -using Lightquark.Types; +using System.Web; +using Discord; +using Discord.WebSocket; +using Lightquark.Types; using Lightquark.Types.EventBus; +using Lightquark.Types.EventBus.Events; +using Lightquark.Types.EventBus.Messages; +using MongoDB.Bson; +using MongoDB.Driver; +using Newtonsoft.Json.Linq; +using Quarkcord.Objects; namespace Quarkcord; public class QuarkcordPlugin : IPlugin { + private static Task Log(LogMessage msg) + { + Console.WriteLine($"[Quarkcord] {msg.ToString()}"); + return Task.CompletedTask; + } + + private string? _token; + private string? _botUserId; + private Lightquark.Types.Mongo.IUser? _user; + private DiscordSocketClient? _client; + private readonly ManualResetEvent _eventBusGetEvent = new(false); + private IEventBus _eventBus = null!; + private NetworkInformation? _networkInformation; + private MongoClient _mongoClient = null!; + private IMongoDatabase _database = null!; + private IMongoCollection<MessagePair> MessagePairs => _database.GetCollection<MessagePair>("messages"); + private IMongoCollection<ChannelPair> ChannelPairs => _database.GetCollection<ChannelPair>("channels"); + private List<ChannelPair> _bridgeChannels = null!; + public void Initialize(IEventBus eventBus) { - throw new NotImplementedException(); + Task.Run(async () => + { + eventBus.Subscribe<BusReadyEvent>(_ => _eventBusGetEvent.Set()); + _eventBusGetEvent.WaitOne(); + _eventBus = eventBus; + var filePath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "lightquark", "quarkcord"); + string mongoConnectionString; + string mongoDb; + if (File.Exists(filePath)) + { + var text = (await File.ReadAllTextAsync(filePath)).Trim().Split(";"); + _token = text[0]; + _botUserId = text[1]; + mongoConnectionString = text[2]; + mongoDb = text[3]; + } + else + { + await File.WriteAllTextAsync(filePath, "INSERT_DISCORD_TOKEN_HERE;INSERT_LQ_BOT_USER_ID_HERE;INSERT_MONGO_CONNECTION_STRING_HERE;INSERT_MONGO_DB_HERE"); + _token = "INSERT_DISCORD_TOKEN_HERE"; + _botUserId = "INSERT_LQ_BOT_USER_ID_HERE"; + mongoConnectionString = "INSERT_MONGO_CONNECTION_STRING_HERE"; + mongoDb = "INSERT_MONGO_DB_HERE"; + } + if (_token == "INSERT_DISCORD_TOKEN_HERE") throw new Exception($"Please add discord token to {filePath}"); + if (_botUserId == "INSERT_LQ_BOT_USER_ID_HERE") throw new Exception($"Please add bot user id to {filePath}"); + if (mongoConnectionString == "INSERT_MONGO_CONNECTION_STRING_HERE") throw new Exception($"Please add mongo connection string to {filePath}"); + if (mongoDb == "INSERT_MONGO_DB_HERE") throw new Exception($"Please add mongo db to {filePath}"); + + _mongoClient = new MongoClient(mongoConnectionString); + _database = _mongoClient.GetDatabase(mongoDb); + + var channelPairCursor = await ChannelPairs.FindAsync(new BsonDocument()); + var channelPairs = await channelPairCursor.ToListAsync(); + _bridgeChannels = channelPairs; + if (_bridgeChannels.Count == 0) + { + await ChannelPairs.InsertOneAsync(new ChannelPair + { + DiscordId = 0, + Id = ObjectId.GenerateNewId(), + LqId = ObjectId.Empty + }); + await MessagePairs.InsertOneAsync(new MessagePair + { + DiscordId = 0, + Id = ObjectId.GenerateNewId(), + LqId = ObjectId.Empty + }); + } + + eventBus.Publish(new GetUserMessage + { + UserId = new ObjectId(_botUserId), + Callback = user => + { + _user = user; + _eventBusGetEvent.Set(); + } + }); + _eventBusGetEvent.WaitOne(); + _eventBusGetEvent.Reset(); + eventBus.Publish(new GetNetworkMessage + { + Callback = network => + { + _networkInformation = network; + _eventBusGetEvent.Set(); + } + }); + _eventBusGetEvent.WaitOne(); + _client = new DiscordSocketClient(new DiscordSocketConfig + { + GatewayIntents = (GatewayIntents)130813 + }); + _client.Log += Log; + await Log(new LogMessage(LogSeverity.Info, "Quarkcord", "Logging in")); + await _client.LoginAsync(TokenType.Bot, _token); + _client.Ready += () => + { + Log(new LogMessage(LogSeverity.Info, "Quarkcord", "Connected!")); + return Task.CompletedTask; + }; + + _client.MessageReceived += DiscordMessageReceived; + _client.MessageUpdated += DiscordMessageUpdated; + _client.MessageDeleted += DiscordMessageDeleted; + + eventBus.Subscribe<MessageCreateEvent>(LqMessageReceived); + eventBus.Subscribe<MessageDeleteEvent>(LqMessageDeleted); + + await _client.StartAsync(); + }); + } + + private async void LqMessageDeleted(MessageDeleteEvent deleteEvent) + { + if (_bridgeChannels.All(bc => bc.LqId != deleteEvent.Message.ChannelId)) return; + var bridgeChannel = _bridgeChannels.Find(bc => bc.LqId == deleteEvent.Message.ChannelId); + if (_client!.GetChannel(bridgeChannel!.DiscordId) is not ITextChannel discordChannel) return; + var messagePairCursor = await MessagePairs.FindAsync(mp => mp.LqId == deleteEvent.Message.Id); + var messagePair = await messagePairCursor.FirstOrDefaultAsync(); + if (messagePair == null) return; + await discordChannel.DeleteMessageAsync(messagePair.DiscordId); + await MessagePairs.DeleteOneAsync(mp => mp.Id == messagePair.Id); + } + + private async void LqMessageReceived(MessageCreateEvent createEvent) + { + if (createEvent.Message.Author!.Id == _user!.Id) return; + if (_bridgeChannels.All(bc => bc.LqId != createEvent.Message.ChannelId)) return; + var bridgeChannel = _bridgeChannels.Find(bc => bc.LqId == createEvent.Message.ChannelId); + if (_client!.GetChannel(bridgeChannel!.DiscordId) is not ITextChannel discordChannel) return; + var webhooks = await discordChannel.GetWebhooksAsync(); + var webhook = webhooks.FirstOrDefault(w => w.Name == $"Quarkcord {_networkInformation?.Name}") + ?? await discordChannel.CreateWebhookAsync($"Quarkcord {_networkInformation?.Name}"); + var webhookClient = new Discord.Webhook.DiscordWebhookClient(webhook.Id, webhook.Token); + var username = + $"{createEvent.Message.Author.Username} via {createEvent.Message.UserAgent} ({_networkInformation!.Name})"; + if (username.Length > 80) + { + username = $"{createEvent.Message.Author.Username} ({_networkInformation!.Name})"; + } + if (username.Length > 80) + { + var toRemove = $" ({_networkInformation!.Name})".Length; + username = $"{createEvent.Message.Author.Username[..(80 - toRemove)]} ({_networkInformation!.Name})"; + } + var message = await webhookClient.SendMessageAsync( + createEvent.Message.Content?.Length <= 2000 ? createEvent.Message.Content : "A message was sent but it is too long. Please view it on Lightquark", + false, + createEvent.Message.Attachments.Select(a => a.MimeType.StartsWith("image") ? + new EmbedBuilder().WithImageUrl(a.Url.ToString()).Build() + : new EmbedBuilder().WithTitle($"{a.Filename} ({HumanReadable.BytesToString(a.Size)})").WithUrl(a.Url.ToString()).Build()), + username, + createEvent.Message.Author.AvatarUriGetter.ToString(), + null, + AllowedMentions.None); + + var messagePair = new MessagePair + { + Id = ObjectId.GenerateNewId(), + LqId = createEvent.Message.Id, + DiscordId = message + }; + await MessagePairs.InsertOneAsync(messagePair); + } + + private async Task DiscordMessageUpdated(Cacheable<IMessage, ulong> oldMessageParam, SocketMessage messageParam, + ISocketMessageChannel channelParam) + { + if (messageParam is not SocketUserMessage message) return; + if (message.Author.Id == _client!.CurrentUser.Id) return; + if (_user == null) + { + await Log(new LogMessage(LogSeverity.Warning, "Quarkcord", "Message received but bot user is null")); + return; + } + if (_bridgeChannels.All(bc => bc.DiscordId != message.Channel.Id)) return; + var bridgeChannel = _bridgeChannels.Find(bc => bc.DiscordId == message.Channel.Id); + var messagePairCursor = await MessagePairs.FindAsync(mp => mp.DiscordId == message.Id); + var messagePair = await messagePairCursor.FirstOrDefaultAsync(); + if (messagePair == null) + { + // Somehow we didn't have this one, lets bridge it now! + await SendOrUpdateLqMessage(message, bridgeChannel!); + } + else + { + await SendOrUpdateLqMessage(message, bridgeChannel!, true, messagePair); + } + } + + private async Task DiscordMessageDeleted(Cacheable<IMessage, ulong> messageParam, Cacheable<IMessageChannel, ulong> channelParam) + { + if (_bridgeChannels.All(bc => bc.DiscordId != channelParam.Id)) return; + var discordMessageId = messageParam.Id; + var messagePairCursor = await MessagePairs.FindAsync(m => m.DiscordId == discordMessageId); + var messagePair = await messagePairCursor.FirstOrDefaultAsync(); + if (messagePair == null) return; + + _eventBus.Publish(new DeleteMessageMessage + { + MessageId = messagePair.LqId + }); + + await MessagePairs.DeleteOneAsync(mp => mp.Id == messagePair.Id); + } + + private async Task SendOrUpdateLqMessage(SocketUserMessage message, ChannelPair bridgeChannel, bool update = false, MessagePair? existingMessagePair = null) + { + if (message.Author.Username.EndsWith($"({_networkInformation!.Name})")) return; + var specialAttributes = new JArray + { + new JObject + { + ["type"] = "botMessage", + ["username"] = (message.Author as SocketGuildUser)?.Nickname ?? message.Author.GlobalName, + ["avatarUri"] = $"{_networkInformation!.CdnBaseUrl}/external/{HttpUtility.UrlEncode(message.Author.GetAvatarUrl())}" + } + }; + if (message.ReferencedMessage != null) + { + var replyCursor = await MessagePairs.FindAsync(mp => mp.DiscordId == message.ReferencedMessage.Id); + var reply = await replyCursor.FirstOrDefaultAsync(); + if (reply != null) + { + specialAttributes.Add(new JObject + { + ["type"] = "reply", + ["replyTo"] = reply.LqId.ToString() + }); + } + } + var lqMessageId = existingMessagePair?.LqId ?? ObjectId.GenerateNewId(); + var messagePair = existingMessagePair ?? new MessagePair + { + DiscordId = message.Id, + Id = ObjectId.GenerateNewId(), + LqId = lqMessageId + }; + if (!update) + { + await MessagePairs.InsertOneAsync(messagePair); + } + var lqAttachments = message.Attachments.Select(a => new LqAttachment + { + FileId = ObjectId.Empty, + Filename = a.Filename, + MimeType = a.ContentType, + Size = a.Size, + Url = new Uri($"{_networkInformation!.CdnBaseUrl}/external/{HttpUtility.UrlEncode(a.Url)}") + }).ToArray(); + LqMessage lqMessage; + if (message.Attachments.Count == 0 && message.CleanContent.Length == 0) + { + specialAttributes.Add(new JObject + { + ["type"] = "clientAttributes", + ["discordMessageId"] = message.Id, + ["quarkcord"] = true, + ["quarkcordUnsupported"] = true + }); + lqMessage = new LqMessage + { + Id = lqMessageId, + AuthorId = _user!.Id, + Content = "Discord message with unsupported content", + ChannelId = bridgeChannel!.LqId, + UserAgent = "Quarkcord", + Timestamp = message.Timestamp.ToUnixTimeMilliseconds(), + VirtualAuthors = [_user], + Edited = update, + Attachments = [], + SpecialAttributes = specialAttributes + }; + } + else + { + specialAttributes.Add(new JObject + { + ["type"] = "clientAttributes", + ["discordMessageId"] = message.Id, + ["quarkcord"] = true + }); + lqMessage = new LqMessage + { + Id = lqMessageId, + AuthorId = _user!.Id, + Content = message.CleanContent, + ChannelId = bridgeChannel!.LqId, + UserAgent = "Quarkcord", + Timestamp = message.Timestamp.ToUnixTimeMilliseconds(), + VirtualAuthors = [_user], + Edited = update, + Attachments = lqAttachments, + SpecialAttributes = specialAttributes + }; + } + + if (update) + { + _eventBus.Publish(new EditMessageMessage + { + Message = lqMessage + }); + } + else + { + _eventBus.Publish(new CreateMessageMessage + { + Message = lqMessage + }); + } } + + private async Task DiscordMessageReceived(SocketMessage messageParam) + { + if (messageParam is not SocketUserMessage message) return; + if (message.Author.Id == _client!.CurrentUser.Id) return; + if (_user == null) + { + await Log(new LogMessage(LogSeverity.Warning, "Quarkcord", "Message received but bot user is null")); + return; + } + if (_bridgeChannels.All(bc => bc.DiscordId != message.Channel.Id)) return; + var bridgeChannel = _bridgeChannels.Find(bc => bc.DiscordId == message.Channel.Id); + + await SendOrUpdateLqMessage(message, bridgeChannel!); + } +} + +public class LqMessage : Lightquark.Types.Mongo.IMessage +{ + public ObjectId Id { get; set; } + public ObjectId AuthorId { get; set; } + public string? Content { get; set; } + public ObjectId ChannelId { get; set; } + public string UserAgent { get; set; } + public long Timestamp { get; set; } + public bool Edited { get; set; } + public Lightquark.Types.Mongo.IAttachment[] Attachments { get; set; } + public JArray SpecialAttributes { get; set; } + public Lightquark.Types.Mongo.IUser? Author => VirtualAuthors?.FirstOrDefault(); + public Lightquark.Types.Mongo.IUser[]? VirtualAuthors { get; set; } +} + +public class LqAttachment : Lightquark.Types.Mongo.IAttachment +{ + public Uri Url { get; set; } + public long Size { get; set; } + public string MimeType { get; set; } + public string Filename { get; set; } + public ObjectId FileId { get; set; } } \ No newline at end of file diff --git a/Quarkcord/Quarkcord.csproj b/Quarkcord/Quarkcord.csproj index 3a635329525b5e27f27fee5099d154e3de6fe893..381d93bd5e5e02145bf7f9504a1cdf8ea4d0e380 100644 --- a/Quarkcord/Quarkcord.csproj +++ b/Quarkcord/Quarkcord.csproj @@ -6,4 +6,15 @@ <Nullable>enable</Nullable> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Discord.Net" Version="3.15.0" /> + <PackageReference Include="Lightquark.Types" Version="2024.6.1-57" /> + <PackageReference Include="MongoDB.Driver" Version="2.25.0" /> + </ItemGroup> + + + <Target Name="PostBuild" AfterTargets="Build" Condition="Exists('local.build')"> + <Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="C:\Users\emi\AppData\Roaming\lightquark\plugins" /> + </Target> + </Project>