From c7ecfd2e823df090348ac485735bac7e097b11fb Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 17 Aug 2023 22:01:19 -0400 Subject: [PATCH 1/6] Break thread management into multiple modules These modules simply expose Discord events and their handlers on a default object export. thread-management.ts then attaches listeners based on these exports. --- discord-scripts/thread-management.ts | 160 +++++++----------- .../thread-management/auto-join.ts | 84 +++++++++ .../thread-management/private-threads.ts | 34 ++++ .../thread-management/reminder-to-thread.ts | 47 +++++ lib/discord/utils.ts | 21 ++- 5 files changed, 242 insertions(+), 104 deletions(-) create mode 100644 discord-scripts/thread-management/auto-join.ts create mode 100644 discord-scripts/thread-management/private-threads.ts create mode 100644 discord-scripts/thread-management/reminder-to-thread.ts diff --git a/discord-scripts/thread-management.ts b/discord-scripts/thread-management.ts index 05e30d5d..9520bac6 100644 --- a/discord-scripts/thread-management.ts +++ b/discord-scripts/thread-management.ts @@ -1,110 +1,64 @@ -import { ChannelType, Client } from "discord.js" -import { isInRecreationalCategory } from "../lib/discord/utils.ts" +import fs from "fs" +import { Client } from "discord.js" +import path from "path" +import { Robot } from "hubot" +import { fileURLToPath } from "url" +import { DiscordEventHandlers } from "../lib/discord/utils.ts" -// Emoji used to suggest a thread. -const THREAD_EMOJI = "šŸ§µ" - -export default function manageThreads(discordClient: Client) { - // When a thread is created, join it. - // - // Additionally, quietly tag a role so that all members of it are subscribed - // to the thread (they may later leave the thread to opt out). The role that - // is tagged is, in order: - // - // - If the containing channel's category is recreational, no role. - // - If the containnig channel has a role with a matching name, that role - // (e.g., a message to #tech will tag a Tech role if it exists). - // - If the containing channel's category has a role with a matching name, that role - // (e.g., a message to #taho-standup inside the Taho category will tag the - // Taho role if it exists). - // - If the containing channel's category is General and the channel is - // #main, @everyone. - discordClient.on("threadCreate", async (thread) => { - await thread.join() - - if (isInRecreationalCategory(thread)) { - return - } - - const { guild: server, parent: containingChannel } = thread - - if ( - thread.type === ChannelType.PrivateThread && - containingChannel?.name?.toLowerCase() !== "operations" - ) { - await thread.send( - "Private threads should largely only be used for discussions around " + - "confidential topics like legal and hiring. They should as a result " + - "almost always be created in #operations; if you know you're " + - "breaking both rules on purpose, go forth and conquer, but otherwise " + - "please start the thread there. I'm also going to auto-tag the " + - "appropriate roles now, which may compromise the privacy of the " + - "thread (**all members of the role who have access to this channel " + - "will have access to the thread**).", - ) - } - - const placeholder = await thread.send("") - - const matchingRole = server.roles.cache.find( - (role) => - role.name.toLowerCase() === containingChannel?.name.toLowerCase(), +export default function manageThreads(discordClient: Client, robot: Robot) { + fs.readdirSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + "./thread-management", + ), + ) + .sort() + .filter( + (file) => + [".ts", ".js"].includes(path.extname(file)) && !file.startsWith("_"), ) + .forEach(async (file) => { + try { + const threadManagementScript: { default: DiscordEventHandlers } = + await import( + path.join("..", "discord-scripts", "thread-management", file) + ) - if (matchingRole !== undefined) { - await placeholder.edit(matchingRole.toString()) - return - } - - const categoryChannel = containingChannel?.parent - const categoryMatchingRole = server.roles.cache.find( - (role) => role.name.toLowerCase() === categoryChannel?.name.toLowerCase(), - ) - - if (categoryMatchingRole !== undefined) { - await placeholder.edit(categoryMatchingRole.toString()) - return - } - - // Monstrous, delete the useless placeholder and pray for our soul. - // Placeholder code as we figure out the best way to handle the General - // category. - await placeholder.delete() - }) - - // Remind users to create a thread with a reacji for reply chains longer than - // 1 reply. Skip for messages in the recreational category. - discordClient.on("messageCreate", async (message) => { - // If we're already in a thread or this is the recreational category, do - // nothing. - const { channel } = message - if (channel.isThread() || isInRecreationalCategory(channel)) { - return - } + Object.entries(threadManagementScript.default).forEach( + ([event, handler]) => { + discordClient.on(event, (...args) => { + const finalArgs = [...args, robot] + // @ts-expect-error We are doing some shenanigans here that TS can't + // handle to always pass a robot as the last parameter to the + // handler. + return handler(...finalArgs) + }) + }, + ) - // If this message is not in reply to anything, do nothing. - if ( - message.reference === null || - message.reference.messageId === undefined - ) { - return - } + if ("setup" in threadManagementScript) { + ;( + threadManagementScript.setup as ( + robot: Robot, + client: Client, + ) => Promise + ).call(undefined, robot, discordClient) + } - // If the message replied to is not in reply to anythinbg, still do nothing. - const repliedMessage = await message.fetchReference() - if ( - repliedMessage.reference === null || - repliedMessage.reference.messageId === undefined - ) { - return - } + robot.logger.info(`Loaded Discord thread management script ${file}.`) + } catch (error) { + const stackString = + // Errors may have a stack trace, or not---anyone's guess! + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "stack" in (error as any) ? `\n${(error as any).stack}` : "" - // Okay, now we've got a chain of two replies, suggest a thread via reacji - // on the original message---if it is indeed the original message in the - // chain. - const potentialOriginalMessage = await repliedMessage.fetchReference() - if (potentialOriginalMessage.reference === null) { - message.react(THREAD_EMOJI) - } - }) + robot.logger.error( + `Failed to load Discord script ${file}: ${JSON.stringify( + error, + null, + 2, + )}${stackString}`, + ) + } + }) } diff --git a/discord-scripts/thread-management/auto-join.ts b/discord-scripts/thread-management/auto-join.ts new file mode 100644 index 00000000..64afe593 --- /dev/null +++ b/discord-scripts/thread-management/auto-join.ts @@ -0,0 +1,84 @@ +import { AnyThreadChannel } from "discord.js" +import { + DiscordEventHandlers, + isInRecreationalCategory, +} from "../../lib/discord/utils.ts" + +// When a thread is created, join it. +// +// Additionally, quietly tag a role so that all members of it are subscribed +// to the thread (they may later leave the thread to opt out). The role that +// is tagged is, in order: +// +// - If the containing channel's category is recreational, no role. +// - If the containnig channel has a role with a matching name, that role +// (e.g., a message to #tech will tag a Tech role if it exists). +// - If the containing channel's category has a role with a matching name, that role +// (e.g., a message to #taho-standup inside the Taho category will tag the +// Taho role if it exists). +// - If the containing channel's category is General and the channel is +// #main, @everyone. +// +// Quiet tags are achieved by dropping a placeholder message and then editing +// it to mention the right role. Discord's behavior in this scenario is not to +// ping the role, but to add all its members to the thread. +async function autoJoinThread( + thread: AnyThreadChannel, +): Promise { + await thread.join() + + if (isInRecreationalCategory(thread)) { + return + } + + const { guild: server, parent: containingChannel } = thread + + const placeholder = await thread.send("") + + const matchingRole = server.roles.cache.find( + (role) => role.name.toLowerCase() === containingChannel?.name.toLowerCase(), + ) + + if (matchingRole !== undefined) { + await placeholder.edit(matchingRole.toString()) + return + } + + const categoryChannel = containingChannel?.parent + const categoryMatchingRole = server.roles.cache.find( + (role) => role.name.toLowerCase() === categoryChannel?.name.toLowerCase(), + ) + + if (categoryMatchingRole !== undefined) { + await placeholder.edit(categoryMatchingRole.toString()) + return + } + + if ( + categoryChannel?.name?.toLowerCase()?.endsWith("general") === true && + containingChannel?.name?.toLowerCase()?.endsWith("main") === true + ) { + await placeholder.edit(server.roles.everyone.toString()) + } + + if ( + categoryChannel?.name?.toLowerCase()?.endsWith("general") === true && + containingChannel?.name?.toLowerCase()?.endsWith("bifrost") === true + ) { + // The everyone role does not work the way other roles work; in particular, + // it does _not_ add everyone to the thread. Instead, it just sits there, + // looking pretty. + await placeholder.edit(server.roles.everyone.toString()) + } + + // If we hit this spot, be a monster and delete the useless placeholder and + // pray for our soul. Placeholder code as we figure out the best way to + // handle the General category. + await placeholder.delete() +} + +const eventHandlers: DiscordEventHandlers = { + threadCreate: autoJoinThread, +} + +export default eventHandlers diff --git a/discord-scripts/thread-management/private-threads.ts b/discord-scripts/thread-management/private-threads.ts new file mode 100644 index 00000000..09cd8dff --- /dev/null +++ b/discord-scripts/thread-management/private-threads.ts @@ -0,0 +1,34 @@ +import { AnyThreadChannel, channelMention } from "discord.js" +import { isPrivate } from "../../lib/discord/utils.ts" + +const PRIVATE_THREAD_CHANNEL = { id: "1079520580228894771", name: "operations" } + +async function privateThreadAdmonishment( + thread: AnyThreadChannel, +): Promise { + const { parent: containingChannel } = thread + + if ( + isPrivate(thread) && + containingChannel?.id?.toLowerCase() !== PRIVATE_THREAD_CHANNEL.id + ) { + await thread.send( + "Private threads should largely only be used for discussions around " + + "confidential topics like legal and hiring. They should as a result " + + `almost always be created in ${channelMention( + PRIVATE_THREAD_CHANNEL.id, + )}; if you know you're ` + + "breaking both rules on purpose, go forth and conquer, but otherwise " + + "please start the thread there. I'm also going to auto-tag the " + + "appropriate roles now, which may compromise the privacy of the " + + "thread (**all members of the role who have access to this channel " + + "will have access to the thread**).", + ) + } +} + +const eventHandlers = { + threadCreate: privateThreadAdmonishment, +} + +export default eventHandlers diff --git a/discord-scripts/thread-management/reminder-to-thread.ts b/discord-scripts/thread-management/reminder-to-thread.ts new file mode 100644 index 00000000..40d26bc2 --- /dev/null +++ b/discord-scripts/thread-management/reminder-to-thread.ts @@ -0,0 +1,47 @@ +import { Message } from "discord.js" +import { + DiscordEventHandlers, + isInRecreationalCategory, +} from "../../lib/discord/utils.ts" + +// Emoji used to suggest a thread. +export const THREAD_EMOJI = "šŸ§µ" + +// Remind users to create a thread with a reacji for reply chains longer than +// 1 reply. Skip for messages in the recreational category. +async function reminderToThread(message: Message) { + // If we're already in a thread or this is the recreational category, do + // nothing. + const { channel } = message + if (channel.isThread() || isInRecreationalCategory(channel)) { + return + } + + // If this message is not in reply to anything, do nothing. + if (message.reference === null || message.reference.messageId === undefined) { + return + } + + // If the message replied to is not in reply to anythinbg, still do nothing. + const repliedMessage = await message.fetchReference() + if ( + repliedMessage.reference === null || + repliedMessage.reference.messageId === undefined + ) { + return + } + + // Okay, now we've got a chain of two replies, suggest a thread via reacji + // on the original message---if it is indeed the original message in the + // chain. + const potentialOriginalMessage = await repliedMessage.fetchReference() + if (potentialOriginalMessage.reference === null) { + message.react(THREAD_EMOJI) + } +} + +const eventHandlers: DiscordEventHandlers = { + messageCreate: reminderToThread, +} + +export default eventHandlers diff --git a/lib/discord/utils.ts b/lib/discord/utils.ts index 0c15f27a..74bd31ae 100644 --- a/lib/discord/utils.ts +++ b/lib/discord/utils.ts @@ -1,8 +1,27 @@ -import { Channel, ChannelType, AnyThreadChannel } from "discord.js" +import { + Channel, + ChannelType, + AnyThreadChannel, + ClientEvents, +} from "discord.js" +import { Robot } from "hubot" import { DiscordBot } from "hubot-discord" +/** + * Hubot Robot type with Discord adapter. + */ export type DiscordHubot = Hubot.Robot +/** + * Available handlers for thread management, all taking the Hubot robot as + * their last param. + */ +export type DiscordEventHandlers = { + [Event in keyof ClientEvents]?: ( + ...params: [...ClientEvents[Event], Robot] + ) => Promise +} + // Category that is treated as recreational, i.e. the rules don't apply baby. export const RECREATIONAL_CATEGORY_ID = "1079492118692757605" From cdbaae7b5d6aca5d415e7bff2d3edca16b626426 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Fri, 18 Aug 2023 23:55:39 -0400 Subject: [PATCH 2/6] Add `isInTestingChannel` thread management util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This checks a thread to see if it is inside a channel that we consider to be a testing channelā€”currently just the playground and bifrost channels. In certain cases, the testing channels will be used as ā€œthings don't happen hereā€, but when developing new features they might be used as ā€œthings only happen hereā€. --- lib/discord/utils.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/discord/utils.ts b/lib/discord/utils.ts index 74bd31ae..3a8a57ae 100644 --- a/lib/discord/utils.ts +++ b/lib/discord/utils.ts @@ -3,10 +3,14 @@ import { ChannelType, AnyThreadChannel, ClientEvents, + ThreadChannel, } from "discord.js" import { Robot } from "hubot" import { DiscordBot } from "hubot-discord" +// Channels that are used for testing, may be treated differently. +const TESTING_CHANNEL_NAMES = ["playground", "bifrost"] + /** * Hubot Robot type with Discord adapter. */ @@ -25,6 +29,18 @@ export type DiscordEventHandlers = { // Category that is treated as recreational, i.e. the rules don't apply baby. export const RECREATIONAL_CATEGORY_ID = "1079492118692757605" +/** + * Checks if a given thread is within a channel used for Hubot testing. At + * times, these channels may be subjected to laxer restrictions. + */ +export function isInTestingChannel(threadChannel: ThreadChannel): boolean { + return ( + TESTING_CHANNEL_NAMES.indexOf( + threadChannel.parent?.name?.toLowerCase() ?? "", + ) !== -1 + ) +} + /** * Checks if a given channel is within the category considered "recreational". * The recreational category contains channels that less formal and aren't From f1a42515df1baebbbed9086bd62981846ac37bdd Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 4 Jan 2024 07:05:11 -0500 Subject: [PATCH 3/6] Rework blow-everything-up into an archiving helper Right now it's built to help me (Antonio) quickly archive threads that are clearly dead from >2 weeks ago without bothering others. --- ...ow-everything-up.ts => help-me-archive.ts} | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) rename discord-scripts/{blow-everything-up.ts => help-me-archive.ts} (66%) diff --git a/discord-scripts/blow-everything-up.ts b/discord-scripts/help-me-archive.ts similarity index 66% rename from discord-scripts/blow-everything-up.ts rename to discord-scripts/help-me-archive.ts index 2e95db9f..f32f4e30 100644 --- a/discord-scripts/blow-everything-up.ts +++ b/discord-scripts/help-me-archive.ts @@ -1,4 +1,4 @@ -import { ChannelType, Client, TextChannel } from "discord.js" +import { Client, TextChannel } from "discord.js" import { Robot } from "hubot" import moment from "moment" @@ -18,15 +18,36 @@ export default async function webhookDiscord( discordClient: Client, robot: Robot, ) { - robot.hear(/blow everything up/, async (msg) => { + robot.hear(/help me archive (.+)/, async (msg) => { + const archiveChannelName = msg.match[1] + const guild = discordClient.guilds.cache.first() if (guild === undefined) { - msg.send("No guild found.") + msg.send("Failed to resolve Discord server.") return } const channels = await guild.channels.fetch() - const archiveThreshold = weekdaysBefore(moment(), 4) - channels + + const archiveChannel = + channels.get(archiveChannelName) ?? + channels.find( + (channel) => + channel !== null && + channel.isTextBased() && + !channel.isDMBased() && + channel.name.toLowerCase() === archiveChannelName.toLowerCase(), + ) ?? + undefined + + if (archiveChannel === undefined) { + msg.send("No matching channel found.") + return + } + + const archiveThreshold = weekdaysBefore(moment(), 14) + + // channels + Array.from([archiveChannel]) .filter( (channel): channel is TextChannel => channel !== null && channel.isTextBased() && channel.viewable, @@ -52,20 +73,9 @@ export default async function webhookDiscord( lastActivity.isBefore(archiveThreshold), ) - const message = `Threads to archive for ${ - channel.name - }:\n- ${threadsWithDates - .map( - ({ thread, lastActivity }) => - `${ - thread.type === ChannelType.PrivateThread - ? "[private]" - : thread.name - }: ${lastActivity.toLocaleString()}`, - ) - .join("\n- ")}` - console.log(message) - msg.reply(message) + threadsWithDates[0]?.thread?.send( + "@ogshadowfiend check archive status here, please.", + ) } catch (err) { console.error( `Error for ${channel.name}: `, From 2333fcece9278a4ae3f485b2a6a0f2512079dae6 Mon Sep 17 00:00:00 2001 From: Antonio Salazar Cardozo Date: Thu, 4 Jan 2024 07:06:24 -0500 Subject: [PATCH 4/6] Better display for thread management script loading errors Sometimes errors will JSON-serialize, but sometimes they won't; in the latter case, we want to try to use the error's built-in stringification in case it works better. --- discord-scripts/thread-management.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/discord-scripts/thread-management.ts b/discord-scripts/thread-management.ts index 9520bac6..3f36b433 100644 --- a/discord-scripts/thread-management.ts +++ b/discord-scripts/thread-management.ts @@ -52,12 +52,13 @@ export default function manageThreads(discordClient: Client, robot: Robot) { // eslint-disable-next-line @typescript-eslint/no-explicit-any "stack" in (error as any) ? `\n${(error as any).stack}` : "" + const errorJson = JSON.stringify(error, null, 2) + + const errorDescription = + errorJson.trim().length > 0 ? errorJson : String(error) + robot.logger.error( - `Failed to load Discord script ${file}: ${JSON.stringify( - error, - null, - 2, - )}${stackString}`, + `Failed to load Discord script ${file}: ${errorDescription}${stackString}`, ) } }) From 99c6d906d3c14fc72c22446856557e9c5428be80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:03:37 +0000 Subject: [PATCH 5/6] Bump axios from 1.6.0 to 1.6.1 Bumps [axios](https://github.com/axios/axios) from 1.6.0 to 1.6.1. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.6.0...v1.6.1) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 440b926d..7c780e4e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@types/passport": "^1.0.12", "@types/passport-github2": "^1.2.5", "@types/uuid": "^9.0.1", - "axios": "^1.6.0", + "axios": "^1.6.1", "canvas": "^2.11.0", "coffeescript": "^2", "cookie-parser": "^1.4.3", diff --git a/yarn.lock b/yarn.lock index 7f9d3190..037f97f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1561,10 +1561,10 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" -axios@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" - integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== +axios@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.1.tgz#76550d644bf0a2d469a01f9244db6753208397d7" + integrity sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g== dependencies: follow-redirects "^1.15.0" form-data "^4.0.0" From ce59b24c5634d7b101c508f36771845d8530721e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:03:33 +0000 Subject: [PATCH 6/6] Bump semver from 5.7.1 to 5.7.2 Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2) --- updated-dependencies: - dependency-name: semver dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/yarn.lock b/yarn.lock index 037f97f9..dbe8051e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5773,34 +5773,22 @@ scoped-http-client@0.11.0: integrity sha512-5xvm9yssGrPC+Rm4L9irhqaNCUjnnTfnFSzFu0iDjoX4e6AsPNht3eusaYlzum+pSmyLy2aS73Uis3vumcvjPw== "semver@2 || 3 || 4 || 5", semver@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@7.x, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== +semver@7.x, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" -semver@^6.0.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^6.3.1: +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.4: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - send@0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"