make bridge user configurable

This commit is contained in:
janderedev 2022-04-24 13:48:42 +02:00
parent f34d01745d
commit 96446b3e1d
No known key found for this signature in database
GPG key ID: 5D5E18ACB990F57A
11 changed files with 349 additions and 9 deletions

View file

@ -0,0 +1,109 @@
import { ulid } from "ulid";
import { dbs } from "../..";
import CommandCategory from "../../struct/commands/CommandCategory";
import SimpleCommand from "../../struct/commands/SimpleCommand";
import MessageCommandContext from "../../struct/MessageCommandContext";
import { DEFAULT_PREFIX } from "../modules/command_handler";
import { isBotManager, NO_MANAGER_MSG } from "../util";
const DISCORD_INVITE_URL = 'https://discord.com/api/oauth2/authorize?client_id=965692929643524136&permissions=536996864&scope=bot%20applications.commands'; // todo: read this from env or smth
export default {
name: 'bridge',
aliases: null,
description: 'Bridge a channel with Discord',
category: CommandCategory.Misc,
run: async (message: MessageCommandContext, args: string[]) => {
if (!await isBotManager(message)) return message.reply(NO_MANAGER_MSG);
switch(args[0]?.toLowerCase()) {
case 'link': {
const count = await dbs.BRIDGE_CONFIG.count({ revolt: message.channel_id });
if (count) return message.reply(`This channel is already bridged.`);
// Invalidate previous bridge request
await dbs.BRIDGE_REQUESTS.remove({ revolt: message.channel_id });
const reqId = ulid();
await dbs.BRIDGE_REQUESTS.insert({
id: reqId,
revolt: message.channel_id,
expires: Date.now() + (1000 * 60 * 15),
});
await message.reply(`### Link request created.\n`
+ `Request ID: \`${reqId}\`\n\n`
+ `[Invite the bridge bot to your Discord server](<${DISCORD_INVITE_URL}>) `
+ `and run \`/bridge confirm ${reqId}\` in the channel you wish to link.\n`
+ `This request expires in 15 minutes.`);
break;
}
case 'unlink': {
const res = await dbs.BRIDGE_CONFIG.remove({ revolt: message.channel_id });
if (res.deletedCount) await message.reply(`Channel unlinked!`);
else await message.reply(`Unable to unlink; no channel linked.`);
break;
}
case 'unlink_all': {
const query = { revolt: { $in: message.channel?.server?.channel_ids || [] } };
if (args[1] == 'CONFIRM') {
const res = await dbs.BRIDGE_CONFIG.remove(query);
if (res.deletedCount) {
await message.reply(`All channels have been unlinked. (Count: **${res.deletedCount}**)`);
} else {
await message.reply(`No bridged channels found; nothing to delete.`);
}
} else {
const res = await dbs.BRIDGE_CONFIG.count(query);
if (!res) await message.reply(`No bridged channels found; nothing to delete.`);
else {
await message.reply(`${res} bridged channels found. `
+ `Run \`${DEFAULT_PREFIX}bridge unlink_all CONFIRM\` to confirm deletion.`);
}
}
break;
}
case 'list': {
const links = await dbs.BRIDGE_CONFIG.find({ revolt: { $in: message.channel?.server?.channel_ids || [] } });
await message.reply({
content: '#',
embeds: [
{
type: 'Text',
title: `Bridges in ${message.channel?.server?.name}`,
description: `**${links.length}** bridged channels found.\n\n`
+ links.map(l => `<#${l.revolt}> **->** ${l.discord}\n`),
}
]
});
break;
}
case 'help': {
await message.reply({
content: '#',
embeds: [
{
type: 'Text',
title: 'Discord Bridge',
description: `Bridges allow you to link your Revolt server to a Discord server `
+ `by relaying all messages.\n\n`
+ `To link a channel, first run \`${DEFAULT_PREFIX}bridge link\` on Revolt. `
+ `This will provide you with a link ID.\n`
+ `On Discord, first [add the Bridge bot to your server](<${DISCORD_INVITE_URL}>), `
+ `then run the command: \`/bridge confirm [ID]\`.\n\n`
+ `You can list all bridges in a Revolt server by running \`${DEFAULT_PREFIX}bridge list\`\n\n`
+ `To unlink a channel, run \`/bridge unlink\` from either Discord or Revolt. If you wish to `
+ `unbridge all channels in a Revolt server, run \`${DEFAULT_PREFIX}bridge unlink_all\`.`
}
]
});
break;
}
default: {
await message.reply(`Run \`${DEFAULT_PREFIX}bridge help\` for help.`);
}
}
}
} as SimpleCommand;

