From 4139048c09425f01d3b2c5ad082ac63f04be8920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BA=D0=B0=D1=84i=D1=84?= <62172116+cafeed28@users.noreply.github.com> Date: Sun, 5 Mar 2023 10:50:46 +0500 Subject: [PATCH] Stickers spoof (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update replugged, use waitForModule, waitForProps * Debug crust 🍞 * sticker spoofer (png only yet) --- README.md | 2 + manifest.json | 2 +- package.json | 4 +- pnpm-lock.yaml | 8 ++-- src/Settings.tsx | 39 +++++++++++++++- src/emoji.ts | 59 ++++++++++++++++++++++++ src/index.ts | 115 ++++++++++++----------------------------------- src/misc.ts | 33 +++++++++++++- src/renderer.ts | 67 +++++++++++++++++++++++++++ src/sticker.ts | 68 ++++++++++++++++++++++++++++ src/types.ts | 45 ++++++++++++++++--- src/webpack.ts | 77 +++++++++++++++++++++++-------- 12 files changed, 396 insertions(+), 123 deletions(-) create mode 100644 src/emoji.ts create mode 100644 src/renderer.ts create mode 100644 src/sticker.ts diff --git a/README.md b/README.md index daef604..b5a3e04 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Use some Nitro features without wasting your money! - - Can use emojis if they are unavailable due to a lack of boosts (they are unavaiable with nitro, use this plugin even if you have nitro 😼) - - Removes unavailable emoji tint in emoji picker +- Send stickers as pictures (only png yet) +- - TODO: apng and lottie support - Stream in 1080p/Source 60 fps quality (use at your own risk! may lead to account ban) - Doesn't spoof premiumType diff --git a/manifest.json b/manifest.json index 0145b21..da5650b 100644 --- a/manifest.json +++ b/manifest.json @@ -7,7 +7,7 @@ "discordID": "942752356595023874", "github": "cafeed28" }, - "version": "2.0.6", + "version": "2.1.0", "updater": { "type": "github", "id": "cafeed28/replugged-nitrospoof" diff --git a/package.json b/package.json index a4b94ff..9e14b3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replugged-nitrospoof", - "version": "2.0.6", + "version": "2.1.0", "description": "Use some Nitro features without wasting your money!", "engines": { "node": ">=14.0.0" @@ -31,7 +31,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-react": "^7.32.2", "prettier": "^2.8.3", - "replugged": "4.0.0-beta0.25", + "replugged": "4.0.0-rc.3", "typescript": "^4.9.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96ae342..18bd98e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ specifiers: eslint-plugin-node: ^11.1.0 eslint-plugin-react: ^7.32.2 prettier: ^2.8.3 - replugged: 4.0.0-beta0.25 + replugged: 4.0.0-rc.3 typescript: ^4.9.5 devDependencies: @@ -25,7 +25,7 @@ devDependencies: eslint-plugin-node: 11.1.0_eslint@8.33.0 eslint-plugin-react: 7.32.2_eslint@8.33.0 prettier: 2.8.3 - replugged: 4.0.0-beta0.25 + replugged: 4.0.0-rc.3 typescript: 4.9.5 packages: @@ -3332,8 +3332,8 @@ packages: engines: {node: '>=8'} dev: true - /replugged/4.0.0-beta0.25: - resolution: {integrity: sha512-51KTyF5RJI6MMtPklYj6GVP/q9+QvTYUUaCtVvLNTIWTv5eV9TUiqcUaLqwL+mJ0d04bkU6PM/Tu3D4Httj5WQ==} + /replugged/4.0.0-rc.3: + resolution: {integrity: sha512-G26boVV9zn32GzvLDx9YOcUowETdD59BNMBu183hwvU2h22fXjD8zeaQlPtqxf0gJO8q1G9EC/c9zN8FafgSQA==} engines: {node: '>=14.0.0'} hasBin: true dependencies: diff --git a/src/Settings.tsx b/src/Settings.tsx index 8be4235..517eb7e 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1,6 +1,6 @@ import { components, util } from "replugged"; import { config } from "./misc"; -import { EmojiStaticExtension } from "./types"; +import { MediaStaticExtension } from "./types"; const { Category, FormItem, Radio, Slider, SwitchItem } = components; @@ -44,7 +44,42 @@ export function Settings() { }, ]} value={emojiStaticExtension.value!} - onChange={(e) => emojiStaticExtension.onChange(e.value as EmojiStaticExtension)} + onChange={(e) => emojiStaticExtension.onChange(e.value as MediaStaticExtension)} + /> + + + + + +
+ { + return `${e}px`; + }} + /> +
+
+ + + emojiStaticExtension.onChange(e.value as MediaStaticExtension)} />
diff --git a/src/emoji.ts b/src/emoji.ts new file mode 100644 index 0000000..efc8018 --- /dev/null +++ b/src/emoji.ts @@ -0,0 +1,59 @@ +import { common } from "replugged"; +import { HIDE_TEXT_SPOILERS, config, userPremiumType } from "./misc"; +import { Emoji, OutgoingMessage, PremiumType } from "./types"; + +function isEmojiAvailable(emoji: Emoji): boolean { + // Unicode emoji + if (emoji.emojiObject) return true; + + // Emoji not available on Discord (e.g. GUILD_SUBSCRIPTION_UNAVAILABLE) + if (!emoji.available) return false; + + // User has Nitro + if (userPremiumType != PremiumType.NONE) return true; + + if (emoji.animated) return false; + + // Note: getGuildId will return null if user is in DMs + if (emoji.guildId == common.guilds.getGuildId()) return true; + + return false; +} + +export function spoofEmojis(message: OutgoingMessage): void { + const escapedIds: string[] = []; + + for (const match of message.content.matchAll(/\\<(a?):(.*?):(.*?)>/gm)) { + escapedIds.push(match[3]); + } + + const hideLinks = config.get("emojiHideLinks", false); + + for (const emoji of message.validNonShortcutEmojis) { + if (escapedIds.includes(emoji.id)) continue; + if (isEmojiAvailable(emoji)) continue; + + const prefix = emoji.animated ? "a" : ""; + const name = emoji.originalName || emoji.name; + const search = `<${prefix}:${name}:${emoji.id}>`; + + const size = config.get("emojiSize"); + const extension = emoji.animated ? "gif" : config.get("emojiStaticExtension"); + const url = `https://cdn.discordapp.com/emojis/${emoji.id}.${extension}?size=${size}`; + + // Move emoji to the end and hide it's link + if (hideLinks && message.content.length > search.length) { + // Remove emoji + message.content = message.content.replace(search, ""); + + // Add spoilers if needed + if (!message.content.includes(HIDE_TEXT_SPOILERS)) message.content += HIDE_TEXT_SPOILERS; + + // Add emoji + message.content += ` ${url} `; + } else { + // Replace emoji with link + message.content = message.content.replace(search, url); + } + } +} diff --git a/src/index.ts b/src/index.ts index 05ebcf9..0c837aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,92 +1,25 @@ -import { Injector, common } from "replugged"; - -import { HIDE_TEXT_SPOILERS, config } from "./misc"; - -import { Emoji, OutgoingMessage, PremiumType, UserFetchResponse } from "./types"; -import { User } from "discord-types/general"; -import { emojiInfo, messageParser, premiumInfo, userProfileFetch, users } from "./webpack"; +import { Injector } from "replugged"; + +import { config, ready, userChanged, userInit } from "./misc"; + +import { + addStickerPreview, + emojiInfo, + isSendableSticker, + messageParser, + premiumInfo, + shouldAttachSticker, + stickerInfo, + stickerPreview, + stickerSendability, + users, +} from "./webpack"; + +import { spoofEmojis } from "./emoji"; +import { spoofSticker } from "./sticker"; const injector = new Injector(); -let userPremiumType: PremiumType; - -function isEmojiAvailable(emoji: Emoji): boolean { - // Unicode emoji - if (emoji.emojiObject) return true; - - // Emoji not available on Discord (e.g. GUILD_SUBSCRIPTION_UNAVAILABLE) - if (!emoji.available) return false; - - // User has Nitro - if (userPremiumType != PremiumType.None) return true; - - if (emoji.animated) return false; - - // Note: getGuildId will return null if user is in DMs - if (emoji.guildId == common.guilds.getGuildId()) return true; - - return false; -} - -function replaceEmojis(message: OutgoingMessage): void { - const escapedIds: string[] = []; - - for (const match of message.content.matchAll(/\\<(a?):(.*?):(.*?)>/gm)) { - escapedIds.push(match[3]); - } - - const hideLinks = config.get("emojiHideLinks", false); - - for (const emoji of message.validNonShortcutEmojis) { - if (escapedIds.includes(emoji.id)) continue; - if (isEmojiAvailable(emoji)) continue; - - const prefix = emoji.animated ? "a" : ""; - const name = emoji.originalName || emoji.name; - const search = `<${prefix}:${name}:${emoji.id}>`; - - const size = config.get("emojiSize"); - const extension = emoji.animated ? "gif" : config.get("emojiStaticExtension"); - const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${extension}?size=${size}`; - - // Move emoji to the end and hide it's link - if (hideLinks && message.content.length > search.length) { - // Remove emoji - message.content = message.content.replace(search, ""); - - // Add spoilers if needed - if (!message.content.includes(HIDE_TEXT_SPOILERS)) message.content += HIDE_TEXT_SPOILERS; - - // Add emoji - message.content += ` ${emojiUrl} `; - } else { - // Replace emoji with link - message.content = message.content.replace(search, emojiUrl); - } - } -} - -let user: User; -let userProfile: UserFetchResponse; -let ready = false; - -async function userInit(): Promise { - user = common.users.getCurrentUser(); - - if (user) userProfile = await userProfileFetch(user.id); - if (userProfile) userPremiumType = userProfile.premium_type ?? PremiumType.None; - ready = Boolean(user && userProfile); - console.log(ready); -} - -async function userChanged(): Promise { - const newUser = common.users.getCurrentUser(); - if (!newUser) return; - if (user && newUser.id == user.id) return; - - await userInit(); -} - export async function start(): Promise { // using premiumType from common.users.getCurrentUser will broke with plugins like No Nitro Upsell await userInit(); @@ -94,7 +27,7 @@ export async function start(): Promise { users.addChangeListener(userChanged); injector.after(messageParser, "parse", (_, message) => { - if (ready) replaceEmojis(message); + if (ready) spoofEmojis(message); return message; }); @@ -106,7 +39,15 @@ export async function start(): Promise { // Emoji picker tint injector.instead(emojiInfo, "getEmojiUnavailableReason", () => null); + // Stickers + injector.instead(stickerInfo, shouldAttachSticker, () => true); + injector.instead(stickerSendability, isSendableSticker, () => true); + injector.instead(stickerPreview, addStickerPreview, async ([_, sticker]) => { + if (ready) await spoofSticker(sticker); + }); + // Stream quality + // FIXME: streamQualityEnable false will disable high quality for nitro users injector.instead(premiumInfo, "canStreamHighQuality", () => config.get("streamQualityEnable")); injector.instead(premiumInfo, "canStreamMidQuality", () => config.get("streamQualityEnable")); } diff --git a/src/misc.ts b/src/misc.ts index c859f81..191a867 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1,11 +1,40 @@ -import { settings } from "replugged"; -import { Config } from "./types"; +import { common, settings } from "replugged"; +import { userProfileFetch } from "./webpack"; + +import { User } from "discord-types/general"; +import { Config, PremiumType, UserFetchResponse } from "./types"; export const config = await settings.init("com.cafeed28.NitroSpoof", { emojiSize: 48, emojiStaticExtension: "png", emojiHideLinks: false, + + stickerSize: 160, + stickerStaticExtension: "png", + streamQualityEnable: false, }); export const HIDE_TEXT_SPOILERS = "||\u200b||".repeat(199); + +export let userPremiumType: PremiumType; + +let user: User; +let userProfile: UserFetchResponse; +export let ready = false; + +export async function userInit(): Promise { + user = common.users.getCurrentUser(); + if (user) userProfile = await userProfileFetch(user.id); + if (userProfile) userPremiumType = userProfile.premium_type ?? PremiumType.NONE; + + ready = Boolean(user && userProfile); +} + +export async function userChanged(): Promise { + const newUser = common.users.getCurrentUser(); + if (!newUser) return; + if (user && newUser.id == user.id) return; + + await userInit(); +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..5cc6aa2 --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,67 @@ +import { config } from "./misc"; + +const CANVAS_ID = "replugged-nitrospoof-renderer-canvas"; + +function getContext(width: number, height: number): CanvasRenderingContext2D { + let canvas = document.getElementById(CANVAS_ID) as HTMLCanvasElement; + + if (!(canvas instanceof HTMLCanvasElement)) { + canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvas.id = CANVAS_ID; + + // document.querySelector(".sidebar-1tnWFu")?.appendChild(canvas); // used to debug canvas, but it's funny so you can uncomment it + } + + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw Error("Canvas)"); + context.clearRect(0, 0, canvas.width, canvas.height); + return context; +} + +function drawImageScaled(image: HTMLImageElement, size: number): void { + const context = getContext(size, size); + + const scaleWidth = size / image.width; + const scaleHeight = size / image.height; + const scale = Math.min(scaleWidth, scaleHeight); + + const offsetX = (size - image.width * scale) / 2; + const offsetY = (size - image.height * scale) / 2; + context.clearRect(0, 0, size, size); + context.drawImage( + image, + 0, + 0, + image.width, + image.height, + offsetX, + offsetY, + image.width * scale, + image.height * scale, + ); +} + +export async function renderPng(data: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + const size = config.get("stickerSize")!; + + const context = getContext(size, size); + + const blob = new Blob([data], { type: "image/png" }); + + const image = new Image(); + image.src = URL.createObjectURL(blob); + image.onload = () => { + drawImageScaled(image, size); + + URL.revokeObjectURL(image.src); + + context.canvas.toBlob((blob) => { + if (!blob) reject(new Error("Failed to get blob from canvas")); + resolve(blob!); + }, "image/png"); + }; + }); +} diff --git a/src/sticker.ts b/src/sticker.ts new file mode 100644 index 0000000..bc2f090 --- /dev/null +++ b/src/sticker.ts @@ -0,0 +1,68 @@ +import { common } from "replugged"; +import { config, userPremiumType } from "./misc"; +import { PremiumType, Sticker, StickerType } from "./types"; +import { files } from "./webpack"; +import { renderPng } from "./renderer"; + +async function download(url: string): Promise { + const res = await fetch(url); + return await res.blob(); +} + +function getUrl(sticker: Sticker): string { + const extension = config.get("stickerStaticExtension"); + + switch (sticker.format_type) { + case StickerType.PNG: + return `https://cdn.discordapp.com/stickers/${sticker.id}.${extension}`; + case StickerType.APNG: + return `https://cdn.discordapp.com/stickers/${sticker.id}.${extension}?passtrough=true`; + case StickerType.LOTTIE: + return `https://cdn.discordapp.com/stickers/${sticker.id}.json`; + } +} + +function isStickerAvailable(sticker: Sticker): boolean { + // Emoji not available on Discord (e.g. GUILD_SUBSCRIPTION_UNAVAILABLE) + if (!sticker.available) return false; + + // User has Nitro + if (userPremiumType != PremiumType.NONE) return true; + + // Note: getGuildId will return null if user is in DMs + if (sticker.guild_id == common.guilds.getGuildId()) return true; + + return false; +} + +export async function spoofSticker(sticker: Sticker): Promise { + if (isStickerAvailable(sticker)) return; + + const url = getUrl(sticker); + const extension = config.get("stickerStaticExtension"); + + const imageFile = await download(url); + const arrayBuffer = await imageFile.arrayBuffer(); + + let renderedImage: Blob; + + switch (sticker.format_type) { + case StickerType.PNG: + renderedImage = await renderPng(arrayBuffer); + break; + default: // TODO: implement Apng and Lottie + return; + } + + files.addFile({ + channelId: common.channels.getChannelId()!, + draftType: 0, + showLargeMessageDialog: false, + file: { + platform: 1, + file: new File([renderedImage], `${sticker.id}.${extension}`, { + type: "image/png", + }), + }, + }); +} diff --git a/src/types.ts b/src/types.ts index 6108f7d..7ce1a92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,22 @@ -export type EmojiStaticExtension = "png" | "webp"; +export type MediaStaticExtension = "png" | "webp"; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type Config = { emojiSize?: number; - emojiStaticExtension?: EmojiStaticExtension; + emojiStaticExtension?: MediaStaticExtension; emojiHideLinks?: boolean; + + stickerSize?: number; + stickerStaticExtension?: MediaStaticExtension; + streamQualityEnable?: boolean; }; export enum PremiumType { - None = 0, - Tier1, - Tier2, - Tier3, + NONE = 0, + TIER_1, + TIER_2, + TIER_3, } /* eslint-disable @typescript-eslint/naming-convention */ @@ -38,7 +42,36 @@ export interface Emoji { }; } +export enum StickerType { + PNG = 1, + APNG, + LOTTIE, +} + +/* eslint-disable @typescript-eslint/naming-convention */ +export interface Sticker { + name: string; + description: string; + id: string; + guild_id: string; + available: boolean; + format_type: StickerType; +} +/* eslint-enable @typescript-eslint/naming-convention */ + export interface OutgoingMessage { content: string; validNonShortcutEmojis: Emoji[]; } + +interface AttachmentFile { + file: File; + platform: number; // TODD: Find enum +} + +export interface Attachment { + file: AttachmentFile; + channelId: string; + showLargeMessageDialog: boolean; + draftType: number; // TODD: Find enum +} diff --git a/src/webpack.ts b/src/webpack.ts index 7919d4d..70c58d9 100644 --- a/src/webpack.ts +++ b/src/webpack.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { types, webpack } from "replugged"; -const { getByProps, getBySource, getExportsForProps, waitForModule } = webpack; +import { webpack } from "replugged"; +const { filters, getFunctionBySource, waitForModule, waitForProps } = webpack; -import { OutgoingMessage, UserFetchResponse } from "./types"; +import type { AnyFunction, ObjectExports } from "replugged/dist/types"; +import type { Attachment, OutgoingMessage, UserFetchResponse } from "./types"; type EmojiInfo = { isEmojiFiltered: () => boolean; @@ -10,39 +11,77 @@ type EmojiInfo = { isEmojiPremiumLocked: () => boolean; getEmojiUnavailableReason: () => null; }; -export const emojiInfo: EmojiInfo = getByProps("getEmojiUnavailableReason")!; +export const emojiInfo = await waitForProps("getEmojiUnavailableReason"); type PremiumInfo = { canStreamHighQuality: () => boolean; canStreamMidQuality: () => boolean; }; -export const premiumInfo: PremiumInfo = getByProps("canStreamHighQuality")!; +export const premiumInfo = await waitForProps("canStreamHighQuality"); type MessageParser = { parse: (message: unknown, content: string) => OutgoingMessage; - parsePreprocessor: types.AnyFunction; + parsePreprocessor: AnyFunction; }; -export const messageParser: MessageParser = getByProps("parse", "parsePreprocessor")!; +export const messageParser = await waitForProps( + "parse", + "parsePreprocessor", +); + +type Users = { + addChangeListener: (listener: () => void) => void; + removeChangeListener: (listener: () => void) => void; +}; + +export const users = await waitForProps("addChangeListener", "getCurrentUser"); + +type AttachmentUploader = { + addFile: (attachment: Attachment) => void; +}; + +export const files = await waitForModule( + filters.bySource('"UPLOAD_ATTACHMENT_ADD_FILES"'), +); type UserFetchFunction = (id: string) => Promise; -const userProfileRaw = getBySource('"USER_PROFILE_FETCH_START"')!; +const userProfileModule = await waitForModule( + filters.bySource('"USER_PROFILE_FETCH_START"'), +); // 99% safe -const userProfileFnName = Object.entries(userProfileRaw).find(([_, v]) => - v.toString().includes(".apply("), -)?.[0]; +export const userProfileFetch = getFunctionBySource( + userProfileModule, + ".apply(", +)!; -if (!userProfileFnName) { +if (!userProfileFetch) { throw new Error("Could not find user profile fetch function"); } -export const userProfileFetch = getExportsForProps(userProfileRaw, [userProfileFnName])![ - userProfileFnName -] as UserFetchFunction; +type AnyModule = { + [key: string]: AnyFunction; +}; -export const users = await waitForModule<{ - addChangeListener: (listener: () => void) => void; - removeChangeListener: (listener: () => void) => void; -}>(webpack.filters.byProps("addChangeListener", "getCurrentUser")); +export const stickerInfo = await waitForModule( + filters.bySource(".ANIMATE_ON_INTERACTION?"), +); +export const shouldAttachSticker: string = webpack.getFunctionKeyBySource( + stickerInfo, + ".EXPRESSION_SUGGESTIONS:", +)!; + +export const stickerSendability = await waitForModule(filters.bySource(".SENDABLE=0")); +export const isSendableSticker: string = webpack.getFunctionKeyBySource( + stickerSendability as ObjectExports, + ".SENDABLE}", +)!; + +export const stickerPreview = await waitForModule( + filters.bySource('"ADD_STICKER_PREVIEW"'), +); +export const addStickerPreview: string = webpack.getFunctionKeyBySource( + stickerPreview as ObjectExports, + '"ADD_STICKER_PREVIEW"', +)!;