aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/plugins/petpet.ts162
-rw-r--r--src/utils/misc.tsx28
2 files changed, 190 insertions, 0 deletions
diff --git a/src/plugins/petpet.ts b/src/plugins/petpet.ts
new file mode 100644
index 0000000..fb8c5de
--- /dev/null
+++ b/src/plugins/petpet.ts
@@ -0,0 +1,162 @@
+import { ApplicationCommandOptionType, findOption, ApplicationCommandInputType, Argument, CommandContext } from "../api/Commands";
+import { Devs } from "../utils/constants";
+import definePlugin from "../utils/types";
+import { lazy, lazyWebpack, suppressErrors } from "../utils/misc";
+import { filters } from "../webpack";
+
+const DRAFT_TYPE = 0;
+const DEFAULT_DELAY = 20;
+const DEFAULT_RESOLUTION = 128;
+const FRAMES = 10;
+
+// https://github.com/mattdesl/gifenc
+// this lib is way better than gif.js and all other libs, they're all so terrible but this one is nice
+// @ts-ignore ts mad
+const getGifEncoder = lazy(() => import("https://unpkg.com/gifenc@1.0.3/dist/gifenc.esm.js"));
+
+const getFrames = lazy(() => Promise.all(
+ Array.from(
+ { length: FRAMES },
+ (_, i) => loadImage(`https://raw.githubusercontent.com/VenPlugs/petpet/main/frames/pet${i}.gif`)
+ ))
+);
+
+const fetchUser = lazyWebpack(filters.byCode(".USER("));
+const promptToUpload = lazyWebpack(filters.byCode("UPLOAD_FILE_LIMIT_ERROR"));
+const UploadStore = lazyWebpack(filters.byProps(["getUploads"]));
+
+function loadImage(source: File | string) {
+ const isFile = source instanceof File;
+ const url = isFile ? URL.createObjectURL(source) : source;
+
+ return new Promise<HTMLImageElement>((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ if (isFile)
+ URL.revokeObjectURL(url);
+ resolve(img);
+ };
+ img.onerror = reject;
+ img.crossOrigin = "Anonymous";
+ img.src = url;
+ });
+}
+
+async function resolveImage(options: Argument[], ctx: CommandContext): Promise<File | string | null> {
+ for (const opt of options) {
+ switch (opt.name) {
+ case "image":
+ const upload = UploadStore.getUploads(ctx.channel.id, DRAFT_TYPE)[0];
+ if (upload) {
+ if (!upload.isImage) throw "Upload is not an image";
+ return upload.item.file;
+ }
+ break;
+ case "url":
+ return opt.value;
+ case "user":
+ try {
+ const user = await fetchUser(opt.value);
+ return user.getAvatarURL(ctx.guild, 2048).replace(/\?size=\d+$/, "?size=2048");
+ } catch (err) {
+ console.error("[petpet] Failed to fetch user\n", err);
+ throw "Failed to fetch user. Check the console for more info.";
+ }
+ }
+ }
+ return null;
+}
+
+export default definePlugin({
+ name: "petpet",
+ description: "headpet a cutie",
+ authors: [Devs.Ven],
+ dependencies: ["CommandsAPI"],
+ commands: [
+ {
+ inputType: ApplicationCommandInputType.BUILT_IN,
+ name: "petpet",
+ description: "Create a petpet gif. You can only specify one of the image options",
+ options: [
+ {
+ name: "delay",
+ description: "The delay between each frame. Defaults to 20.",
+ type: ApplicationCommandOptionType.INTEGER
+ },
+ {
+ name: "resolution",
+ description: "Resolution for the gif. Defaults to 120. If you enter an insane number and it freezes Discord that's your fault.",
+ type: ApplicationCommandOptionType.INTEGER
+ },
+ {
+ name: "image",
+ description: "Image attachment to use",
+ type: ApplicationCommandOptionType.ATTACHMENT
+ },
+ {
+ name: "url",
+ description: "URL to fetch image from",
+ type: ApplicationCommandOptionType.STRING
+ },
+ {
+ name: "user",
+ description: "User whose avatar to use as image",
+ type: ApplicationCommandOptionType.USER
+ }
+ ],
+ execute: suppressErrors("petpetExecute", async (opts, cmdCtx) => {
+ const { GIFEncoder, quantize, applyPalette } = await getGifEncoder();
+ const frames = await getFrames();
+
+ try {
+ var url = await resolveImage(opts, cmdCtx);
+ if (!url) throw "No Image specified!";
+ } catch (err) {
+ // Todo make this send a clyde message once that PR is done
+ console.log(err);
+ return;
+ }
+
+ const avatar = await loadImage(url);
+
+ const delay = findOption(opts, "delay", DEFAULT_DELAY);
+ const resolution = findOption(opts, "resolution", DEFAULT_RESOLUTION);
+
+ const gif = new GIFEncoder();
+
+ const canvas = document.createElement("canvas");
+ canvas.width = canvas.height = resolution;
+ const ctx = canvas.getContext("2d")!;
+
+ for (let i = 0; i < FRAMES; i++) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const j = i < FRAMES / 2 ? i : FRAMES - i;
+ const width = 0.8 + j * 0.02;
+ const height = 0.8 - j * 0.05;
+ const offsetX = (1 - width) * 0.5 + 0.1;
+ const offsetY = 1 - height - 0.08;
+
+ ctx.drawImage(avatar, offsetX * resolution, offsetY * resolution, width * resolution, height * resolution);
+ ctx.drawImage(frames[i], 0, 0, resolution, resolution);
+
+ const { data } = ctx.getImageData(0, 0, resolution, resolution);
+ const palette = quantize(data, 256);
+ const index = applyPalette(data, palette);
+
+ gif.writeFrame(index, resolution, resolution, {
+ transparent: true,
+ palette,
+ delay,
+ });
+ }
+
+ gif.finish();
+ const file = new File([gif.bytesView()], "petpet.gif", { type: "image/gif" });
+ // Immediately after the command finishes, Discord clears all input, including pending attachments.
+ // Thus, setImmediate is needed to make this execute after Discord cleared the input
+ setImmediate(() => promptToUpload([file], cmdCtx.channel, DRAFT_TYPE));
+ }),
+ },
+ ]
+});
diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx
index 7a733ed..dfeb330 100644
--- a/src/utils/misc.tsx
+++ b/src/utils/misc.tsx
@@ -129,3 +129,31 @@ export function classes(...classes: string[]) {
export function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms));
}
+
+/**
+ * Wraps a Function into a try catch block and logs any errors caught
+ * Due to the nature of this function, not all paths return a result.
+ * Thus, for consistency, the returned functions will always return void or Promise<void>
+ *
+ * @param name Name identifying the wrapped function. This will appear in the logged errors
+ * @param func Function (async or sync both work)
+ * @param thisObject Optional thisObject
+ * @returns Wrapped Function
+ */
+export function suppressErrors<F extends Function>(name: string, func: F, thisObject?: any): F {
+ return (func.constructor.name === "AsyncFunction"
+ ? async function (this: any) {
+ try {
+ await func.apply(thisObject ?? this, arguments);
+ } catch (e) {
+ console.error(`Caught an Error in ${name || "anonymous"}\n`, e);
+ }
+ }
+ : function (this: any) {
+ try {
+ func.apply(thisObject ?? this, arguments);
+ } catch (e) {
+ console.error(`Caught an Error in ${name || "anonymous"}\n`, e);
+ }
+ }) as any as F;
+}