diff --git a/src/plugins/petpet.ts b/src/plugins/petpet.ts new file mode 100644 index 00000000..fb8c5de8 --- /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((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 { + 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 7a733ed8..dfeb3306 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 { 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 + * + * @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(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; +}