fix: handle 429 rate limit

This commit is contained in:
Skillz
2022-12-28 01:38:14 -06:00
parent d9bd4ca318
commit 76bf1d95fa
27 changed files with 1045 additions and 99 deletions

View File

@@ -25,6 +25,7 @@
},
"dependencies": {
"@discordeno/transformer": "18.0.0-alpha.1",
"@discordeno/utils": "18.0.0-alpha.1",
"dotenv": "^16.0.3"
},
"devDependencies": {

View File

@@ -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<DiscordUser>(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<DiscordMessage>(
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<void>
/** Make a request to be sent to the api. */
makeRequest: <T = unknown>(method: RequestMethods, url: string) => Promise<T>
makeRequest: <T = unknown>(method: RequestMethods, url: string, body?: Record<string, any>) => Promise<T>
/** Make a get request to the api */
get: <T = unknown>(url: string) => Promise<T>
/** Make a post request to the api. */
post: <T = unknown>(url: string, body?: Record<string, any>) => Promise<T>
/**
* Get a user's data from the api
*
* @param id The user's id
* @returns {DiscordUser}
* @returns {Camelize<DiscordUser>}
*/
getUser: (id: BigString) => Promise<Camelize<DiscordUser>>
/**
* Send a message to a channel.
*
* @returns {Message}
*/
sendMessage: (channelId: BigString, options: CreateMessageOptions) => Promise<Camelize<DiscordMessage>>
}
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<string, any>
/** 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<void>
/** Resolve handler when a request succeeds. */
resolve: (value: any | PromiseLike<any>) => void
/** Reject handler when a request fails. */

View File

@@ -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}` })
}))
})
})
})

View File

@@ -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')

View File

@@ -1 +1,2 @@
export * from './message.js'
export * from './user.js'

View File

@@ -0,0 +1,15 @@
import type { Camelize, DiscordMessage } from '@discordeno/types'
export function c1amelize1Message (payload: DiscordMessage): Camelize<DiscordMessage> {
return {
id: payload.id,
content: payload.content
}
}
export function s1nakelize1Message (payload: Camelize<DiscordMessage>): DiscordMessage {
return {
id: payload.id,
content: payload.content
}
}

View File

@@ -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
}
}

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -0,0 +1,7 @@
{
"all": true,
"src": "src",
"reporter": ["text", "lcov"],
"include": ["src/**/*.ts"],
"exclude": ["tests"]
}

View File

@@ -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
}

31
packages/utils/.swcrc Normal file
View File

@@ -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"
}

158
packages/utils/README.md Normal file
View File

@@ -0,0 +1,158 @@
# Discordeno
<img align="right" src="https://raw.githubusercontent.com/discordeno/discordeno/main/site/static/img/logo.png" height="150px">
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)

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
export * from './utils.js'

View File

@@ -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<void> {
// 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<T extends {}, Y extends PropertyKey = string> (
// obj: T,
// prop: Y
// ): obj is T & Record<Y, unknown> {
// // eslint-disable-next-line no-prototype-builtins
// return obj.hasOwnProperty(prop)
// }

View File

@@ -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)
}
})

View File

@@ -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)
})

View File

@@ -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<any, any>
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()
})
})

View File

@@ -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)
})

View File

@@ -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'
)
})

View File

@@ -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'
)
})

View File

@@ -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)
})

View File

@@ -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
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx"
],
"exclude": [
"node_modules",
"dist",
"test",
"tests"
]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "tsconfig/test.json",
"include": [
"tests",
],
"exclude": [
"node_modules",
"dist",
"src"
]
}

View File

@@ -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"