mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-14 10:33:30 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
36
packages/builders/src/messages/embed/Assertions.ts
Normal file
36
packages/builders/src/messages/embed/Assertions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { APIEmbedField } from 'discord-api-types/v9';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const fieldNamePredicate = z.string().min(1).max(256);
|
||||
|
||||
export const fieldValuePredicate = z.string().min(1).max(1024);
|
||||
|
||||
export const fieldInlinePredicate = z.boolean().optional();
|
||||
|
||||
export const embedFieldPredicate = z.object({
|
||||
name: fieldNamePredicate,
|
||||
value: fieldValuePredicate,
|
||||
inline: fieldInlinePredicate,
|
||||
});
|
||||
|
||||
export const embedFieldsArrayPredicate = embedFieldPredicate.array();
|
||||
|
||||
export const fieldLengthPredicate = z.number().lte(25);
|
||||
|
||||
export function validateFieldLength(fields: APIEmbedField[], amountAdding: number): void {
|
||||
fieldLengthPredicate.parse(fields.length + amountAdding);
|
||||
}
|
||||
|
||||
export const authorNamePredicate = fieldNamePredicate.nullable();
|
||||
|
||||
export const urlPredicate = z.string().url().nullish();
|
||||
|
||||
export const colorPredicate = z.number().gte(0).lte(0xffffff).nullable();
|
||||
|
||||
export const descriptionPredicate = z.string().min(1).max(4096).nullable();
|
||||
|
||||
export const footerTextPredicate = z.string().min(1).max(2048).nullable();
|
||||
|
||||
export const timestampPredicate = z.union([z.number(), z.date()]).nullable();
|
||||
|
||||
export const titlePredicate = fieldNamePredicate.nullable();
|
||||
326
packages/builders/src/messages/embed/Embed.ts
Normal file
326
packages/builders/src/messages/embed/Embed.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type {
|
||||
APIEmbed,
|
||||
APIEmbedAuthor,
|
||||
APIEmbedField,
|
||||
APIEmbedFooter,
|
||||
APIEmbedImage,
|
||||
APIEmbedProvider,
|
||||
APIEmbedThumbnail,
|
||||
APIEmbedVideo,
|
||||
} from 'discord-api-types/v9';
|
||||
import {
|
||||
authorNamePredicate,
|
||||
colorPredicate,
|
||||
descriptionPredicate,
|
||||
embedFieldsArrayPredicate,
|
||||
fieldInlinePredicate,
|
||||
fieldNamePredicate,
|
||||
fieldValuePredicate,
|
||||
footerTextPredicate,
|
||||
timestampPredicate,
|
||||
titlePredicate,
|
||||
urlPredicate,
|
||||
validateFieldLength,
|
||||
} from './Assertions';
|
||||
|
||||
export interface AuthorOptions {
|
||||
name: string;
|
||||
url?: string;
|
||||
iconURL?: string;
|
||||
}
|
||||
|
||||
export interface FooterOptions {
|
||||
text: string;
|
||||
iconURL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an embed in a message (image/video preview, rich embed, etc.)
|
||||
*/
|
||||
export class Embed implements APIEmbed {
|
||||
/**
|
||||
* An array of fields of this embed
|
||||
*/
|
||||
public fields: APIEmbedField[];
|
||||
|
||||
/**
|
||||
* The embed title
|
||||
*/
|
||||
public title?: string;
|
||||
|
||||
/**
|
||||
* The embed description
|
||||
*/
|
||||
public description?: string;
|
||||
|
||||
/**
|
||||
* The embed url
|
||||
*/
|
||||
public url?: string;
|
||||
|
||||
/**
|
||||
* The embed color
|
||||
*/
|
||||
public color?: number;
|
||||
|
||||
/**
|
||||
* The timestamp of the embed in the ISO format
|
||||
*/
|
||||
public timestamp?: string;
|
||||
|
||||
/**
|
||||
* The embed thumbnail data
|
||||
*/
|
||||
public thumbnail?: APIEmbedThumbnail;
|
||||
|
||||
/**
|
||||
* The embed image data
|
||||
*/
|
||||
public image?: APIEmbedImage;
|
||||
|
||||
/**
|
||||
* Received video data
|
||||
*/
|
||||
public video?: APIEmbedVideo;
|
||||
|
||||
/**
|
||||
* The embed author data
|
||||
*/
|
||||
public author?: APIEmbedAuthor;
|
||||
|
||||
/**
|
||||
* Received data about the embed provider
|
||||
*/
|
||||
public provider?: APIEmbedProvider;
|
||||
|
||||
/**
|
||||
* The embed footer data
|
||||
*/
|
||||
public footer?: APIEmbedFooter;
|
||||
|
||||
public constructor(data: APIEmbed = {}) {
|
||||
this.title = data.title;
|
||||
this.description = data.description;
|
||||
this.url = data.url;
|
||||
this.color = data.color;
|
||||
this.thumbnail = data.thumbnail;
|
||||
this.image = data.image;
|
||||
this.video = data.video;
|
||||
this.author = data.author;
|
||||
this.provider = data.provider;
|
||||
this.footer = data.footer;
|
||||
this.fields = data.fields ?? [];
|
||||
|
||||
if (data.timestamp) this.timestamp = new Date(data.timestamp).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The accumulated length for the embed title, description, fields, footer text, and author name
|
||||
*/
|
||||
public get length(): number {
|
||||
return (
|
||||
(this.title?.length ?? 0) +
|
||||
(this.description?.length ?? 0) +
|
||||
this.fields.reduce((prev, curr) => prev + curr.name.length + curr.value.length, 0) +
|
||||
(this.footer?.text.length ?? 0) +
|
||||
(this.author?.name.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a field to the embed (max 25)
|
||||
*
|
||||
* @param field The field to add.
|
||||
*/
|
||||
public addField(field: APIEmbedField): this {
|
||||
return this.addFields(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds fields to the embed (max 25)
|
||||
*
|
||||
* @param fields The fields to add
|
||||
*/
|
||||
public addFields(...fields: APIEmbedField[]): this {
|
||||
// Data assertions
|
||||
embedFieldsArrayPredicate.parse(fields);
|
||||
|
||||
// Ensure adding these fields won't exceed the 25 field limit
|
||||
validateFieldLength(this.fields, fields.length);
|
||||
|
||||
this.fields.push(...Embed.normalizeFields(...fields));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes, replaces, or inserts fields in the embed (max 25)
|
||||
*
|
||||
* @param index The index to start at
|
||||
* @param deleteCount The number of fields to remove
|
||||
* @param fields The replacing field objects
|
||||
*/
|
||||
public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this {
|
||||
// Data assertions
|
||||
embedFieldsArrayPredicate.parse(fields);
|
||||
|
||||
// Ensure adding these fields won't exceed the 25 field limit
|
||||
validateFieldLength(this.fields, fields.length - deleteCount);
|
||||
|
||||
this.fields.splice(index, deleteCount, ...Embed.normalizeFields(...fields));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the author of this embed
|
||||
*
|
||||
* @param options The options for the author
|
||||
*/
|
||||
public setAuthor(options: AuthorOptions | null): this {
|
||||
if (options === null) {
|
||||
this.author = undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
const { name, iconURL, url } = options;
|
||||
// Data assertions
|
||||
authorNamePredicate.parse(name);
|
||||
urlPredicate.parse(iconURL);
|
||||
urlPredicate.parse(url);
|
||||
|
||||
this.author = { name, url, icon_url: iconURL };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the color of this embed
|
||||
*
|
||||
* @param color The color of the embed
|
||||
*/
|
||||
public setColor(color: number | null): this {
|
||||
// Data assertions
|
||||
colorPredicate.parse(color);
|
||||
|
||||
this.color = color ?? undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the description of this embed
|
||||
*
|
||||
* @param description The description
|
||||
*/
|
||||
public setDescription(description: string | null): this {
|
||||
// Data assertions
|
||||
descriptionPredicate.parse(description);
|
||||
|
||||
this.description = description ?? undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the footer of this embed
|
||||
*
|
||||
* @param options The options for the footer
|
||||
*/
|
||||
public setFooter(options: FooterOptions | null): this {
|
||||
if (options === null) {
|
||||
this.footer = undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
const { text, iconURL } = options;
|
||||
// Data assertions
|
||||
footerTextPredicate.parse(text);
|
||||
urlPredicate.parse(iconURL);
|
||||
|
||||
this.footer = { text, icon_url: iconURL };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image of this embed
|
||||
*
|
||||
* @param url The URL of the image
|
||||
*/
|
||||
public setImage(url: string | null): this {
|
||||
// Data assertions
|
||||
urlPredicate.parse(url);
|
||||
|
||||
this.image = url ? { url } : undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the thumbnail of this embed
|
||||
*
|
||||
* @param url The URL of the thumbnail
|
||||
*/
|
||||
public setThumbnail(url: string | null): this {
|
||||
// Data assertions
|
||||
urlPredicate.parse(url);
|
||||
|
||||
this.thumbnail = url ? { url } : undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the timestamp of this embed
|
||||
*
|
||||
* @param timestamp The timestamp or date
|
||||
*/
|
||||
public setTimestamp(timestamp: number | Date | null = Date.now()): this {
|
||||
// Data assertions
|
||||
timestampPredicate.parse(timestamp);
|
||||
|
||||
this.timestamp = timestamp ? new Date(timestamp).toISOString() : undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the title of this embed
|
||||
*
|
||||
* @param title The title
|
||||
*/
|
||||
public setTitle(title: string | null): this {
|
||||
// Data assertions
|
||||
titlePredicate.parse(title);
|
||||
|
||||
this.title = title ?? undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the URL of this embed
|
||||
*
|
||||
* @param url The URL
|
||||
*/
|
||||
public setURL(url: string | null): this {
|
||||
// Data assertions
|
||||
urlPredicate.parse(url);
|
||||
|
||||
this.url = url ?? undefined;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the embed to a plain object
|
||||
*/
|
||||
public toJSON(): APIEmbed {
|
||||
return { ...this };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes field input and resolves strings
|
||||
*
|
||||
* @param fields Fields to normalize
|
||||
*/
|
||||
public static normalizeFields(...fields: APIEmbedField[]): APIEmbedField[] {
|
||||
return fields.flat(Infinity).map((field) => {
|
||||
fieldNamePredicate.parse(field.name);
|
||||
fieldValuePredicate.parse(field.value);
|
||||
fieldInlinePredicate.parse(field.inline);
|
||||
|
||||
return { name: field.name, value: field.value, inline: field.inline ?? undefined };
|
||||
});
|
||||
}
|
||||
}
|
||||
319
packages/builders/src/messages/formatters.ts
Normal file
319
packages/builders/src/messages/formatters.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { Snowflake } from 'discord-api-types/globals';
|
||||
import type { URL } from 'url';
|
||||
|
||||
/**
|
||||
* Wraps the content inside a codeblock with no language
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function codeBlock<C extends string>(content: C): `\`\`\`\n${C}\`\`\``;
|
||||
|
||||
/**
|
||||
* Wraps the content inside a codeblock with the specified language
|
||||
*
|
||||
* @param language The language for the codeblock
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function codeBlock<L extends string, C extends string>(language: L, content: C): `\`\`\`${L}\n${C}\`\`\``;
|
||||
export function codeBlock(language: string, content?: string): string {
|
||||
return typeof content === 'undefined' ? `\`\`\`\n${language}\`\`\`` : `\`\`\`${language}\n${content}\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the content inside \`backticks\`, which formats it as inline code
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function inlineCode<C extends string>(content: C): `\`${C}\`` {
|
||||
return `\`${content}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into italic text
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function italic<C extends string>(content: C): `_${C}_` {
|
||||
return `_${content}_`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into bold text
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function bold<C extends string>(content: C): `**${C}**` {
|
||||
return `**${content}**`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into underscored text
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function underscore<C extends string>(content: C): `__${C}__` {
|
||||
return `__${content}__`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into strike-through text
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function strikethrough<C extends string>(content: C): `~~${C}~~` {
|
||||
return `~~${content}~~`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into a quote. This needs to be at the start of the line for Discord to format it
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function quote<C extends string>(content: C): `> ${C}` {
|
||||
return `> ${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content into a block quote. This needs to be at the start of the line for Discord to format it
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function blockQuote<C extends string>(content: C): `>>> ${C}` {
|
||||
return `>>> ${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the URL into `<>`, which stops it from embedding
|
||||
*
|
||||
* @param url The URL to wrap
|
||||
*/
|
||||
export function hideLinkEmbed<C extends string>(url: C): `<${C}>`;
|
||||
|
||||
/**
|
||||
* Wraps the URL into `<>`, which stops it from embedding
|
||||
*
|
||||
* @param url The URL to wrap
|
||||
*/
|
||||
export function hideLinkEmbed(url: URL): `<${string}>`;
|
||||
export function hideLinkEmbed(url: string | URL) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
return `<${url}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the content and the URL into a masked URL
|
||||
*
|
||||
* @param content The content to display
|
||||
* @param url The URL the content links to
|
||||
*/
|
||||
export function hyperlink<C extends string>(content: C, url: URL): `[${C}](${string})`;
|
||||
|
||||
/**
|
||||
* Formats the content and the URL into a masked URL
|
||||
*
|
||||
* @param content The content to display
|
||||
* @param url The URL the content links to
|
||||
*/
|
||||
export function hyperlink<C extends string, U extends string>(content: C, url: U): `[${C}](${U})`;
|
||||
|
||||
/**
|
||||
* Formats the content and the URL into a masked URL
|
||||
*
|
||||
* @param content The content to display
|
||||
* @param url The URL the content links to
|
||||
* @param title The title shown when hovering on the masked link
|
||||
*/
|
||||
export function hyperlink<C extends string, T extends string>(
|
||||
content: C,
|
||||
url: URL,
|
||||
title: T,
|
||||
): `[${C}](${string} "${T}")`;
|
||||
|
||||
/**
|
||||
* Formats the content and the URL into a masked URL
|
||||
*
|
||||
* @param content The content to display
|
||||
* @param url The URL the content links to
|
||||
* @param title The title shown when hovering on the masked link
|
||||
*/
|
||||
export function hyperlink<C extends string, U extends string, T extends string>(
|
||||
content: C,
|
||||
url: U,
|
||||
title: T,
|
||||
): `[${C}](${U} "${T}")`;
|
||||
export function hyperlink(content: string, url: string | URL, title?: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
return title ? `[${content}](${url} "${title}")` : `[${content}](${url})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the content inside spoiler (hidden text)
|
||||
*
|
||||
* @param content The content to wrap
|
||||
*/
|
||||
export function spoiler<C extends string>(content: C): `||${C}||` {
|
||||
return `||${content}||`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a user ID into a user mention
|
||||
*
|
||||
* @param userId The user ID to format
|
||||
*/
|
||||
export function userMention<C extends Snowflake>(userId: C): `<@${C}>` {
|
||||
return `<@${userId}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a user ID into a member-nickname mention
|
||||
*
|
||||
* @param memberId The user ID to format
|
||||
*/
|
||||
export function memberNicknameMention<C extends Snowflake>(memberId: C): `<@!${C}>` {
|
||||
return `<@!${memberId}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a channel ID into a channel mention
|
||||
*
|
||||
* @param channelId The channel ID to format
|
||||
*/
|
||||
export function channelMention<C extends Snowflake>(channelId: C): `<#${C}>` {
|
||||
return `<#${channelId}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a role ID into a role mention
|
||||
*
|
||||
* @param roleId The role ID to format
|
||||
*/
|
||||
export function roleMention<C extends Snowflake>(roleId: C): `<@&${C}>` {
|
||||
return `<@&${roleId}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an emoji ID into a fully qualified emoji identifier
|
||||
*
|
||||
* @param emojiId The emoji ID to format
|
||||
*/
|
||||
export function formatEmoji<C extends Snowflake>(emojiId: C, animated?: false): `<:_:${C}>`;
|
||||
|
||||
/**
|
||||
* Formats an emoji ID into a fully qualified emoji identifier
|
||||
*
|
||||
* @param emojiId The emoji ID to format
|
||||
* @param animated Whether the emoji is animated or not. Defaults to `false`
|
||||
*/
|
||||
export function formatEmoji<C extends Snowflake>(emojiId: C, animated?: true): `<a:_:${C}>`;
|
||||
|
||||
/**
|
||||
* Formats an emoji ID into a fully qualified emoji identifier
|
||||
*
|
||||
* @param emojiId The emoji ID to format
|
||||
* @param animated Whether the emoji is animated or not. Defaults to `false`
|
||||
*/
|
||||
export function formatEmoji<C extends Snowflake>(emojiId: C, animated = false): `<a:_:${C}>` | `<:_:${C}>` {
|
||||
return `<${animated ? 'a' : ''}:_:${emojiId}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date into a short date-time string
|
||||
*
|
||||
* @param date The date to format, defaults to the current time
|
||||
*/
|
||||
export function time(date?: Date): `<t:${bigint}>`;
|
||||
|
||||
/**
|
||||
* Formats a date given a format style
|
||||
*
|
||||
* @param date The date to format
|
||||
* @param style The style to use
|
||||
*/
|
||||
export function time<S extends TimestampStylesString>(date: Date, style: S): `<t:${bigint}:${S}>`;
|
||||
|
||||
/**
|
||||
* Formats the given timestamp into a short date-time string
|
||||
*
|
||||
* @param seconds The time to format, represents an UNIX timestamp in seconds
|
||||
*/
|
||||
export function time<C extends number>(seconds: C): `<t:${C}>`;
|
||||
|
||||
/**
|
||||
* Formats the given timestamp into a short date-time string
|
||||
*
|
||||
* @param seconds The time to format, represents an UNIX timestamp in seconds
|
||||
* @param style The style to use
|
||||
*/
|
||||
export function time<C extends number, S extends TimestampStylesString>(seconds: C, style: S): `<t:${C}:${S}>`;
|
||||
export function time(timeOrSeconds?: number | Date, style?: TimestampStylesString): string {
|
||||
if (typeof timeOrSeconds !== 'number') {
|
||||
timeOrSeconds = Math.floor((timeOrSeconds?.getTime() ?? Date.now()) / 1000);
|
||||
}
|
||||
|
||||
return typeof style === 'string' ? `<t:${timeOrSeconds}:${style}>` : `<t:${timeOrSeconds}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The [message formatting timestamp styles](https://discord.com/developers/docs/reference#message-formatting-timestamp-styles) supported by Discord
|
||||
*/
|
||||
export const TimestampStyles = {
|
||||
/**
|
||||
* Short time format, consisting of hours and minutes, e.g. 16:20
|
||||
*/
|
||||
ShortTime: 't',
|
||||
|
||||
/**
|
||||
* Long time format, consisting of hours, minutes, and seconds, e.g. 16:20:30
|
||||
*/
|
||||
LongTime: 'T',
|
||||
|
||||
/**
|
||||
* Short date format, consisting of day, month, and year, e.g. 20/04/2021
|
||||
*/
|
||||
ShortDate: 'd',
|
||||
|
||||
/**
|
||||
* Long date format, consisting of day, month, and year, e.g. 20 April 2021
|
||||
*/
|
||||
LongDate: 'D',
|
||||
|
||||
/**
|
||||
* Short date-time format, consisting of short date and short time formats, e.g. 20 April 2021 16:20
|
||||
*/
|
||||
ShortDateTime: 'f',
|
||||
|
||||
/**
|
||||
* Long date-time format, consisting of long date and short time formats, e.g. Tuesday, 20 April 2021 16:20
|
||||
*/
|
||||
LongDateTime: 'F',
|
||||
|
||||
/**
|
||||
* Relative time format, consisting of a relative duration format, e.g. 2 months ago
|
||||
*/
|
||||
RelativeTime: 'R',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The possible values, see {@link TimestampStyles} for more information
|
||||
*/
|
||||
export type TimestampStylesString = typeof TimestampStyles[keyof typeof TimestampStyles];
|
||||
|
||||
/**
|
||||
* An enum with all the available faces from Discord's native slash commands
|
||||
*/
|
||||
export enum Faces {
|
||||
/**
|
||||
* ¯\\_(ツ)\\_/¯
|
||||
*/
|
||||
Shrug = '¯\\_(ツ)\\_/¯',
|
||||
|
||||
/**
|
||||
* (╯°□°)╯︵ ┻━┻
|
||||
*/
|
||||
Tableflip = '(╯°□°)╯︵ ┻━┻',
|
||||
|
||||
/**
|
||||
* ┬─┬ ノ( ゜-゜ノ)
|
||||
*/
|
||||
Unflip = '┬─┬ ノ( ゜-゜ノ)',
|
||||
}
|
||||
Reference in New Issue
Block a user