Add localization

The "command versioning" system works the same say as before, but instead of a command version the code updates the commands every time they change based on the SHA1 of the commands
This commit is contained in:
Fleny
2024-06-15 22:02:09 +02:00
parent 2387ff5702
commit 2b1da8d2cd
19 changed files with 484 additions and 24 deletions

View File

@@ -2,7 +2,7 @@
# General Configurations
#
# For Prisma use, it should be a postgres connection url
# Used by Prisma, it should be a postgres connection string
# Template: postgres://[username]:[password]@[host]:[port]/[db]
# Replate the [...] from the template with your values
# TEMPLATE-SETUP: Add a postgres connection string

View File

@@ -22,6 +22,7 @@
"@discordeno/bot": "19.0.0-next.ad7e74c",
"@fastify/multipart": "^8.3.0",
"@influxdata/influxdb-client": "^1.33.2",
"@prisma/client": "^5.15.0",
"amqplib": "^0.10.4",
"fastify": "^4.27.0"
},
@@ -30,6 +31,7 @@
"@swc/core": "^1.5.25",
"@types/amqplib": "^0.10.5",
"@types/node": "^20.14.2",
"prisma": "^5.15.0",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Guild" (
"guildId" BIGINT NOT NULL,
"language" TEXT NOT NULL DEFAULT 'english',
"commands" TEXT,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("guildId")
);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,21 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Guild {
// The guild id
guildId BigInt @id
// The language for translations in this server
language String @default("english")
// Hash of the command objects that have been deployed
// Used to check when to deploy the commands to a guild
commands String?
}

View File

