From 9aeb2f4b05cc481aab98531d6ed741858fa9254d Mon Sep 17 00:00:00 2001
From: Emilia <emilia@jumpsca.re>
Date: Sat, 1 Jun 2024 19:36:57 +0300
Subject: [PATCH] The bridge (in one commit :>)

---
 .gitignore                                  |   3 +-
 .idea/.idea.Quarkcord/.idea/.gitignore      |  13 +
 .idea/.idea.Quarkcord/.idea/encodings.xml   |   4 +
 .idea/.idea.Quarkcord/.idea/indexLayout.xml |   8 +
 .idea/.idea.Quarkcord/.idea/vcs.xml         |   6 +
 Quarkcord/HumanReadable.cs                  |  41 ++-
 Quarkcord/Objects/ChannelPair.cs            |   2 +-
 Quarkcord/Objects/LqUser.cs                 |  44 ++-
 Quarkcord/Objects/MessagePair.cs            |   9 +-
 Quarkcord/Program.cs                        | 364 +++++++++++++++++++-
 Quarkcord/Quarkcord.csproj                  |  11 +
 11 files changed, 495 insertions(+), 10 deletions(-)
 create mode 100644 .idea/.idea.Quarkcord/.idea/.gitignore
 create mode 100644 .idea/.idea.Quarkcord/.idea/encodings.xml
 create mode 100644 .idea/.idea.Quarkcord/.idea/indexLayout.xml
 create mode 100644 .idea/.idea.Quarkcord/.idea/vcs.xml

diff --git a/.gitignore b/.gitignore
index add57be..2063d74 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 0000000..4d045d0
--- /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 0000000..df87cf9
--- /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 0000000..7b08163
--- /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 0000000..94a25f7
--- /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 ac86d46..b547fbd 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 9679291..f014273 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 edd9c6f..7baf3c8 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 744afee..9679291 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 5b65f86..d8ddd2a 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 3a63532..381d93b 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>
-- 
GitLab