View file

@ -11,6 +11,8 @@ import PendingLogin from './struct/PendingLogin';
import TempBan from './struct/TempBan'; import TempBan from './struct/TempBan';
import { VoteEntry } from './bot/commands/votekick'; import { VoteEntry } from './bot/commands/votekick';
import ScannedUser from './struct/ScannedUser'; import ScannedUser from './struct/ScannedUser';
import BridgeRequest from './struct/BridgeRequest';
import BridgeConfig from './struct/BridgeConfig';
logger.info('Initializing client'); logger.info('Initializing client');
@ -32,6 +34,8 @@ const dbs = {
TEMPBANS: db.get<TempBan>('tempbans'), TEMPBANS: db.get<TempBan>('tempbans'),
VOTEKICKS: db.get<VoteEntry>('votekicks'), VOTEKICKS: db.get<VoteEntry>('votekicks'),
SCANNED_USERS: db.get<ScannedUser>('scanned_users'), SCANNED_USERS: db.get<ScannedUser>('scanned_users'),
BRIDGE_CONFIG: db.get<BridgeConfig>('bridge_config'),
BRIDGE_REQUESTS: db.get<BridgeRequest>('bridge_requests'),
} }
export { client, dbs } export { client, dbs }

View file

@ -0,0 +1,13 @@
export default class {
// Revolt channel ID
revolt?: string;
// Discord channel ID
discord?: string;
// Discord webhook
discordWebhook?: {
id: string;
token: string;
}
}

View file

@ -0,0 +1,9 @@
export default class {
// Bridge request ID, needed to confirm link from Discord side
id: string;
// The Revolt channel ID
revolt: string;
expires: number;
}

View file

@ -13,8 +13,10 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@discordjs/rest": "^0.4.1",
"@janderedev/revolt.js": "^5.2.8-patch.2", "@janderedev/revolt.js": "^5.2.8-patch.2",
"axios": "^0.26.1", "axios": "^0.26.1",
"discord-api-types": "^0.31.2",
"discord.js": "^13.6.0", "discord.js": "^13.6.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",

View file

@ -23,5 +23,6 @@ const login = () => new Promise((resolve: (value: Discord.Client) => void) => {
}); });
import('./events'); import('./events');
import('./commands');
export { client, login } export { client, login }

View file

