mirror of
https://github.com/janderedev/automod.git
synced 2024-12-22 10:45:27 +00:00
H
This commit is contained in:
parent
3e1da96360
commit
0d198c269b
|
@ -13,10 +13,12 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/monk": "^6.0.0",
|
||||
"axios": "^0.22.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"log75": "2.0.1",
|
||||
"monk": "^7.3.4",
|
||||
"revolt.js": "^5.1.0-alpha.6"
|
||||
"revolt.js": "^5.1.0-alpha.6",
|
||||
"ulid": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.4.3"
|
||||
|
|
66
src/bot/commands/bot_managers.ts
Normal file
66
src/bot/commands/bot_managers.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import Command from "../../struct/Command";
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { hasPerm, parseUser } from "../util";
|
||||
import ServerConfig from "../../struct/ServerConfig";
|
||||
import { client } from "../..";
|
||||
import { User } from "revolt.js/dist/maps/Users";
|
||||
|
||||
const SYNTAX = '/admin add @user; /admin remove @user; /admin list';
|
||||
|
||||
export default {
|
||||
name: 'admin',
|
||||
aliases: [ 'admins', 'manager', 'managers' ],
|
||||
description: 'Allow users to control the bot\'s configuration',
|
||||
syntax: SYNTAX,
|
||||
serverOnly: true,
|
||||
run: async (message: Message, args: string[]) => {
|
||||
if (!hasPerm(message.member!, 'ManageServer'))
|
||||
return message.reply('You need **ManageServer** permission to use this command.');
|
||||
|
||||
let config: ServerConfig = (await client.db.get('servers').findOne({ id: message.channel?.server_id })) ?? {};
|
||||
let admins = config.botManagers ?? [];
|
||||
let user: User|null;
|
||||
|
||||
switch(args[0]?.toLowerCase()) {
|
||||
case 'add':
|
||||
case 'new':
|
||||
if (!args[1]) return message.reply('No user specified.');
|
||||
user = await parseUser(args[1]);
|
||||
if (!user) return message.reply('I can\'t find that user.');
|
||||
|
||||
if (admins.indexOf(user._id) > -1) return message.reply('This user is already added as bot admin.');
|
||||
|
||||
admins.push(user._id);
|
||||
await client.db.get('servers').update({ id: message.channel?.server_id }, { $set: { botManagers: admins } });
|
||||
|
||||
message.reply(`✅ Added \`@${user.username}\` to bot admins.`);
|
||||
break;
|
||||
case 'remove':
|
||||
case 'delete':
|
||||
case 'rm':
|
||||
case 'del':
|
||||
if (!args[1]) return message.reply('No user specified.');
|
||||
user = await parseUser(args[1]);
|
||||
if (!user) return message.reply('I can\'t find that user.');
|
||||
|
||||
if (admins.indexOf(user._id) == -1) return message.reply('This user is not added as bot admin.');
|
||||
|
||||
admins = admins.filter(a => a != user?._id);
|
||||
await client.db.get('servers').update({ id: message.channel?.server_id }, { $set: { botManagers: admins } });
|
||||
|
||||
message.reply(`✅ Removed \`@${user.username}\` from bot admins.`);
|
||||
break;
|
||||
case 'list':
|
||||
case 'ls':
|
||||
case 'show':
|
||||
message.reply(`# Bot admins\n`
|
||||
+ `Users with **ManageServer** permission can add or remove admins.\n\n`
|
||||
+ `${admins.map(a => `* <@${a}>`).join('\n')}\n\n`
|
||||
+ `${admins.length} user${admins.length == 1 ? '' : 's'}.`)
|
||||
?.catch(e => message.reply(e));
|
||||
break;
|
||||
default:
|
||||
message.reply(`Available subcommands: ${SYNTAX}`);
|
||||
}
|
||||
}
|
||||
} as Command;
|
|
@ -11,7 +11,5 @@ export default {
|
|||
message.reply(`Server ID: ${message.channel?.server_id || 'None'}\n`
|
||||
+ `Channel ID: ${message.channel_id}\n`
|
||||
+ `User ID: ${message.author_id}`);
|
||||
|
||||
console.log(hasPerm(message.member!, 'BanMembers'));
|
||||
}
|
||||
} as Command;
|
||||
|
|
42
src/bot/commands/eval.ts
Normal file
42
src/bot/commands/eval.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import Command from "../../struct/Command";
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { inspect } from 'util';
|
||||
|
||||
export default {
|
||||
name: 'eval',
|
||||
aliases: [ 'e' ],
|
||||
description: 'Evaluate JS code',
|
||||
restrict: 'BOTOWNER',
|
||||
removeEmptyArgs: false,
|
||||
serverOnly: false,
|
||||
run: async (message: Message, args: string[]) => {
|
||||
let cmd = `let { client } = require("../..");`
|
||||
+ `let axios = require("axios").default;`
|
||||
+ `let crypto = require("crypto");`
|
||||
+ args.join(' ');
|
||||
|
||||
let m = await message.channel?.sendMessage(`Executing...`);
|
||||
|
||||
try {
|
||||
let e = eval(cmd);
|
||||
|
||||
if (e instanceof Promise) {
|
||||
await m?.edit({ content: `## **Promise**<pending>` });
|
||||
e.then((res) => {
|
||||
m?.edit({
|
||||
content: `## **Promise**<resolved>\n\`\`\`js\n${`${inspect(res)}`.substr(0, 1960)}\n\`\`\``
|
||||
});
|
||||
})
|
||||
.catch((res) => {
|
||||
m?.edit({
|
||||
content: `## **Promise**<rejected>\n\`\`\`js\n${`${inspect(res)}`.substr(0, 1960)}\n\`\`\``
|
||||
});
|
||||
});
|
||||
} else {
|
||||
message.channel?.sendMessage(`\`\`\`js\n${inspect(e).substr(0, 1980)}\n\`\`\``);
|
||||
}
|
||||
} catch(e) {
|
||||
m?.edit({ content: `## Execution failed\n\`\`\`js\n${inspect(e).substr(0, 1960)}\n\`\`\`` });
|
||||
}
|
||||
}
|
||||
} as Command;
|
|
@ -3,7 +3,7 @@ import { Message } from "revolt.js/dist/maps/Messages";
|
|||
import { client } from "../..";
|
||||
import ServerConfig from "../../struct/ServerConfig";
|
||||
import { DEFAULT_PREFIX } from "../modules/command_handler";
|
||||
import { hasPerm } from "../util";
|
||||
import { hasPerm, isBotManager, NO_MANAGER_MSG } from "../util";
|
||||
|
||||
const SYNTAX = '/prefix set [new prefix]; /prefix get; prefix clear';
|
||||
const MENTION_TEXT = 'You can also @mention me instead of using the prefix.';
|
||||
|
@ -16,9 +16,10 @@ export default {
|
|||
serverOnly: true,
|
||||
run: async (message: Message, args: string[]) => {
|
||||
let config: ServerConfig = (await client.db.get('servers').findOne({ id: message.channel?.server_id })) ?? {};
|
||||
|
||||
switch(args[0]?.toLowerCase()) {
|
||||
case 'set':
|
||||
if (!hasPerm(message.member!, 'ManageServer')) return message.reply('You need ManageServer permission for this.');
|
||||
if (!await isBotManager(message.member!)) return message.reply(NO_MANAGER_MSG);
|
||||
|
||||
args.shift();
|
||||
if (args.length == 0) return message.reply('You need to specify a prefix.');
|
||||
|
@ -41,7 +42,7 @@ export default {
|
|||
break;
|
||||
case 'clear':
|
||||
case 'reset':
|
||||
if (!hasPerm(message.member!, 'ManageServer')) return message.reply('You need ManageServer permission for this.');
|
||||
if (!await isBotManager(message.member!)) return message.reply(NO_MANAGER_MSG);
|
||||
|
||||
if (config.prefix != null) {
|
||||
await client.db.get('servers').update({ 'id': message.channel?.server_id }, { $set: { 'prefix': null } });
|
||||
|
|
33
src/bot/commands/settings.ts
Normal file
33
src/bot/commands/settings.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import Command from "../../struct/Command";
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { client } from "../..";
|
||||
import AutomodSettings from "../../struct/antispam/AutomodSettings";
|
||||
import AntispamRule from "../../struct/antispam/AntispamRule";
|
||||
import ModerationAction from "../../struct/antispam/ModerationAction";
|
||||
import { isBotManager, NO_MANAGER_MSG } from "../util";
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
export default {
|
||||
name: 'settings',
|
||||
aliases: [ 'setting' ],
|
||||
description: 'change antispam settings',
|
||||
serverOnly: false,
|
||||
run: async (message: Message, args: string[]) => {
|
||||
if (!isBotManager(message.member!)) return message.reply(NO_MANAGER_MSG);
|
||||
|
||||
let settings = {
|
||||
spam: [
|
||||
{
|
||||
id: ulid(),
|
||||
max_msg: 5,
|
||||
timeframe: 3,
|
||||
action: ModerationAction.Delete,
|
||||
channels: [ '01FHJD5D2PBRTEVPNFM1FRY85J' ],
|
||||
} as AntispamRule
|
||||
]
|
||||
} as AutomodSettings;
|
||||
|
||||
client.db.get('servers')
|
||||
.update({ id: message.channel?.server_id }, { $set: { automodSettings: settings } });
|
||||
}
|
||||
} as Command;
|
46
src/bot/commands/shell_eval.ts
Normal file
46
src/bot/commands/shell_eval.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import Command from "../../struct/Command";
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { exec } from 'child_process';
|
||||
|
||||
export default {
|
||||
name: 'shell',
|
||||
aliases: [ 'exec', 'sh' ],
|
||||
description: 'Run code in a shell',
|
||||
restrict: 'BOTOWNER',
|
||||
removeEmptyArgs: false,
|
||||
serverOnly: false,
|
||||
run: async (message: Message, args: string[]) => {
|
||||
let cmd = args.join(' ');
|
||||
|
||||
let m = await message.channel?.sendMessage(`Executing...`);
|
||||
|
||||
try {
|
||||
let editMsg = () => {
|
||||
if (str != '' && str != oldStr) {
|
||||
if (str.length > 2000) {
|
||||
str = str.substr(str.length - 2000);
|
||||
}
|
||||
|
||||
m?.edit({ content: str })
|
||||
.catch(e => console.warn('Failed to edit message'));
|
||||
}
|
||||
}
|
||||
|
||||
let str = '', oldStr = '';
|
||||
let e = exec(cmd);
|
||||
let i = setInterval(editMsg, 1000);
|
||||
|
||||
e.stdout?.on('data', m => {
|
||||
str += m;
|
||||
});
|
||||
|
||||
e.on('exit', (code) => {
|
||||
clearInterval(i);
|
||||
str += `\n\n**Exit code:** ${code}`;
|
||||
editMsg();
|
||||
});
|
||||
} catch(e) {
|
||||
message.channel?.sendMessage(`${e}`);
|
||||
}
|
||||
}
|
||||
} as Command;
|
65
src/bot/modules/antispam.ts
Normal file
65
src/bot/modules/antispam.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { client } from "../..";
|
||||
import ModerationAction from "../../struct/antispam/ModerationAction";
|
||||
import ServerConfig from "../../struct/ServerConfig";
|
||||
import logger from "../logger";
|
||||
|
||||
let msgCountStore: Map<string, { users: any }> = new Map();
|
||||
|
||||
/**
|
||||
*
|
||||
* @param message
|
||||
* @returns true if ok, false if spam rule triggered
|
||||
*/
|
||||
async function antispam(message: Message): Promise<boolean> {
|
||||
let serverRules: ServerConfig = await client.db.get('servers').findOne({ id: message.channel?.server_id }) ?? {};
|
||||
if (!serverRules.automodSettings) return true;
|
||||
|
||||
let ruleTriggered = false;
|
||||
|
||||
for (const rule of serverRules.automodSettings.spam) {
|
||||
if (msgCountStore.get(rule.id) == null) {
|
||||
msgCountStore.set(rule.id, { users: {} });
|
||||
}
|
||||
|
||||
if (rule.channels?.indexOf(message.channel_id) == -1) break;
|
||||
|
||||
let store = msgCountStore.get(rule.id)!;
|
||||
if (!store.users[message.channel_id]) store.users[message.channel_id] = {}
|
||||
let userStore = store.users[message.channel_id];
|
||||
|
||||
if (!userStore.count) userStore.count = 1;
|
||||
else userStore.count++;
|
||||
|
||||
setTimeout(() => userStore.count--, rule.timeframe * 1000);
|
||||
|
||||
if (userStore.count > rule.max_msg) {
|
||||
logger.info(`Antispam rule triggered: ${rule.max_msg}/${rule.timeframe} -> ${ModerationAction[rule.action]}`);
|
||||
ruleTriggered = true;
|
||||
|
||||
switch(rule.action) {
|
||||
case ModerationAction.Delete:
|
||||
message.delete()
|
||||
.catch(() => logger.warn('Antispam: Failed to delete message') );
|
||||
break;
|
||||
case ModerationAction.Warn:
|
||||
if (!userStore.warnTriggered) {
|
||||
userStore.warnTriggered = true;
|
||||
setTimeout(() => userStore.warnTriggered = false, 5000);
|
||||
message.channel?.sendMessage(`<@${message.author_id}>, stop spamming (placeholder warn message)`);
|
||||
}
|
||||
break;
|
||||
case ModerationAction.Kick:
|
||||
message.reply('(Kick user)');
|
||||
break;
|
||||
case ModerationAction.Ban:
|
||||
message.reply('(Ban user)');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !ruleTriggered;
|
||||
}
|
||||
|
||||
export { antispam }
|
|
@ -4,6 +4,7 @@ import { client } from "../../index";
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ServerConfig from "../../struct/ServerConfig";
|
||||
import { antispam } from "./antispam";
|
||||
|
||||
const DEFAULT_PREFIX = process.env['PREFIX'] ?? '/';
|
||||
|
||||
|
@ -15,6 +16,9 @@ client.on('message', async message => {
|
|||
logger.debug(`Message -> ${message.content}`);
|
||||
if (typeof message.content != 'string' || message.author_id == client.user?._id || !message.channel) return;
|
||||
|
||||
// Send message through anti spam check
|
||||
if (!antispam(message)) return;
|
||||
|
||||
let config: ServerConfig = (await client.db.get('servers').findOne({ 'id': message.channel?.server_id })) ?? {};
|
||||
let guildPrefix = config.prefix ?? DEFAULT_PREFIX;
|
||||
|
||||
|
@ -34,12 +38,26 @@ client.on('message', async message => {
|
|||
let cmd = commands.find(c => c.name == cmdName || (c.aliases?.indexOf(cmdName!) ?? -1) > -1);
|
||||
if (!cmd) return;
|
||||
|
||||
let ownerIDs = process.env['BOT_OWNERS'] ? process.env['BOT_OWNERS'].split(',') : [];
|
||||
if (cmd.restrict == 'BOTOWNER' && ownerIDs.indexOf(message.author_id) == -1) {
|
||||
logger.warn(`User ${message.author?.username} tried to run owner-only command: ${cmdName}`);
|
||||
message.reply('🔒 Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Command: ${message.author?.username} in ${message.channel?.server?.name}: ${message.content}`);
|
||||
|
||||
// Create document for server in DB, if not already present
|
||||
if (JSON.stringify(config) == '{}') await client.db.get('servers').insert({ id: message.channel?.server_id });
|
||||
|
||||
if (cmd.serverOnly && !message.channel?.server) {
|
||||
return message.reply('This command is not available in direct messages.');
|
||||
}
|
||||
|
||||
if (cmd.removeEmptyArgs !== false) {
|
||||
args = args.filter(a => a.length > 0);
|
||||
}
|
||||
|
||||
try {
|
||||
cmd.run(message, args);
|
||||
} catch(e) {
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { Member } from "revolt.js/dist/maps/Members";
|
||||
import { User } from "revolt.js/dist/maps/Users";
|
||||
import { client } from "..";
|
||||
import ServerConfig from "../struct/ServerConfig";
|
||||
|
||||
let ServerPermissions = {
|
||||
['View' as string]: 1 << 0,
|
||||
|
@ -13,15 +16,66 @@ let ServerPermissions = {
|
|||
['RemoveAvatars' as string]: 1 << 15,
|
||||
}
|
||||
|
||||
const NO_MANAGER_MSG = '🔒 Missing permission';
|
||||
const USER_MENTION_REGEX = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
|
||||
const CHANNEL_MENTION_REGEX = /^<#[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
|
||||
|
||||
/**
|
||||
* Parses user input and returns an user object.
|
||||
* Supports: `userID`, `<@userID>` (mention), `username`, `@username` (if user is cached).
|
||||
* @param text
|
||||
* @returns null if not found, otherwise user object
|
||||
*/
|
||||
async function parseUser(text: string): Promise<User|null> {
|
||||
if (!text) return null;
|
||||
|
||||
let uid: string|null = null;
|
||||
if (USER_MENTION_REGEX.test(text)) {
|
||||
uid = text.replace(/<@|>/g, '').toUpperCase();
|
||||
} else if (/^[0-9A-HJ-KM-NP-TV-Z]{26}$/gi.test(text)) {
|
||||
uid = text.toUpperCase();
|
||||
} else {
|
||||
if (text.startsWith('@')) text = text.substr(1);
|
||||
|
||||
// Why is there no .find() or .filter()
|
||||
let user: User|null = null;
|
||||
client.users.forEach(u => {
|
||||
if (u.username?.toLowerCase() == text.toLowerCase()) {
|
||||
user = u;
|
||||
}
|
||||
});
|
||||
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
if (uid) return await client.users.fetch(uid) || null;
|
||||
else return null;
|
||||
}
|
||||
|
||||
async function isBotManager(member: Member) {
|
||||
return hasPerm(member, 'ManageServer')
|
||||
|| (((await client.db.get('servers').findOne({ id: member.server?._id }) || {}) as ServerConfig)
|
||||
.botManagers?.indexOf(member.user?._id!) ?? -1) > -1;
|
||||
}
|
||||
|
||||
function hasPerm(member: Member, perm: 'View'|'ManageRoles'|'ManageChannels'|'ManageServer'| // its late and im tired
|
||||
'KickMembers'|'BanMembers'|'ChangeNickname'| // dont judge my code
|
||||
'ManageNicknames'|'ChangeAvatar'|'RemoveAvatars') {
|
||||
'ManageNicknames'|'ChangeAvatar'|'RemoveAvatars'): boolean {
|
||||
let p = ServerPermissions[perm];
|
||||
if (member.server?.owner == member.user?._id) return true;
|
||||
|
||||
// this should work but im not 100% certain
|
||||
let userPerm = member.roles?.map(id => member.server?.roles?.[id])
|
||||
.reduce((sum: number, cur: any) => sum | cur.permissions[0], member.server?.default_permissions[0]) ?? 0;
|
||||
|
||||
// TODO how the fuck do bitfields work
|
||||
return false;
|
||||
return !!(userPerm & p);
|
||||
}
|
||||
|
||||
export { hasPerm }
|
||||
export {
|
||||
hasPerm,
|
||||
isBotManager,
|
||||
parseUser,
|
||||
NO_MANAGER_MSG,
|
||||
USER_MENTION_REGEX,
|
||||
CHANNEL_MENTION_REGEX
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ class Command {
|
|||
aliases: string[] | null;
|
||||
description: string | null;
|
||||
syntax?: string | null;
|
||||
restrict?: 'BOTOWNER' | null;
|
||||
removeEmptyArgs?: boolean | null;
|
||||
run: Function;
|
||||
serverOnly: boolean;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import AutomodSettings from "./antispam/AutomodSettings";
|
||||
|
||||
class ServerConfig {
|
||||
id: string | undefined;
|
||||
prefix: string | undefined;
|
||||
spaceAfterPrefix: boolean | undefined;
|
||||
automodSettings: AutomodSettings | undefined;
|
||||
botManagers: string[] | undefined;
|
||||
}
|
||||
|
||||
export default ServerConfig;
|
||||
|
|
19
src/struct/antispam/AntispamRule.ts
Normal file
19
src/struct/antispam/AntispamRule.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import ModerationAction from "./ModerationAction";
|
||||
|
||||
/**
|
||||
* Allow a maximum of X messages per X seconds.
|
||||
* Example: max_msg = 5, timeframe = 3, action: Delete
|
||||
* Allows a maximum of 5 messages within 3 seconds,
|
||||
* and will delete any additional messages.
|
||||
*
|
||||
* `channels` optionally limits the rule to specific channels.
|
||||
*/
|
||||
class AntispamRule {
|
||||
id: string;
|
||||
max_msg: number;
|
||||
timeframe: number;
|
||||
action: ModerationAction;
|
||||
channels: string[] | null;
|
||||
}
|
||||
|
||||
export default AntispamRule;
|
7
src/struct/antispam/AutomodSettings.ts
Normal file
7
src/struct/antispam/AutomodSettings.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import AntispamRule from "./AntispamRule";
|
||||
|
||||
class AutomodSettings {
|
||||
spam: AntispamRule[];
|
||||
}
|
||||
|
||||
export default AutomodSettings;
|
8
src/struct/antispam/ModerationAction.ts
Normal file
8
src/struct/antispam/ModerationAction.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
enum ModerationAction {
|
||||
Delete = 0,
|
||||
Warn = 1,
|
||||
Kick = 2,
|
||||
Ban = 3,
|
||||
}
|
||||
|
||||
export default ModerationAction;
|
|
@ -41,6 +41,13 @@ axios@^0.21.4:
|
|||
dependencies:
|
||||
follow-redirects "^1.14.0"
|
||||
|
||||
axios@^0.22.0:
|
||||
version "0.22.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.22.0.tgz#bf702c41fb50fbca4539589d839a077117b79b25"
|
||||
integrity sha512-Z0U3uhqQeg1oNcihswf4ZD57O3NrR1+ZXhxaROaWpDmsDTx7T2HNBV2ulBtie2hwJptu8UvgnJoK+BIqdzh/1w==
|
||||
dependencies:
|
||||
follow-redirects "^1.14.4"
|
||||
|
||||
base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
|
@ -106,7 +113,7 @@ exponential-backoff@^3.1.0:
|
|||
resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.0.tgz#9409c7e579131f8bd4b32d7d8094a911040f2e68"
|
||||
integrity sha512-oBuz5SYz5zzyuHINoe9ooePwSu0xApKWgeNzok4hZ5YKXFh9zrQBEM15CXqoZkJJPuI2ArvqjPQd8UKJA753XA==
|
||||
|
||||
follow-redirects@^1.14.0:
|
||||
follow-redirects@^1.14.0, follow-redirects@^1.14.4:
|
||||
version "1.14.4"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
|
||||
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
|
||||
|
|
Loading…
Reference in a new issue