2019-05-04 18:41:50 +03:00
|
|
|
import {
|
2020-07-27 21:51:03 +03:00
|
|
|
AnyInvite,
|
2020-07-05 15:59:15 +03:00
|
|
|
Attachment,
|
2020-08-05 18:52:15 +03:00
|
|
|
BaseInvite,
|
2020-07-05 15:59:15 +03:00
|
|
|
ChannelInvite,
|
2019-05-04 18:41:50 +03:00
|
|
|
Client,
|
2019-10-11 01:59:56 +03:00
|
|
|
Embed,
|
2019-05-04 18:41:50 +03:00
|
|
|
EmbedOptions,
|
|
|
|
Emoji,
|
|
|
|
Guild,
|
2019-07-22 13:50:24 +03:00
|
|
|
GuildAuditLog,
|
2019-05-04 18:41:50 +03:00
|
|
|
GuildAuditLogEntry,
|
2020-07-05 15:59:15 +03:00
|
|
|
GuildChannel,
|
2020-07-27 21:51:03 +03:00
|
|
|
GuildInvite,
|
2019-05-04 18:41:50 +03:00
|
|
|
Member,
|
2019-11-27 22:02:11 +02:00
|
|
|
Message,
|
2019-08-10 00:13:35 +03:00
|
|
|
MessageContent,
|
2020-07-05 15:59:15 +03:00
|
|
|
PossiblyUncachedMessage,
|
2020-08-05 18:52:15 +03:00
|
|
|
RESTChannelInvite,
|
|
|
|
RESTPrivateInvite,
|
2019-05-04 18:41:50 +03:00
|
|
|
TextableChannel,
|
|
|
|
TextChannel,
|
|
|
|
User,
|
|
|
|
} from "eris";
|
2018-07-31 02:42:45 +03:00
|
|
|
import url from "url";
|
|
|
|
import tlds from "tlds";
|
2019-07-22 01:33:38 +03:00
|
|
|
import emojiRegex from "emoji-regex";
|
2019-07-22 00:09:45 +03:00
|
|
|
import * as t from "io-ts";
|
2018-07-08 13:57:27 +03:00
|
|
|
|
2019-01-15 03:04:47 +02:00
|
|
|
import fs from "fs";
|
|
|
|
import https from "https";
|
|
|
|
import tmp from "tmp";
|
2020-07-22 22:56:21 +03:00
|
|
|
import { helpers } from "knub";
|
2019-10-11 01:59:56 +03:00
|
|
|
import { SavedMessage } from "./data/entities/SavedMessage";
|
2020-08-10 00:24:06 +03:00
|
|
|
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
2019-12-01 15:57:35 +02:00
|
|
|
import { either } from "fp-ts/lib/Either";
|
|
|
|
import moment from "moment-timezone";
|
2020-07-05 05:00:54 +03:00
|
|
|
import { SimpleCache } from "./SimpleCache";
|
2020-07-22 22:56:21 +03:00
|
|
|
import { logger } from "./logger";
|
2020-08-11 04:16:06 +03:00
|
|
|
import { unsafeCoerce } from "fp-ts/lib/function";
|
2020-07-22 22:56:21 +03:00
|
|
|
|
|
|
|
const fsp = fs.promises;
|
2019-01-15 03:04:47 +02:00
|
|
|
|
2019-04-14 14:05:16 +03:00
|
|
|
const delayStringMultipliers = {
|
|
|
|
w: 1000 * 60 * 60 * 24 * 7,
|
|
|
|
d: 1000 * 60 * 60 * 24,
|
|
|
|
h: 1000 * 60 * 60,
|
|
|
|
m: 1000 * 60,
|
|
|
|
s: 1000,
|
2019-12-01 15:57:35 +02:00
|
|
|
x: 1,
|
2019-04-14 14:05:16 +03:00
|
|
|
};
|
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
export const MS = 1;
|
|
|
|
export const SECONDS = 1000 * MS;
|
|
|
|
export const MINUTES = 60 * SECONDS;
|
|
|
|
export const HOURS = 60 * MINUTES;
|
|
|
|
export const DAYS = 24 * HOURS;
|
2019-12-01 23:23:34 +02:00
|
|
|
export const WEEKS = 7 * 24 * HOURS;
|
2019-08-04 13:14:23 +03:00
|
|
|
|
2019-12-02 01:11:40 +02:00
|
|
|
export const EMPTY_CHAR = "\u200b";
|
|
|
|
|
2020-08-09 17:28:21 +03:00
|
|
|
// https://discord.com/developers/docs/reference#snowflakes
|
|
|
|
export const MIN_SNOWFLAKE = 0b000000000000000000000000000000000000000000_00001_00001_000000000001;
|
|
|
|
// 0b111111111111111111111111111111111111111111_11111_11111_111111111111 without _ which BigInt doesn't support
|
|
|
|
export const MAX_SNOWFLAKE = BigInt("0b1111111111111111111111111111111111111111111111111111111111111111");
|
|
|
|
|
|
|
|
const snowflakePattern = /^[1-9]\d+$/;
|
|
|
|
export function isValidSnowflake(str: string) {
|
|
|
|
if (!str.match(snowflakePattern)) return false;
|
|
|
|
if (parseInt(str, 10) < MIN_SNOWFLAKE) return false;
|
|
|
|
if (BigInt(str) > MAX_SNOWFLAKE) return false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2020-05-08 18:29:17 +03:00
|
|
|
export const DISCORD_HTTP_ERROR_NAME = "DiscordHTTPError";
|
|
|
|
export const DISCORD_REST_ERROR_NAME = "DiscordRESTError";
|
|
|
|
|
|
|
|
export function isDiscordHTTPError(err: Error | string) {
|
|
|
|
return typeof err === "object" && err.constructor?.name === DISCORD_HTTP_ERROR_NAME;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isDiscordRESTError(err: Error | string) {
|
|
|
|
return typeof err === "object" && err.constructor?.name === DISCORD_REST_ERROR_NAME;
|
|
|
|
}
|
|
|
|
|
2019-11-27 22:02:11 +02:00
|
|
|
export function tNullable<T extends t.Type<any, any>>(type: T) {
|
2019-09-29 15:54:19 +03:00
|
|
|
return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`);
|
2019-08-22 02:58:32 +03:00
|
|
|
}
|
|
|
|
|
2019-11-28 02:34:41 +02:00
|
|
|
function typeHasProps(type: any): type is t.TypeC<any> {
|
|
|
|
return type.props != null;
|
|
|
|
}
|
|
|
|
|
|
|
|
function typeIsArray(type: any): type is t.ArrayC<any> {
|
|
|
|
return type._tag === "ArrayType";
|
|
|
|
}
|
|
|
|
|
|
|
|
export type TDeepPartial<T> = T extends t.InterfaceType<any>
|
|
|
|
? TDeepPartialProps<T["props"]>
|
|
|
|
: T extends t.DictionaryType<any, any>
|
|
|
|
? t.DictionaryType<T["domain"], TDeepPartial<T["codomain"]>>
|
|
|
|
: T extends t.UnionType<any[]>
|
|
|
|
? t.UnionType<Array<TDeepPartial<T["types"][number]>>>
|
|
|
|
: T extends t.IntersectionType<any>
|
|
|
|
? t.IntersectionType<Array<TDeepPartial<T["types"][number]>>>
|
|
|
|
: T extends t.ArrayType<any>
|
|
|
|
? t.ArrayType<TDeepPartial<T["type"]>>
|
|
|
|
: T;
|
|
|
|
|
|
|
|
// Based on t.PartialC
|
|
|
|
export interface TDeepPartialProps<P extends t.Props>
|
|
|
|
extends t.PartialType<
|
|
|
|
P,
|
|
|
|
{
|
|
|
|
[K in keyof P]?: TDeepPartial<t.TypeOf<P[K]>>;
|
|
|
|
},
|
|
|
|
{
|
|
|
|
[K in keyof P]?: TDeepPartial<t.OutputOf<P[K]>>;
|
|
|
|
}
|
|
|
|
> {}
|
|
|
|
|
|
|
|
export function tDeepPartial<T>(type: T): TDeepPartial<T> {
|
2020-07-30 20:10:50 +03:00
|
|
|
if (type instanceof t.InterfaceType || type instanceof t.PartialType) {
|
2019-11-28 02:34:41 +02:00
|
|
|
const newProps = {};
|
|
|
|
for (const [key, prop] of Object.entries(type.props)) {
|
|
|
|
newProps[key] = tDeepPartial(prop);
|
|
|
|
}
|
|
|
|
return t.partial(newProps) as TDeepPartial<T>;
|
|
|
|
} else if (type instanceof t.DictionaryType) {
|
|
|
|
return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial<T>;
|
|
|
|
} else if (type instanceof t.UnionType) {
|
|
|
|
return t.union(type.types.map(unionType => tDeepPartial(unionType))) as TDeepPartial<T>;
|
|
|
|
} else if (type instanceof t.IntersectionType) {
|
|
|
|
const types = type.types.map(intersectionType => tDeepPartial(intersectionType));
|
|
|
|
return (t.intersection(types as [t.Mixed, t.Mixed]) as unknown) as TDeepPartial<T>;
|
|
|
|
} else if (type instanceof t.ArrayType) {
|
|
|
|
return t.array(tDeepPartial(type.type)) as TDeepPartial<T>;
|
|
|
|
} else {
|
|
|
|
return type as TDeepPartial<T>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function tDeepPartialProp(prop: any) {
|
|
|
|
if (typeHasProps(prop)) {
|
|
|
|
return tDeepPartial(prop);
|
|
|
|
} else if (typeIsArray(prop)) {
|
|
|
|
return t.array(tDeepPartialProp(prop.type));
|
|
|
|
} else {
|
|
|
|
return prop;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
// https://stackoverflow.com/a/49262929/316944
|
|
|
|
export type Not<T, E> = T & Exclude<T, E>;
|
|
|
|
|
2020-08-11 04:16:06 +03:00
|
|
|
// io-ts partial dictionary type
|
|
|
|
// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345
|
|
|
|
export interface PartialDictionaryC<D extends t.Mixed, C extends t.Mixed>
|
|
|
|
extends t.DictionaryType<
|
|
|
|
D,
|
|
|
|
C,
|
|
|
|
{
|
|
|
|
[K in t.TypeOf<D>]?: t.TypeOf<C>;
|
|
|
|
},
|
|
|
|
{
|
|
|
|
[K in t.OutputOf<D>]?: t.OutputOf<C>;
|
|
|
|
},
|
|
|
|
unknown
|
|
|
|
> {}
|
|
|
|
|
|
|
|
export const tPartialDictionary = <D extends t.Mixed, C extends t.Mixed>(
|
|
|
|
domain: D,
|
|
|
|
codomain: C,
|
|
|
|
name?: string,
|
|
|
|
): PartialDictionaryC<D, C> => {
|
|
|
|
return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name));
|
|
|
|
};
|
|
|
|
|
2019-11-27 22:02:11 +02:00
|
|
|
/**
|
|
|
|
* Mirrors EmbedOptions from Eris
|
|
|
|
*/
|
|
|
|
export const tEmbed = t.type({
|
|
|
|
title: tNullable(t.string),
|
|
|
|
description: tNullable(t.string),
|
|
|
|
url: tNullable(t.string),
|
|
|
|
timestamp: tNullable(t.string),
|
|
|
|
color: tNullable(t.number),
|
|
|
|
footer: tNullable(
|
|
|
|
t.type({
|
|
|
|
text: t.string,
|
|
|
|
icon_url: tNullable(t.string),
|
|
|
|
proxy_icon_url: tNullable(t.string),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
image: tNullable(
|
|
|
|
t.type({
|
|
|
|
url: tNullable(t.string),
|
|
|
|
proxy_url: tNullable(t.string),
|
|
|
|
width: tNullable(t.number),
|
|
|
|
height: tNullable(t.number),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
thumbnail: tNullable(
|
|
|
|
t.type({
|
|
|
|
url: tNullable(t.string),
|
|
|
|
proxy_url: tNullable(t.string),
|
|
|
|
width: tNullable(t.number),
|
|
|
|
height: tNullable(t.number),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
video: tNullable(
|
|
|
|
t.type({
|
|
|
|
url: tNullable(t.string),
|
|
|
|
width: tNullable(t.number),
|
|
|
|
height: tNullable(t.number),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
provider: tNullable(
|
|
|
|
t.type({
|
|
|
|
name: t.string,
|
|
|
|
url: tNullable(t.string),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
fields: tNullable(
|
|
|
|
t.array(
|
|
|
|
t.type({
|
|
|
|
name: tNullable(t.string),
|
|
|
|
value: tNullable(t.string),
|
|
|
|
inline: tNullable(t.boolean),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
author: tNullable(
|
|
|
|
t.type({
|
|
|
|
name: t.string,
|
|
|
|
url: tNullable(t.string),
|
|
|
|
width: tNullable(t.number),
|
|
|
|
height: tNullable(t.number),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
});
|
|
|
|
|
2020-05-22 23:21:43 +03:00
|
|
|
export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };
|
|
|
|
|
|
|
|
export const tStrictMessageContent = t.type({
|
|
|
|
content: tNullable(t.string),
|
|
|
|
tts: tNullable(t.boolean),
|
|
|
|
disableEveryone: tNullable(t.boolean),
|
|
|
|
embed: tNullable(tEmbed),
|
|
|
|
});
|
|
|
|
|
2020-05-22 23:38:11 +03:00
|
|
|
export const tMessageContent = t.union([t.string, tStrictMessageContent]);
|
|
|
|
|
2019-08-22 02:58:32 +03:00
|
|
|
export function dropPropertiesByName(obj, propName) {
|
|
|
|
if (obj.hasOwnProperty(propName)) delete obj[propName];
|
|
|
|
for (const value of Object.values(obj)) {
|
|
|
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
|
|
dropPropertiesByName(value, propName);
|
|
|
|
}
|
|
|
|
}
|
2019-07-22 00:09:45 +03:00
|
|
|
}
|
|
|
|
|
2019-12-01 15:57:35 +02:00
|
|
|
export const tAlphanumeric = new t.Type<string, string>(
|
|
|
|
"tAlphanumeric",
|
|
|
|
(s): s is string => typeof s === "string",
|
|
|
|
(from, to) =>
|
|
|
|
either.chain(t.string.validate(from, to), s => {
|
|
|
|
return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s);
|
|
|
|
}),
|
|
|
|
s => s,
|
|
|
|
);
|
|
|
|
|
|
|
|
export const tDateTime = new t.Type<string, string>(
|
|
|
|
"tDateTime",
|
|
|
|
(s): s is string => typeof s === "string",
|
|
|
|
(from, to) =>
|
|
|
|
either.chain(t.string.validate(from, to), s => {
|
|
|
|
const parsed =
|
2020-08-10 00:24:06 +03:00
|
|
|
s.length === 10 ? moment.utc(s, "YYYY-MM-DD") : s.length === 19 ? moment.utc(s, "YYYY-MM-DD HH:mm:ss") : null;
|
2019-12-01 15:57:35 +02:00
|
|
|
|
|
|
|
return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime");
|
|
|
|
}),
|
|
|
|
s => s,
|
|
|
|
);
|
|
|
|
|
|
|
|
export const tDelayString = new t.Type<string, string>(
|
|
|
|
"tDelayString",
|
|
|
|
(s): s is string => typeof s === "string",
|
|
|
|
(from, to) =>
|
|
|
|
either.chain(t.string.validate(from, to), s => {
|
|
|
|
const ms = convertDelayStringToMS(s);
|
|
|
|
return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s);
|
|
|
|
}),
|
|
|
|
s => s,
|
|
|
|
);
|
|
|
|
|
2018-07-08 13:57:27 +03:00
|
|
|
/**
|
|
|
|
* Turns a "delay string" such as "1h30m" to milliseconds
|
|
|
|
*/
|
2019-04-14 14:05:16 +03:00
|
|
|
export function convertDelayStringToMS(str, defaultUnit = "m"): number {
|
2019-04-13 02:35:53 +03:00
|
|
|
const regex = /^([0-9]+)\s*([wdhms])?[a-z]*\s*/;
|
2018-07-08 13:57:27 +03:00
|
|
|
let match;
|
|
|
|
let ms = 0;
|
|
|
|
|
|
|
|
str = str.trim();
|
|
|
|
|
|
|
|
// tslint:disable-next-line
|
|
|
|
while (str !== "" && (match = str.match(regex)) !== null) {
|
2019-04-14 14:05:16 +03:00
|
|
|
ms += match[1] * ((match[2] && delayStringMultipliers[match[2]]) || delayStringMultipliers[defaultUnit]);
|
2018-07-08 13:57:27 +03:00
|
|
|
str = str.slice(match[0].length);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Invalid delay string
|
|
|
|
if (str !== "") {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return ms;
|
|
|
|
}
|
|
|
|
|
2019-12-01 15:57:35 +02:00
|
|
|
export function convertMSToDelayString(ms: number): string {
|
|
|
|
let result = "";
|
|
|
|
let remaining = ms;
|
|
|
|
for (const [abbr, multiplier] of Object.entries(delayStringMultipliers)) {
|
|
|
|
if (multiplier <= remaining) {
|
|
|
|
const amount = Math.floor(remaining / multiplier);
|
|
|
|
result += `${amount}${abbr}`;
|
|
|
|
remaining -= amount * multiplier;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (remaining === 0) break;
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2020-01-12 13:44:31 +02:00
|
|
|
export function successMessage(str, emoji = "<:zep_check:650361014180904971>") {
|
|
|
|
return emoji ? `${emoji} ${str}` : str;
|
2018-07-08 13:57:27 +03:00
|
|
|
}
|
|
|
|
|
2020-01-12 13:44:31 +02:00
|
|
|
export function errorMessage(str, emoji = "⚠") {
|
|
|
|
return emoji ? `${emoji} ${str}` : str;
|
2018-07-08 13:57:27 +03:00
|
|
|
}
|
|
|
|
|
2019-05-26 00:13:42 +03:00
|
|
|
export function get(obj, path, def?): any {
|
|
|
|
let cursor = obj;
|
|
|
|
const pathParts = path.split(".");
|
|
|
|
for (const part of pathParts) {
|
|
|
|
cursor = cursor[part];
|
|
|
|
if (cursor === undefined) return def;
|
|
|
|
if (cursor == null) return null;
|
|
|
|
}
|
|
|
|
return cursor;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function has(obj, path): boolean {
|
|
|
|
return get(obj, path) !== undefined;
|
2018-07-08 13:57:27 +03:00
|
|
|
}
|
2018-07-09 02:48:36 +03:00
|
|
|
|
2018-07-29 18:46:49 +03:00
|
|
|
export function stripObjectToScalars(obj, includedNested: string[] = []) {
|
2019-05-03 08:13:10 +03:00
|
|
|
const result = Array.isArray(obj) ? [] : {};
|
2018-07-09 02:48:36 +03:00
|
|
|
|
|
|
|
for (const key in obj) {
|
|
|
|
if (
|
|
|
|
obj[key] == null ||
|
|
|
|
typeof obj[key] === "string" ||
|
|
|
|
typeof obj[key] === "number" ||
|
|
|
|
typeof obj[key] === "boolean"
|
|
|
|
) {
|
|
|
|
result[key] = obj[key];
|
|
|
|
} else if (typeof obj[key] === "object") {
|
|
|
|
const prefix = `${key}.`;
|
|
|
|
const nestedNested = includedNested
|
|
|
|
.filter(p => p === key || p.startsWith(prefix))
|
|
|
|
.map(p => (p === key ? p : p.slice(prefix.length)));
|
|
|
|
|
|
|
|
if (nestedNested.length) {
|
|
|
|
result[key] = stripObjectToScalars(obj[key], nestedNested);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2018-12-15 23:01:45 +02:00
|
|
|
export const snowflakeRegex = /[1-9][0-9]{5,19}/;
|
|
|
|
|
2019-02-09 14:36:31 +02:00
|
|
|
const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`);
|
2018-07-29 15:18:26 +03:00
|
|
|
export function isSnowflake(v: string): boolean {
|
2018-12-15 23:01:45 +02:00
|
|
|
return isSnowflakeRegex.test(v);
|
2018-07-29 15:18:26 +03:00
|
|
|
}
|
2018-07-29 18:46:49 +03:00
|
|
|
|
2018-07-29 23:30:24 +03:00
|
|
|
export function sleep(ms: number): Promise<void> {
|
|
|
|
return new Promise(resolve => {
|
|
|
|
setTimeout(resolve, ms);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-07-29 18:46:49 +03:00
|
|
|
/**
|
|
|
|
* Attempts to find a relevant audit log entry for the given user and action
|
|
|
|
*/
|
2020-03-28 15:21:13 +02:00
|
|
|
const auditLogNextAttemptAfterFail: Map<string, number> = new Map();
|
|
|
|
const AUDIT_LOG_FAIL_COOLDOWN = 2 * MINUTES;
|
|
|
|
|
2018-07-29 18:46:49 +03:00
|
|
|
export async function findRelevantAuditLogEntry(
|
2018-07-29 23:30:24 +03:00
|
|
|
guild: Guild,
|
2018-07-29 18:46:49 +03:00
|
|
|
actionType: number,
|
|
|
|
userId: string,
|
|
|
|
attempts: number = 3,
|
2019-02-09 14:36:31 +02:00
|
|
|
attemptDelay: number = 3000,
|
2018-07-29 18:46:49 +03:00
|
|
|
): Promise<GuildAuditLogEntry> {
|
2020-03-28 15:21:13 +02:00
|
|
|
if (auditLogNextAttemptAfterFail.has(guild.id) && auditLogNextAttemptAfterFail.get(guild.id) > Date.now()) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2019-07-22 13:50:24 +03:00
|
|
|
let auditLogs: GuildAuditLog;
|
|
|
|
try {
|
|
|
|
auditLogs = await guild.getAuditLogs(5, null, actionType);
|
|
|
|
} catch (e) {
|
2020-05-08 18:29:17 +03:00
|
|
|
if (isDiscordRESTError(e) && e.code === 50013) {
|
2020-08-11 02:44:54 +03:00
|
|
|
// If we don't have permission to read audit log, set audit log requests on cooldown
|
2020-03-28 15:21:13 +02:00
|
|
|
auditLogNextAttemptAfterFail.set(guild.id, Date.now() + AUDIT_LOG_FAIL_COOLDOWN);
|
2020-08-11 02:44:54 +03:00
|
|
|
} else if (isDiscordHTTPError(e) && e.code === 500) {
|
|
|
|
// Ignore internal server errors which seem to be pretty common with audit log requests
|
|
|
|
} else if (e.message.startsWith("Request timed out")) {
|
|
|
|
// Ignore timeouts, try again next loop
|
|
|
|
} else {
|
|
|
|
throw e;
|
2020-03-28 15:21:13 +02:00
|
|
|
}
|
2019-07-22 13:50:24 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const entries = auditLogs ? auditLogs.entries : [];
|
2018-07-29 18:46:49 +03:00
|
|
|
|
2019-07-22 13:50:24 +03:00
|
|
|
entries.sort((a, b) => {
|
2018-07-29 18:46:49 +03:00
|
|
|
if (a.createdAt > b.createdAt) return -1;
|
|
|
|
if (a.createdAt > b.createdAt) return 1;
|
|
|
|
return 0;
|
|
|
|
});
|
|
|
|
|
|
|
|
const cutoffTS = Date.now() - 1000 * 60 * 2;
|
|
|
|
|
2019-07-22 13:50:24 +03:00
|
|
|
const relevantEntry = entries.find(entry => {
|
2018-07-29 23:30:24 +03:00
|
|
|
return entry.targetID === userId && entry.createdAt >= cutoffTS;
|
2018-07-29 18:46:49 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
if (relevantEntry) {
|
|
|
|
return relevantEntry;
|
|
|
|
} else if (attempts > 0) {
|
2018-07-29 23:30:24 +03:00
|
|
|
await sleep(attemptDelay);
|
|
|
|
return findRelevantAuditLogEntry(guild, actionType, userId, attempts - 1, attemptDelay);
|
2018-07-29 18:46:49 +03:00
|
|
|
} else {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2020-04-23 18:57:31 +03:00
|
|
|
const realLinkRegex = /https?:\/\/\S+/; // http://anything or https://anything
|
|
|
|
const plainLinkRegex = /((?!https?:\/\/)\S)+\.\S+/; // anything.anything, without http:// or https:// preceding it
|
|
|
|
// Both of the above, with precedence on the first one
|
|
|
|
const urlRegex = new RegExp(`(${realLinkRegex.source}|${plainLinkRegex.source})`, "g");
|
2018-07-31 02:42:45 +03:00
|
|
|
const protocolRegex = /^[a-z]+:\/\//;
|
|
|
|
|
2020-04-11 16:56:55 +03:00
|
|
|
interface MatchedURL extends url.URL {
|
|
|
|
input: string;
|
|
|
|
}
|
|
|
|
|
2020-07-29 22:58:14 +03:00
|
|
|
export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] {
|
2019-11-27 20:41:45 +02:00
|
|
|
let matches = str.match(urlRegex) || [];
|
2020-07-29 22:58:14 +03:00
|
|
|
if (onlyUnique) {
|
|
|
|
matches = unique(matches);
|
|
|
|
}
|
2019-08-18 16:40:15 +03:00
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
return matches.reduce((urls, match) => {
|
2020-04-11 16:56:55 +03:00
|
|
|
const withProtocol = protocolRegex.test(match) ? match : `https://${match}`;
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2020-04-11 16:56:55 +03:00
|
|
|
let matchUrl: MatchedURL;
|
2018-07-31 02:42:45 +03:00
|
|
|
try {
|
2020-04-11 16:56:55 +03:00
|
|
|
matchUrl = new url.URL(withProtocol) as MatchedURL;
|
|
|
|
matchUrl.input = match;
|
2018-07-31 02:42:45 +03:00
|
|
|
} catch (e) {
|
|
|
|
return urls;
|
|
|
|
}
|
|
|
|
|
|
|
|
const hostnameParts = matchUrl.hostname.split(".");
|
|
|
|
const tld = hostnameParts[hostnameParts.length - 1];
|
|
|
|
if (tlds.includes(tld)) {
|
|
|
|
urls.push(matchUrl);
|
|
|
|
}
|
|
|
|
|
|
|
|
return urls;
|
|
|
|
}, []);
|
|
|
|
}
|
|
|
|
|
2020-08-05 18:52:15 +03:00
|
|
|
export function parseInviteCodeInput(str: string): string {
|
|
|
|
if (str.match(/^[a-z0-9]{6,}$/i)) {
|
|
|
|
return str;
|
|
|
|
}
|
|
|
|
|
|
|
|
return getInviteCodesInString(str)[0];
|
|
|
|
}
|
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
export function getInviteCodesInString(str: string): string[] {
|
2020-05-04 21:56:15 +03:00
|
|
|
const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite|discord.com\/invite)\/([a-z0-9]+)/gi;
|
2019-11-30 22:05:48 +02:00
|
|
|
return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]);
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
export const unicodeEmojiRegex = emojiRegex();
|
2019-01-06 12:30:52 +02:00
|
|
|
export const customEmojiRegex = /<a?:(.*?):(\d+)>/;
|
|
|
|
|
|
|
|
const matchAllEmojiRegex = new RegExp(`(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})`, "g");
|
2018-07-31 02:42:45 +03:00
|
|
|
|
|
|
|
export function getEmojiInString(str: string): string[] {
|
2018-12-15 23:01:45 +02:00
|
|
|
return str.match(matchAllEmojiRegex) || [];
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
2019-01-12 13:42:11 +02:00
|
|
|
export function isEmoji(str: string): boolean {
|
|
|
|
return str.match(`^(${unicodeEmojiRegex.source})|(${customEmojiRegex.source})$`) !== null;
|
|
|
|
}
|
|
|
|
|
2019-02-09 14:36:31 +02:00
|
|
|
export function isUnicodeEmoji(str: string): boolean {
|
|
|
|
return str.match(`^${unicodeEmojiRegex.source}$`) !== null;
|
|
|
|
}
|
|
|
|
|
2018-07-31 04:02:45 +03:00
|
|
|
export function trimLines(str: string) {
|
|
|
|
return str
|
|
|
|
.trim()
|
|
|
|
.split("\n")
|
|
|
|
.map(l => l.trim())
|
|
|
|
.join("\n")
|
|
|
|
.trim();
|
|
|
|
}
|
|
|
|
|
2020-08-06 00:46:47 +03:00
|
|
|
export function trimEmptyLines(str: string) {
|
|
|
|
return str
|
|
|
|
.split("\n")
|
|
|
|
.filter(l => l.trim() !== "")
|
|
|
|
.join("\n");
|
|
|
|
}
|
|
|
|
|
2019-03-07 22:35:33 +02:00
|
|
|
export function asSingleLine(str: string) {
|
|
|
|
return trimLines(str).replace(/\n/g, " ");
|
|
|
|
}
|
|
|
|
|
2019-08-22 01:22:26 +03:00
|
|
|
export function trimEmptyStartEndLines(str: string) {
|
|
|
|
const lines = str.split("\n");
|
|
|
|
let emptyLinesAtStart = 0;
|
|
|
|
let emptyLinesAtEnd = 0;
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
if (line.match(/^\s*$/)) {
|
|
|
|
emptyLinesAtStart++;
|
|
|
|
} else {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = lines.length - 1; i > 0; i--) {
|
|
|
|
if (lines[i].match(/^\s*$/)) {
|
|
|
|
emptyLinesAtEnd++;
|
|
|
|
} else {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return lines.slice(emptyLinesAtStart, emptyLinesAtEnd ? -1 * emptyLinesAtEnd : null).join("\n");
|
|
|
|
}
|
|
|
|
|
|
|
|
export function trimIndents(str: string, indentLength: number) {
|
|
|
|
return str
|
|
|
|
.split("\n")
|
|
|
|
.map(line => line.slice(indentLength))
|
|
|
|
.join("\n");
|
|
|
|
}
|
|
|
|
|
2019-08-22 02:58:32 +03:00
|
|
|
export function indentLine(str: string, indentLength: number) {
|
|
|
|
return " ".repeat(indentLength) + str;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function indentLines(str: string, indentLength: number) {
|
|
|
|
return str
|
|
|
|
.split("\n")
|
|
|
|
.map(line => indentLine(line, indentLength))
|
|
|
|
.join("\n");
|
|
|
|
}
|
|
|
|
|
2018-07-31 04:02:45 +03:00
|
|
|
export const emptyEmbedValue = "\u200b";
|
2020-08-05 20:18:30 +03:00
|
|
|
export const preEmbedPadding = emptyEmbedValue + "\n";
|
2018-07-31 04:02:45 +03:00
|
|
|
export const embedPadding = "\n" + emptyEmbedValue;
|
2018-08-01 19:13:32 +03:00
|
|
|
|
|
|
|
export const userMentionRegex = /<@!?([0-9]+)>/g;
|
2019-02-17 16:01:04 +02:00
|
|
|
export const roleMentionRegex = /<@&([0-9]+)>/g;
|
2019-02-17 16:45:29 +02:00
|
|
|
export const channelMentionRegex = /<#([0-9]+)>/g;
|
2018-08-01 19:13:32 +03:00
|
|
|
|
|
|
|
export function getUserMentions(str: string) {
|
|
|
|
const regex = new RegExp(userMentionRegex.source, "g");
|
|
|
|
const userIds = [];
|
|
|
|
let match;
|
|
|
|
|
|
|
|
// tslint:disable-next-line
|
|
|
|
while ((match = regex.exec(str)) !== null) {
|
|
|
|
userIds.push(match[1]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return userIds;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function getRoleMentions(str: string) {
|
|
|
|
const regex = new RegExp(roleMentionRegex.source, "g");
|
|
|
|
const roleIds = [];
|
|
|
|
let match;
|
|
|
|
|
|
|
|
// tslint:disable-next-line
|
|
|
|
while ((match = regex.exec(str)) !== null) {
|
|
|
|
roleIds.push(match[1]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return roleIds;
|
|
|
|
}
|
2018-08-02 00:51:25 +03:00
|
|
|
|
|
|
|
/**
|
2019-11-30 18:07:25 +02:00
|
|
|
* Disable link previews in the given string by wrapping links in < >
|
2018-08-02 00:51:25 +03:00
|
|
|
*/
|
|
|
|
export function disableLinkPreviews(str: string): string {
|
2019-04-20 19:03:30 +03:00
|
|
|
return str.replace(/(?<!<)(https?:\/\/\S+)/gi, "<$1>");
|
2018-08-02 00:51:25 +03:00
|
|
|
}
|
2018-08-05 00:18:50 +03:00
|
|
|
|
2018-11-24 17:59:05 +02:00
|
|
|
export function deactivateMentions(content: string): string {
|
|
|
|
return content.replace(/@/g, "@\u200b");
|
|
|
|
}
|
|
|
|
|
2019-11-30 18:07:25 +02:00
|
|
|
/**
|
|
|
|
* Disable inline code in the given string by replacing backticks/grave accents with acute accents
|
|
|
|
* FIXME: Find a better way that keeps the grave accents? Can't use the code block approach here since it's just 1 character.
|
|
|
|
*/
|
|
|
|
export function disableInlineCode(content: string): string {
|
|
|
|
return content.replace(/`/g, "\u00b4");
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Disable code blocks in the given string by adding invisible unicode characters between backticks
|
|
|
|
*/
|
2018-11-24 17:59:05 +02:00
|
|
|
export function disableCodeBlocks(content: string): string {
|
|
|
|
return content.replace(/`/g, "`\u200b");
|
|
|
|
}
|
|
|
|
|
2018-12-15 17:15:32 +02:00
|
|
|
export function useMediaUrls(content: string): string {
|
2020-05-04 21:56:15 +03:00
|
|
|
return content.replace(/cdn\.discord(app)?\.com/g, "media.discordapp.net");
|
2018-12-15 17:15:32 +02:00
|
|
|
}
|
|
|
|
|
2019-02-15 03:55:18 +02:00
|
|
|
export function chunkArray<T>(arr: T[], chunkSize): T[][] {
|
|
|
|
const chunks: T[][] = [];
|
|
|
|
let currentChunk = [];
|
|
|
|
|
|
|
|
for (let i = 0; i < arr.length; i++) {
|
|
|
|
currentChunk.push(arr[i]);
|
|
|
|
if ((i !== 0 && i % chunkSize === 0) || i === arr.length - 1) {
|
|
|
|
chunks.push(currentChunk);
|
|
|
|
currentChunk = [];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return chunks;
|
|
|
|
}
|
|
|
|
|
2018-11-24 19:14:12 +02:00
|
|
|
export function chunkLines(str: string, maxChunkLength = 2000): string[] {
|
|
|
|
if (str.length < maxChunkLength) {
|
|
|
|
return [str];
|
|
|
|
}
|
|
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
|
|
|
while (str.length) {
|
|
|
|
if (str.length <= maxChunkLength) {
|
|
|
|
chunks.push(str);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
const slice = str.slice(0, maxChunkLength);
|
|
|
|
|
|
|
|
const lastLineBreakIndex = slice.lastIndexOf("\n");
|
|
|
|
if (lastLineBreakIndex === -1) {
|
|
|
|
chunks.push(str.slice(0, maxChunkLength));
|
|
|
|
str = str.slice(maxChunkLength);
|
|
|
|
} else {
|
|
|
|
chunks.push(str.slice(0, lastLineBreakIndex));
|
|
|
|
str = str.slice(lastLineBreakIndex + 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return chunks;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2019-04-30 05:32:50 +03:00
|
|
|
* Chunks a long message to multiple smaller messages, retaining leading and trailing line breaks, open code blocks, etc.
|
2020-07-29 22:48:49 +03:00
|
|
|
*
|
|
|
|
* Default maxChunkLength is 1990, a bit under the message length limit of 2000, so we have space to add code block
|
|
|
|
* shenanigans to the start/end when needed. Take this into account when choosing a custom maxChunkLength as well.
|
2018-11-24 19:14:12 +02:00
|
|
|
*/
|
2020-07-29 22:48:49 +03:00
|
|
|
export function chunkMessageLines(str: string, maxChunkLength = 1990): string[] {
|
|
|
|
const chunks = chunkLines(str, maxChunkLength);
|
2019-04-30 05:32:50 +03:00
|
|
|
let openCodeBlock = false;
|
|
|
|
|
2018-11-24 19:14:12 +02:00
|
|
|
return chunks.map(chunk => {
|
2019-04-30 05:32:50 +03:00
|
|
|
// If the chunk starts with a newline, add an invisible unicode char so Discord doesn't strip it away
|
2018-11-24 19:14:12 +02:00
|
|
|
if (chunk[0] === "\n") chunk = "\u200b" + chunk;
|
2019-04-30 05:32:50 +03:00
|
|
|
// If the chunk ends with a newline, add an invisible unicode char so Discord doesn't strip it away
|
2018-11-24 19:14:12 +02:00
|
|
|
if (chunk[chunk.length - 1] === "\n") chunk = chunk + "\u200b";
|
2019-04-30 05:32:50 +03:00
|
|
|
// If the previous chunk had an open code block, open it here again
|
|
|
|
if (openCodeBlock) {
|
|
|
|
openCodeBlock = false;
|
|
|
|
if (chunk.startsWith("```")) {
|
|
|
|
// Edge case: chunk starts with a code block delimiter, e.g. the previous chunk and this one were split right before the end of a code block
|
|
|
|
// Fix: just strip the code block delimiter away from here, we don't need it anymore
|
|
|
|
chunk = chunk.slice(3);
|
|
|
|
} else {
|
|
|
|
chunk = "```" + chunk;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// If the chunk has an open code block, close it and open it again in the next chunk
|
|
|
|
const codeBlockDelimiters = chunk.match(/```/g);
|
|
|
|
if (codeBlockDelimiters && codeBlockDelimiters.length % 2 !== 0) {
|
|
|
|
chunk += "```";
|
|
|
|
openCodeBlock = true;
|
|
|
|
}
|
|
|
|
|
2018-11-24 19:14:12 +02:00
|
|
|
return chunk;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-01-06 15:27:51 +02:00
|
|
|
export async function createChunkedMessage(channel: TextableChannel, messageText: string) {
|
|
|
|
const chunks = chunkMessageLines(messageText);
|
|
|
|
for (const chunk of chunks) {
|
|
|
|
await channel.createMessage(chunk);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-01-15 03:04:47 +02:00
|
|
|
/**
|
|
|
|
* Downloads the file from the given URL to a temporary file, with retry support
|
|
|
|
*/
|
|
|
|
export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path: string; deleteFn: () => void }> {
|
|
|
|
return new Promise(resolve => {
|
|
|
|
tmp.file((err, path, fd, deleteFn) => {
|
|
|
|
if (err) throw err;
|
|
|
|
|
|
|
|
const writeStream = fs.createWriteStream(path);
|
|
|
|
|
|
|
|
https
|
|
|
|
.get(attachmentUrl, res => {
|
|
|
|
res.pipe(writeStream);
|
|
|
|
writeStream.on("finish", () => {
|
|
|
|
writeStream.end();
|
|
|
|
resolve({
|
|
|
|
path,
|
2019-02-09 14:36:31 +02:00
|
|
|
deleteFn,
|
2019-01-15 03:04:47 +02:00
|
|
|
});
|
|
|
|
});
|
|
|
|
})
|
|
|
|
.on("error", httpsErr => {
|
|
|
|
fsp.unlink(path);
|
|
|
|
|
|
|
|
if (retries === 0) {
|
|
|
|
throw httpsErr;
|
|
|
|
} else {
|
2019-08-05 01:40:27 +03:00
|
|
|
console.warn("File download failed, retrying. Error given:", httpsErr.message); // tslint:disable-line
|
2019-01-15 03:04:47 +02:00
|
|
|
resolve(downloadFile(attachmentUrl, retries - 1));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-02-17 16:45:29 +02:00
|
|
|
type ItemWithRanking<T> = [T, number];
|
|
|
|
export function simpleClosestStringMatch<T>(searchStr, haystack: T[], getter = null): T {
|
|
|
|
const normalizedSearchStr = searchStr.toLowerCase();
|
|
|
|
|
|
|
|
// See if any haystack item contains a part of the search string
|
|
|
|
const itemsWithRankings: Array<ItemWithRanking<T>> = haystack.map(item => {
|
|
|
|
const itemStr: string = getter ? getter(item) : item;
|
|
|
|
const normalizedItemStr = itemStr.toLowerCase();
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
do {
|
|
|
|
if (!normalizedItemStr.includes(normalizedSearchStr.slice(0, i + 1))) break;
|
|
|
|
i++;
|
|
|
|
} while (i < normalizedSearchStr.length);
|
|
|
|
|
|
|
|
if (i > 0 && normalizedItemStr.startsWith(normalizedSearchStr.slice(0, i))) {
|
|
|
|
// Slightly prioritize items that *start* with the search string
|
|
|
|
i += 0.5;
|
|
|
|
}
|
|
|
|
|
|
|
|
return [item, i] as ItemWithRanking<T>;
|
|
|
|
});
|
|
|
|
|
|
|
|
// Sort by best match
|
|
|
|
itemsWithRankings.sort((a, b) => {
|
|
|
|
return a[1] > b[1] ? -1 : 1;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (itemsWithRankings[0][1] === 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return itemsWithRankings[0][0];
|
|
|
|
}
|
|
|
|
|
2019-02-17 22:49:14 +02:00
|
|
|
type sorterDirection = "ASC" | "DESC";
|
|
|
|
type sorterGetterFn = (any) => any;
|
|
|
|
type sorterGetterFnWithDirection = [sorterGetterFn, sorterDirection];
|
|
|
|
type sorterGetterResolvable = string | sorterGetterFn;
|
|
|
|
type sorterGetterResolvableWithDirection = [sorterGetterResolvable, sorterDirection];
|
|
|
|
type sorterFn = (a: any, b: any) => number;
|
|
|
|
|
|
|
|
function resolveGetter(getter: sorterGetterResolvable): sorterGetterFn {
|
|
|
|
if (typeof getter === "string") {
|
|
|
|
return obj => obj[getter];
|
|
|
|
}
|
|
|
|
|
|
|
|
return getter;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function multiSorter(getters: Array<sorterGetterResolvable | sorterGetterResolvableWithDirection>): sorterFn {
|
|
|
|
const resolvedGetters: sorterGetterFnWithDirection[] = getters.map(getter => {
|
|
|
|
if (Array.isArray(getter)) {
|
|
|
|
return [resolveGetter(getter[0]), getter[1]] as sorterGetterFnWithDirection;
|
|
|
|
} else {
|
|
|
|
return [resolveGetter(getter), "ASC"] as sorterGetterFnWithDirection;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return (a, b) => {
|
|
|
|
for (const getter of resolvedGetters) {
|
|
|
|
const aVal = getter[0](a);
|
|
|
|
const bVal = getter[0](b);
|
|
|
|
if (aVal > bVal) return getter[1] === "ASC" ? 1 : -1;
|
|
|
|
if (aVal < bVal) return getter[1] === "ASC" ? -1 : 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export function sorter(getter: sorterGetterResolvable, direction: sorterDirection = "ASC"): sorterFn {
|
|
|
|
return multiSorter([[getter, direction]]);
|
|
|
|
}
|
|
|
|
|
2018-12-15 17:04:04 +02:00
|
|
|
export function noop() {
|
|
|
|
// IT'S LITERALLY NOTHING
|
|
|
|
}
|
|
|
|
|
2019-01-12 13:42:11 +02:00
|
|
|
export type CustomEmoji = {
|
|
|
|
id: string;
|
|
|
|
} & Emoji;
|
2019-04-13 17:35:02 +03:00
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
export type UserNotificationMethod = { type: "dm" } | { type: "channel"; channel: TextChannel };
|
2019-04-13 17:35:02 +03:00
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
export const disableUserNotificationStrings = ["no", "none", "off"];
|
2019-04-13 17:35:02 +03:00
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
export interface UserNotificationResult {
|
|
|
|
method: UserNotificationMethod | null;
|
|
|
|
success: boolean;
|
2019-04-13 17:35:02 +03:00
|
|
|
text?: string;
|
|
|
|
}
|
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
/**
|
|
|
|
* Attempts to notify the user using one of the specified methods. Only the first one that succeeds will be used.
|
|
|
|
* @param methods List of methods to try, in priority order
|
|
|
|
*/
|
2019-04-13 17:35:02 +03:00
|
|
|
export async function notifyUser(
|
|
|
|
user: User,
|
|
|
|
body: string,
|
2020-01-29 02:44:11 +02:00
|
|
|
methods: UserNotificationMethod[],
|
|
|
|
): Promise<UserNotificationResult> {
|
|
|
|
if (methods.length === 0) {
|
|
|
|
return { method: null, success: true };
|
2019-04-13 17:35:02 +03:00
|
|
|
}
|
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
let lastError: Error = null;
|
2019-04-13 17:35:02 +03:00
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
for (const method of methods) {
|
|
|
|
if (method.type === "dm") {
|
|
|
|
try {
|
|
|
|
const dmChannel = await user.getDMChannel();
|
|
|
|
await dmChannel.createMessage(body);
|
2019-04-13 17:35:02 +03:00
|
|
|
return {
|
2020-01-29 02:44:11 +02:00
|
|
|
method,
|
|
|
|
success: true,
|
|
|
|
text: "user notified with a direct message",
|
2019-04-13 17:35:02 +03:00
|
|
|
};
|
2020-01-29 02:44:11 +02:00
|
|
|
} catch (e) {
|
|
|
|
lastError = e;
|
2019-04-13 17:35:02 +03:00
|
|
|
}
|
2020-01-29 02:44:11 +02:00
|
|
|
} else if (method.type === "channel") {
|
|
|
|
try {
|
|
|
|
await method.channel.createMessage(`<@!${user.id}> ${body}`);
|
|
|
|
return {
|
|
|
|
method,
|
|
|
|
success: true,
|
|
|
|
text: `user notified in <#${method.channel.id}>`,
|
|
|
|
};
|
|
|
|
} catch (e) {
|
|
|
|
lastError = e;
|
|
|
|
}
|
|
|
|
}
|
2019-04-13 17:35:02 +03:00
|
|
|
}
|
|
|
|
|
2020-01-29 02:44:11 +02:00
|
|
|
const errorText = lastError ? `failed to message user: ${lastError.message}` : `failed to message user`;
|
|
|
|
|
2019-04-13 17:35:02 +03:00
|
|
|
return {
|
2020-01-29 02:44:11 +02:00
|
|
|
method: null,
|
|
|
|
success: false,
|
|
|
|
text: errorText,
|
2019-04-13 17:35:02 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export function ucfirst(str) {
|
|
|
|
if (typeof str !== "string" || str === "") return str;
|
|
|
|
return str[0].toUpperCase() + str.slice(1);
|
|
|
|
}
|
2019-04-14 13:30:48 +03:00
|
|
|
|
2019-04-20 19:03:30 +03:00
|
|
|
export class UnknownUser {
|
|
|
|
public id: string = null;
|
|
|
|
public username = "Unknown";
|
|
|
|
public discriminator = "0000";
|
2019-04-19 12:25:25 +03:00
|
|
|
|
2019-04-20 19:03:30 +03:00
|
|
|
constructor(props = {}) {
|
|
|
|
for (const key in props) {
|
|
|
|
this[key] = props[key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-04 16:47:42 +03:00
|
|
|
export function isObjectLiteral(obj) {
|
|
|
|
let deepestPrototype = obj;
|
|
|
|
while (Object.getPrototypeOf(deepestPrototype) != null) {
|
|
|
|
deepestPrototype = Object.getPrototypeOf(deepestPrototype);
|
|
|
|
}
|
|
|
|
return Object.getPrototypeOf(obj) === deepestPrototype;
|
|
|
|
}
|
|
|
|
|
2019-08-04 17:30:47 +03:00
|
|
|
const keyMods = ["+", "-", "="];
|
2019-08-04 15:44:41 +03:00
|
|
|
export function deepKeyIntersect(obj, keyReference) {
|
|
|
|
const result = {};
|
2019-08-04 17:30:47 +03:00
|
|
|
for (let [key, value] of Object.entries(obj)) {
|
|
|
|
if (!keyReference.hasOwnProperty(key)) {
|
|
|
|
// Temporary solution so we don't erase keys with modifiers
|
|
|
|
// Modifiers will be removed soon(tm) so we can remove this when that happens as well
|
|
|
|
let found = false;
|
|
|
|
for (const mod of keyMods) {
|
|
|
|
if (keyReference.hasOwnProperty(mod + key)) {
|
|
|
|
key = mod + key;
|
|
|
|
found = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!found) continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
// Also temp (because modifier shenanigans)
|
|
|
|
result[key] = keyReference[key];
|
|
|
|
} else if (
|
|
|
|
value != null &&
|
|
|
|
typeof value === "object" &&
|
|
|
|
typeof keyReference[key] === "object" &&
|
|
|
|
isObjectLiteral(value)
|
|
|
|
) {
|
2019-08-04 15:44:41 +03:00
|
|
|
result[key] = deepKeyIntersect(value, keyReference[key]);
|
|
|
|
} else {
|
|
|
|
result[key] = value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2019-05-02 18:25:25 +03:00
|
|
|
const unknownUsers = new Set();
|
|
|
|
const unknownMembers = new Set();
|
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
export function resolveUserId(bot: Client, value: string) {
|
2019-08-14 10:55:34 +03:00
|
|
|
if (value == null) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2019-04-20 19:03:30 +03:00
|
|
|
// A user mention?
|
|
|
|
const mentionMatch = value.match(/^<@!?(\d+)>$/);
|
|
|
|
if (mentionMatch) {
|
2019-08-04 13:14:23 +03:00
|
|
|
return mentionMatch[1];
|
2019-04-20 19:03:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// A non-mention, full username?
|
2019-08-04 13:14:23 +03:00
|
|
|
const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
|
|
|
|
if (usernameMatch) {
|
|
|
|
const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
|
|
|
|
if (user) return user.id;
|
2019-04-20 19:03:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// Just a user ID?
|
2020-08-09 20:10:03 +03:00
|
|
|
if (isValidSnowflake(value)) {
|
2019-08-04 13:14:23 +03:00
|
|
|
return value;
|
|
|
|
}
|
2019-04-20 19:03:30 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2020-07-05 05:00:54 +03:00
|
|
|
/**
|
|
|
|
* Finds a matching User for the passed user id, user mention, or full username (with discriminator).
|
|
|
|
* If a user is not found, returns an UnknownUser instead.
|
|
|
|
*/
|
2020-07-22 22:56:21 +03:00
|
|
|
export function getUser(client: Client, userResolvable: string): User | UnknownUser {
|
|
|
|
const id = resolveUserId(client, userResolvable);
|
|
|
|
return id ? client.users.get(id) || new UnknownUser({ id }) : new UnknownUser();
|
2020-07-05 05:00:54 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Resolves a User from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc.
|
|
|
|
* If the user is not found in the cache, it's fetched from the API.
|
|
|
|
*/
|
2020-01-29 02:44:11 +02:00
|
|
|
export async function resolveUser(bot: Client, value: string): Promise<User | UnknownUser>;
|
|
|
|
export async function resolveUser<T>(bot: Client, value: Not<T, string>): Promise<UnknownUser>;
|
|
|
|
export async function resolveUser<T>(bot, value) {
|
|
|
|
if (typeof value !== "string") {
|
2019-08-04 13:14:23 +03:00
|
|
|
return new UnknownUser();
|
2019-04-20 19:03:30 +03:00
|
|
|
}
|
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
const userId = resolveUserId(bot, value);
|
2019-08-05 01:40:27 +03:00
|
|
|
if (!userId) {
|
2020-08-09 20:10:03 +03:00
|
|
|
return null;
|
2019-08-05 01:40:27 +03:00
|
|
|
}
|
|
|
|
|
2020-08-09 20:10:03 +03:00
|
|
|
// If we have the user cached, return that directly
|
2019-08-04 13:14:23 +03:00
|
|
|
if (bot.users.has(userId)) {
|
|
|
|
return bot.users.get(userId);
|
|
|
|
}
|
2019-04-20 19:03:30 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
// We don't want to spam the API by trying to fetch unknown users again and again,
|
|
|
|
// so we cache the fact that they're "unknown" for a while
|
|
|
|
if (unknownUsers.has(userId)) {
|
|
|
|
return new UnknownUser({ id: userId });
|
|
|
|
}
|
2019-05-02 18:25:25 +03:00
|
|
|
|
2019-08-05 01:40:27 +03:00
|
|
|
const freshUser = await bot.getRESTUser(userId).catch(noop);
|
2019-08-04 13:14:23 +03:00
|
|
|
if (freshUser) {
|
|
|
|
bot.users.add(freshUser, bot);
|
|
|
|
return freshUser;
|
2019-05-02 18:25:25 +03:00
|
|
|
}
|
2019-04-20 19:03:30 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
unknownUsers.add(userId);
|
|
|
|
setTimeout(() => unknownUsers.delete(userId), 15 * MINUTES);
|
|
|
|
|
2019-04-20 19:03:30 +03:00
|
|
|
return new UnknownUser({ id: userId });
|
|
|
|
}
|
|
|
|
|
2020-07-05 05:00:54 +03:00
|
|
|
/**
|
|
|
|
* Resolves a guild Member from the passed user id, user mention, or full username (with discriminator).
|
|
|
|
* If the member is not found in the cache, it's fetched from the API.
|
|
|
|
*/
|
2019-04-20 19:03:30 +03:00
|
|
|
export async function resolveMember(bot: Client, guild: Guild, value: string): Promise<Member> {
|
2019-08-04 13:14:23 +03:00
|
|
|
const userId = resolveUserId(bot, value);
|
|
|
|
if (!userId) return null;
|
2019-05-02 18:25:25 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
// If we have the member cached, return that directly
|
|
|
|
if (guild.members.has(userId)) {
|
|
|
|
return guild.members.get(userId);
|
2019-05-02 18:25:25 +03:00
|
|
|
}
|
2019-04-19 12:25:25 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
// We don't want to spam the API by trying to fetch unknown members again and again,
|
|
|
|
// so we cache the fact that they're "unknown" for a while
|
|
|
|
const unknownKey = `${guild.id}-${userId}`;
|
|
|
|
if (unknownMembers.has(unknownKey)) {
|
|
|
|
return null;
|
|
|
|
}
|
2019-05-04 18:41:50 +03:00
|
|
|
|
2019-08-04 13:14:23 +03:00
|
|
|
logger.debug(`Fetching unknown member (${userId} in ${guild.name} (${guild.id})) from the API`);
|
|
|
|
|
|
|
|
const freshMember = await bot.getRESTGuildMember(guild.id, userId).catch(noop);
|
|
|
|
if (freshMember) {
|
|
|
|
freshMember.id = userId;
|
|
|
|
return freshMember;
|
|
|
|
}
|
|
|
|
|
|
|
|
unknownMembers.add(unknownKey);
|
|
|
|
setTimeout(() => unknownMembers.delete(unknownKey), 15 * MINUTES);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2019-05-04 18:41:50 +03:00
|
|
|
|
2020-07-05 05:00:54 +03:00
|
|
|
/**
|
|
|
|
* Resolves a role from the passed role ID, role mention, or role name.
|
|
|
|
* In the event of duplicate role names, this function will return the first one it comes across.
|
|
|
|
*
|
|
|
|
* FIXME: Define "first one it comes across" better
|
|
|
|
*/
|
2020-01-12 11:47:54 +02:00
|
|
|
export async function resolveRoleId(bot: Client, guildId: string, value: string) {
|
|
|
|
if (value == null) {
|
2020-01-10 01:04:58 +11:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2020-01-12 11:47:54 +02:00
|
|
|
// Role mention
|
2020-01-10 01:04:58 +11:00
|
|
|
const mentionMatch = value.match(/^<@&?(\d+)>$/);
|
2020-01-12 11:47:54 +02:00
|
|
|
if (mentionMatch) {
|
2020-01-10 01:04:58 +11:00
|
|
|
return mentionMatch[1];
|
|
|
|
}
|
|
|
|
|
2020-01-12 11:47:54 +02:00
|
|
|
// Role name
|
|
|
|
const roleList = await bot.getRESTGuildRoles(guildId);
|
|
|
|
const role = roleList.filter(x => x.name.toLocaleLowerCase() === value.toLocaleLowerCase());
|
|
|
|
if (role[0]) {
|
2020-01-10 01:04:58 +11:00
|
|
|
return role[0].id;
|
|
|
|
}
|
|
|
|
|
2020-01-12 11:47:54 +02:00
|
|
|
// Role ID
|
2020-01-10 01:04:58 +11:00
|
|
|
const idMatch = value.match(/^\d+$/);
|
|
|
|
if (idMatch) {
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2020-07-05 05:00:54 +03:00
|
|
|
const inviteCache = new SimpleCache<Promise<ChannelInvite>>(10 * MINUTES, 200);
|
|
|
|
|
2020-08-05 18:52:15 +03:00
|
|
|
export async function resolveInvite(client: Client, code: string, withCounts?: boolean): Promise<AnyInvite | null> {
|
|
|
|
const key = `${code}:${withCounts ? 1 : 0}`;
|
|
|
|
|
|
|
|
if (inviteCache.has(key)) {
|
|
|
|
return inviteCache.get(key);
|
2020-07-05 05:00:54 +03:00
|
|
|
}
|
|
|
|
|
2020-08-05 18:52:15 +03:00
|
|
|
const promise = client.getInvite(code, withCounts).catch(() => null);
|
|
|
|
inviteCache.set(key, promise);
|
2020-07-05 05:00:54 +03:00
|
|
|
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
|
2019-08-10 00:13:35 +03:00
|
|
|
export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) {
|
|
|
|
const msg = await channel.createMessage(content);
|
2020-07-06 02:08:11 +03:00
|
|
|
const reply = await helpers.waitForReaction(bot, msg, ["✅", "❌"], userId);
|
2019-08-10 00:13:35 +03:00
|
|
|
msg.delete().catch(noop);
|
|
|
|
return reply && reply.name === "✅";
|
|
|
|
}
|
2019-10-11 01:59:56 +03:00
|
|
|
|
|
|
|
export function messageSummary(msg: SavedMessage) {
|
|
|
|
// Regular text content
|
|
|
|
let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "<no text content>") + "```";
|
|
|
|
|
|
|
|
// Rich embed
|
|
|
|
const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich");
|
|
|
|
if (richEmbed) result += "Embed:```" + disableCodeBlocks(JSON.stringify(richEmbed)) + "```";
|
|
|
|
|
|
|
|
// Attachments
|
|
|
|
if (msg.data.attachments) {
|
|
|
|
result +=
|
|
|
|
"Attachments:\n" + msg.data.attachments.map((a: Attachment) => disableLinkPreviews(a.url)).join("\n") + "\n";
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
2019-10-11 22:56:34 +03:00
|
|
|
|
|
|
|
export function verboseUserMention(user: User | UnknownUser): string {
|
2020-03-28 15:21:13 +02:00
|
|
|
if (user.id == null) {
|
|
|
|
return `**${user.username}#${user.discriminator}**`;
|
|
|
|
}
|
|
|
|
|
2019-10-11 22:56:34 +03:00
|
|
|
return `<@!${user.id}> (**${user.username}#${user.discriminator}**, \`${user.id}\`)`;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function verboseUserName(user: User | UnknownUser): string {
|
2020-03-28 15:21:13 +02:00
|
|
|
if (user.id == null) {
|
|
|
|
return `**${user.username}#${user.discriminator}**`;
|
|
|
|
}
|
|
|
|
|
2019-10-11 22:56:34 +03:00
|
|
|
return `**${user.username}#${user.discriminator}** (\`${user.id}\`)`;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function verboseChannelMention(channel: GuildChannel): string {
|
|
|
|
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
|
|
|
|
}
|
2019-11-27 22:02:11 +02:00
|
|
|
|
|
|
|
export function messageLink(message: Message): string;
|
2019-11-30 19:54:44 +02:00
|
|
|
export function messageLink(guildId: string, channelId: string, messageId: string): string;
|
2019-11-27 22:02:11 +02:00
|
|
|
export function messageLink(guildIdOrMessage: string | Message | null, channelId?: string, messageId?: string): string {
|
|
|
|
let guildId;
|
|
|
|
if (guildIdOrMessage == null) {
|
|
|
|
// Full arguments without a guild id -> DM/Group chat
|
|
|
|
guildId = "@me";
|
|
|
|
} else if (guildIdOrMessage instanceof Message) {
|
|
|
|
// Message object as the only argument
|
|
|
|
guildId = (guildIdOrMessage.channel as GuildChannel).guild?.id ?? "@me";
|
|
|
|
channelId = guildIdOrMessage.channel.id;
|
|
|
|
messageId = guildIdOrMessage.id;
|
|
|
|
} else {
|
|
|
|
// Full arguments with all IDs
|
|
|
|
guildId = guildIdOrMessage;
|
|
|
|
}
|
|
|
|
|
2020-05-04 21:56:15 +03:00
|
|
|
return `https://discord.com/channels/${guildId}/${channelId}/${messageId}`;
|
2019-11-27 22:02:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export function isValidEmbed(embed: any): boolean {
|
|
|
|
const result = decodeAndValidateStrict(tEmbed, embed);
|
|
|
|
return !(result instanceof StrictValidationError);
|
|
|
|
}
|
2020-01-12 16:34:33 +02:00
|
|
|
|
|
|
|
const formatter = new Intl.NumberFormat("en-US");
|
|
|
|
export function formatNumber(numberToFormat: number): string {
|
|
|
|
return formatter.format(numberToFormat);
|
|
|
|
}
|
2020-01-21 00:24:04 +02:00
|
|
|
|
|
|
|
interface IMemoizedItem {
|
|
|
|
createdAt: number;
|
|
|
|
value: any;
|
|
|
|
}
|
|
|
|
|
|
|
|
const memoizeCache: Map<any, IMemoizedItem> = new Map();
|
|
|
|
export function memoize<T>(fn: (...args: any[]) => T, key?, time?): T {
|
|
|
|
const realKey = key ?? fn;
|
|
|
|
|
|
|
|
if (memoizeCache.has(realKey)) {
|
|
|
|
const memoizedItem = memoizeCache.get(realKey);
|
|
|
|
if (!time || memoizedItem.createdAt > Date.now() - time) {
|
|
|
|
return memoizedItem.value;
|
|
|
|
}
|
|
|
|
|
|
|
|
memoizeCache.delete(realKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
const value = fn();
|
|
|
|
memoizeCache.set(realKey, {
|
|
|
|
createdAt: Date.now(),
|
|
|
|
value,
|
|
|
|
});
|
|
|
|
|
|
|
|
return value;
|
|
|
|
}
|
2020-05-22 23:38:11 +03:00
|
|
|
|
|
|
|
type RecursiveRenderFn = (str: string) => string | Promise<string>;
|
|
|
|
|
|
|
|
export async function renderRecursively(value, fn: RecursiveRenderFn) {
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
const result = [];
|
|
|
|
for (const item of value) {
|
|
|
|
result.push(await renderRecursively(item, fn));
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
} else if (value === null) {
|
|
|
|
return null;
|
|
|
|
} else if (typeof value === "object") {
|
|
|
|
const result = {};
|
|
|
|
for (const [prop, _value] of Object.entries(value)) {
|
|
|
|
result[prop] = await renderRecursively(_value, fn);
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
} else if (typeof value === "string") {
|
|
|
|
return fn(value);
|
|
|
|
}
|
|
|
|
|
|
|
|
return value;
|
|
|
|
}
|
2020-07-05 05:00:54 +03:00
|
|
|
|
|
|
|
export function canUseEmoji(client: Client, emoji: string): boolean {
|
|
|
|
if (isUnicodeEmoji(emoji)) {
|
|
|
|
return true;
|
|
|
|
} else if (isSnowflake(emoji)) {
|
|
|
|
for (const guild of client.guilds.values()) {
|
|
|
|
if (guild.emojis.some(e => (e as any).id === emoji)) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
throw new Error(`Invalid emoji ${emoji}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function trimPluginDescription(str) {
|
|
|
|
const emptyLinesTrimmed = trimEmptyStartEndLines(str);
|
|
|
|
const lines = emptyLinesTrimmed.split("\n");
|
|
|
|
const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length;
|
|
|
|
return trimIndents(emptyLinesTrimmed, firstLineIndentation);
|
|
|
|
}
|
2020-07-05 15:59:15 +03:00
|
|
|
|
|
|
|
export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message {
|
|
|
|
return (msg as Message).createdAt != null;
|
|
|
|
}
|
2020-07-27 21:51:03 +03:00
|
|
|
|
|
|
|
export function isGuildInvite(invite: AnyInvite): invite is GuildInvite {
|
|
|
|
return (invite as GuildInvite).guild != null;
|
|
|
|
}
|
2020-07-28 21:34:01 +03:00
|
|
|
|
2020-08-05 18:52:15 +03:00
|
|
|
export function isRESTGuildInvite(invite: BaseInvite): invite is RESTChannelInvite {
|
|
|
|
return (invite as any).guild != null;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isRESTGroupDMInvite(invite: BaseInvite): invite is RESTPrivateInvite {
|
|
|
|
return (invite as any).guild == null && (invite as any).channel != null;
|
|
|
|
}
|
|
|
|
|
2020-07-28 21:34:01 +03:00
|
|
|
export function asyncMap<T, R>(arr: T[], fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
|
|
return Promise.all(arr.map((item, index) => fn(item)));
|
|
|
|
}
|
2020-07-29 22:58:14 +03:00
|
|
|
|
|
|
|
export function unique<T>(arr: T[]): T[] {
|
|
|
|
return Array.from(new Set(arr));
|
|
|
|
}
|