Development (#103)

diff --git a/.gitignore b/.gitignore
index 07c1488..9e8d50b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,3 +147,6 @@
 .yarn/build-state.yml
 .yarn/install-state.gz
 .pnp.*
+
+# nix
+result/
diff --git a/flake.lock b/flake.lock
index 136c082..aea00e0 100644
--- a/flake.lock
+++ b/flake.lock
@@ -10,11 +10,11 @@
         "sops-nix": "sops-nix"
       },
       "locked": {
-        "lastModified": 1686252801,
-        "narHash": "sha256-ASguQr5onfE7HzawjAvhck2y7NDZ3bdhqFC8O3/XrXU=",
+        "lastModified": 1687544653,
+        "narHash": "sha256-mZV1qMUBfZHfucHWEMvyuNZHxggaGEDafMDmMq4aQJQ=",
         "ref": "refs/heads/production",
-        "rev": "7f3559f9a56d28b8f5352040f18305082a6dacc0",
-        "revCount": 53,
+        "rev": "18e02e77cc828ec39fb6667d54d02d176e352c1a",
+        "revCount": 70,
         "type": "git",
         "url": "ssh://git@github.com/clicksminuteper/nixfiles"
       },
@@ -51,11 +51,11 @@
         "pre-commit-hooks": "pre-commit-hooks"
       },
       "locked": {
-        "lastModified": 1686642933,
-        "narHash": "sha256-5Z4uitx27QtZIrG9sOSJlZRNcVQn/TI36nj7o70n5Dw=",
+        "lastModified": 1687378457,
+        "narHash": "sha256-EkRX4S60BGFlQuEYJegk/9aCODPdMV6XLI+2C2HTBEI=",
         "owner": "cachix",
         "repo": "devenv",
-        "rev": "f5278b5d56e39f86a299a2e1889906933a26f762",
+        "rev": "c6ac4dbf501edafe0d6860e821b9e9fff0828921",
         "type": "github"
       },
       "original": {
@@ -134,11 +134,11 @@
         "systems": "systems_2"
       },
       "locked": {
-        "lastModified": 1685518550,
-        "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
+        "lastModified": 1687171271,
+        "narHash": "sha256-BJlq+ozK2B1sJDQXS3tzJM5a+oVZmi1q0FlBK/Xqv7M=",
         "owner": "numtide",
         "repo": "flake-utils",
-        "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
+        "rev": "abfb11bd1aec8ced1c9bb9adfe68018230f4fb3c",
         "type": "github"
       },
       "original": {
@@ -152,11 +152,11 @@
         "systems": "systems_3"
       },
       "locked": {
-        "lastModified": 1685518550,
-        "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
+        "lastModified": 1687171271,
+        "narHash": "sha256-BJlq+ozK2B1sJDQXS3tzJM5a+oVZmi1q0FlBK/Xqv7M=",
         "owner": "numtide",
         "repo": "flake-utils",
-        "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
+        "rev": "abfb11bd1aec8ced1c9bb9adfe68018230f4fb3c",
         "type": "github"
       },
       "original": {
diff --git a/result b/result
deleted file mode 120000
index 0025238..0000000
--- a/result
+++ /dev/null
@@ -1 +0,0 @@
-/nix/store/fmvzckvxnqrfbgv0mvzghgi87602pgcm-nucleus-1.1.0
\ No newline at end of file
diff --git a/src/config/default.ts b/src/config/default.ts
index 69de019..ea5600d 100644
--- a/src/config/default.ts
+++ b/src/config/default.ts
@@ -61,7 +61,7 @@
     stats: {},
     logging: {
         logs: {
-            enabled: false,
+            enabled: true,
             channel: null,
             toLog: "3fffff"
         },
diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts
index 6a79447..3ead1c5 100644
--- a/src/events/interactionCreate.ts
+++ b/src/events/interactionCreate.ts
@@ -46,6 +46,61 @@
                     : false;
             return await modifySuggestion(interaction, value);
         }
+        if (interaction.customId === "log:message.edit") {
+            await interaction.channel?.messages.fetch({ message: interaction.message.id, force: true });
+            const attachment = interaction.message.embeds[0]?.image ?? interaction.message.attachments.first();
+            if (!attachment) return;
+            const attachmentData = await (await fetch(attachment.url)).text();
+            const decoded = atob(attachmentData);
+            const json = (
+                JSON.parse(decoded) as { data: { count: number; value: string; added?: boolean; removed?: boolean }[] }
+            ).data;
+            // "Before" is everything where added is false
+            // "After" is everything where removed is false
+            const before: string = json
+                .filter((d) => !d.added)
+                .map((d) => d.value)
+                .join("");
+            const after: string = json
+                .filter((d) => !d.removed)
+                .map((d) => d.value)
+                .join("");
+            const { renderDateFooter } = client.logger;
+            await interaction.reply({
+                embeds: [
+                    new EmojiEmbed()
+                        .setTitle("Before")
+                        .setDescription(before)
+                        .setStatus("Danger")
+                        .setEmoji("ICONS.OPP.ADD"),
+                    new EmojiEmbed()
+                        .setTitle("After")
+                        .setDescription(after)
+                        .setStatus("Success")
+                        .setEmoji("ICONS.ADD")
+                        .setFooter({ text: `Edited at ${renderDateFooter(interaction.message.createdTimestamp!)}` }) // Created timestamp of the log is when the edit was made
+                ],
+                ephemeral: true
+            });
+        } else if (interaction.customId === "log:message.delete") {
+            await interaction.channel?.messages.fetch({ message: interaction.message.id, force: true });
+            const attachment = interaction.message.embeds[0]?.image ?? interaction.message.attachments.first();
+            if (!attachment) return;
+            const attachmentData = await (await fetch(attachment.url)).text();
+            const decoded = atob(attachmentData);
+            const json = JSON.parse(decoded) as { data: string };
+            await interaction.reply({
+                embeds: [
+                    new EmojiEmbed()
+                        .setTitle("Message")
+                        .setDescription(json.data)
+                        .setStatus("Danger")
+                        .setEmoji("MESSAGE.DELETE")
+                        .setFooter({ text: `Deleted at ${client.logger.renderDateFooter(Date.now())}` })
+                ],
+                ephemeral: true
+            });
+        }
         switch (interaction.customId) {
             case "rolemenu": {
                 return await roleMenu(interaction);
diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts
index b847400..8188a67 100644
--- a/src/events/messageDelete.ts
+++ b/src/events/messageDelete.ts
@@ -1,5 +1,6 @@
 import type { NucleusClient } from "../utils/client.js";
-import Discord, { AuditLogEvent, GuildAuditLogsEntry, Message, User } from "discord.js";
+import Discord, { AuditLogEvent, ButtonStyle, GuildAuditLogsEntry, Message, User } from "discord.js";
+import { imageDataEasterEgg } from "../utils/defaults.js";
 
 export const event = "messageDelete";
 
@@ -33,6 +34,7 @@
     if (config) {
         attachmentJump = ` [[View attachments]](${config})`;
     }
+    const imageData = JSON.stringify({ data: message.content, extra: imageDataEasterEgg }, null, 2);
     const data = {
         meta: {
             type: "messageDelete",
@@ -40,7 +42,9 @@
             calculateType: "messageDelete",
             color: NucleusColors.red,
             emoji: "MESSAGE.DELETE",
-            timestamp: Date.now()
+            timestamp: Date.now(),
+            imageData: imageData,
+            buttons: [{ buttonText: "View text", buttonId: "log:message.delete", buttonStyle: ButtonStyle.Secondary }]
         },
         separate: {
             start: content ? `**Message:**\n\`\`\`${content}\`\`\`` : "**Message:** *Message had no content*"
diff --git a/src/events/messageEdit.ts b/src/events/messageEdit.ts
index f41901c..35de994 100644
--- a/src/events/messageEdit.ts
+++ b/src/events/messageEdit.ts
@@ -1,7 +1,9 @@
 import type { NucleusClient } from "../utils/client.js";
-import { AttachmentBuilder, Message, MessageReference } from "discord.js";
+import { Message, MessageReference, ButtonStyle } from "discord.js";
 import type Discord from "discord.js";
 import * as diff from "diff";
+import addPlural from "../utils/plurals.js";
+import { imageDataEasterEgg } from "../utils/defaults.js";
 
 export const event = "messageUpdate";
 
@@ -63,47 +65,15 @@
         return;
     }
     const differences = diff.diffChars(oldContent, newContent);
-    const green = "\x1B[36m";
-    const red = "\x1B[41m";
-    const skipped = "\x1B[40;33m";
-    const reset = "\x1B[0m";
-    const bold = "\x1B[1m";
-    // console.log(differences);
-    // let contentAdd = "";
-    // let contentRemove = "";
-    // if (differences.map((d) => (d.added || d.removed ? 1 : 0)).filter((f) => f === 1).length > 0) {
-    //     const cutoff = 20;
-    //     differences.forEach((part) => {
-    //         if (!part.added && !part.removed && part.value.length > cutoff) {
-    //             contentAdd +=
-    //                 reset +
-    //                 part.value.slice(0, cutoff / 2) +
-    //                 skipped +
-    //                 `(${part.value.length - cutoff} more)` +
-    //                 reset +
-    //                 part.value.slice(-(cutoff / 2));
-    //             contentRemove +=
-    //                 reset +
-    //                 part.value.slice(0, cutoff / 2) +
-    //                 skipped +
-    //                 `(${part.value.length - cutoff} more)` +
-    //                 reset +
-    //                 part.value.slice(-(cutoff / 2));
-    //         } else {
-    //             if (part.added || part.removed) {
-    //                 part.value = part.value.replaceAll(" ", "▁");
-    //             }
-    //             if (part.added) {
-    //                 contentAdd += green + part.value + reset;
-    //             } else if (part.removed) {
-    //                 contentRemove += red + part.value + reset;
-    //             } else {
-    //                 contentAdd += part.value;
-    //                 contentRemove += part.value;
-    //             }
-    //         }
-    //     });
-    const key = `\n\n${bold}Key:${reset} ${green}Added${reset} | ${red}Removed${reset} | ${skipped}Skipped${reset}`;
+    const charsAdded = differences
+        .filter((d) => d.added)
+        .map((d) => d.count)
+        .reduce((a, b) => a! + b!, 0)!;
+    const charsRemoved = differences
+        .filter((d) => d.removed)
+        .map((d) => d.count)
+        .reduce((a, b) => a! + b!, 0)!;
+    const imageData = JSON.stringify({ data: differences, extra: imageDataEasterEgg }, null, 2);
     const data = {
         meta: {
             type: "messageUpdate",
@@ -112,16 +82,11 @@
             color: NucleusColors.yellow,
             emoji: "MESSAGE.EDIT",
             timestamp: newMessage.editedTimestamp,
-            files: [
-                new AttachmentBuilder(Buffer.from(JSON.stringify(differences), "base64"), {
-                    name: "diff.json",
-                    description: "A JSON file containing the differences between the two messages."
-                })
-            ],
-            showDetails: true
+            buttons: [{ buttonText: "View Changes", buttonStyle: ButtonStyle.Secondary, buttonId: `log:message.edit` }],
+            imageData: imageData
         },
         separate: {
-            start: `To read the full log press the button below.\n\`\`\`ansi\n${key}\`\`\``,
+            start: `${addPlural(charsAdded, "character")} added, ${addPlural(charsRemoved, "character")} removed`,
             end: `[[Jump to message]](${newMessage.url})`
         },
         list: {
diff --git a/src/utils/database.ts b/src/utils/database.ts
index 1bb1904..96cd068 100644
--- a/src/utils/database.ts
+++ b/src/utils/database.ts
@@ -471,8 +471,12 @@
                         if (child.type === ComponentType.Button) {
                             obj.style = child.style;
                             obj.label = child.label ?? "";
-                        } else if (child.type > 2) {
-                            // FIXME: Can we write this more clearly to make it obvious what we mean by 2 here?
+                        } else if (
+                            child.type === ComponentType.StringSelect ||
+                            child.type === ComponentType.UserSelect ||
+                            child.type === ComponentType.ChannelSelect ||
+                            child.type === ComponentType.RoleSelect
+                        ) {
                             obj.placeholder = child.placeholder ?? "";
                         }
                         return obj;
diff --git a/src/utils/defaults.ts b/src/utils/defaults.ts
index 0d4c9e3..ec337f4 100644
--- a/src/utils/defaults.ts
+++ b/src/utils/defaults.ts
@@ -43,3 +43,10 @@
 export { Embed };
 
 export const unknownServerIcon = "";
+
+export const imageDataEasterEgg =
+    "The image in this embed contains data about the below log.\n" +
+    "It isn't designed to be read by humans, but you can decode it with any base64 decoder, and then read it as JSON.\n" +
+    "We use base 64 to get around people using virus tests and the file being blocked, and an image to have the embed hidden (files can't be suppressed)\n" +
+    "If you've got to this point and are reading this hidden message, you should come and work with us " +
+    "at https://discord.gg/w35pXdrxKW (Internal development server) and let us know how you got here!";
diff --git a/src/utils/log.ts b/src/utils/log.ts
index 30804b3..b2b078d 100644
--- a/src/utils/log.ts
+++ b/src/utils/log.ts
@@ -4,7 +4,6 @@
 import generateKeyValueList from "./generateKeyValueList.js";
 import client from "./client.js";
 import { DiscordAPIError } from "discord.js";
-import { Stream } from "node:stream";
 import EmojiEmbed from "./generateEmojiEmbed.js";
 
 const wait = promisify(setTimeout);
@@ -17,15 +16,8 @@
         color: number;
         emoji: string;
         timestamp: number;
-        files?: (
-            | Discord.BufferResolvable
-            | Stream
-            | Discord.JSONEncodable<Discord.APIAttachment>
-            | Discord.Attachment
-            | Discord.AttachmentBuilder
-            | Discord.AttachmentPayload
-        )[];
-        showDetails?: boolean;
+        buttons?: { buttonText: string; buttonId: string; buttonStyle: Discord.ButtonStyle }[];
+        imageData?: string;
     };
     list: Record<string | symbol | number, unknown>;
     hidden: {
@@ -47,6 +39,13 @@
     return true;
 }
 
+const NucleusColors = {
+    red: 0xf27878,
+    yellow: 0xf2d478,
+    green: 0x68d49e,
+    blue: 0x72aef5
+};
+
 export const Logger = {
     renderUser(user: Discord.User | string) {
         if (typeof user === "string") user = client.users.cache.get(user)!;
@@ -62,6 +61,13 @@
         t = Math.floor((t /= 1000));
         return `<t:${t}:R> (<t:${t}:D> at <t:${t}:T>)`;
     },
+    renderDateFooter(t: number) {
+        if (isNaN(t)) return "Unknown";
+        const date = new Date(t);
+        return `${date.getUTCFullYear()}-${
+            date.getUTCMonth() + 1
+        }-${date.getUTCDate()} at ${date.getUTCHours()}:${date.getUTCMinutes()}:${date.getUTCSeconds()} UTC`;
+    },
     renderNumberDelta(num1: number, num2: number) {
         const delta = num2 - num1;
         return `${num1} -> ${num2} (${delta > 0 ? "+" : ""}${delta})`;
@@ -86,11 +92,7 @@
     renderEmoji(emoji: Discord.GuildEmoji) {
         return `<${emoji.animated ? "a" : ""}:${emoji.name}:${emoji.id}> [\`:${emoji.name}:\`]`;
     },
-    NucleusColors: {
-        red: 0xf27878,
-        yellow: 0xf2d478,
-        green: 0x68d49e
-    },
+    NucleusColors,
     async getAuditLog(
         guild: Discord.Guild,
         event: Discord.GuildAuditLogsResolvable,
@@ -106,6 +108,7 @@
             throw e;
         }
     },
+
     async log(log: LoggerOptions): Promise<void> {
         if (!(await isLogging(log.hidden.guild, log.meta.calculateType))) return;
         const config = await client.database.guilds.read(log.hidden.guild);
@@ -138,17 +141,29 @@
                         )
                         .setTimestamp(log.meta.timestamp)
                         .setColor(log.meta.color)
+                        .setImage(log.meta.imageData ? "attachment://extra_log_data.json.base64" : null)
                 ];
-                if (log.meta.files) messageOptions.files = log.meta.files;
-                if (log.meta.showDetails) {
-                    components.addComponents(
-                        new Discord.ButtonBuilder()
-                            .setCustomId("log:showDetails")
-                            .setLabel("Show Details")
-                            .setStyle(Discord.ButtonStyle.Primary)
-                    );
+                if (log.meta.buttons) {
+                    const buttons = [];
+                    for (const button of log.meta.buttons) {
+                        buttons.push(
+                            new Discord.ButtonBuilder()
+                                .setCustomId(button.buttonId)
+                                .setLabel(button.buttonText)
+                                .setStyle(button.buttonStyle)
+                        );
+                    }
+                    components.addComponents(buttons);
                     messageOptions.components = [components];
                 }
+                if (log.meta.imageData) {
+                    messageOptions.files = [
+                        {
+                            attachment: Buffer.from(btoa(log.meta.imageData), "utf-8"), // Use base 64 to prevent virus scanning (EICAR)
+                            name: "extra_log_data.json.base64"
+                        }
+                    ];
+                }
                 await channel.send(messageOptions);
             }
         }