Merge branch 'main' into threads

This commit is contained in:
ITOH
2021-05-01 18:36:54 +02:00
38 changed files with 263 additions and 31 deletions
+1 -1
View File
@@ -1 +1 @@
* @ayntee @Skillz4Killz @itohatweb
* @Skillz4Killz @itohatweb
+4 -4
View File
@@ -15,13 +15,13 @@ jobs:
with:
deno-version: ${{ matrix.deno }}
- name: Cache dependencies
run: deno cache --no-check mod.ts
run: deno cache mod.ts
- name: Run test script for maintainers
if: ${{ github.actor == 'ayntee' || github.actor == 'Skillz4Killz' || github.actor == 'itohatweb' }}
run: deno test --unstable --coverage=coverage -A --no-check tests/mod.ts
if: ${{ github.actor == 'Skillz4Killz' || github.actor == 'itohatweb' }}
run: deno test --unstable --coverage=coverage -A tests/mod.ts
- name: Run test script if label added
if: ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'run-tests' }}
run: DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} deno test --unstable --coverage=coverage --allow-net --no-check tests/mod.ts
run: DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} deno test --unstable --coverage=coverage --allow-net tests/mod.ts
- name: Create coverage report
run: deno --unstable coverage ./coverage --lcov > coverage.lcov
- name: Collect and upload the coverage report
-5
View File
@@ -5,7 +5,6 @@ import { DiscordGatewayIntents } from "./types/gateway/gateway_intents.ts";
import { baseEndpoints, GATEWAY_VERSION } from "./util/constants.ts";
import { ws } from "./ws/ws.ts";
export let authorization = "";
export let secretKey = "";
export let botId = "";
export let applicationId = "";
@@ -16,7 +15,6 @@ export let proxyWSURL = `wss://gateway.discord.gg`;
export async function startBot(config: BotConfig) {
if (config.eventHandlers) eventHandlers = config.eventHandlers;
authorization = `Bot ${config.token}`;
ws.identifyPayload.token = `Bot ${config.token}`;
rest.token = `Bot ${config.token}`;
ws.identifyPayload.intents = config.intents.reduce(
@@ -38,8 +36,6 @@ export async function startBot(config: BotConfig) {
proxyWSURL = ws.botGatewayData.url;
// ws.lastShardId = ws.maxShards;
ws.spawnShards();
}
@@ -70,7 +66,6 @@ export function setApplicationId(id: string) {
* Advanced Devs: This function will allow you to have an insane amount of customization potential as when you get to large bots you need to be able to optimize every tiny detail to make you bot work the way you need.
*/
export async function startBigBrainBot(options: BigBrainBotConfig) {
authorization = `Bot ${options.token}`;
rest.token = `Bot ${options.token}`;
if (options.secretKey) secretKey = options.secretKey;
-2
View File
@@ -30,8 +30,6 @@ export async function handleGuildUpdate(data: DiscordGatewayPayload) {
if (Array.isArray(cachedValue) && Array.isArray(value)) {
const different = (cachedValue.length !== value.length) ||
cachedValue.find((val) => !value.includes(val)) ||
// TODO: check if this really works hehe
// @ts-ignore typescript thinks that this is not an array
value.find((val) => !cachedValue.includes(val));
if (!different) return;
}
+11 -2
View File
@@ -1,3 +1,4 @@
import { cacheHandlers } from "../../cache.ts";
import { rest } from "../../rest/rest.ts";
import { structures } from "../../structures/mod.ts";
import { Guild } from "../../types/guilds/guild.ts";
@@ -5,6 +6,7 @@ import { ModifyGuild } from "../../types/guilds/modify_guild.ts";
import { endpoints } from "../../util/constants.ts";
import { requireBotGuildPermissions } from "../../util/permissions.ts";
import { urlToBase64 } from "../../util/utils.ts";
import { ws } from "../../ws/ws.ts";
/** Modify a guilds settings. Requires the MANAGE_GUILD permission. */
export async function editGuild(guildId: string, options: ModifyGuild) {
@@ -28,6 +30,13 @@ export async function editGuild(guildId: string, options: ModifyGuild) {
options,
);
// TODO: use ws.botGatewayData to calculate the shard ID
return structures.createDiscordenoGuild(result, -1);
const cached = await cacheHandlers.get("guilds", guildId);
return structures.createDiscordenoGuild(
result,
cached?.shardId ||
Number(
(BigInt(result.id) >> 22n % BigInt(ws.botGatewayData.shards))
.toString(),
),
);
}
+2 -2
View File
@@ -1,5 +1,6 @@
import { cacheHandlers } from "../../cache.ts";
import { rest } from "../../rest/rest.ts";
import { GuildWidgetDetails } from "../../types/guilds/guild_widget_details.ts";
import { Errors } from "../../types/misc/errors.ts";
import { endpoints } from "../../util/constants.ts";
@@ -11,8 +12,7 @@ export async function getWidget(guildId: string, options?: { force: boolean }) {
if (!guild?.widgetEnabled) throw new Error(Errors.GUILD_WIDGET_NOT_ENABLED);
}
// TODO: add return type
return await rest.runMethod(
return await rest.runMethod<GuildWidgetDetails>(
"get",
`${endpoints.GUILD_WIDGET(guildId)}.json`,
);
+8 -2
View File
@@ -1,6 +1,7 @@
import { rest } from "../../rest/rest.ts";
import { CreateChannelInvite } from "../../types/invites/create_channel_invite.ts";
import { Invite } from "../../types/mod.ts";
import { Invite } from "../../types/invites/invite.ts";
import { Errors } from "../../types/misc/errors.ts";
import { endpoints } from "../../util/constants.ts";
import { requireBotChannelPermissions } from "../../util/permissions.ts";
@@ -11,7 +12,12 @@ export async function createInvite(
) {
await requireBotChannelPermissions(channelId, ["CREATE_INSTANT_INVITE"]);
// TODO: add proper options validation
if (options.maxAge && (options.maxAge < 0 || options.maxAge > 604800)) {
throw new Error(Errors.INVITE_MAX_AGE_INVALID);
}
if (options.maxUses && (options.maxUses < 0 || options.maxUses > 100)) {
throw new Error(Errors.INVITE_MAX_USES_INVALID);
}
return await rest.runMethod<Invite>(
"post",
+56
View File
@@ -3,6 +3,7 @@ import { rest } from "../../rest/rest.ts";
import { structures } from "../../structures/mod.ts";
import { DiscordChannelTypes } from "../../types/channels/channel_types.ts";
import { DiscordAllowedMentionsTypes } from "../../types/messages/allowed_mentions_types.ts";
import { ButtonStyles } from "../../types/messages/components/button_styles.ts";
import { CreateMessage } from "../../types/messages/create_message.ts";
import { Message } from "../../types/messages/message.ts";
import { Errors } from "../../types/misc/errors.ts";
@@ -11,6 +12,8 @@ import { endpoints } from "../../util/constants.ts";
import { requireBotChannelPermissions } from "../../util/permissions.ts";
import { camelKeysToSnakeCase } from "../../util/utils.ts";
import { validateLength } from "../../util/validate_length.ts";
import { isActionRow } from "../type_guards/is_action_row.ts";
import { isButton } from "../type_guards/is_button.ts";
/** Send a message to the channel. Requires SEND_MESSAGES permission. */
export async function sendMessage(
@@ -93,6 +96,59 @@ export async function sendMessage(
}
}
if (content.components?.length) {
let actionRowCounter = 0;
for (const component of content.components) {
// 5 Link buttons can not have a customId
if (isButton(component)) {
if (
component.type === ButtonStyles.Link &&
component.customId
) {
throw new Error(Errors.LINK_BUTTON_CANNOT_HAVE_CUSTOM_ID);
}
// Other buttons must have a customId
if (
!component.customId && component.type !== ButtonStyles.Link
) {
throw new Error(Errors.BUTTON_REQUIRES_CUSTOM_ID);
}
if (!validateLength(component.label, { max: 80 })) {
throw new Error(Errors.COMPONENT_LABEL_TOO_BIG);
}
if (
component.customId &&
!validateLength(component.customId, { max: 100 })
) {
throw new Error(Errors.COMPONENT_CUSTOM_ID_TOO_BIG);
}
}
if (!isActionRow(component)) {
continue;
}
actionRowCounter++;
// Max of 5 ActionRows per message
if (actionRowCounter > 5) throw new Error(Errors.TOO_MANY_ACTION_ROWS);
// Max of 5 Buttons (or any component type) within an ActionRow
if (component.components?.length > 5) {
throw new Error(Errors.TOO_MANY_COMPONENTS);
}
}
}
if (
content.nonce &&
!validateLength(content.nonce.toString(), { max: 25 })
) {
throw new Error(Errors.NONCE_TOO_LONG);
}
const result = await rest.runMethod<Message>(
"post",
endpoints.CHANNEL_MESSAGES(channelId),
+5
View File
@@ -122,6 +122,9 @@ import { executeWebhook } from "./webhooks/execute_webhook.ts";
import { getWebhook } from "./webhooks/get_webhook.ts";
import { getWebhooks } from "./webhooks/get_webhooks.ts";
import { getWebhookWithToken } from "./webhooks/get_webhook_with_token.ts";
// Type Guards
import { isActionRow } from "./type_guards/is_action_row.ts";
import { isButton } from "./type_guards/is_button.ts";
export {
addDiscoverySubcategory,
@@ -225,6 +228,8 @@ export {
guildBannerURL,
guildIconURL,
guildSplashURL,
isActionRow,
isButton,
isChannelSynced,
kick,
kickMember,
+10
View File
@@ -0,0 +1,10 @@
import { ActionRow } from "../../types/messages/components/action_row.ts";
import { MessageComponent } from "../../types/messages/components/message_components.ts";
import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts";
/** A type guard function to tell if it is a action row component */
export function isActionRow(
component: MessageComponent,
): component is ActionRow {
return component.type === MessageComponentTypes.ActionRow;
}
+10
View File
@@ -0,0 +1,10 @@
import { ButtonComponent } from "../../types/messages/components/button_component.ts";
import { MessageComponent } from "../../types/messages/components/message_components.ts";
import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts";
/** A type guard function to tell if it is a button component */
export function isButton(
component: MessageComponent,
): component is ButtonComponent {
return component.type === MessageComponentTypes.Button;
}
+1
View File
@@ -43,6 +43,7 @@ export enum DiscordJsonErrorCodes {
MaximumNumberOfInvitesReached,
MaximumNumberOfGuildDiscoverySubcategoriesHasBeenReached = 30030,
GuildAlreadyHasTemplate = 30031,
MaximumNumberOfBansForNonGuildMembersHaveBeenExceeded = 30035,
UnauthorizedProvideAValidTokenAndTryAgain = 40001,
YouNeedToVerifyYourAccountInOrderToPerformThisAction,
RequestEntityTooLargeTrySendingSomethingSmallerInSize = 40005,
+2
View File
@@ -110,4 +110,6 @@ export interface Guild {
approximatePresenceCount?: number;
/** The welcome screen of a Community guild, shown to new members, returned when in the invite object */
welcomeScreen?: WelcomeScreen;
/** `true` if this guild is designated as NSFW */
nsfw: boolean;
}
+24
View File
@@ -0,0 +1,24 @@
import { SnakeCasedPropertiesDeep } from "../util.ts";
export interface GuildWidgetDetails {
id: string;
name: string;
instantInvite: string;
channels: {
id: string;
name: string;
position: number;
}[];
members: {
id: string;
username: string;
discriminator: string;
avatar?: string | null;
status: string;
avatar_url: string;
}[];
presenceCount: number;
}
/** https://discord.com/developers/docs/resources/guild#get-guild-widget-example-get-guild-widget */
export type DiscordGuildWidget = SnakeCasedPropertiesDeep<GuildWidgetDetails>;
@@ -4,11 +4,15 @@ import { ApplicationCommandInteractionDataResolved } from "./application_command
/** https://discord.com/developers/docs/interactions/slash-commands#interaction-applicationcommandinteractiondata */
export interface ApplicationCommandInteractionData {
/** The Id of the invoked command */
id: string;
id?: string;
/** The name of the invoked command */
name: string;
name?: string;
/** Converted users + roles + channels */
resolved?: ApplicationCommandInteractionDataResolved;
/** The params + values from the user */
options?: ApplicationCommandInteractionDataOption[];
/** with the value you defined for this component */
customId?: string;
/** The type of this component */
componentType?: 2;
}
@@ -8,6 +8,7 @@ export enum DiscordApplicationCommandOptionTypes {
USER,
CHANNEL,
ROLE,
MENTIONABLE,
}
export type ApplicationCommandOptionTypes =
+3
View File
@@ -1,4 +1,5 @@
import { GuildMemberWithUser } from "../guilds/guild_member.ts";
import { Message } from "../messages/message.ts";
import { User } from "../users/user.ts";
import { ApplicationCommandInteractionData } from "./application_command_interaction_data.ts";
import { DiscordInteractionTypes } from "./interaction_types.ts";
@@ -25,4 +26,6 @@ export interface Interaction {
token: string;
/** Read-only property, always `1` */
version: 1;
/** For the message the button was attached to */
message?: Message;
}
@@ -6,6 +6,8 @@ export enum DiscordInteractionResponseTypes {
ChannelMessageWithSource = 4,
/** ACK an interaction and edit a response later, the user sees a loading state */
DeferredChannelMessageWithSource = 5,
/** It has no data fields. You can send this type **only in response to a button interaction .** It will acknowledge the interaction and update the button to a loading state. */
DeferredMessageUpdate,
}
export type InteractionResponseTypes = DiscordInteractionResponseTypes;
@@ -2,6 +2,7 @@
export enum DiscordInteractionTypes {
Ping = 1,
ApplicationCommand,
Button,
}
export type InteractionTypes = DiscordInteractionTypes;
+1 -1
View File
@@ -1,7 +1,7 @@
import { DiscordInviteTargetTypes } from "./invite_target_types.ts";
export interface CreateChannelInvite {
/** Durationi of invite in seconds before expiry, or 0 for never. Between 0 and 604800 (7 days). Default: 86400 (24 hours) */
/** Duration of invite in seconds before expiry, or 0 for never. Between 0 and 604800 (7 days). Default: 86400 (24 hours) */
maxAge?: number;
/** Max number of users or 0 for unlimited. Between 0 and 100. Default: 0 */
maxUses?: number;
+2
View File
@@ -2,4 +2,6 @@
export interface GetInvite {
/** Whether the invite should contain approximate member counts */
withCounts?: boolean;
/** Whether the invite should contain the expiration date */
withExpiration?: boolean;
}
+2
View File
@@ -24,4 +24,6 @@ export interface Invite {
approximatePresenceCount?: number;
/** Approximate count of total members */
approximateMemberCount?: number;
/** The expiration date of this invite, returned from the `GET /invites/<code>` endpoint when `with_expiration` is `true` */
expiresAt?: string | null;
}
@@ -0,0 +1,9 @@
import { ButtonComponent } from "./button_component.ts";
// TODO: add docs link
export interface ActionRow {
/** Action rows are a group of buttons. */
type: 1;
/** The button components */
components: ButtonComponent[];
}
@@ -0,0 +1,29 @@
import { SnakeCasedPropertiesDeep } from "../../util.ts";
import { ButtonStyles } from "./button_styles.ts";
// TODO: add docs link
export interface ButtonComponent {
/** All button components have type 2 */
type: 2;
/** for what the button says (max 80 characters) */
label: string;
/** a dev-defined unique string sent on click (max 100 characters). type 5 Link buttons can not have a custom_id */
customId?: string;
/** For different styles/colors of the buttons */
style: ButtonStyles;
/** Emoji object that includes fields of name, id, and animated supporting unicode and custom emojis. */
emoji?: {
/** Emoji id */
id: string | null;
/** Emoji name (can only be null in reaction emoji objects) */
name: string | null;
/** Whether this emoji is animated */
animated?: boolean;
};
/** optional url for link-style buttons that can navigate a user to the web. Only type 5 Link buttons can have a url */
url?: string;
/** Whether or not this button is disabled */
disabled?: boolean;
}
export type DiscordButtonComponent = SnakeCasedPropertiesDeep<ButtonComponent>;
@@ -0,0 +1,16 @@
// TODO: add docs link
export enum DiscordButtonStyles {
/** A blurple button */
Primary = 1,
/** A grey button */
Secondary,
/** A green button */
Success,
/** A red button */
Danger,
/** A button that navigates to a URL */
Link,
}
export type ButtonStyles = DiscordButtonStyles;
export const ButtonStyles = DiscordButtonStyles;
@@ -0,0 +1,10 @@
// TODO: add docs link
export enum DiscordMessageComponentTypes {
/** A row of components at the bottom of a message */
ActionRow = 1,
/** A button! */
Button,
}
export type MessageComponentTypes = DiscordMessageComponentTypes;
export const MessageComponentTypes = DiscordMessageComponentTypes;
@@ -0,0 +1,6 @@
import { ActionRow } from "./action_row.ts";
import { ButtonComponent } from "./button_component.ts";
export type MessageComponent = ActionRow | ButtonComponent;
export type MessageComponents = MessageComponent[];
+3
View File
@@ -3,6 +3,7 @@ import { AllowedMentions } from "../messages/allowed_mentions.ts";
import { MessageReference } from "../messages/message_reference.ts";
import { FileContent } from "../misc/file_content.ts";
import { SnakeCasedPropertiesDeep } from "../util.ts";
import { MessageComponents } from "./components/message_components.ts";
export interface CreateMessage {
/** The message contents (up to 2000 characters) */
@@ -19,6 +20,8 @@ export interface CreateMessage {
messageReference?: MessageReference;
/** The contents of the file being sent */
file?: FileContent | FileContent[];
/** The components you would like to have sent in this message */
components?: MessageComponents;
}
/** https://discord.com/developers/docs/resources/channel#create-message */
+3
View File
@@ -1,5 +1,6 @@
import { Embed } from "../embeds/embed.ts";
import { AllowedMentions } from "./allowed_mentions.ts";
import { Attachment } from "./attachment.ts";
/** https://discord.com/developers/docs/resources/channel#edit-message-json-params */
export interface EditMessage {
@@ -11,4 +12,6 @@ export interface EditMessage {
flags?: 4 | null;
/** Allowed mentions for the message */
allowedMentions?: AllowedMentions | null;
/** Attached files to keep */
attachments?: Attachment | null;
}
+3
View File
@@ -6,6 +6,7 @@ import { MessageInteraction } from "../interactions/message_interaction.ts";
import { Application } from "../oauth2/application.ts";
import { User } from "../users/user.ts";
import { Attachment } from "./attachment.ts";
import { MessageComponents } from "./components/message_components.ts";
import { MessageActivity } from "./message_activity.ts";
import { MessageReference } from "./message_reference.ts";
import { MessageSticker } from "./message_sticker.ts";
@@ -86,4 +87,6 @@ export interface Message {
interaction?: MessageInteraction;
/** The thread that was started from this message, includes thread member object */
thread?: Omit<Channel, "member"> & { member: ThreadMember };
/** The components related to this message */
components: MessageComponents;
}
-5
View File
@@ -17,11 +17,6 @@ export interface MessageSticker {
* Note: The URL for fetching sticker assets is currently private.
*/
asset: string;
/**
* Sticker preview asset hash
* Note: The URL for fetching sticker assets is currently private.
*/
previewAsset?: string | null;
/** Type of sticker format */
formatType: DiscordMessageStickerFormatTypes;
}
+10
View File
@@ -68,9 +68,19 @@ export enum Errors {
USERNAME_INVALID_USERNAME = "USERNAME_INVALID_USERNAME",
USERNAME_MAX_LENGTH = "USERNAME_MAX_LENGTH",
USERNAME_MIN_LENGTH = "USERNAME_MIN_LENGTH",
NONCE_TOO_LONG = "NONCE_TOO_LONG",
INVITE_MAX_AGE_INVALID = "INVITE_MAX_AGE_INVALID",
INVITE_MAX_USES_INVALID = "INVITE_MAX_USES_INVALID",
// API Errors
RATE_LIMIT_RETRY_MAXED = "RATE_LIMIT_RETRY_MAXED",
REQUEST_CLIENT_ERROR = "REQUEST_CLIENT_ERROR",
REQUEST_SERVER_ERROR = "REQUEST_SERVER_ERROR",
REQUEST_UNKNOWN_ERROR = "REQUEST_UNKNOWN_ERROR",
// Component Errors
TOO_MANY_COMPONENTS = "TOO_MANY_COMPONENTS",
TOO_MANY_ACTION_ROWS = "TOO_MANY_ACTION_ROWS",
LINK_BUTTON_CANNOT_HAVE_CUSTOM_ID = "LINK_BUTTON_CANNOT_HAVE_CUSTOM_ID",
COMPONENT_LABEL_TOO_BIG = "COMPONENT_LABEL_TOO_BIG",
COMPONENT_CUSTOM_ID_TOO_BIG = "COMPONENT_CUSTOM_ID_TOO_BIG",
BUTTON_REQUIRES_CUSTOM_ID = "BUTTON_REQUIRES_CUSTOM_ID",
}
+5
View File
@@ -131,6 +131,11 @@ export * from "./members/guild_member_add.ts";
export * from "./members/guild_member_remove.ts";
export * from "./members/guild_member_update.ts";
export * from "./members/search_guild_members.ts";
export * from "./messages/components/action_row.ts";
export * from "./messages/components/button_component.ts";
export * from "./messages/components/button_styles.ts";
export * from "./messages/components/message_component_types.ts";
export * from "./messages/components/message_components.ts";
export * from "./messages/allowed_mentions.ts";
export * from "./messages/allowed_mentions_types.ts";
export * from "./messages/attachment.ts";
+4 -1
View File
@@ -1,6 +1,7 @@
import { Embed } from "../embeds/embed.ts";
import { AllowedMentions } from "../messages/allowed_mentions.ts";
import { FileContent } from "../mod.ts";
import { FileContent } from "../misc/file_content.ts";
import { Attachment } from "../messages/attachment.ts";
/** https://discord.com/developers/docs/resources/webhook#edit-webhook-message-jsonform-params */
export interface EditWebhookMessage {
@@ -12,4 +13,6 @@ export interface EditWebhookMessage {
file: FileContent | FileContent[];
/** Allowed mentions for the message */
allowedMentions?: AllowedMentions | null;
/** Attached files to keep */
attachments?: Attachment | null;
}
+9
View File
@@ -35,6 +35,15 @@ export async function createShard(shardId: number) {
}
switch (event.code) {
// Discordeno tests finished
case 3061:
return;
case 3063: // Resharded
case 3064: // Resuming
case 3065: // Reidentifying
case 3066: // Missing ACK
// Will restart shard manually
return ws.log("CLOSED_RECONNECT", { shardId, payload: event });
case DiscordGatewayCloseEventCodes.UnknownOpcode:
case DiscordGatewayCloseEventCodes.DecodeError:
case DiscordGatewayCloseEventCodes.AuthenticationFailed:
+2 -1
View File
@@ -61,5 +61,6 @@ export function log(
| "DEBUG",
data: unknown,
) {
console.log(type, data);
// This is just a placeholder for the dev to override
if (!type && !data) console.log(type, data);
}
-1
View File
@@ -50,7 +50,6 @@ export function spawnShards(firstShardId = 0) {
}
}
console.log("BUCKETS", ws.buckets);
// SPREAD THIS OUT TO DIFFERENT CLUSTERS TO BEGIN STARTING UP
ws.buckets.forEach(async (bucket, bucketId) => {
ws.log(
+2 -2
View File
@@ -1,11 +1,11 @@
import { defaultTestOptions, tempData } from "../ws/start_bot.ts";
import { assertEquals, assertExists } from "../deps.ts";
import { cache } from "../../src/cache.ts";
import { DiscordReaction } from "../../src/types/messages/reaction.ts";
import { sendMessage } from "../../src/helpers/messages/send_message.ts";
import { addReaction } from "../../src/helpers/messages/add_reaction.ts";
import { createEmoji } from "../../src/helpers/emojis/create_emoji.ts";
import { delayUntil } from "../util/delay_until.ts";
import { Reaction } from "../../src/types/messages/reaction.ts";
async function ifItFailsBlameWolf(type: "getter" | "raw", custom = false) {
const message = await sendMessage(tempData.channelId, "Hello World!");
@@ -54,7 +54,7 @@ async function ifItFailsBlameWolf(type: "getter" | "raw", custom = false) {
await cache.messages
.get(message.id)
?.reactions?.filter(
(reaction: DiscordReaction) =>
(reaction: Reaction) =>
reaction.emoji?.name === (custom ? "blamewolf" : "❤"),
).length,
1,