diff --git a/packages/rest/package.json b/packages/rest/package.json index 503f89679..a97221278 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@discordeno/transformer": "18.0.0-alpha.1", + "@discordeno/utils": "18.0.0-alpha.1", "dotenv": "^16.0.3" }, "devDependencies": { diff --git a/packages/rest/src/manager.ts b/packages/rest/src/manager.ts index 00899260d..d900544e6 100644 --- a/packages/rest/src/manager.ts +++ b/packages/rest/src/manager.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import TRANSFORMERS from '@discordeno/transformer' -import type { BigString, Camelize, DiscordUser } from '@discordeno/types' +import type { BigString, Camelize, CreateMessageOptions, DiscordCreateMessage, DiscordMessage, DiscordUser, GetMessagesOptions } from '@discordeno/types' +import { delay } from '@discordeno/utils' // TODO: make dynamic based on package.json file const version = '18.0.0-alpha.1' @@ -12,10 +13,39 @@ export function createRestManager (options: CreateRestManagerOptions): RestManag baseUrl: options.baseUrl ?? 'https://discord.com/api', routes: { + // Channel Endpoints + channels: { + message: (channelId, messageId) => { + return `/channels/${channelId}/messages/${messageId}` + }, + + messages: (channelId, options?: GetMessagesOptions) => { + const url = `/channels/${channelId}/messages?` + + if (options) { + // if (isGetMessagesAfter(options) && options.after) { + // url += `after=${options.after}` + // } + // if (isGetMessagesBefore(options) && options.before) { + // url += `&before=${options.before}` + // } + // if (isGetMessagesAround(options) && options.around) { + // url += `&around=${options.around}` + // } + // if (isGetMessagesLimit(options) && options.limit) { + // url += `&limit=${options.limit}` + // } + } + + return url + } + }, + // User endpoints user (userId: BigString) { return `/users/${userId}` } + }, createRequest (options) { @@ -45,6 +75,10 @@ export function createRestManager (options: CreateRestManagerOptions): RestManag options.body.reason = undefined } + if (options.body && !['GET', 'DELETE'].includes(options.method)) { + headers['Content-Type'] = 'application/json' + } + return { headers, body: (options.body?.file ?? JSON.stringify(options.body)) as @@ -55,31 +89,72 @@ export function createRestManager (options: CreateRestManagerOptions): RestManag }, async sendRequest (options) { - const response = await fetch(`${rest.baseUrl}/v${rest.version}/${options.url}`, rest.createRequest({ method: options.method, url: options.url })) + const response = await fetch( + `${rest.baseUrl}/v${rest.version}/${options.url}`, + rest.createRequest({ method: options.method, url: options.url, body: options.body }) + ) + if (response.status < 200 || response.status >= 400) { // If NOT rate limited remove from queue if (response.status === 429) { - // TODO: RATELIMITED HANDLING - return options.reject('RATELIMITED 429') - } else { - // INVALID REQUEST - const body = JSON.stringify(await response.json()) - return options.reject({ - ok: false, - status: response.status, - body - }) + // const json = await response.json() + + // TOO MANY ATTEMPTS, GET RID OF REQUEST FROM QUEUE. + // if ( + // options.retryCount !== undefined && + // options.retryCount++ >= rest.maxRetryCount + // ) { + // rest.debug(`[REST - RetriesMaxed] ${JSON.stringify(options)}`) + // // REMOVE ITEM FROM QUEUE TO PREVENT RETRY + // options.reject?.({ + // ok: false, + // status: response.status, + // error: + // 'The options was rate limited and it maxed out the retries limit.' + // }) + + // // @ts-expect-error Code should never reach here + // return + // } + + // RATE LIMITED, ADD BACK TO QUEUE + + // rest.invalidBucket.handleCompletedRequest( + // response.status, + // response.headers.get('X-RateLimit-Scope') === 'shared' + // ) + + // console.log('rate limited', json.retry_after, response.headers) + const resetAfter = response.headers.get('x-ratelimit-reset-after') + if (resetAfter) await delay(Number(resetAfter) * 1000) + options.retryCount++ + + return await options.retryRequest?.(options) } + + // INVALID REQUEST + const body = JSON.stringify(await response.json()) + return options.reject({ + ok: false, + status: response.status, + body + }) } options.resolve(await response.json()) }, - async makeRequest (method, url) { + async makeRequest (method, url, body) { return await new Promise((resolve, reject) => { rest.sendRequest({ url, method, + body, + retryCount: 0, + retryRequest: async function (options: SendRequestOptions) { + // TODO: should change to reprocess queue item + await rest.sendRequest(options) + }, resolve, reject }) @@ -90,9 +165,51 @@ export function createRestManager (options: CreateRestManagerOptions): RestManag return await rest.makeRequest('GET', url) }, + async post (url, body) { + return await rest.makeRequest('POST', url, body) + }, + async getUser (id) { const result = await rest.get(rest.routes.user(id)) return TRANSFORMERS.user(result) + }, + + /** + * Sends a message to a channel. + * + * @param channelId - The ID of the channel to send the message in. + * @param options - The parameters for the creation of the message. + * @returns An instance of the created {@link DiscordMessage}. + * + * @remarks + * Requires that the bot user be able to see the contents of the channel the message is to be sent in. + * + * If sending a message to a guild channel: + * - Requires the `SEND_MESSAGES` permission. + * + * If sending a TTS message: + * - Requires the `SEND_TTS_MESSAGES` permission. + * + * If sending a message as a reply to another message: + * - Requires the `READ_MESSAGE_HISTORY` permission. + * - The message being replied to cannot be a system message. + * + * ⚠️ The maximum size of a request (accounting for any attachments and message content) for bot users is _8 MiB_. + * + * Fires a _Message Create_ gateway event. + * + * @see {@link https://discord.com/developers/docs/resources/channel#create-message} + */ + async sendMessage (channelId: BigString, options: CreateMessageOptions) { + const result = await rest.post( + rest.routes.channels.messages(channelId), + { + content: options.content + // TODO: other options + } as DiscordCreateMessage + ) + + return TRANSFORMERS.message(result) } } @@ -130,25 +247,40 @@ export interface RestManager { routes: { /** A specific user route. */ user: (id: BigString) => string + /** Routes for channel related endpoints. */ + channels: { + /** Route for a specific message */ + message: (channelId: BigString, id: BigString) => string + /** Route for handling non-specific messages. */ + messages: (channelId: BigString, options?: GetMessagesOptions) => string + } } /** Creates the request body and headers that are necessary to send a request. Will handle different types of methods and everything necessary for discord. */ createRequest: (options: CreateRequestBodyOptions) => RequestBody /** Sends a request to the api. */ sendRequest: (options: SendRequestOptions) => Promise /** Make a request to be sent to the api. */ - makeRequest: (method: RequestMethods, url: string) => Promise + makeRequest: (method: RequestMethods, url: string, body?: Record) => Promise /** Make a get request to the api */ get: (url: string) => Promise + /** Make a post request to the api. */ + post: (url: string, body?: Record) => Promise /** * Get a user's data from the api * * @param id The user's id - * @returns {DiscordUser} + * @returns {Camelize} */ getUser: (id: BigString) => Promise> + /** + * Send a message to a channel. + * + * @returns {Message} + */ + sendMessage: (channelId: BigString, options: CreateMessageOptions) => Promise> } -export type RequestMethods = 'GET' +export type RequestMethods = 'GET' | 'POST' export type ApiVersions = 9 | 10 export interface CreateRequestBodyOptions { @@ -170,6 +302,12 @@ export interface SendRequestOptions { url: string /** The method to use when sending the request. */ method: RequestMethods + /** The body to be sent in the request. */ + body?: Record + /** The amount of times this request has been retried. */ + retryCount: number + /** Handler to retry a request should it be rate limited. */ + retryRequest?: (options: SendRequestOptions) => Promise /** Resolve handler when a request succeeds. */ resolve: (value: any | PromiseLike) => void /** Reject handler when a request fails. */ diff --git a/packages/rest/tests/e2e/message.spec.ts b/packages/rest/tests/e2e/message.spec.ts new file mode 100644 index 000000000..670b28843 --- /dev/null +++ b/packages/rest/tests/e2e/message.spec.ts @@ -0,0 +1,20 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { rest } from '../utils.js' + +describe('[rest] Message related tests', () => { + describe('Send a message', () => { + it('With content', async () => { + const message = await rest.sendMessage('1057524844712964146', { content: 'testing rate limit manager' }) + expect(message.content).to.be.equal('testing rate limit manager') + }) + }) + + describe('Rate limit manager testing', () => { + it('Send 10 messages to 1 channel', async () => { + await Promise.all([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(async (i) => { + await rest.sendMessage('1057524844712964146', { content: `testing rate limit manager ${i}` }) + })) + }) + }) +}) diff --git a/packages/rest/tests/e2e/user.spec.ts b/packages/rest/tests/e2e/user.spec.ts index 65d0dda46..c6625e092 100644 --- a/packages/rest/tests/e2e/user.spec.ts +++ b/packages/rest/tests/e2e/user.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { describe, it } from 'mocha' import { rest } from '../utils.js' -describe('[rest] manager', () => { +describe('[rest] User related tests', () => { describe('Get a user from the api', () => { it('With a valid user id', async () => { const user = await rest.getUser('130136895395987456') diff --git a/packages/transformer/src/casing/index.ts b/packages/transformer/src/casing/index.ts index 72e50f894..b0d03dc50 100644 --- a/packages/transformer/src/casing/index.ts +++ b/packages/transformer/src/casing/index.ts @@ -1 +1,2 @@ +export * from './message.js' export * from './user.js' diff --git a/packages/transformer/src/casing/message.ts b/packages/transformer/src/casing/message.ts new file mode 100644 index 000000000..a51c20de3 --- /dev/null +++ b/packages/transformer/src/casing/message.ts @@ -0,0 +1,15 @@ +import type { Camelize, DiscordMessage } from '@discordeno/types' + +export function c1amelize1Message (payload: DiscordMessage): Camelize { + return { + id: payload.id, + content: payload.content + } +} + +export function s1nakelize1Message (payload: Camelize): DiscordMessage { + return { + id: payload.id, + content: payload.content + } +} diff --git a/packages/transformer/src/index.ts b/packages/transformer/src/index.ts index 0a561b008..e16baee73 100644 --- a/packages/transformer/src/index.ts +++ b/packages/transformer/src/index.ts @@ -1,11 +1,14 @@ +import { c1amelize1Message, s1nakelize1Message } from './casing/message.js' import { c1amelize1User, s1nakelize1User } from './casing/user.js' export * from './casing/index.js' export const TRANSFORMERS = { + message: c1amelize1Message, user: c1amelize1User, reverse: { + message: s1nakelize1Message, user: s1nakelize1User } } diff --git a/packages/types/src/discord.ts b/packages/types/src/discord.ts index 839e543e2..120b61acf 100644 --- a/packages/types/src/discord.ts +++ b/packages/types/src/discord.ts @@ -45,7 +45,7 @@ // WebhookTypes // } from './shared.js' -import type { UserFlags, PremiumTypes } from './shared' +import type { PremiumTypes, UserFlags } from './shared' /** https://discord.com/developers/docs/resources/user#user-object */ export interface DiscordUser { @@ -1006,29 +1006,29 @@ export interface DiscordUser { // user: DiscordUser // } -// /** https://discord.com/developers/docs/resources/channel#message-object */ -// export interface DiscordMessage { -// /** id of the message */ -// id: string -// /** id of the channel the message was sent in */ -// channel_id: string -// /** -// * id of the guild the message was sent in -// * Note: For MESSAGE_CREATE and MESSAGE_UPDATE events, the message object may not contain a guild_id or member field since the events are sent directly to the receiving user and the bot who sent the message, rather than being sent through the guild like non-ephemeral messages. -// */ -// guild_id?: string -// /** -// * The author of this message (not guaranteed to be a valid user) -// * Note: The author object follows the structure of the user object, but is only a valid user in the case where the message is generated by a user or bot user. If the message is generated by a webhook, the author object corresponds to the webhook's id, username, and avatar. You can tell if a message is generated by a webhook by checking for the webhook_id on the message object. -// */ -// author: DiscordUser -// /** -// * Member properties for this message's author -// * Note: The member object exists in `MESSAGE_CREATE` and `MESSAGE_UPDATE` events from text-based guild channels. This allows bots to obtain real-time member data without requiring bots to store member state in memory. -// */ -// member?: DiscordMember -// /** Contents of the message */ -// content?: string +/** https://discord.com/developers/docs/resources/channel#message-object */ +export interface DiscordMessage { + /** id of the message */ + id: string + // /** id of the channel the message was sent in */ + // channel_id: string + // /** + // * id of the guild the message was sent in + // * Note: For MESSAGE_CREATE and MESSAGE_UPDATE events, the message object may not contain a guild_id or member field since the events are sent directly to the receiving user and the bot who sent the message, rather than being sent through the guild like non-ephemeral messages. + // */ + // guild_id?: string + // /** + // * The author of this message (not guaranteed to be a valid user) + // * Note: The author object follows the structure of the user object, but is only a valid user in the case where the message is generated by a user or bot user. If the message is generated by a webhook, the author object corresponds to the webhook's id, username, and avatar. You can tell if a message is generated by a webhook by checking for the webhook_id on the message object. + // */ + // author: DiscordUser + // /** + // * Member properties for this message's author + // * Note: The member object exists in `MESSAGE_CREATE` and `MESSAGE_UPDATE` events from text-based guild channels. This allows bots to obtain real-time member data without requiring bots to store member state in memory. + // */ + // member?: DiscordMember + /** Contents of the message */ + content?: string // /** When this message was sent */ // timestamp: string // /** When this message was edited (or null if never) */ @@ -1093,7 +1093,7 @@ export interface DiscordUser { // sticker_items?: DiscordStickerItem[] // /** A generally increasing integer (there may be gaps or duplicates) that represents the approximate position of the message in a thread, it can be used to estimate the relative position of the message in a thread in company with `total_message_sent` on parent thread */ // position?: number -// } +} // /** https://discord.com/developers/docs/resources/channel#channel-mention-object */ // export interface DiscordChannelMention { @@ -2765,36 +2765,36 @@ export interface DiscordUser { // components?: DiscordMessageComponents // } -// export interface DiscordCreateMessage { -// /** The message contents (up to 2000 characters) */ -// content?: string -// /** Can be used to verify a message was sent (up to 25 characters). Value will appear in the Message Create event. */ -// nonce?: string | number -// /** true if this is a TTS message */ -// tts?: boolean -// /** Embedded `rich` content (up to 6000 characters) */ -// embeds?: DiscordEmbed[] -// /** Allowed mentions for the message */ -// allowed_mentions?: DiscordAllowedMentions -// /** Include to make your message a reply */ -// message_reference?: { -// /** id of the originating message */ -// message_id?: string -// /** -// * id of the originating message's channel -// * Note: `channel_id` is optional when creating a reply, but will always be present when receiving an event/response that includes this data model. -// */ -// channel_id?: string -// /** id of the originating message's guild */ -// guild_id?: string -// /** When sending, whether to error if the referenced message doesn't exist instead of sending as a normal (non-reply) message, default true */ -// fail_if_not_exists: boolean -// } -// /** The components you would like to have sent in this message */ -// components?: DiscordMessageComponents -// /** IDs of up to 3 stickers in the server to send in the message */ -// stickerIds?: [string] | [string, string] | [string, string, string] -// } +export interface DiscordCreateMessage { + /** The message contents (up to 2000 characters) */ + content?: string + /** Can be used to verify a message was sent (up to 25 characters). Value will appear in the Message Create event. */ + nonce?: string | number + /** true if this is a TTS message */ + tts?: boolean + /** Embedded `rich` content (up to 6000 characters) */ + // embeds?: DiscordEmbed[] + /** Allowed mentions for the message */ + // allowed_mentions?: DiscordAllowedMentions + /** Include to make your message a reply */ + message_reference?: { + /** id of the originating message */ + message_id?: string + /** + * id of the originating message's channel + * Note: `channel_id` is optional when creating a reply, but will always be present when receiving an event/response that includes this data model. + */ + channel_id?: string + /** id of the originating message's guild */ + guild_id?: string + /** When sending, whether to error if the referenced message doesn't exist instead of sending as a normal (non-reply) message, default true */ + fail_if_not_exists: boolean + } + /** The components you would like to have sent in this message */ + // components?: DiscordMessageComponents + /** IDs of up to 3 stickers in the server to send in the message */ + stickerIds?: [string] | [string, string] | [string, string, string] +} // export interface DiscordCreateScheduledEvent { // /** the channel id of the scheduled event. */ diff --git a/packages/types/src/discordeno.ts b/packages/types/src/discordeno.ts index 3af867774..eb4f1daf4 100644 --- a/packages/types/src/discordeno.ts +++ b/packages/types/src/discordeno.ts @@ -1,4 +1,40 @@ -export {} +import type { BigString } from "./shared" + +export interface CreateMessageOptions { + /** The message contents (up to 2000 characters) */ + content?: string +// /** Can be used to verify a message was sent (up to 25 characters). Value will appear in the Message Create event. */ +// nonce?: string | number +// /** true if this is a TTS message */ +// tts?: boolean + /** Embedded `rich` content (up to 6000 characters) */ +// embeds?: Embed[] + /** Allowed mentions for the message */ +// allowedMentions?: AllowedMentions + /** Include to make your message a reply */ +// messageReference?: { +// /** id of the originating message */ +// messageId?: BigString +// /** +// * id of the originating message's channel +// * Note: `channel_id` is optional when creating a reply, but will always be present when receiving an event/response that includes this data model. +// */ +// channelId?: BigString +// /** id of the originating message's guild */ +// guildId?: BigString +// /** When sending, whether to error if the referenced message doesn't exist instead of sending as a normal (non-reply) message, default true */ +// failIfNotExists: boolean +// } + /** The contents of the file being sent */ +// file?: FileContent | FileContent[] +// /** The components you would like to have sent in this message */ +// components?: MessageComponents +// /** IDs of up to 3 stickers in the server to send in the message */ +// stickerIds?: +// | [BigString] +// | [BigString, BigString] +// | [BigString, BigString, BigString] +} // import type { // AllowedMentionsTypes, // ApplicationCommandTypes, @@ -244,35 +280,35 @@ export {} // } // } -// /** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ -// export interface GetMessagesLimit { -// /** Max number of messages to return (1-100) default 50 */ -// limit?: number -// } +/** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ +export interface GetMessagesLimit { + /** Max number of messages to return (1-100) default 50 */ + limit?: number +} -// /** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ -// export interface GetMessagesAround extends GetMessagesLimit { -// /** Get messages around this message id */ -// around?: BigString -// } +/** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ +export interface GetMessagesAround extends GetMessagesLimit { + /** Get messages around this message id */ + around?: BigString +} -// /** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ -// export interface GetMessagesBefore extends GetMessagesLimit { -// /** Get messages before this message id */ -// before?: BigString -// } +/** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ +export interface GetMessagesBefore extends GetMessagesLimit { + /** Get messages before this message id */ + before?: BigString +} -// /** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ -// export interface GetMessagesAfter extends GetMessagesLimit { -// /** Get messages after this message id */ -// after?: BigString -// } +/** https://discord.com/developers/docs/resources/channel#get-channel-messages-query-string-params */ +export interface GetMessagesAfter extends GetMessagesLimit { + /** Get messages after this message id */ + after?: BigString +} -// export type GetMessagesOptions = -// | GetMessagesAfter -// | GetMessagesBefore -// | GetMessagesAround -// | GetMessagesLimit +export type GetMessagesOptions = + | GetMessagesAfter + | GetMessagesBefore + | GetMessagesAround + | GetMessagesLimit // /** https://discord.com/developers/docs/resources/channel#get-reactions-query-string-params */ // export interface GetReactions { diff --git a/packages/utils/.c8rc.json b/packages/utils/.c8rc.json new file mode 100644 index 000000000..f135073e4 --- /dev/null +++ b/packages/utils/.c8rc.json @@ -0,0 +1,7 @@ +{ + "all": true, + "src": "src", + "reporter": ["text", "lcov"], + "include": ["src/**/*.ts"], + "exclude": ["tests"] +} diff --git a/packages/utils/.mocharc.json b/packages/utils/.mocharc.json new file mode 100644 index 000000000..4689d4a04 --- /dev/null +++ b/packages/utils/.mocharc.json @@ -0,0 +1,10 @@ +{ + "require": "ts-node/register", + "loader": "ts-node/esm", + "recursive": true, + "timeout": 2000, + "watch-extensions": "ts", + "watch-files": ["src", "tests"], + "enable-source-maps": true, + "parallel": false +} diff --git a/packages/utils/.swcrc b/packages/utils/.swcrc new file mode 100644 index 000000000..5ae43bb99 --- /dev/null +++ b/packages/utils/.swcrc @@ -0,0 +1,31 @@ +{ + "minify": true, + "jsc": { + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "target": "es2022", + "keepClassNames": true, + "loose": true, + "minify": { + "compress": { + "unused": true + }, + "mangle": true + } + }, + "module": { + "type": "es6", + "strict": false, + "strictMode": true, + "lazy": false, + "noInterop": false + }, + "sourceMaps": "inline" +} \ No newline at end of file diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 000000000..961c5a7de --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,158 @@ +# Discordeno + + + +Discord API library for [Deno](https://deno.land) + +Discordeno follows [semantic versioning](https://semver.org/) + +[![Discord](https://img.shields.io/discord/785384884197392384?color=7289da&logo=discord&logoColor=dark)](https://discord.com/invite/5vBgXk3UcZ) +![Test](https://github.com/discordeno/discordeno/workflows/Test/badge.svg) +[![Coverage](https://img.shields.io/codecov/c/gh/discordeno/discordeno)](https://codecov.io/gh/discordeno/discordeno) + +## Features + +Discordeno is actively maintained to guarantee **excellent performance and ease.** + +- **Simple, Efficient, and Lightweight**: Discordeno is lightweight, simple to use, and adaptable. By default, no + caching. +- **Functional API**: The functional API eliminates the challenges of extending built-in classes and inheritance while + ensuring overall simple but performant code. +- **Cross Runtime**: Supports the Node.js and Deno runtimes. +- **Standalone components**: Discordeno offers the option to have practically any component of a bot as a separate + piece, including standalone REST, gateways, custom caches, and more. +- **Plugins:** Designed to allow you to overwrite any portion of the code with your own code. Never go through the + hassle of maintaining your fork in order to acquire something that is specifically tailored to your requirements. + Plugins may be used for nearly anything; for instance, we have a few authorised plugins. + + - A caching plugin that makes anything cacheable. + - A plugin for sweepers that allows them to periodically clear the cache. + - The permission plugin internally verifies any missing permissions before sending a call to the Discord API to + prevent the client from receiving a Discord global ban. + +- **Flexibility:** You may easily delete an object's attributes if your bot doesn't require them. For instance, you + shouldn't be required to keep `Channel.topic` if your bot doesn't require it. You may save GBs of RAM in this way. A + few lines of code are all that are needed to accomplish this for any property on any object. + +### REST + +- Freedom from 1 hour downtimes due to invalid requests + - By lowering the maximum downtime to 10 minutes, Discordeno will prevent your bot from being down for an hour. +- Freedom from global rate limit errors + - As your bot grows, you want to handle global rate limits better. Shards don't communicate fast enough to truly + handle it properly so this allows 1 rest handler across the entire bot. + - You may really run numerous instances of your bot on different hosts, all of which will connect to the same REST + server. +- REST does not rest! + - Separate rest guarantees that your queued requests will continue to be processed even if your bot breaks for + whatever reason. + - Seamless updates! There's a chance you'll lose a lot of messages or replies that are waiting to be given when you + wish to update and restart the bot. You may restart your bot using this technique and never have to worry about + losing any answers. +- Single source of contact to Discord API + - As a result, you will be able to send requests to Discord from any location, even a bot dashboard. You are no longer + need to interact with your bot processes in order to submit a request or do anything else. Your bot process should + be freed up to handle bot events. +- Scalability! Scalability! Scalability! + +### Gateway + +- **Zero Downtime Updates:** + - A few seconds are needed to update your bot. When using conventional sharding, you must restart in addition to going + through a 1/5s rate-limited process of identifying all of your shards. As WS processing has been relocated to a + proxy process, you may resume the bot code right away without worrying about any delays. Normally, if you had a bot + that was spread across 200,000 servers, restarting it after making a simple modification would take 20 minutes. +- **Zero Downtime Resharding:** + - At various periods in time, Discord stops allowing your bot to be added to new servers. Consider 150 shards + operating on 150,000 servers, for instance. Your shards may support a maximum of 150 \* 2500 = 375,000 servers. Your + bot will be unable to join new servers once it reaches this point until it re-shards. + - DD proxy provides 2 types of re-sharding. Automated and manual. You can also have both. + - Automated: This system will automatically begin a Zero-downtime resharding process behind the scenes when you + reach 80% of your maximum servers allowed by your shards. For example, since 375,000 was the max, at 300,000 we + would begin re-sharding behind the scenes with ZERO DOWNTIME. + - 80% of maximum servers reached (The % of 80% is customizable.) + - Identify limits have room to allow re-sharding. (Also customizable) + - Manual: You can also trigger this manually should you choose. +- **Horizontal Scaling:** + - The bot may be scaled horizontally thanks to the proxy mechanism. When your business grows significantly, you have + two options: you can either keep investing money to upgrade your server or you may expand horizontally by purchasing + numerous more affordable servers. The proxy enables WS handling on a totally other system. +- **No Loss Restarts:** + - Without the proxy mechanism, you would typically lose numerous events while restarting a bot. Users could issue + instructions or send messages that are not screened. As your bot population increases, this amount grows sharply. + Users who don't receive the automatic roles or any other activities your bot should do may join. You may keep + restarting your bot thanks to the proxy technology without ever losing any events. While your bot is unavailable, + events will be added to a queue (the maximum size of the queue is configurable), and once the bot is back online, + the queue will start processing all of the events. +- **Controllers:** + - You have complete control over everything inside the proxy thanks to the controller aspect. To simply override the + handler, you may supply a function. For instance, you may simply give a method to override a specific function if + you want it to behave differently rather than forking and maintaining your fork. +- **Clustering With Workers:** + - Utilize all of your CPU cores to their greatest potential by distributing the workload across employees. To enhance + efficiency, manage how many employees and shards there are each worker! + +### Custom Cache + +Have your cache setup in any way you like. Redis, PGSQL or any cache layer you would like. + +## Getting Started + +### Minimal Example + +Here is a minimal example to get started with: + +```typescript +import { + createBot, + Intents, + startBot, +} from "https://deno.land/x/discordeno@13.0.0/mod.ts"; + +const bot = createBot({ + token: process.env.DISCORD_TOKEN, + intents: Intents.Guilds | Intents.GuildMessages, + events: { + ready() { + console.log("Successfully connected to gateway"); + }, + }, +}); + +// Another way to do events +bot.events.messageCreate = function (b, message) { + // Process the message here with your command handler. +}; + +await startBot(bot); +``` + +### Tools + +This library is not intended for beginners, however if you still want to utilise it, check out these excellent official +and unofficial templates: + +**Templates** + +- [Discordeno Template (official)](https://github.com/discordeno/discordeno/tree/main/template) +- [Serverless Slash Commands Template (official)](https://github.com/discordeno/serverless-deno-deploy-template) +- [`create-discordeno-bot` (WIP, unoffical)](https://github.com/Reboot-Codes/create-discordeno-bot/) +- [Add Your Own!](https://github.com/discordeno/discordeno/pulls) + +**Frameworks** + +- [Amethyst Framework](https://github.com/AmethystFramework/framework) +- [Add Your Own!](https://github.com/discordeno/discordeno/pulls) + +**Plugins** + +- [Cache Plugin](plugins/cache) +- [Fileloader Plugin](plugins/fileloader) +- [Helpers Plugin](plugins/helpers) +- [Permissions Plugin](plugins/permissions) + +## Links + +- [Website](https://discordeno.mod.land) +- [Documentation](https://doc.deno.land/https/deno.land/x/discordeno/mod.ts) +- [Discord](https://discord.com/invite/5vBgXk3UcZ) diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..acb718d07 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,46 @@ +{ + "name": "@discordeno/utils", + "version": "18.0.0-alpha.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/discordeno/discordeno.git" + }, + "scripts": { + "build": "swc --delete-dir-on-start src --out-dir dist", + "build:type": "tsc --skipDefaultLibCheck --declaration --emitDeclarationOnly --declarationDir dist", + "release-build": "yarn build && yarn build:type", + "fmt": "eslint --fix \"src/**/*.ts*\"", + "lint": "eslint \"src/**/*.ts*\"", + "test:unit-coverage": "c8 mocha --no-warnings 'tests/**/*.spec.ts'", + "test:unit": "c8 --r lcov mocha --no-warnings 'tests/**/*.spec.ts' && node ../../scripts/coveragePathFixing.js utils", + "test:deno-unit": "swc tests --delete-dir-on-start --out-dir denoTestsDist && node ../../scripts/fixDenoTestExtension.js && deno test -A --import-map ../../denoImportMap.json denoTestsDist", + "test:unit:watch": "mocha --no-warnings --watch --parallel 'tests/**/*.spec.ts'", + "test:type": "tsc --noEmit", + "test:test-type": "tsc --project tsconfig.test.json" + }, + "devDependencies": { + "@discordeno/types": "18.0.0-alpha.1", + "@swc/cli": "^0.1.57", + "@swc/core": "^1.3.21", + "@types/chai": "^4", + "@types/mocha": "^10", + "@types/node": "^18.11.15", + "@types/sinon": "^10.0.13", + "c8": "^7.12.0", + "chai": "^4.3.7", + "eslint": "^8.0.1", + "eslint-config-discordeno": "*", + "mocha": "^10.1.0", + "sinon": "^15.0.0", + "ts-node": "^10.9.1", + "tsconfig": "*", + "typescript": "^4.9.3" + }, + "dependencies": { + "tweetnacl": "^1.0.3" + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..84814912c --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1 @@ +export * from './utils.js' diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts new file mode 100644 index 000000000..ea29b9ee1 --- /dev/null +++ b/packages/utils/src/utils.ts @@ -0,0 +1,36 @@ +// import type { ImageFormat, ImageSize } from '@discordeno/types' + +/** Pause the execution for a given amount of milliseconds. */ +export async function delay (ms: number): Promise { + // eslint-disable-next-line @typescript-eslint/return-await + return new Promise( + (resolve): NodeJS.Timeout => + setTimeout((): void => { + resolve() + }, ms) + ) +} + +// /** Help format an image url. */ +// export function formatImageURL ( +// url: string, +// size: ImageSize = 128, +// format?: ImageFormat +// ): string { +// return `${url}.${ +// // eslint-disable-next-line @typescript-eslint/restrict-template-expressions +// format ?? (url.includes('/a_') ? 'gif' : 'jpg') +// // eslint-disable-next-line @typescript-eslint/restrict-template-expressions +// }?size=${size}` +// } + +// // Typescript is not so good as we developers so we need this little utility function to help it out +// // Taken from https://fettblog.eu/typescript-hasownproperty/ +// /** TS save way to check if a property exists in an object */ +// export function hasProperty ( +// obj: T, +// prop: Y +// ): obj is T & Record { +// // eslint-disable-next-line no-prototype-builtins +// return obj.hasOwnProperty(prop) +// } diff --git a/packages/utils/tests/base64.spec.ts b/packages/utils/tests/base64.spec.ts new file mode 100644 index 000000000..146612382 --- /dev/null +++ b/packages/utils/tests/base64.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { Buffer } from 'node:buffer' +import { decode, encode } from '../src/base64.js' + +describe('base64.ts', () => { + describe('encode', () => { + it('can encode string to base64', () => { + expect(encode('Man Ё𤭢')).to.be.equal('TWFuINCB8KStog==') + }) + it('can encode Uint8Array to base64', () => { + expect( + encode(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173, 162])) + ).to.be.equal('TWFuINCB8KStog==') + }) + it('can encode Buffer to base64', () => { + expect( + encode(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173, 162])) + ).to.be.equal('TWFuINCB8KStog==') + }) + }) + + describe('decode', () => { + it('can dencode string to Uint8Array', () => { + expect(new TextDecoder().decode(decode('TWFuINCB8KStog=='))).to.be.equal( + 'Man Ё𤭢' + ) + }) + }) +}) + +/** Old test */ +it('[utils] encode some bytes to base64', () => { + expect( + encode(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])) + ).to.be.deep.equal('AQIDBAUGBwgJCg==') +}) + +it('[utils] decode some base64 to bytes', () => { + expect(decode('AQIDBAUGBwgJCg==')).to.be.deep.equal( + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + ) +}) + +it('[utils] encode/decode base64 roundtrip should work', () => { + for (let i = 0; i < 10; i++) { + const bytes: number[] = [] + for (let i = 0; i < 10000; i++) { + bytes.push(Math.floor(Math.random() * 256)) + } + const data = new Uint8Array(bytes) + expect(decode(encode(data))).to.be.deep.equal(data) + } +}) diff --git a/packages/utils/tests/bigint.spec.ts b/packages/utils/tests/bigint.spec.ts new file mode 100644 index 000000000..f61c80af4 --- /dev/null +++ b/packages/utils/tests/bigint.spec.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai' +import { it } from 'mocha' +import { bigintToSnowflake, snowflakeToBigint } from '../src/bigint.js' + +it('[bigint] - Transform a snowflake string to bigint', () => { + const text = '130136895395987456' + const big = 130136895395987456n + const result = snowflakeToBigint(text) + + expect(big).to.be.equal(result) + expect(text).to.be.not.equal(result) +}) + +it('[bigint] - Transform a bigint to a string', () => { + const text = '130136895395987456' + const big = 130136895395987456n + const result = bigintToSnowflake(big) + + expect(text).to.be.equal(result) + expect(big).to.be.not.equal(result) +}) diff --git a/packages/utils/tests/collection.spec.ts b/packages/utils/tests/collection.spec.ts new file mode 100644 index 000000000..64a137821 --- /dev/null +++ b/packages/utils/tests/collection.spec.ts @@ -0,0 +1,146 @@ +import { expect } from 'chai' +import { beforeEach, describe, it } from 'mocha' +import sinon from 'sinon' +import { Collection } from '../src/collection.js' + +describe('[collection]', () => { + let collection: Collection + + beforeEach(() => { + collection = new Collection() + }) + + it('[collection] collection values to array', () => { + const testCollection = new Collection([ + ['best', 'tri'], + ['proficient', 'yui'] + ]) + expect(testCollection.array()).to.be.deep.equal(['tri', 'yui']) + }) + + it('[collection] get a random value', () => { + const testCollection = new Collection([['best', 'tri']]) + + expect(testCollection.random() ?? '').to.be.oneOf(['best', 'tri']) + expect(collection.random()).to.be.undefined + }) + + describe('', () => { + beforeEach(() => { + collection.set('best developer', 'triformine') + }) + it('[collection] Set a value without maxSize', () => { + expect(collection.size).to.be.equal(1) + expect(collection.get('best developer')).to.be.equal('triformine') + }) + describe('', () => { + beforeEach(() => { + collection.set('deno', 'yes') + }) + it('[collection] get the value of the first element', () => { + expect(collection.first()).to.be.equal('triformine') + }) + + it('[collection] get the value of the last element', () => { + expect(collection.last()).to.be.equal('yes') + }) + }) + }) + + describe('[collection] Create a collection with maxSize', () => { + const maxSize = 2 + + const maxCollection = new Collection([], { + maxSize + }) + + expect(maxCollection).to.exist + expect(maxCollection.maxSize).to.exist + expect(maxCollection.maxSize).to.be.equal(maxSize) + + describe('[collection] Test if maxSize works properly', () => { + maxCollection.set('foo', 'bar') + maxCollection.set('me', 'you') + + expect(maxCollection.size).to.be.equal(2) + + maxCollection.set('this', 'not') + + expect(maxCollection.size).to.be.equal(2) + + it('[collection] Test if forceSet ignore maxSize', () => { + maxCollection.forceSet('this', 'not') + + expect(maxCollection.size).to.be.equal(3) + }) + }) + }) + + const testCollection = new Collection([ + ['a', 1], + ['b', 2], + ['c', 3] + ]) + + it('[collection] find by key or value', () => { + expect(testCollection.find((v, k) => v === 2)).to.be.equal(2) + expect(testCollection.find((v, k) => k === 'b')).to.be.equal(2) + }) + + it('[collection] filter by key or value', () => { + expect(testCollection.filter((v, k) => v === 3).size).to.be.equal(1) + expect(testCollection.filter((v, k) => k === 'd').size).to.be.equal(0) + }) + + it('[collection] map', () => { + expect(testCollection.map((k, v) => `${v}${k}`)).to.be.deep.equal([ + 'a1', + 'b2', + 'c3' + ]) + }) + + it('[collection] some', () => { + expect(testCollection.some((v, _) => v === 1)).to.be.equal(true) + expect(testCollection.some((v, _) => v === 4)).to.be.equal(false) + }) + + it('[collection] every', () => { + expect(testCollection.every((v, _) => v !== 0)).to.be.equal(true) + expect(testCollection.every((v, _) => v === 1)).to.be.equal(false) + }) + + it('[collection] reduce', () => { + expect(testCollection.reduce((acc, val) => acc + val, 0)).to.be.equal(6) + }) + + it('[collection] start sweeper', async () => { + const clock = sinon.useFakeTimers() + const sweeperCollection = new Collection( + [ + ['a', 1], + ['b', 2] + ], + { + sweeper: { + filter: (v, _) => v === 1, + interval: 50 + } + } + ) + + try { + await clock.tickAsync(49) + expect(sweeperCollection.size).to.be.equal(2) + await clock.tickAsync(1) + expect(sweeperCollection.size).to.be.equal(1) + } catch (err) { + sweeperCollection.stopSweeper() + + throw err + } + + sweeperCollection.stopSweeper() + clock.restore() + }) +}) diff --git a/packages/utils/tests/hash.spec.ts b/packages/utils/tests/hash.spec.ts new file mode 100644 index 000000000..4962a41ef --- /dev/null +++ b/packages/utils/tests/hash.spec.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { expect } from 'chai' +import { it } from 'mocha' +import { iconBigintToHash, iconHashToBigInt } from '../src/hash.js' + +const iconHash = '4bbb271a13f7195031adcc06a2d867ce' +const iconBigInt = 3843769888406823508519992434416504301518n +const a_iconHash = 'a_4bbb271a13f7195031adcc06a2d867ce' +const a_iconBigInt = 3503487521485885045056617826984736090062n + +it('[utils] icon hash to bigint', () => { + expect(iconHashToBigInt(iconHash)).to.be.equal(iconBigInt) +}) + +it('[utils] icon bigint to hash', () => { + expect(iconBigintToHash(iconBigInt)).to.be.equal(iconHash) +}) + +it('[utils] icon hash to bigint a_ (animated)', () => { + expect(iconHashToBigInt(a_iconHash)).to.be.equal(a_iconBigInt) +}) + +it('[utils] icon bigint to hash a_ (animated)', () => { + expect(iconBigintToHash(a_iconBigInt)).to.be.equal(a_iconHash) +}) diff --git a/packages/utils/tests/token.spec.ts b/packages/utils/tests/token.spec.ts new file mode 100644 index 000000000..9786383e9 --- /dev/null +++ b/packages/utils/tests/token.spec.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai' +import { it } from 'mocha' +import { removeTokenPrefix } from '../src/token.js' + +it('[token] Remove token prefix when Bot is prefixed.', () => { + expect(removeTokenPrefix('Bot discordeno is best lib')).to.be.equal( + 'discordeno is best lib' + ) +}) + +it('[token] Remove token prefix when Bot is NOT prefixed.', () => { + expect(removeTokenPrefix('discordeno is best lib')).to.be.equal( + 'discordeno is best lib' + ) +}) diff --git a/packages/utils/tests/utils.spec.ts b/packages/utils/tests/utils.spec.ts new file mode 100644 index 000000000..a01ac5ae7 --- /dev/null +++ b/packages/utils/tests/utils.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import sinon from 'sinon' +import { delay, formatImageURL, hasProperty } from '../src/utils.js' + +describe('utils.ts', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + sinon.restore() + clock.restore() + }) + + it('will', async () => { + let delayEnded = false + delay(31).then(() => { + delayEnded = true + }) + expect(delayEnded).to.be.false + await clock.tickAsync(30) + expect(delayEnded).to.be.false + await clock.tickAsync(31) + expect(delayEnded).to.be.true + }) +}) + +const obj = { prop: 'lts372005' } + +it('[utils] hasProperty does HAVE property', () => { + expect(hasProperty(obj, 'prop')).to.be.equal(true) +}) + +it('[utils] hasProperty does NOT HAVE property', () => { + expect(hasProperty(obj, 'lts372005')).to.be.equal(false) +}) + +it('[utils] format image url', () => { + expect(formatImageURL('https://skillz.is.pro')).to.be.equal( + 'https://skillz.is.pro.jpg?size=128' + ) + expect(formatImageURL('https://skillz.is.pro', 1024)).to.be.equal( + 'https://skillz.is.pro.jpg?size=1024' + ) + expect(formatImageURL('https://skillz.is.pro', 1024, 'gif')).to.be.equal( + 'https://skillz.is.pro.gif?size=1024' + ) + expect(formatImageURL('https://skillz.is.pro', undefined, 'gif')).to.be.equal( + 'https://skillz.is.pro.gif?size=128' + ) +}) diff --git a/packages/utils/tests/validateLength.spec.ts b/packages/utils/tests/validateLength.spec.ts new file mode 100644 index 000000000..4137037db --- /dev/null +++ b/packages/utils/tests/validateLength.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { it } from 'mocha' +import { validateLength } from '../src/validateLength.js' + +it('[utils] Validate length is too low', () => { + expect(validateLength('test', { min: 5 })).to.be.equal(false) +}) + +it('[utils] Validate length is too high', () => { + expect(validateLength('test', { max: 3 })).to.be.equal(false) +}) + +it('[utils] Validate length is NOT just right in between.', () => { + expect(validateLength('test', { min: 5, max: 3 })).to.be.equal(false) +}) + +it('[utils] Validate length is NOT too low', () => { + expect(validateLength('test', { min: 3 })).to.be.equal(true) +}) + +it('[utils] Validate length is NOT too high', () => { + expect(validateLength('test', { max: 5 })).to.be.equal(true) +}) + +it('[utils] Validate length is just right in between.', () => { + expect(validateLength('test', { min: 3, max: 6 })).to.be.equal(true) +}) diff --git a/packages/utils/tests/verifySignature.spec.ts b/packages/utils/tests/verifySignature.spec.ts new file mode 100644 index 000000000..3dce99a51 --- /dev/null +++ b/packages/utils/tests/verifySignature.spec.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import { Buffer } from 'node:buffer' +import Sinon from 'sinon' +import nacl from 'tweetnacl' +import { verifySignature } from '../src/verifySignature.js' + +describe('VerifySignature', () => { + let publicKey: Uint8Array + let secretKey: Uint8Array + let clock: Sinon.SinonFakeTimers + + beforeEach(() => { + clock = Sinon.useFakeTimers(); + + ({ publicKey, secretKey } = nacl.sign.keyPair()) + }) + + afterEach(() => { + Sinon.restore() + clock.restore() + }) + + it('reutrn true if signature is verified', () => { + const timestamp = Date.now().toString() + const body = 'test body' + const signature = nacl.sign.detached( + Buffer.from(timestamp + body), + secretKey + ) + + const verifiedSignature = verifySignature({ + publicKey: Buffer.from(publicKey).toString('hex'), + signature: Buffer.from(signature).toString('hex'), + timestamp, + body + }) + + expect(verifiedSignature.body).equal(body) + expect(verifiedSignature.isValid).true + }) +}) diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..50e6ef6eb --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.tsx" + ], + "exclude": [ + "node_modules", + "dist", + "test", + "tests" + ] +} \ No newline at end of file diff --git a/packages/utils/tsconfig.test.json b/packages/utils/tsconfig.test.json new file mode 100644 index 000000000..eeb80c521 --- /dev/null +++ b/packages/utils/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/test.json", + "include": [ + "tests", + ], + "exclude": [ + "node_modules", + "dist", + "src" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0a063e53b..4468a9d4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,6 +27,7 @@ __metadata: dependencies: "@discordeno/transformer": 18.0.0-alpha.1 "@discordeno/types": 18.0.0-alpha.1 + "@discordeno/utils": 18.0.0-alpha.1 "@swc/cli": ^0.1.57 "@swc/core": ^1.3.21 "@types/chai": ^4 @@ -87,6 +88,30 @@ __metadata: languageName: unknown linkType: soft +"@discordeno/utils@18.0.0-alpha.1, @discordeno/utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "@discordeno/utils@workspace:packages/utils" + dependencies: + "@discordeno/types": 18.0.0-alpha.1 + "@swc/cli": ^0.1.57 + "@swc/core": ^1.3.21 + "@types/chai": ^4 + "@types/mocha": ^10 + "@types/node": ^18.11.15 + "@types/sinon": ^10.0.13 + c8: ^7.12.0 + chai: ^4.3.7 + eslint: ^8.0.1 + eslint-config-discordeno: "*" + mocha: ^10.1.0 + sinon: ^15.0.0 + ts-node: ^10.9.1 + tsconfig: "*" + tweetnacl: ^1.0.3 + typescript: ^4.9.3 + languageName: unknown + linkType: soft + "@eslint/eslintrc@npm:^1.4.0": version: 1.4.0 resolution: "@eslint/eslintrc@npm:1.4.0" @@ -497,7 +522,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^18.11.9": +"@types/node@npm:*, @types/node@npm:^18.11.15, @types/node@npm:^18.11.9": version: 18.11.18 resolution: "@types/node@npm:18.11.18" checksum: 03f17f9480f8d775c8a72da5ea7e9383db5f6d85aa5fefde90dd953a1449bd5e4ffde376f139da4f3744b4c83942166d2a7603969a6f8ea826edfb16e6e3b49d @@ -5194,6 +5219,13 @@ __metadata: languageName: node linkType: hard +"tweetnacl@npm:^1.0.3": + version: 1.0.3 + resolution: "tweetnacl@npm:1.0.3" + checksum: e4a57cac188f0c53f24c7a33279e223618a2bfb5fea426231991652a13247bea06b081fd745d71291fcae0f4428d29beba1b984b1f1ce6f66b06a6d1ab90645c + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0"