@ -0,0 +1,121 @@
// fuck slash commands
import { client } from "./client";
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v9';
import { BRIDGE_CONFIG, BRIDGE_REQUESTS, logger } from "..";
import { TextChannel } from "discord.js";
const COMMANDS: any[] = [
{
name: 'bridge',
description: 'Confirm or delete Revolt bridges',
options: [
{
name: 'confirm',
description: 'Confirm a bridge initiated from Revolt',
type: 1, // Subcommand
options: [
{
name: "id",
description: "The bridge request ID",
required: true,
type: 3,
}
],
},
{
name: 'unlink',
description: 'Unbridge the current channel',
type: 1,
},
],
}
];
const rest = new REST({ version: '9' }).setToken(process.env['DISCORD_TOKEN']!);
client.once('ready', async () => {
try {
logger.info(`Refreshing application commands.`);
if (process.env.NODE_ENV != 'production' && process.env.DEV_GUILD) {
await rest.put(
Routes.applicationGuildCommands(client.user!.id, process.env.DEV_GUILD),
{ body: COMMANDS },
);
logger.done(`Application commands for ${process.env.DEV_GUILD} have been updated.`);
} else {
await rest.put(
Routes.applicationCommands(client.user!.id),
{ body: COMMANDS },
);
logger.done(`Global application commands have been updated.`);
}
} catch(e) {
console.error(e);
}
});
client.on('interactionCreate', async interaction => {
try {
if (!interaction.isCommand()) return;
logger.debug(`Command received: /${interaction.commandName}`);
// The revolutionary Jan command handler
switch(interaction.commandName) {
case 'bridge':
if (!interaction.memberPermissions?.has('MANAGE_GUILD')) {
return await interaction.reply(`\`MANAGE_GUILD\` permission is required for this.`);
}
const ownPerms = (interaction.channel as TextChannel).permissionsFor(client.user!)!;
switch(interaction.options.getSubcommand(true)) {
case 'confirm':
if (!ownPerms.has('MANAGE_WEBHOOKS'))
return interaction.reply('Sorry, I lack permission to manage webhooks in this channel.');
const id = interaction.options.getString('id', true);
const request = await BRIDGE_REQUESTS.findOne({ id: id });
if (!request || request.expires < Date.now()) return await interaction.reply('Unknown ID.');
const webhook = await (interaction.channel as TextChannel)
.createWebhook('AutoMod Bridge', { avatar: client.user?.avatarURL() });
await BRIDGE_REQUESTS.remove({ id: id });
await BRIDGE_CONFIG.insert({
discord: interaction.channelId,
revolt: request.revolt,
discordWebhook: {
id: webhook.id,
token: webhook.token || '',
}
});
return await interaction.reply(`✅ Channel bridged!`);
case 'unlink':
const res = await BRIDGE_CONFIG.findOneAndDelete({ discord: interaction.channelId });
if (res?._id) {
await interaction.reply('Channel unbridged.');
if (ownPerms.has('MANAGE_WEBHOOKS') && res.discordWebhook) {
try {
const hooks = await (interaction.channel as TextChannel).fetchWebhooks();
if (hooks.get(res?.discordWebhook?.id)) await hooks.get(res?.discordWebhook?.id)
?.delete('Channel has been unbridged');
} catch(_) {}
}
}
else await interaction.reply('This channel is not bridged.');
break;
default: await interaction.reply('Unknown subcommand');
}
break;
}
} catch(e) {
console.error(e);
if (interaction.isCommand()) interaction.reply('An error has occurred: ' + e).catch(() => {});
}
});

View file

@ -6,6 +6,7 @@ import { login as loginDiscord } from './discord/client';
import { ICollection } from 'monk'; import { ICollection } from 'monk';
import BridgeConfig from './types/BridgeConfig'; import BridgeConfig from './types/BridgeConfig';
import BridgedMessage from './types/BridgedMessage'; import BridgedMessage from './types/BridgedMessage';
import BridgeRequest from './types/BridgeRequest';
config(); config();
@ -13,6 +14,7 @@ const logger: Log75 = new (Log75 as any).default(LogLevel.Debug);
const db = getDb(); const db = getDb();
const BRIDGED_MESSAGES: ICollection<BridgedMessage> = db.get('bridged_messages'); const BRIDGED_MESSAGES: ICollection<BridgedMessage> = db.get('bridged_messages');
const BRIDGE_CONFIG: ICollection<BridgeConfig> = db.get('bridge_config'); const BRIDGE_CONFIG: ICollection<BridgeConfig> = db.get('bridge_config');
const BRIDGE_REQUESTS: ICollection<BridgeRequest> = db.get('bridge_requests');
for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) { for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) {
if (!process.env[v]) { if (!process.env[v]) {
@ -28,4 +30,4 @@ for (const v of [ 'REVOLT_TOKEN', 'DISCORD_TOKEN', 'DB_STRING' ]) {
]); ]);
})(); })();
export { logger, db, BRIDGED_MESSAGES, BRIDGE_CONFIG } export { logger, db, BRIDGED_MESSAGES, BRIDGE_CONFIG, BRIDGE_REQUESTS }

View file

