Plugin

FakeNitro

Allows you to send fake emojis/stickers, use nitro themes, and stream in nitro quality

Emotes Appearance Customisation Chat
index.tsx
Download

Source

src/plugins/fakeNitro/index.tsx
1import { addMessagePreEditListener, addMessagePreSendListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents";
2import { definePluginSettings } from "@api/Settings";
3import { ApngBlendOp, ApngDisposeOp, parseAPNG } from "@utils/apng";
4import { Devs } from "@utils/constants";
5import { getCurrentGuild } from "@utils/discord";
6import { Logger } from "@utils/Logger";
7import definePlugin, { OptionType } from "@utils/types";
8import type { Emoji, Message, RenderModalProps, Sticker } from "@vencord/discord-types";
9import { StickerFormatType } from "@vencord/discord-types/enums";
10import { findByCodeLazy, findByPropsLazy, proxyLazyWebpack } from "@webpack";
11import { ChannelStore, ConfirmModal, DraftType, EmojiStore, FluxDispatcher, Forms, GuildMemberStore, IconUtils, lodash, openModal, Parser, PermissionsBits, PermissionStore, StickersStore, UploadHandler, UserSettingsActionCreators, UserSettingsProtoStore, UserStore } from "@webpack/common";
12import { applyPalette, GIFEncoder, quantize } from "gifenc";
13import type { ReactElement, ReactNode } from "react";
14
15const BINARY_READ_OPTIONS = findByPropsLazy("readerFactory");
16
17function searchProtoClassField(localName: string, protoClass: any) {
18 const field = protoClass?.fields?.find((field: any) => field.localName === localName);
19 if (!field) return;
20
21 const fieldGetter = Object.values(field).find(value => typeof value === "function") as any;
22 return fieldGetter?.();
23}
24
25const PreloadedUserSettingsActionCreators = proxyLazyWebpack(() => UserSettingsActionCreators.PreloadedUserSettingsActionCreators);
26const AppearanceSettingsActionCreators = proxyLazyWebpack(() => searchProtoClassField("appearance", PreloadedUserSettingsActionCreators.ProtoClass));
27const ClientThemeSettingsActionsCreators = proxyLazyWebpack(() => searchProtoClassField("clientThemeSettings", AppearanceSettingsActionCreators));
28
29const isUnusableRoleSubscriptionEmoji = findByCodeLazy(".getUserIsAdmin(");
30
31const enum EmojiIntentions {
32 REACTION,
33 STATUS,
34 COMMUNITY_CONTENT,
35 CHAT,
36 GUILD_STICKER_RELATED_EMOJI,
37 GUILD_ROLE_BENEFIT_EMOJI,
38 COMMUNITY_CONTENT_ONLY,
39 SOUNDBOARD,
40 VOICE_CHANNEL_TOPIC,
41 GIFT,
42 AUTO_SUGGESTION,
43 POLLS
44}
45
46const IS_BYPASSEABLE_INTENTION = `[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`;
47
48const enum FakeNoticeType {
49 Sticker,
50 Emoji
51}
52
53const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/;
54const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./;
55const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/;
56const hyperLinkRegex = /\[.+?\]\((https?:\/\/.+?)\)/;
57
58const settings = definePluginSettings({
59 enableEmojiBypass: {
60 description: "Allows sending fake emojis (also bypasses missing permission to use custom emojis)",
61 type: OptionType.BOOLEAN,
62 default: true,
63 restartNeeded: true
64 },
65 emojiSize: {
66 description: "Size of the emojis when sending",
67 type: OptionType.SLIDER,
68 default: 48,
69 markers: [32, 48, 56, 64, 96, 128, 160, 256, 512]
70 },
71 transformEmojis: {
72 description: "Whether to transform fake emojis into real ones",
73 type: OptionType.BOOLEAN,
74 default: true,
75 restartNeeded: true
76 },
77 enableStickerBypass: {
78 description: "Allows sending fake stickers (also bypasses missing permission to use stickers)",
79 type: OptionType.BOOLEAN,
80 default: true,
81 restartNeeded: true
82 },
83 stickerSize: {
84 description: "Size of the stickers when sending",
85 type: OptionType.SLIDER,
86 default: 160,
87 markers: [32, 64, 128, 160, 256, 512]
88 },
89 transformStickers: {
90 description: "Whether to transform fake stickers into real ones",
91 type: OptionType.BOOLEAN,
92 default: true,
93 restartNeeded: true
94 },
95 transformCompoundSentence: {
96 description: "Whether to transform fake stickers and emojis in compound sentences (sentences with more content than just the fake emoji or sticker link)",
97 type: OptionType.BOOLEAN,
98 default: false
99 },
100 enableStreamQualityBypass: {
101 description: "Allow streaming in nitro quality",
102 type: OptionType.BOOLEAN,
103 default: true,
104 restartNeeded: true
105 },
106 useHyperLinks: {
107 description: "Whether to use hyperlinks when sending fake emojis and stickers",
108 type: OptionType.BOOLEAN,
109 default: true
110 },
111 hyperLinkText: {
112 description: "What text the hyperlink should use. {{NAME}} will be replaced with the emoji/sticker name.",
113 type: OptionType.STRING,
114 default: "{{NAME}}"
115 },
116 disableEmbedPermissionCheck: {
117 description: "Whether to disable the embed permission check when sending fake emojis and stickers",
118 type: OptionType.BOOLEAN,
119 default: false
120 }
121});
122
123function hasPermission(channelId: string, permission: bigint) {
124 const channel = ChannelStore.getChannel(channelId);
125
126 if (!channel || channel.isPrivate()) return true;
127
128 return PermissionStore.can(permission, channel);
129}
130
131const hasExternalEmojiPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_EMOJIS);
132const hasExternalStickerPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.USE_EXTERNAL_STICKERS);
133const hasEmbedPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.EMBED_LINKS);
134const hasAttachmentPerms = (channelId: string) => hasPermission(channelId, PermissionsBits.ATTACH_FILES);
135
136function getWordBoundary(origStr: string, offset: number) {
137 return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " ";
138}
139
140function CannotEmbedNoticeModal({ modalProps, resolve }: { modalProps: RenderModalProps; resolve: (value: boolean) => void; }) {
141 const s = settings.use(["disableEmbedPermissionCheck"]);
142 return (
143 <ConfirmModal
144 {...modalProps}
145 title="Hold on!"
146 subtitle="You are trying to send/edit a message that contains a FakeNitro emoji or sticker, however you do not have permissions to embed links in the current channel. Are you sure you want to send this message? Your FakeNitro items will appear as a link only."
147 confirmText="Send Anyway"
148 cancelText="Cancel"
149 onConfirm={() => resolve(true)}
150 onCloseCallback={() => setImmediate(() => resolve(false))}
151 checkboxProps={{
152 checked: s.disableEmbedPermissionCheck === true,
153 onChange: checked => s.disableEmbedPermissionCheck = checked
154 }}
155 />
156 );
157}
158
159function showCannotEmbedNotice() {
160 return new Promise<boolean>(resolve => {
161 openModal(props => <CannotEmbedNoticeModal modalProps={props} resolve={resolve} />);
162 });
163}
164
165export default definePlugin({
166 name: "FakeNitro",
167 authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.fawn, Devs.captain, Devs.Nuckyz, Devs.AutumnVN, Devs.sadan],
168 description: "Allows you to send fake emojis/stickers, use nitro themes, and stream in nitro quality",
169 tags: ["Emotes", "Appearance", "Customisation", "Chat"],
170 dependencies: ["MessageEventsAPI"],
171
172 settings,
173
174 patches: [
175 {
176 find: "canUseCustomStickersEverywhere:",
177 replacement: [
178 {
179 match: /(?<=canUseCustomStickersEverywhere:function\(\i\)\{)/,
180 replace: "return true;",
181 predicate: () => settings.store.enableStickerBypass
182 },
183 {
184 match: /(?<=canUseHighVideoUploadQuality:function\(\i\)\{)/,
185 replace: "return true;",
186 predicate: () => settings.store.enableStreamQualityBypass
187 },
188 {
189 match: /(?<=canStreamQuality:function\(\i,\i\)\{)/,
190 replace: "return true;",
191 predicate: () => settings.store.enableStreamQualityBypass
192 },
193 {
194 match: /(?<=canUseClientThemes:function\(\i\)\{)/,
195 replace: "return true;"
196 },
197 {
198 match: /(?<=canUsePremiumAppIcons:function\(\i\)\{)/,
199 replace: "return true;"
200 }
201 ],
202 },
203 // Patch the emoji picker in voice calls to not be bypassed by fake nitro
204 {
205 find: &#039;.getByName("fork_and_knife")&#039;,
206 predicate: () => settings.store.enableEmojiBypass,
207 replacement: {
208 match: ".CHAT",
209 replace: ".STATUS"
210 }
211 },
212 {
213 find: ".GUILD_SUBSCRIPTION_UNAVAILABLE;",
214 group: true,
215 predicate: () => settings.store.enableEmojiBypass,
216 replacement: [
217 {
218 // Create a variable for the intention of using the emoji
219 match: /(?<=\.USE_EXTERNAL_EMOJIS.+?;)(?<=intention:(\i).+?)/,
220 replace: (_, intention) => `const fakeNitroIntention=${intention};`
221 },
222 {
223 // Disallow the emoji for external if the intention doesn't allow it
224 match: /&&!\i&&!\i(?=\)return \i\.\i\.DISALLOW_EXTERNAL;)/,
225 replace: m => `${m}&&!${IS_BYPASSEABLE_INTENTION}`
226 },
227 {
228 // Disallow the emoji for unavailable if the intention doesn't allow it
229 match: /!\i\.available(?=\)return \i\.\i\.GUILD_SUBSCRIPTION_UNAVAILABLE;)/,
230 replace: m => `${m}&&!${IS_BYPASSEABLE_INTENTION}`
231 },
232 {
233 // Disallow the emoji for premium locked if the intention doesn't allow it
234 match: /!(\i\.\i\.canUseEmojisEverywhere\(\i\))/,
235 replace: m => `(${m}&&!${IS_BYPASSEABLE_INTENTION})`
236 },
237 {
238 // Allow animated emojis to be used if the intention allows it
239 match: /(?<=\|\|)\i\.\i\.canUseAnimatedEmojis\(\i\)/,
240 replace: m => `(${m}||${IS_BYPASSEABLE_INTENTION})`
241 }
242 ]
243 },
244 // Allows the usage of subscription-locked emojis
245 {
246 find: ".getUserIsAdmin(",
247 replacement: {
248 match: /(function \i\(\i,\i)\){(.{0,250}.getUserIsAdmin\(.+?return!1})/,
249 replace: (_, rest1, rest2) => `${rest1},fakeNitroOriginal){if(!fakeNitroOriginal)return false;${rest2}`
250 }
251 },
252 // Make stickers always available
253 {
254 find: &#039;"SENDABLE"&#039;,
255 predicate: () => settings.store.enableStickerBypass,
256 replacement: {
257 match: /\i\.available\?/,
258 replace: "true?"
259 }
260 },
261 // Remove boost requirements to stream with high quality
262 {
263 find: "#{intl::STREAM_FPS_OPTION}",
264 predicate: () => settings.store.enableStreamQualityBypass,
265 replacement: {
266 match: /guildPremiumTier:\i\.\i\.TIER_\d,?/g,
267 replace: ""
268 }
269 },
270 {
271 find: &#039;"UserSettingsProtoStore"&#039;,
272 replacement: [
273 {
274 // Overwrite incoming connection settings proto with our local settings
275 match: /(?<=CONNECTION_OPEN:function\((\i)\){)/,
276 replace: (_, props) => `$self.handleProtoChange(${props}.userSettingsProto,${props}.user);`
277 },
278 {
279 // Overwrite non local proto changes with our local settings
280 match: /let{settings:/,
281 replace: "arguments[0].local||$self.handleProtoChange(arguments[0].settings.proto);$&"
282 }
283 ]
284 },
285 // Call our function to handle changing the gradient theme when selecting a new one
286 {
287 find: ",updateTheme(",
288 replacement: {
289 match: /(function \i\(\i\){let{backgroundGradientPresetId:(\i).+?)(\i\.\i\.updateAsync.+?theme=(.+?),.+?},\i\))/,
290 replace: (_, rest, backgroundGradientPresetId, originalCall, theme) => `${rest}$self.handleGradientThemeSelect(${backgroundGradientPresetId},${theme},()=>${originalCall});`
291 }
292 },
293 // Allow users to use custom client themes
294 {
295 find: &#039;("custom_themes_editor_footer")&#039;,
296 replacement: {
297 match: /(?<=\i=)\(0,\i\.\i\)\(\i\.\i\.TIER_2\)(?=,|;)/g,
298 replace: "true"
299 }
300 },
301 {
302 find: &#039;["strong","em","u","text","inlineCode","s","spoiler"]&#039;,
303 replacement: [
304 {
305 // Call our function to decide whether the emoji link should be kept or not
306 predicate: () => settings.store.transformEmojis,
307 match: /1!==(\i)\.length\|\|1!==\i\.length/,
308 replace: (m, content) => `${m}||$self.shouldKeepEmojiLink(${content}[0])`
309 },
310 {
311 // Patch the rendered message content to add fake nitro emojis or remove sticker links
312 predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
313 match: /(?=return{hasSpoilerEmbeds:\i,hasBailedAst:\i,content:(\i))/,
314 replace: (_, content) => `${content}=$self.patchFakeNitroEmojisOrRemoveStickersLinks(${content},arguments[2]?.formatInline);`
315 }
316 ]
317 },
318 {
319 find: "}renderStickersAccessories(",
320 replacement: [
321 {
322 // Call our function to decide whether the embed should be ignored or not
323 predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
324 match: /(renderEmbeds\((\i)\){)(.+?embeds\.map\(\((\i),\i\)?=>{)/,
325 replace: (_, rest1, message, rest2, embed) => `${rest1}const fakeNitroMessage=${message};${rest2}if($self.shouldIgnoreEmbed(${embed},fakeNitroMessage))return null;`
326 },
327 {
328 // Patch the stickers array to add fake nitro stickers
329 predicate: () => settings.store.transformStickers,
330 match: /renderStickersAccessories\((\i)\){let (\i)=\(0,\i\.\i\)\(\i\).+?;/,
331 replace: (m, message, stickers) => `${m}${stickers}=$self.patchFakeNitroStickers(${stickers},${message});`
332 },
333 {
334 // Filter attachments to remove fake nitro stickers or emojis
335 predicate: () => settings.store.transformStickers,
336 match: /renderAttachments\(\i\){.+?{attachments:(\i).+?;/,
337 replace: (m, attachments) => `${m}${attachments}=$self.filterAttachments(${attachments});`
338 }
339 ]
340 },
341 {
342 find: "#{intl::STICKER_POPOUT_UNJOINED_PRIVATE_GUILD_DESCRIPTION}",
343 predicate: () => settings.store.transformStickers,
344 replacement: [
345 {
346 // Export the renderable sticker to be used in the fake nitro sticker notice
347 match: /let{renderableSticker:(\i).{0,270}sticker:\i,channel:\i,/,
348 replace: (m, renderableSticker) => `${m}fakeNitroRenderableSticker:${renderableSticker},`
349 },
350 {
351 // Add the fake nitro sticker notice
352 match: /(let \i,{sticker:\i,channel:\i,closePopout:\i.+?}=(\i).+?;)(.+?description:)(\i)(?=,sticker:\i)/,
353 replace: (_, rest, props, rest2, reactNode) => `${rest}let{fakeNitroRenderableSticker}=${props};${rest2}$self.addFakeNotice(${FakeNoticeType.Sticker},${reactNode},!!fakeNitroRenderableSticker?.fake)`
354 }
355 ]
356 },
357 {
358 find: ".EMOJI_UPSELL_POPOUT_MORE_EMOJIS_OPENED,",
359 predicate: () => settings.store.transformEmojis,
360 replacement: {
361 // Export the emoji node to be used in the fake nitro emoji notice
362 match: /isDiscoverable:\i,shouldHideRoleSubscriptionCTA:\i,(?<={node:(\i),.+?)/,
363 replace: (m, node) => `${m}fakeNitroNode:${node},`
364 }
365 },
366 {
367 find: "#{intl::EMOJI_POPOUT_UNJOINED_DISCOVERABLE_GUILD_DESCRIPTION}",
368 predicate: () => settings.store.transformEmojis,
369 replacement: {
370 // Add the fake nitro emoji notice
371 match: /(?<=emojiDescription:)(\i)(?<=\1=\(\i=>\{.+?\}\)\((\i)\)[,;].+?)/,
372 replace: (_, reactNode, props) => `$self.addFakeNotice(${FakeNoticeType.Emoji},${reactNode},!!${props}?.fakeNitroNode?.fake)`
373 }
374 },
375 // Separate patch for allowing using custom app icons
376 {
377 find: "getCurrentDesktopIcon(),",
378 replacement: {
379 match: /\i\.\i\.isPremium\(\i\.\i\.getCurrentUser\(\)\)/,
380 replace: "true"
381 }
382 },
383 // Make all Soundboard sounds available
384 {
385 find: &#039;type:"GUILD_SOUNDBOARD_SOUND_CREATE"&#039;,
386 replacement: {
387 match: /(?<=type:"(?:SOUNDBOARD_SOUNDS_RECEIVED|GUILD_SOUNDBOARD_SOUND_CREATE|GUILD_SOUNDBOARD_SOUND_UPDATE|GUILD_SOUNDBOARD_SOUNDS_UPDATE)".+?available:)\i\.available/g,
388 replace: "true"
389 }
390 }
391 ],
392
393 get guildId() {
394 return getCurrentGuild()?.id;
395 },
396
397 get canUseEmotes() {
398 return (UserStore.getCurrentUser().premiumType ?? 0) > 0;
399 },
400
401 get canUseStickers() {
402 return (UserStore.getCurrentUser().premiumType ?? 0) > 1;
403 },
404
405 handleProtoChange(proto: any, user: any) {
406 try {
407 if (proto == null || typeof proto === "string") return;
408
409 const premiumType: number = user?.premium_type ?? UserStore?.getCurrentUser()?.premiumType ?? 0;
410
411 if (premiumType !== 2) {
412 proto.appearance ??= AppearanceSettingsActionCreators.create();
413
414 const protoStoreAppearenceSettings = UserSettingsProtoStore.settings.appearance;
415
416 const appearanceSettingsOverwrite = AppearanceSettingsActionCreators.create({
417 ...proto.appearance,
418 theme: protoStoreAppearenceSettings?.theme,
419 clientThemeSettings: protoStoreAppearenceSettings?.clientThemeSettings
420 });
421
422 proto.appearance = appearanceSettingsOverwrite;
423 }
424 } catch (err) {
425 new Logger("FakeNitro").error(err);
426 }
427 },
428
429 handleGradientThemeSelect(backgroundGradientPresetId: number | undefined, theme: number, original: () => void) {
430 const premiumType = UserStore?.getCurrentUser()?.premiumType ?? 0;
431 if (premiumType === 2 || backgroundGradientPresetId == null) return original();
432
433 if (!PreloadedUserSettingsActionCreators || !AppearanceSettingsActionCreators || !ClientThemeSettingsActionsCreators || !BINARY_READ_OPTIONS) return;
434
435 const currentAppearanceSettings = PreloadedUserSettingsActionCreators.getCurrentValue().appearance;
436
437 const newAppearanceProto = currentAppearanceSettings != null
438 ? AppearanceSettingsActionCreators.fromBinary(AppearanceSettingsActionCreators.toBinary(currentAppearanceSettings), BINARY_READ_OPTIONS)
439 : AppearanceSettingsActionCreators.create();
440
441 newAppearanceProto.theme = theme;
442
443 const clientThemeSettingsDummy = ClientThemeSettingsActionsCreators.create({
444 backgroundGradientPresetId: {
445 value: backgroundGradientPresetId
446 }
447 });
448
449 newAppearanceProto.clientThemeSettings ??= clientThemeSettingsDummy;
450 newAppearanceProto.clientThemeSettings.backgroundGradientPresetId = clientThemeSettingsDummy.backgroundGradientPresetId;
451
452 const proto = PreloadedUserSettingsActionCreators.ProtoClass.create();
453 proto.appearance = newAppearanceProto;
454
455 FluxDispatcher.dispatch({
456 type: "USER_SETTINGS_PROTO_UPDATE",
457 local: true,
458 partial: true,
459 settings: {
460 type: 1,
461 proto
462 }
463 });
464 },
465
466 trimContent(content: Array<any>) {
467 const firstContent = content[0];
468 if (typeof firstContent === "string") {
469 content[0] = firstContent.trimStart();
470 content[0] || content.shift();
471 } else if (typeof firstContent?.props?.children === "string") {
472 firstContent.props.children = firstContent.props.children.trimStart();
473 firstContent.props.children || content.shift();
474 }
475
476 const lastIndex = content.length - 1;
477 const lastContent = content[lastIndex];
478 if (typeof lastContent === "string") {
479 content[lastIndex] = lastContent.trimEnd();
480 content[lastIndex] || content.pop();
481 } else if (typeof lastContent?.props?.children === "string") {
482 lastContent.props.children = lastContent.props.children.trimEnd();
483 lastContent.props.children || content.pop();
484 }
485 },
486
487 clearEmptyArrayItems(array: Array<any>) {
488 return array.filter(item => item != null);
489 },
490
491 ensureChildrenIsArray(child: ReactElement<any>) {
492 if (!Array.isArray(child.props.children)) child.props.children = [child.props.children];
493 },
494
495 patchFakeNitroEmojisOrRemoveStickersLinks(content: Array<any>, inline: boolean) {
496 // If content has more than one child or it's a single ReactElement like a header, list or span
497 if ((content.length > 1 || typeof content[0]?.type === "string") && !settings.store.transformCompoundSentence) return content;
498
499 let nextIndex = content.length;
500
501 const transformLinkChild = (child: ReactElement<any>) => {
502 if (settings.store.transformEmojis) {
503 const fakeNitroMatch = child.props.href.match(fakeNitroEmojiRegex);
504 if (fakeNitroMatch) {
505 let url: URL | null = null;
506 try {
507 url = new URL(child.props.href);
508 } catch { }
509
510 const emojiName = EmojiStore.getCustomEmojiById(fakeNitroMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroEmoji";
511 const isAnimated = fakeNitroMatch[2] === "gif" || url?.searchParams.get("animated") === "true";
512
513 return Parser.defaultRules.customEmoji.react({
514 jumboable: !inline && content.length === 1 && typeof content[0].type !== "string",
515 animated: isAnimated,
516 emojiId: fakeNitroMatch[1],
517 name: emojiName,
518 fake: true
519 }, void 0, { key: String(nextIndex++) });
520 }
521 }
522
523 if (settings.store.transformStickers) {
524 if (fakeNitroStickerRegex.test(child.props.href)) return null;
525
526 const gifMatch = child.props.href.match(fakeNitroGifStickerRegex);
527 if (gifMatch) {
528 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
529 if (StickersStore.getStickerById(gifMatch[1])) return null;
530 }
531 }
532
533 return child;
534 };
535
536 const transformChild = (child: ReactElement<any>) => {
537 if (child?.props?.trusted != null) return transformLinkChild(child);
538 if (child?.props?.children != null) {
539 if (!Array.isArray(child.props.children)) {
540 child.props.children = modifyChild(child.props.children);
541 return child;
542 }
543
544 child.props.children = modifyChildren(child.props.children);
545 if (child.props.children.length === 0) return null;
546 return child;
547 }
548
549 return child;
550 };
551
552 const modifyChild = (child: ReactElement<any>) => {
553 const newChild = transformChild(child);
554
555 if (newChild?.type === "ul" || newChild?.type === "ol") {
556 this.ensureChildrenIsArray(newChild);
557 if (newChild.props.children.length === 0) return null;
558
559 let listHasAnItem = false;
560 for (const [index, child] of newChild.props.children.entries()) {
561 if (child == null) {
562 delete newChild.props.children[index];
563 continue;
564 }
565
566 this.ensureChildrenIsArray(child);
567 if (child.props.children.length > 0) listHasAnItem = true;
568 else delete newChild.props.children[index];
569 }
570
571 if (!listHasAnItem) return null;
572
573 newChild.props.children = this.clearEmptyArrayItems(newChild.props.children);
574 }
575
576 return newChild;
577 };
578
579 const modifyChildren = (children: Array<ReactElement<any>>) => {
580 for (const [index, child] of children.entries()) children[index] = modifyChild(child);
581
582 children = this.clearEmptyArrayItems(children);
583
584 return children;
585 };
586
587 try {
588 const newContent = modifyChildren(lodash.cloneDeep(content));
589 this.trimContent(newContent);
590
591 return newContent;
592 } catch (err) {
593 new Logger("FakeNitro").error(err);
594 return content;
595 }
596 },
597
598 patchFakeNitroStickers(stickers: Array<any>, message: Message) {
599 const itemsToMaybePush: Array<string> = [];
600
601 const contentItems = message.content.split(/\s/);
602 if (settings.store.transformCompoundSentence) itemsToMaybePush.push(...contentItems);
603 else if (contentItems.length === 1) itemsToMaybePush.push(contentItems[0]);
604
605 itemsToMaybePush.push(...message.attachments.filter(attachment => attachment.content_type === "image/gif").map(attachment => attachment.url));
606
607 for (const item of itemsToMaybePush) {
608 if (!settings.store.transformCompoundSentence && !item.startsWith("http") && !hyperLinkRegex.test(item)) continue;
609
610 const imgMatch = item.match(fakeNitroStickerRegex);
611 if (imgMatch) {
612 let url: URL | null = null;
613 try {
614 url = new URL(item);
615 } catch { }
616
617 const stickerName = StickersStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker";
618 stickers.push({
619 format_type: 1,
620 id: imgMatch[1],
621 name: stickerName,
622 fake: true
623 });
624
625 continue;
626 }
627
628 const gifMatch = item.match(fakeNitroGifStickerRegex);
629 if (gifMatch) {
630 if (!StickersStore.getStickerById(gifMatch[1])) continue;
631
632 const stickerName = StickersStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker";
633 stickers.push({
634 format_type: 2,
635 id: gifMatch[1],
636 name: stickerName,
637 fake: true
638 });
639 }
640 }
641
642 return stickers;
643 },
644
645 shouldIgnoreEmbed(embed: Message["embeds"][number], message: Message) {
646 try {
647 const contentItems = message.content.split(/\s/);
648 if (contentItems.length > 1 && !settings.store.transformCompoundSentence) return false;
649
650 switch (embed.type) {
651 case "image": {
652 const url = embed.url ?? embed.image?.url;
653 if (!url) return false;
654 if (
655 !settings.store.transformCompoundSentence
656 && !contentItems.some(item => item === url || item.match(hyperLinkRegex)?.[1] === url)
657 ) return false;
658
659 if (settings.store.transformEmojis) {
660 if (fakeNitroEmojiRegex.test(url)) return true;
661 }
662
663 if (settings.store.transformStickers) {
664 if (fakeNitroStickerRegex.test(url)) return true;
665
666 const gifMatch = url.match(fakeNitroGifStickerRegex);
667 if (gifMatch) {
668 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
669 if (StickersStore.getStickerById(gifMatch[1])) return true;
670 }
671 }
672
673 break;
674 }
675 }
676 } catch (e) {
677 new Logger("FakeNitro").error("Error in shouldIgnoreEmbed:", e);
678 }
679
680 return false;
681 },
682
683 filterAttachments(attachments: Message["attachments"]) {
684 return attachments.filter(attachment => {
685 if (attachment.content_type !== "image/gif") return true;
686
687 const match = attachment.url.match(fakeNitroGifStickerRegex);
688 if (match) {
689 // There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickersStore contains the id of the fake sticker
690 if (StickersStore.getStickerById(match[1])) return false;
691 }
692
693 return true;
694 });
695 },
696
697 shouldKeepEmojiLink(link: any) {
698 return link.target && fakeNitroEmojiRegex.test(link.target);
699 },
700
701 addFakeNotice(type: FakeNoticeType, node: Array<ReactNode>, fake: boolean) {
702 if (!fake) return node;
703
704 node = Array.isArray(node) ? node : [node];
705
706 switch (type) {
707 case FakeNoticeType.Sticker: {
708 node.push(" This is a FakeNitro sticker and renders like a real sticker only for you. Appears as a link to non-plugin users.");
709
710 return node;
711 }
712 case FakeNoticeType.Emoji: {
713 node.push(" This is a FakeNitro emoji and renders like a real emoji only for you. Appears as a link to non-plugin users.");
714
715 return node;
716 }
717 }
718 },
719
720 getStickerLink({ format_type, id }: Sticker) {
721 const ext = format_type === StickerFormatType.GIF ? "gif" : "png";
722 return `https:class="ts-cmt">//media.discordapp.net/stickers/${id}.${ext}?size=${settings.store.stickerSize}`;
723 },
724
725 async sendAnimatedSticker(stickerLink: string, stickerId: string, channelId: string) {
726
727 const { frames, width, height } = await fetch(stickerLink)
728 .then(res => res.arrayBuffer())
729 .then(parseAPNG);
730
731 const gif = GIFEncoder();
732 const resolution = settings.store.stickerSize;
733
734 const canvas = document.createElement("canvas");
735 canvas.width = resolution;
736 canvas.height = resolution;
737
738 const ctx = canvas.getContext("2d", {
739 willReadFrequently: true
740 })!;
741
742 const scale = resolution / Math.max(width, height);
743 ctx.scale(scale, scale);
744
745 let previousFrameData: ImageData;
746
747 for (const frame of frames) {
748 const { left, top, width, height, img, delay, blendOp, disposeOp } = frame;
749
750 previousFrameData = ctx.getImageData(left, top, width, height);
751
752 if (blendOp === ApngBlendOp.SOURCE) {
753 ctx.clearRect(left, top, width, height);
754 }
755
756 ctx.drawImage(img, left, top, width, height);
757
758 const { data } = ctx.getImageData(0, 0, resolution, resolution);
759
760 const palette = quantize(data, 256);
761 const index = applyPalette(data, palette);
762
763 gif.writeFrame(index, resolution, resolution, {
764 transparent: true,
765 palette,
766 delay
767 });
768
769 if (disposeOp === ApngDisposeOp.BACKGROUND) {
770 ctx.clearRect(left, top, width, height);
771 } else if (disposeOp === ApngDisposeOp.PREVIOUS) {
772 ctx.putImageData(previousFrameData, left, top);
773 }
774 }
775
776 gif.finish();
777
778 const file = new File([gif.bytesView() as Uint8Array<ArrayBuffer>], `${stickerId}.gif`, { type: "image/gif" });
779 UploadHandler.promptToUpload([file], ChannelStore.getChannel(channelId), DraftType.ChannelMessage);
780 },
781
782 canUseEmote(e: Emoji, channelId: string) {
783 if (e.type === 0) return true;
784 if (e.available === false) return false;
785
786 if (isUnusableRoleSubscriptionEmoji(e, this.guildId, true)) return false;
787
788 let isUsableTwitchSubEmote = false;
789 if (e.managed && e.guildId) {
790 const myRoles = GuildMemberStore.getSelfMember(e.guildId)?.roles ?? [];
791 isUsableTwitchSubEmote = e.roles.some(r => myRoles.includes(r));
792 }
793
794 if (this.canUseEmotes || isUsableTwitchSubEmote)
795 return e.guildId === this.guildId || hasExternalEmojiPerms(channelId);
796 else
797 return !e.animated && e.guildId === this.guildId;
798 },
799
800 start() {
801 const s = settings.store;
802
803 if (!s.enableEmojiBypass && !s.enableStickerBypass) {
804 return;
805 }
806
807 this.preSend = addMessagePreSendListener(async (channelId, messageObj, extra) => {
808 const { guildId } = this;
809
810 let hasBypass = false;
811
812 stickerBypass: {
813 if (!s.enableStickerBypass)
814 break stickerBypass;
815
816 const sticker = StickersStore.getStickerById(extra.stickers?.[0]!);
817 if (!sticker)
818 break stickerBypass;
819
820 // Discord Stickers are now free yayyy!! :D
821 if ("pack_id" in sticker)
822 break stickerBypass;
823
824 const canUseStickers = this.canUseStickers && hasExternalStickerPerms(channelId);
825 if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId))
826 break stickerBypass;
827
828 const link = this.getStickerLink(sticker);
829
830 if (sticker.format_type === StickerFormatType.APNG) {
831 if (!hasAttachmentPerms(channelId)) {
832 openModal(props => (
833 <ConfirmModal
834 {...props}
835 title="Hold on!"
836 confirmText="OK"
837 variant="primary"
838 >
839 <div>
840 <Forms.FormText>
841 You cannot send this message because it contains an animated FakeNitro sticker,
842 and you do not have permissions to attach files in the current channel. Please remove the sticker to proceed.
843 </Forms.FormText>
844 </div>
845 </ConfirmModal>
846 ));
847 } else {
848 this.sendAnimatedSticker(link, sticker.id, channelId);
849 }
850
851 return { cancel: true };
852 } else {
853 hasBypass = true;
854
855 const url = new URL(link);
856 url.searchParams.set("name", sticker.name);
857 url.searchParams.set("lossless", "true");
858
859 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", sticker.name);
860
861 messageObj.content += `${getWordBoundary(messageObj.content, messageObj.content.length - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}`;
862 extra.stickers!.length = 0;
863 }
864 }
865
866 if (s.enableEmojiBypass) {
867 for (const emoji of messageObj.validNonShortcutEmojis) {
868 if (this.canUseEmote(emoji, channelId)) continue;
869
870 hasBypass = true;
871
872 const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
873
874 const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
875 url.searchParams.set("size", s.emojiSize.toString());
876 url.searchParams.set("name", emoji.name);
877 url.searchParams.set("lossless", "true");
878
879 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name);
880
881 messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => {
882 return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + match.length)}`;
883 });
884 }
885 }
886
887 if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) {
888 if (!await showCannotEmbedNotice()) {
889 return { cancel: true };
890 }
891 }
892
893 return { cancel: false };
894 });
895
896 this.preEdit = addMessagePreEditListener(async (channelId, __, messageObj) => {
897 if (!s.enableEmojiBypass) return;
898
899 let hasBypass = false;
900
901 messageObj.content = messageObj.content.replace(/(?<!\\)<a?:(?:\w+):(\d+)>/ig, (emojiStr, emojiId, offset, origStr) => {
902 const emoji = EmojiStore.getCustomEmojiById(emojiId);
903 if (emoji == null) return emojiStr;
904 if (this.canUseEmote(emoji, channelId)) return emojiStr;
905
906 hasBypass = true;
907
908 const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
909 url.searchParams.set("size", s.emojiSize.toString());
910 url.searchParams.set("name", emoji.name);
911 url.searchParams.set("lossless", "true");
912
913 const linkText = s.hyperLinkText.replaceAll("{{NAME}}", emoji.name);
914
915 return `${getWordBoundary(origStr, offset - 1)}${s.useHyperLinks ? `[${linkText}](${url})` : url}${getWordBoundary(origStr, offset + emojiStr.length)}`;
916 });
917
918 if (hasBypass && !s.disableEmbedPermissionCheck && !hasEmbedPerms(channelId)) {
919 if (!await showCannotEmbedNotice()) {
920 return { cancel: true };
921 }
922 }
923
924 return { cancel: false };
925 });
926 },
927
928 stop() {
929 removeMessagePreSendListener(this.preSend);
930 removeMessagePreEditListener(this.preEdit);
931 }
932});
933