@@ -1,4 +1,4 @@
import { Collection, createBot, type Bot } from '@discordeno/bot'
import { Collection, LogDepth, createBot, type Bot, type logger } from '@discordeno/bot'
import assert from 'node:assert'
import { DISCORD_TOKEN, GATEWAY_INTENTS, REST_AUTHORIZATION, REST_URL } from '../config.js'
import type { Command } from './commands.js'
@@ -29,6 +29,7 @@ props.interaction.id = true
props.interaction.data = true
props.interaction.type = true
props.interaction.token = true
props.interaction.guildId = true
props.message.id = true
@@ -36,6 +37,9 @@ props.message.id = true
function createCustomBot<TBot extends Bot = Bot>(rawBot: TBot): CustomBot<TBot> {
const bot = rawBot as CustomBot<TBot>
// We need to set the log depth for the default discordeno logger or else only the first param will be logged
;(bot.logger as typeof logger).setDepth(LogDepth.Full)
bot.commands = new Collection()
return bot

View File

@@ -1,21 +1,46 @@
import type {
ApplicationCommandOptionTypes,
ApplicationCommandTypes,
Attachment,
CamelizedDiscordApplicationCommandOption,
ChannelTypes,
CreateApplicationCommand,
CreateSlashApplicationCommand,
Interaction,
Member,
Role,
User,
} from '@discordeno/bot'
import { bot } from './bot.js'
import type { DefaultLocale, TranslationKey } from './languages/languages.js'
import { translate } from './languages/translate.js'
export default function createCommand<const TOptions extends CommandOptions>(command: Command<TOptions>): void {
bot.commands.set(command.name, command as Command)
bot.commands.set(translate('english', command.name), command as Command)
}
export type Command<TOptions extends CommandOptions = CommandOptions> = CreateApplicationCommand & {
export type Command<TOptions extends CommandOptions = CommandOptions> =
| SlashApplicationCommand<TOptions>
| (Omit<SlashApplicationCommand<TOptions>, 'options' | 'description' | 'descriptionLocalizations'> & {
/** The type of the command */
type: ApplicationCommandTypes.Message | ApplicationCommandTypes.User
})
// This is needed to properly support ApplicationCommandTypes.Message or ApplicationCommandTypes.User commands
type SlashApplicationCommand<TOptions extends CommandOptions> = CreateSlashApplicationCommand & {
/**
* @remarks
* The value should be set to the translation key for the name of this command
*
* @inheritdoc
*/
name: TranslationKey
/**
* @remarks
* The value should be set to the translation key for the name of this command
*
* @inheritdoc
*/
description: TranslationKey
/** @inheritdoc */
options?: TOptions
/**
@@ -34,7 +59,23 @@ export type GetCommandOptions<T extends CommandOptions> = T extends CommandOptio
? { [Prop in keyof BuildOptions<T> as Prop]: BuildOptions<T>[Prop] }
: never
export type CommandOption = CamelizedDiscordApplicationCommandOption
export type CommandOption = CamelizedDiscordApplicationCommandOption & {
/**
* @remarks
* The value should be set to the translation key for the name of this command
*
* @inheritdoc
*/
name: TranslationKey
/**
* @remarks
* The value should be set to the translation key for the name of this command
*
* @inheritdoc
*/
description: TranslationKey
}
export type CommandOptions = CommandOption[]
// Option parsing
@@ -73,7 +114,7 @@ interface TypeToResolvedMap {
type ConvertTypeToResolved<T extends ApplicationCommandOptionTypes> = T extends keyof TypeToResolvedMap ? TypeToResolvedMap[T] : ResolvedValues
type SubCommandApplicationCommand = ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup
type GetOptionName<T> = T extends { name: string } ? T['name'] : never
type GetOptionName<T> = T extends { name: TranslationKey } ? (DefaultLocale[T['name']] extends string ? DefaultLocale[T['name']] : never) : never
type GetOptionValue<T> = T extends { type: ApplicationCommandOptionTypes; required?: boolean }
? T extends { type: SubCommandApplicationCommand; options?: CommandOptions }
? BuildOptions<T['options']>

View File

@@ -0,0 +1,47 @@
import { ApplicationCommandOptionTypes, DiscordInteractionContextType } from '@discordeno/bot'
import assert from 'node:assert'
import createCommand from '../commands.js'
import type { LanguageNames } from '../languages/languages.js'
import languages from '../languages/languages.js'
import { languageCache, translate } from '../languages/translate.js'
import prisma from '../prisma.js'
import { updateCommands } from '../utils/updateCommands.js'
createCommand({
name: 'languageCommandName',
description: 'languageCommandDescription',
// Allow this command only in Guilds, it will not appear in DMs
contexts: [DiscordInteractionContextType.Guild],
// By default, allow only someone with MANAGE_GUILD to run this command
defaultMemberPermissions: ['MANAGE_GUILD'],
options: [
{
name: 'languageCommandOptionName',
description: 'languageCommandDescription',
type: ApplicationCommandOptionTypes.String,
choices: Object.keys(languages).map((key) => ({ name: key, value: key })),
required: true,
},
],
async run(interaction, options) {
assert(interaction.guildId, '/language - The guildId is missing in the interaction')
await interaction.defer(true)
const language = options.language as LanguageNames
// Update the db
await prisma.guild.upsert({
where: { guildId: interaction.guildId },
create: { language, guildId: interaction.guildId },
update: { language },
})
// Update the cache
languageCache.set(interaction.guildId, language)
// Update the commands for this guild so they get the new translation
await updateCommands(interaction.guildId)
// Let the user know its been updated.
await interaction.respond(translate(interaction.guildId, 'languageCommandUpdated', language))
},
})

View File

@@ -1,16 +1,17 @@
import { snowflakeToTimestamp } from '@discordeno/bot'
import { bot } from '../bot.js'
import createCommand from '../commands.js'
import { translate } from '../languages/translate.js'
createCommand({
name: 'ping',
description: 'The ping command',
name: 'pingCommandName',
description: 'pingCommandDescription',
async run(interaction) {
await interaction.respond(`🏓 Ping?`)
await interaction.respond(translate(interaction.guildId, 'pingCommandInitialResponse'))
const response = await bot.helpers.getOriginalInteractionResponse(interaction.token)
const ping = snowflakeToTimestamp(response.id) - snowflakeToTimestamp(interaction.id)
await interaction.edit(`🏓 Pong! Gateway Latency: TBD, Roundtrip Latency: ${ping}ms. I am online and responsive! 🕙`)
await interaction.edit(translate(interaction.guildId, 'pingCommandResponseWithLatencies', 0, ping))
},
})

View File

@@ -1,5 +1,6 @@
import { InteractionTypes, commandOptionsParser } from '@discordeno/bot'
import { bot } from '../../bot.js'
import { loadLocale } from '../../languages/translate.js'
bot.events.interactionCreate = async (interaction) => {
const isCommandOrAutocomplete =
@@ -7,6 +8,10 @@ bot.events.interactionCreate = async (interaction) => {
if (!interaction.data || !isCommandOrAutocomplete) return
if (interaction.guildId) {
await loadLocale(interaction.guildId)
}
const command = bot.commands.get(interaction.data.name)
if (!command) {
@@ -15,6 +20,7 @@ bot.events.interactionCreate = async (interaction) => {
}
// TODO: log the command was triggered
// TODO: handle autocomplete
const options = commandOptionsParser(interaction)

View File

@@ -0,0 +1,34 @@
import { hasProperty, type DiscordGatewayPayload } from '@discordeno/bot'
import { bot } from '../bot.js'
import { updateCommands, usesLatestCommands } from '../utils/updateCommands.js'
bot.events.raw = async (payload) => {
// Check if the guild needs a command update
const guildId = attemptToGetGuildId(payload)
if (guildId && !(await usesLatestCommands(guildId))) {
await updateCommands(guildId)
}
}
function attemptToGetGuildId(payload: DiscordGatewayPayload): bigint | undefined {
const data = payload.d
if (payload.t === 'GUILD_CREATE' || payload.t === 'GUILD_UPDATE') {
// Attempt to find the guild_id
if (typeof data !== 'object' || !data || !hasProperty(data, 'id') || typeof data.id !== 'string') return
return BigInt(data.id)
}
// Attempt to find the guild_id in another object
if (typeof data !== 'object' || !data || !hasProperty(data, 'guild_id') || typeof data.guild_id !== 'string') return
// The bigint constructor throws an error if you pass in something that isn't a number
const isNumber = Number.isInteger(Number.parseInt(data.guild_id))
if (!isNumber) return undefined
return BigInt(data.guild_id)
}

View File

@@ -13,7 +13,9 @@ import {
import { bot } from './bot.js'
import { buildFastifyApp } from './fastify.js'
import importDirectory from './utils/loader.js'
import { updateCommands } from './utils/updateCommands.js'
// Initialize the prisma client
import './prisma.js'
assert(EVENT_HANDLER_AUTHORIZATION, 'The EVENT_HANDLER_AUTHORIZATION environment variable is missing')
assert(EVENT_HANDLER_HOST, 'The EVENT_HANDLER_HOST environment variable is missing')
@@ -26,8 +28,6 @@ assert(!Number.isNaN(portNumber), 'The EVENT_HANDLER_PORT environment variable s
await importDirectory('./dist/bot/commands')
await importDirectory('./dist/bot/events')
await updateCommands()
if (MESSAGEQUEUE_ENABLE) {
await connectToRabbitMQ()
}

View File

@@ -0,0 +1,27 @@
import type { LanguageLocale } from './languages.js'
export default {
//
// slash command handler
//
executeCommandNotFound: '❌ Something went wrong. I was not able to find this command.',
executeCommandError: '❌ Something went wrong. The command execution has thrown an error.',
//
// /language command
//
languageCommandName: 'language',
languageCommandDescription: '⚙️ Change the bot language.',
languageCommandOptionName: 'language',
languageCommandOptionDescription: 'What language would you like to set?',
languageCommandUpdated: (language: string) => `The language has been updated to ${language}`,
//
// /ping command
//
pingCommandName: 'ping',
pingCommandDescription: '🏓 Check whether the bot is online and responsive.',
pingCommandInitialResponse: '🏓 Pong! I am online and responsive! 🕙',
pingCommandResponseWithLatencies: (shardLatency, restLatency) =>
`🏓 Pong! Gateway Latency: ${shardLatency}ms, Roundtrip Latency: ${restLatency}ms. I am online and responsive! 🕙`,
} as const satisfies LanguageLocale

View File

@@ -0,0 +1,41 @@
import english from './english.js'
// TEMPLATE-SETUP: Import other locales if you have them
const languages: Record<LanguageNames, LanguageLocale> = {
english,
}
export default languages
// TEMPLATE-SETUP: when adding a new locale, you should add them to this type with an OR
export type LanguageNames = 'english'
// TEMPLATE-SETUP: when adding new translation keys, you should add them to ensure all the locales have a translation
// When the translation does not need any parameters you can type it as `string`, if there is the need for parameters you can type is a function that returns a string
export interface LanguageLocale {
//
// slash command handler
//
executeCommandNotFound: string
executeCommandError: string
//
// /language command
//
languageCommandName: string
languageCommandDescription: string
languageCommandOptionName: string
languageCommandOptionDescription: string
languageCommandUpdated: (language: LanguageNames) => string
//
// /ping command
//
pingCommandName: string
pingCommandDescription: string
pingCommandInitialResponse: string
pingCommandResponseWithLatencies: (shardLatency: number, restLatency: number) => string
}
export type TranslationKey = keyof LanguageLocale
export type DefaultLocale = typeof english

View File

@@ -0,0 +1,53 @@
import { Collection } from '@discordeno/bot'
import prisma from '../prisma.js'
import languages, { type LanguageLocale, type LanguageNames, type TranslationKey } from './languages.js'
export const languageCache = new Collection<bigint, LanguageNames>()
export function translate<TKey extends TranslationKey>(
guildIdOrLanguage: bigint | LanguageNames | undefined,
key: TKey,
...params: GetTranslationArguments<TKey>
): string {
const locale = getLocale(guildIdOrLanguage ?? 'english')
const translation = locale[key]
if (typeof translation === 'function') {
// This type cast is needed to avoid TS doing stuff with the statically typed functions union
const translationFunction = translation as (...args: unknown[]) => string
return translationFunction(...params)
}
return translation
}
export function getLocale(guildIdOrLanguage: bigint | LanguageNames): LanguageLocale {
return languages[getLanguage(guildIdOrLanguage)]
}
export function getLanguage(guildIdOrLanguage: bigint | LanguageNames): LanguageNames {
const language =
typeof guildIdOrLanguage === 'string'
? // guildIdOrLanguage is actually a language, so we can return it as is
guildIdOrLanguage
: // guildIdOrLanguage is a guildId, so we need to get from the cache what is the language for that server
languageCache.get(guildIdOrLanguage) ?? 'english'
return language
}
export async function loadLocale(guildId: bigint): Promise<void> {
if (languageCache.has(guildId)) return
const dbLanguage = await prisma.guild.findFirst({
where: { guildId },
})
const language = (dbLanguage?.language ?? 'english') as LanguageNames
// set the cache for the next time
languageCache.set(guildId, language)
}
type GetTranslationArguments<TKey extends TranslationKey> = LanguageLocale[TKey] extends (...args: infer U) => unknown ? U : []

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export default new PrismaClient()

View File

@@ -1,19 +1,112 @@
import assert from 'node:assert'
import { type CamelizedDiscordApplicationCommandOption, type CreateApplicationCommand, type CreateSlashApplicationCommand } from '@discordeno/bot'
import { createHash } from 'node:crypto'
import { DEVELOPMENT, DEV_SERVER_ID } from '../../config.js'
import { bot } from '../bot.js'
import type { Command } from '../commands.js'
import type { TranslationKey } from '../languages/languages.js'
import { loadLocale, translate } from '../languages/translate.js'
import prisma from '../prisma.js'
export async function updateCommands(): Promise<void> {
bot.logger.info('Updating commands')
const commandCache = new Map<CreateApplicationCommand, CreateApplicationCommand>()
const userCommands = bot.commands.filter((x) => !x.devOnly).array()
await bot.helpers.upsertGlobalApplicationCommands(userCommands)
// TODO: add some comments in this file, it is currently quite hard to understand what is going on
if (DEVELOPMENT) {
assert(DEV_SERVER_ID, 'The DEV_SERVER_ID environment is missing')
export async function updateCommands(guildId: bigint): Promise<void> {
bot.logger.info(`Updating commands for guildId ${guildId}`)
bot.logger.info('Updating developer commands')
await loadLocale(guildId)
const devCommands = bot.commands.filter((x) => x.devOnly ?? false).array()
await bot.helpers.upsertGuildApplicationCommands(DEV_SERVER_ID, devCommands)
const userCommands = bot.commands
.filter((x) => (guildId === BigInt(DEV_SERVER_ID ?? -1n) && DEVELOPMENT ? true : !x.devOnly))
.array()
.map((x) => translateCommands(guildId, x))
await bot.helpers.upsertGuildApplicationCommands(guildId, userCommands)
bot.logger.info(`Saving the command hash for guildId ${guildId}`)
await prisma.guild.upsert({
where: { guildId },
create: { guildId, commands: currentCommandHash },
update: { commands: currentCommandHash },
})
}
let currentCommandHash: string
const guildCommandHashes = new Map<bigint, string>()
export async function usesLatestCommands(guildId: bigint): Promise<boolean> {
const current = await getCurrentCommandHash(guildId)
return current === currentCommandHash
}
export async function getCurrentCommandHash(guildId: bigint): Promise<string | null> {
if (!currentCommandHash) {
const serializedCommands = JSON.stringify(bot.commands.array())
currentCommandHash = createHash('sha1').update(serializedCommands).digest('hex')
}
const cached = guildCommandHashes.get(guildId)
if (cached) return cached
const commandVersion = await prisma.guild.findFirst({ where: { guildId } })
if (commandVersion?.commands) guildCommandHashes.set(guildId, commandVersion.commands)
return commandVersion?.commands ?? null
}
function translateCommands(guildId: bigint, command: Command): CreateApplicationCommand {
const cached = commandCache.get(command)
if (cached) return cached
// we don't want to modify the original command, so we transform it and copying it in the process
const appCommand = transformCommand(command)
appCommand.name = translate(guildId, appCommand.name as TranslationKey)
if ('description' in appCommand) {
appCommand.description = translate(guildId, appCommand.description as TranslationKey)
if (appCommand.options) translateOptions(guildId, appCommand.options)
}
commandCache.set(command, appCommand)
return appCommand
}
function translateOptions(guildId: bigint, options: CamelizedDiscordApplicationCommandOption[]): void {
for (const option of options) {
option.name = translate(guildId, option.name as TranslationKey)
option.description = translate(guildId, option.description as TranslationKey)
if (option.options) translateOptions(guildId, option.options)
}
}
// Transform our custom Command object to the discordeno CreateApplicationCommand to avoid issues
function transformCommand(command: Command): CreateApplicationCommand {
const discordPayload = {} as CreateApplicationCommand
if (command.name) discordPayload.name = command.name
if (command.defaultMemberPermissions) discordPayload.defaultMemberPermissions = command.defaultMemberPermissions
if (command.dmPermission) discordPayload.dmPermission = command.dmPermission
if (command.contexts) discordPayload.contexts = command.contexts
if (command.integrationTypes) discordPayload.integrationTypes = command.integrationTypes
if (command.nameLocalizations) discordPayload.nameLocalizations = command.nameLocalizations
if (command.nsfw) discordPayload.nsfw = command.nsfw
if (command.type) discordPayload.type = command.type
if ('description' in command) {
const payload = discordPayload as CreateSlashApplicationCommand
if (command.description) payload.description = command.description
if (command.descriptionLocalizations) payload.descriptionLocalizations = command.descriptionLocalizations
if (command.options) payload.options = command.options
}
return discordPayload
}

View File

@@ -102,6 +102,11 @@ function createShard(shardId: number): DiscordenoShard {
return await promise
}
// We do not want to camelize the packet, so we need to override the function as the default behavior is to camelize
shard.forwardToBot = (packet) => {
shard.events.message?.(shard, packet)
}
shard.events.message = async (shard, payload) => {
const body = JSON.stringify({ payload, shardId: shard.id })

View File

@@ -224,6 +224,64 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:^5.15.0":
version: 5.15.0
resolution: "@prisma/client@npm:5.15.0"
peerDependencies:
prisma: "*"
peerDependenciesMeta:
prisma:
optional: true
checksum: 6948885425a62fef5a90f039761acbd1f61c9713e8e49f30892244926ad402b3489c0b527d5d6f6469ed1057848dd6d052eec51c99d279d4f956fd021279330f
languageName: node
linkType: hard
"@prisma/debug@npm:5.15.0":
version: 5.15.0
resolution: "@prisma/debug@npm:5.15.0"
checksum: a0464ae8ba938ad611cf00101f8725977e822f48ad8d1ca5cd17b98e586264b8936db79b0b3521d9859651802f73da92508856aea363c9767fa418d749af72b7
languageName: node
linkType: hard
"@prisma/engines-version@npm:5.15.0-29.12e25d8d06f6ea5a0252864dd9a03b1bb51f3022":
version: 5.15.0-29.12e25d8d06f6ea5a0252864dd9a03b1bb51f3022
resolution: "@prisma/engines-version@npm:5.15.0-29.12e25d8d06f6ea5a0252864dd9a03b1bb51f3022"
checksum: 5e4e29c701134e395fb06ddb94471356fa0cee3abc237726443643bf6462e8781d2f8de536f2b5ccdb6c00d9a9a6fb4f9d68e0a8ee744648ebd1924c359e865c
languageName: node
linkType: hard
"@prisma/engines@npm:5.15.0":
version: 5.15.0
resolution: "@prisma/engines@npm:5.15.0"
dependencies:
"@prisma/debug": "npm:5.15.0"
"@prisma/engines-version": "npm:5.15.0-29.12e25d8d06f6ea5a0252864dd9a03b1bb51f3022"
"@prisma/fetch-engine": "npm:5.15.0"
"@prisma/get-platform": "npm:5.15.0"
checksum: bbe3c942793602fc3cfbd9a5b772cdb5cece2b2515a41fe49e503b7c21c76c0215d4c3861d3db29677b5e1bce0baf98e226c9c3239946f3909b8cc72ac422da3
languageName: node
linkType: hard
"@prisma/fetch-engine@npm:5.15.0":
version: 5.15.0
resolution: "@prisma/fetch-engine@npm:5.15.0"
dependencies:
"@prisma/debug": "npm:5.15.0"
"@prisma/engines-version": "npm:5.15.0-29.12e25d8d06f6ea5a0252864dd9a03b1bb51f3022"
"@prisma/get-platform": "npm:5.15.0"
checksum: fd3696095cd4e780806655cfd23332235eda51231840467d90d663937583ed5ac2950e4aa193f1586fd19d62028eb52d4ddacff9b0a247f76eb701621e43098e
languageName: node
linkType: hard
"@prisma/get-platform@npm:5.15.0":
version: 5.15.0
resolution: "@prisma/get-platform@npm:5.15.0"
dependencies:
"@prisma/debug": "npm:5.15.0"
checksum: b969d502aea4a9bb3cae2f751e8c9729f161ebe0442dd44c4a2fd68e0a940111ee133e96e402a7ca9156a231d1e197bdd4966823f8fa610e623a6bae7bb55c8d
languageName: node
linkType: hard
"@sindresorhus/is@npm:^4.0.0":
version: 4.6.0
resolution: "@sindresorhus/is@npm:4.6.0"
@@ -829,12 +887,14 @@ __metadata:
"@discordeno/bot": "npm:19.0.0-next.ad7e74c"
"@fastify/multipart": "npm:^8.3.0"
"@influxdata/influxdb-client": "npm:^1.33.2"
"@prisma/client": "npm:^5.15.0"
"@swc/cli": "npm:^0.3.12"
"@swc/core": "npm:^1.5.25"
"@types/amqplib": "npm:^0.10.5"
"@types/node": "npm:^20.14.2"
amqplib: "npm:^0.10.4"
fastify: "npm:^4.27.0"
prisma: "npm:^5.15.0"
typescript: "npm:^5.4.5"
languageName: unknown
linkType: soft
@@ -1984,6 +2044,17 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:^5.15.0":
version: 5.15.0
resolution: "prisma@npm:5.15.0"
dependencies:
"@prisma/engines": "npm:5.15.0"
bin:
prisma: build/index.js
checksum: 8baa30db5edcc244adf4c0a6af36f4b5b3e5fd6b15db75bea6ddc636d2ebdbae9c3c2824a7a6dab6eec9630646e2ea9a5140e5806c86963bad6484560dc27b5d
languageName: node
linkType: hard
"proc-log@npm:^3.0.0":
version: 3.0.0
resolution: "proc-log@npm:3.0.0"