@ -77,8 +77,36 @@ client.on('message', async message => {
if (bridgedMsg) return logger.debug(`Revolt: Message has already been bridged; ignoring`); if (bridgedMsg) return logger.debug(`Revolt: Message has already been bridged; ignoring`);
if (!bridgeCfg?.discord) return logger.debug(`Revolt: No Discord channel associated`); if (!bridgeCfg?.discord) return logger.debug(`Revolt: No Discord channel associated`);
if (!bridgeCfg.discordWebhook) { if (!bridgeCfg.discordWebhook) {
// Todo: Create a new webhook instead of exiting logger.debug(`Revolt: No Discord webhook stored; Creating new Webhook`);
return logger.debug(`Revolt: No Discord webhook stored`);
try {
const channel = await discordClient.channels.fetch(bridgeCfg.discord);
if (!channel || !channel.isText()) throw 'Error: Unable to fetch channel';
const ownPerms = (channel as TextChannel).permissionsFor(discordClient.user!);
if (!ownPerms?.has('MANAGE_WEBHOOKS')) throw 'Error: Bot user does not have MANAGE_WEBHOOKS permission';
const hook = await (channel as TextChannel).createWebhook('AutoMod Bridge', { avatar: discordClient.user?.avatarURL() });
bridgeCfg.discordWebhook = {
id: hook.id,
token: hook.token || '',
};
await BRIDGE_CONFIG.update(
{ revolt: message.channel_id },
{
$set: {
discordWebhook: bridgeCfg.discordWebhook,
}
}
);
} catch(e) {
logger.warn(`Unable to create new webhook for channel ${bridgeCfg.discord}; Deleting link\n${e}`);
await BRIDGE_CONFIG.remove({ revolt: message.channel_id });
await message.channel?.sendMessage(':warning: I was unable to create a webhook in the bridged Discord channel. '
+ `The bridge has been removed; if you wish to rebridge, use the \`/bridge\` command.`).catch(() => {});
return;
}
} }
await BRIDGED_MESSAGES.update( await BRIDGED_MESSAGES.update(
@ -103,8 +131,8 @@ client.on('message', async message => {
); );
const client = new WebhookClient({ const client = new WebhookClient({
id: bridgeCfg.discordWebhook.id, id: bridgeCfg.discordWebhook!.id,
token: bridgeCfg.discordWebhook.token, token: bridgeCfg.discordWebhook!.token,
}); });
const payload: MessagePayload|WebhookMessageOptions = { const payload: MessagePayload|WebhookMessageOptions = {
@ -176,7 +204,15 @@ client.on('message', async message => {
}); });
}) })
.catch(async e => { .catch(async e => {
console.error('Failed to execute webhook', e?.response?.data ?? e); console.error('Failed to execute webhook:', e?.response?.data ?? e);
if (`${e}` == 'DiscordAPIError: Unknown Webhook') {
try {
logger.warn('Revolt: Got Unknown Webhook error, deleting webhook config');
await BRIDGE_CONFIG.update({ revolt: message.channel_id }, { $set: { discordWebhook: undefined } });
} catch(e) {
console.error(e);
}
}
}); });
} catch(e) { } catch(e) {
console.error(e); console.error(e);

View file

@ -0,0 +1,9 @@
export default class {
// Bridge request ID, needed to confirm link from Discord side
id: string;
// The Revolt channel ID
revolt: string;
expires: number;
}

View file

@ -18,6 +18,25 @@
resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.4.0.tgz#b6488286a1cc7b41b644d7e6086f25a1c1e6f837" resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.4.0.tgz#b6488286a1cc7b41b644d7e6086f25a1c1e6f837"
integrity sha512-zmjq+l/rV35kE6zRrwe8BHqV78JvIh2ybJeZavBi5NySjWXqN3hmmAKg7kYMMXSeiWtSsMoZ/+MQi0DiQWy2lw== integrity sha512-zmjq+l/rV35kE6zRrwe8BHqV78JvIh2ybJeZavBi5NySjWXqN3hmmAKg7kYMMXSeiWtSsMoZ/+MQi0DiQWy2lw==
"@discordjs/collection@^0.7.0-dev":
version "0.7.0-dev.1650672508-3617093"
resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.7.0-dev.1650672508-3617093.tgz#2b418f650922b1e52b057b481558bb6b377bd4d2"
integrity sha512-Got8gPiFFEwY0tJo6hK/ZGvg8LFEYMyopchL/l5WjvN5YXDSKqlcSfWk3SqA9F8Eb2ZloauUoXY2B3uMMJUUBA==
"@discordjs/rest@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@discordjs/rest/-/rest-0.4.1.tgz#d0a7e79df7a7f59bd01630013b3c70231e22a31d"
integrity sha512-rtWy+AIfNlfjGkAgA2TJLASdqli07aTNQceVGT6RQQiQaEqV0nsfBO4WtDlDzk7PmO3w+InP3dpwEolJI5jz0A==
dependencies:
"@discordjs/collection" "^0.7.0-dev"
"@sapphire/async-queue" "^1.3.1"
"@sapphire/snowflake" "^3.2.1"
"@types/node-fetch" "^2.6.1"
discord-api-types "^0.29.0"
form-data "^4.0.0"
node-fetch "^2.6.7"
tslib "^2.3.1"
"@insertish/exponential-backoff@3.1.0-patch.0": "@insertish/exponential-backoff@3.1.0-patch.0":
version "3.1.0-patch.0" version "3.1.0-patch.0"
resolved "https://registry.yarnpkg.com/@insertish/exponential-backoff/-/exponential-backoff-3.1.0-patch.0.tgz#1fff134f70fc0906d11d09069d51183b542e42cf" resolved "https://registry.yarnpkg.com/@insertish/exponential-backoff/-/exponential-backoff-3.1.0-patch.0.tgz#1fff134f70fc0906d11d09069d51183b542e42cf"
@ -55,11 +74,16 @@
ulid "^2.3.0" ulid "^2.3.0"
ws "^8.2.2" ws "^8.2.2"
"@sapphire/async-queue@^1.1.9": "@sapphire/async-queue@^1.1.9", "@sapphire/async-queue@^1.3.1":
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.3.1.tgz#9d861e626dbffae02d808e13f823d4510e450a78" resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.3.1.tgz#9d861e626dbffae02d808e13f823d4510e450a78"
integrity sha512-FFTlPOWZX1kDj9xCAsRzH5xEJfawg1lNoYAA+ecOWJMHOfiZYb1uXOI3ne9U4UILSEPwfE68p3T9wUHwIQfR0g== integrity sha512-FFTlPOWZX1kDj9xCAsRzH5xEJfawg1lNoYAA+ecOWJMHOfiZYb1uXOI3ne9U4UILSEPwfE68p3T9wUHwIQfR0g==
"@sapphire/snowflake@^3.2.1":
version "3.2.1"
resolved "https://registry.yarnpkg.com/@sapphire/snowflake/-/snowflake-3.2.1.tgz#027f217779ec7fd324d35cf941b3de49b4f67877"
integrity sha512-vmZq1I6J6iNRQVXP+N9HzOMOY4ORB3MunoFeWCw/aBnZTf1cDgDvP0RZFQS53B1TN95AIgFY9T+ItQ/fWAUYWQ==
"@sindresorhus/is@^4.2.0": "@sindresorhus/is@^4.2.0":
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
@ -80,7 +104,7 @@
"@types/bson" "*" "@types/bson" "*"
"@types/node" "*" "@types/node" "*"
"@types/node-fetch@^2.5.12": "@types/node-fetch@^2.5.12", "@types/node-fetch@^2.6.1":
version "2.6.1" version "2.6.1"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975"
integrity sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA== integrity sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==
@ -196,6 +220,16 @@ discord-api-types@^0.26.0:
resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.26.1.tgz#726f766ddc37d60da95740991d22cb6ef2ed787b" resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.26.1.tgz#726f766ddc37d60da95740991d22cb6ef2ed787b"
integrity sha512-T5PdMQ+Y1MEECYMV5wmyi9VEYPagEDEi4S0amgsszpWY0VB9JJ/hEvM6BgLhbdnKky4gfmZEXtEEtojN8ZKJQQ== integrity sha512-T5PdMQ+Y1MEECYMV5wmyi9VEYPagEDEi4S0amgsszpWY0VB9JJ/hEvM6BgLhbdnKky4gfmZEXtEEtojN8ZKJQQ==
discord-api-types@^0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.29.0.tgz#8346352b623ddd8d8eed386b6eb758e2d82d6005"
integrity sha512-Ekq1ICNpOTVajXKZguNFrsDeTmam+ZeA38txsNLZnANdXUjU6QBPIZLUQTC6MzigFGb0Tt8vk4xLnXmzv0shNg==
discord-api-types@^0.31.2:
version "0.31.2"
resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.31.2.tgz#8d131e25340bd695815af3bb77128a6993c1b516"
integrity sha512-gpzXTvFVg7AjKVVJFH0oJGC0q0tO34iJGSHZNz9u3aqLxlD6LfxEs9wWVVikJqn9gra940oUTaPFizCkRDcEiA==
discord.js@^13.6.0: discord.js@^13.6.0:
version "13.6.0" version "13.6.0"
resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.6.0.tgz#d8a8a591dbf25cbcf9c783d5ddf22c4694860475" resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.6.0.tgz#d8a8a591dbf25cbcf9c783d5ddf22c4694860475"
@ -389,7 +423,7 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
node-fetch@^2.6.1: node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.7" version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==