refactor: node goes brr 🐙 + turborepo support

This commit is contained in:
Yuzu 2022-07-29 17:20:47 -05:00
parent 0b5176dace
commit c30d10618e
110 changed files with 19172 additions and 348 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
indent_size = 4
quote_type = single
[Makefile]
indent_style = tab

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules/
build
dist
examples/**

175
.eslintrc.js Normal file
View File

@ -0,0 +1,175 @@
module.exports = {
root: true,
env: {
browser: true,
es2020: true,
node: true,
},
extends: [
'prettier',
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
ignorePatterns: [
'node_modules',
'dist',
'coverage',
'**/*.js',
'**/*.d.ts',
],
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
global: {
NodeJS: true,
},
rules: {
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'default', format: null },
{
selector: 'variable',
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
},
{ selector: 'typeLike', format: ['PascalCase'] },
],
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-interface': 'off',
'@typescript-eslint/adjacent-overload-signatures': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/no-array-constructor': 'error',
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-this-alias': 'error',
'@typescript-eslint/no-use-before-define': 'error',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unnecessary-type-arguments': 'off', // disabled as it started to be buggy
'@typescript-eslint/quotes': [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: true },
],
'@typescript-eslint/semi': ['error', 'always'],
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-redeclare': ['error'],
'@typescript-eslint/member-delimiter-style': [
'error',
{
multiline: { delimiter: 'semi', requireLast: true },
singleline: { delimiter: 'semi', requireLast: false },
},
],
'@typescript-eslint/space-before-function-paren': [
'error',
{
anonymous: 'always',
named: 'never',
asyncArrow: 'always',
},
],
'arrow-parens': ['error', 'as-needed'],
'no-var': 'error',
'prefer-const': 'error',
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'constructor-super': 'error',
'for-direction': 'error',
'getter-return': 'error',
'no-async-promise-executor': 'error',
'no-case-declarations': 'error',
'no-class-assign': 'error',
'no-compare-neg-zero': 'error',
'no-cond-assign': 'error',
'no-const-assign': 'error',
'no-constant-condition': 'error',
'no-control-regex': 'error',
'no-debugger': 'error',
'no-delete-var': 'error',
'no-dupe-args': 'error',
'no-dupe-keys': 'error',
'no-duplicate-case': 'error',
'no-empty': 'error',
'no-empty-character-class': 'error',
'no-empty-pattern': 'error',
'no-ex-assign': 'error',
'no-extra-boolean-cast': 'error',
'no-extra-semi': 'error',
'no-fallthrough': 'error',
'no-func-assign': 'error',
'no-global-assign': 'error',
'no-inner-declarations': 'error',
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-misleading-character-class': 'error',
'no-mixed-spaces-and-tabs': 'error',
'no-new-symbol': 'error',
'no-obj-calls': 'error',
'no-octal': 'error',
'no-prototype-builtins': 'error',
'no-redeclare': 'off',
'no-regex-spaces': 'error',
'no-self-assign': 'error',
'no-shadow-restricted-names': 'error',
'no-sparse-arrays': 'error',
'no-this-before-super': 'error',
'no-undef': 'error',
'no-unexpected-multiline': 'error',
'no-unreachable': 'error',
'no-unsafe-finally': 'error',
'no-unsafe-negation': 'error',
'no-unused-labels': 'error',
'no-useless-catch': 'error',
'no-useless-escape': 'error',
'no-with': 'error',
'require-yield': 'error',
'use-isnan': 'error',
'valid-typeof': 'error',
// 'comma-dangle': ['error', 'never'], // always-multiline
'dot-notation': 'error',
'eol-last': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-console': 'error',
'no-duplicate-imports': 'off',
'no-multiple-empty-lines': 'error',
'no-throw-literal': 'error',
'no-trailing-spaces': 'error',
'no-undef-init': 'error',
'object-shorthand': 'error',
'quote-props': ['error', 'consistent-as-needed'],
'spaced-comment': 'error',
yoda: 'error',
curly: 'error',
'object-curly-spacing': ['error', 'always'],
'lines-between-class-members': [
'error',
'always',
{ exceptAfterSingleLine: true },
],
'padded-blocks': ['error', { classes: 'never' }], // always
'no-else-return': 'error',
'block-spacing': ['error', 'always'],
'space-before-blocks': ['error', 'always'],
'brace-style': ['error', '1tbs', { allowSingleLine: true }],
'keyword-spacing': ['error', { before: true, after: true }],
'space-in-parens': ['error', 'never'],
},
settings: {},
};

View File

@ -1,23 +0,0 @@
---
name: Bug report
about: Bugs
title: ''
labels: Bug
assignees: ''
---
**Describe the bug** A clear and concise description of what the bug is.
**Biscuit Version** eg: 0.1.0-rc7
**To Reproduce** Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior** A clear and concise description of what you expected to happen.
**etc** Whatever

View File

@ -1,18 +0,0 @@
---
name: Feature request
about: Suggestions
title: ''
labels: Feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem
is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like** A clear and concise description of what you want to happen.
**Describe alternatives you've considered** A clear and concise description of any alternative solutions or features
you've considered.
**Limitations** A set of limitations of the API or the library by itself

60
.gitignore vendored
View File

@ -1,18 +1,50 @@
# editors
.vim/
.vscode/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# build
npm/
# etc
# Enviorment
.env
# Examples
node_modules/
package-lock.json
package.json
bun.lockb
# NPM
npm/
# Docs
docs.json
# DOCS
docs.json
packages/core/docs.json
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# node
out/
dist/
build
package-lock.json
# misc
.DS_Store
*.pem
*.vs
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.env
# turbo
.turbo
# tests
__tests__
__test__

4
.prettierrc.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
};

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"editor.tabSize": 4,
"editor.insertSpaces": false,
"editor.detectIndentation": true
}

0
CODE_OF_CONDUCT.md Normal file
View File

0
CONTRIBUTING.md Normal file
View File

201
LICENSE
View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,92 +0,0 @@
# biscuit
## A brand new bleeding edge non bloated Discord library
[![nest.land](https://nest.land/badge.svg)](https://nest.land/package/biscuit)
[![npm](https://img.shields.io/npm/v/@oasisjs/biscuit?color=red&label=package&logo=npm&style=flat)](https://www.npmjs.com/package/@oasisjs/biscuit)
[![downloads](https://img.shields.io/npm/dw/@oasisjs/biscuit?color=green&logo=npm&style=flat)](https://www.npmjs.com/package/@oasisjs/biscuit)
[![deno.land](https://img.shields.io/badge/deno-%5E1.23.3-informational?color=blue&logo=deno&style=flat)](https://deno.land/x/biscuit)
<img align="right" src="https://raw.githubusercontent.com/oasisjs/biscuit/main/assets/biscuit.svg" alt="biscuit"/>
### Install (for [node18](https://nodejs.org/en/download/))
```sh-session
npm install @oasisjs/biscuit
pnpm add @oasisjs/biscuit
yarn add @oasisjs/biscuit
```
get a quick bot: `deno run --allow-net https://crux.land/2CENgN [token]`
The biscuit Discord library is built ontop of Discordeno and webspec APIs, we aim to provide portability. Join our
[Discord](https://discord.gg/zmuvzzEFz2)
### Most importantly, biscuit is:
- A modular [Discordeno](https://github.com/discordeno/discordeno) fork
- A framework to build Discord bots
- A bleeding edge API to contact Discord
Biscuit is primarily inspired by Discord.js and Discordeno but it does not include a cache layer by default, we believe
that you should not make software that does things it is not supposed to do.
### Why biscuit?:
- [Minimal](https://en.wikipedia.org/wiki/Unix_philosophy), non feature-rich!
- Crossplatform
- Consistent
- Performant
- Small bundles
### Example bot (TS/JS)
```js
import Biscuit, { GatewayIntents } from '@oasisjs/biscuit';
const intents = GatewayIntents.MessageContent | GatewayIntents.Guilds | GatewayIntents.GuildMessages;
const session = new Biscuit({ token: 'your token', intents });
session.on('ready', ({ user }) => {
console.log('Logged in as:', user.username);
});
session.on('messageCreate', (message) => {
if (message.content.startsWith('!ping')) {
message.reply({ content: 'pong!' });
}
});
session.start();
```
### Minimal style guide
- 4 spaces, no tabs
- Semi-colons are mandatory
- Run `deno fmt`
- Avoid circular dependencies
### Contrib guide
- Install Deno extension [here](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno)
- Run `deno check` to make sure the library works
- Avoid sharing state between classes
### Compatibility (bun)
**⚠️ DISCLAIMER:** since bun is unstable I highly recommend running biscuit on node!
- We got the library running on EndeavourOS but it spams the ready event multiple times
- We got the library running on Arch/Artix Linux but breaks when sending fetch requests
- We got the library running on WSL (Ubuntu) without any trouble
> if you really want to use the library with bun remember to clone the repo instead of installing it via the registry
### Known issues:
- some properties may be not implemented yet
- some structures are not implemented (see https://github.com/oasisjs/biscuit/issues)
- cache (wip)
- no optimal way to create embeds, should be fixed in builders tho
- no optimal way to deliver a webspec bun version to the registry (#50)

0
SECURITY.md Normal file
View File

0
examples/.env.example Normal file
View File

6
examples/package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "examples",
"dependencies": {
"dotenv": "^16.0.1"
}
}

69
examples/src/index.ts Normal file
View File

@ -0,0 +1,69 @@
import './utils/experimental.util';
import 'dotenv/config';
import { colors } from './utils/colors.util';
import { DefaultRestAdapter } from '@biscuitland/rest';
import { DefaultWsAdapter } from '@biscuitland/ws';
import { GatewayIntents } from '@biscuitland/api-types';
import { Biscuit } from '@biscuitland/core';
import { ReadyEvent } from './operations';
import { MeRest } from './operations';
import { AgentWs } from './operations';
const argv = process.argv.slice(2);
const boostrap = async () => {
const biscuit = new Biscuit({
intents: GatewayIntents.Guilds,
token: process.env.AUTH!,
});
await biscuit.start();
await operations(biscuit);
};
const operations = async (biscuit: Biscuit) => {
switch (argv[0]) {
case '--events':
console.log(colors.cyan('Starting examples of events'));
switch (argv[1]) {
case 'ready':
new ReadyEvent(biscuit.events);
break;
}
break;
case '--rest':
console.log(colors.cyan('Starting examples of rest'));
switch (argv[1]) {
case 'me':
new MeRest(biscuit.rest as DefaultRestAdapter);
break;
}
break;
case '--ws':
console.log(colors.cyan('Starting examples of ws'));
switch (argv[1]) {
case 'agent':
new AgentWs(biscuit.ws as DefaultWsAdapter);
break;
}
break;
}
};
boostrap();

View File

@ -0,0 +1,17 @@
import { EventAdapter } from '@biscuitland/core';
export class ReadyEvent {
events: EventAdapter;
constructor(events: EventAdapter) {
this.events = events;
if (events) {
this.execute();
}
}
async execute() {
this.events.on('ready', () => console.log('[1/1] successful'));
}
}

View File

@ -0,0 +1,8 @@
/** events */
export { ReadyEvent } from './events/ready.ws';
/** rest */
export { MeRest } from './rest/me.rest';
/** ws */
export { AgentWs } from './ws/agent.ws';

View File

@ -0,0 +1,22 @@
import { DiscordUser } from '@biscuitland/api-types';
import { DefaultRestAdapter } from '@biscuitland/rest';
export class MeRest {
rest: DefaultRestAdapter;
constructor(rest: DefaultRestAdapter) {
this.rest = rest;
if (rest) {
this.execute();
}
}
async execute() {
const { username } = await this.rest.get<DiscordUser>('/users/@me');
if (username) {
console.log('[1/1] successful [%s]', username);
}
}
}

View File

@ -0,0 +1,23 @@
import { DefaultWsAdapter } from '@biscuitland/ws';
export class AgentWs {
ws: DefaultWsAdapter;
constructor(ws: DefaultWsAdapter) {
this.ws = ws;
if (ws) {
this.execute();
}
}
async execute() {
const shard = this.ws.agent.shards.get(0);
if (shard && shard.socket) {
shard.socket.onmessage = (_message: any) => {
// operations
};
}
}
}

View File

@ -0,0 +1,7 @@
const wrap = (fn: (text: string) => string) => (text: string) => fn(text);
export const colors = {
yellow: wrap((text: string) => `\x1b[33m${text}\x1B[39m`),
white: wrap((text: string) => `\x1b[37m${text}\x1B[39m`),
cyan: wrap((text: string) => `\x1b[36m${text}\x1B[39m`),
};

View File

@ -0,0 +1,15 @@
const originalEmit = process.emit;
// @ts-ignore
process.emit = function (name, data: any, ..._args: any[]) {
if (
name === `warning` &&
typeof data === `object` &&
data.name === `ExperimentalWarning`
) {
return false;
}
// @ts-ignore
return originalEmit.apply(process, arguments);
};

31
examples/tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"lib": ["es2020"],
"moduleResolution": "node",
"declaration": true,
"sourceMap": false,
"strict": true,
"suppressImplicitAnyIndexErrors": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"preserveConstEnums": true,
"outDir": "dist",
/* Type Checking */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"exclude": ["**/node_modules", "**/dist"],
"include": ["src/**/*"]
}

3906
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "biscuit",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "turbo run build",
"clean": "turbo run clean",
"lint": "turbo run lint",
"dev": "turbo run dev --parallel"
},
"engines": {
"npm": ">=7.0.0",
"node": ">=14.0.0"
},
"devDependencies": {
"@types/node": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^5.30.7",
"eslint": "^8.20.0",
"eslint-config-prettier": "^8.5.0",
"ts-node": "^10.9.1",
"turbo": "^1.3.4",
"typescript": "^4.7.4"
},
"packageManager": "npm@8.14.0"
}

View File

@ -0,0 +1,18 @@
{
"name": "@biscuitland/api-types",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist && rm -rf .turbo",
"dev": "tsup --watch"
},
"devDependencies": {
"tsup": "^6.1.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
export * as Constants from './utils/constants';
export * from './utils/routes';
export * from './v10/index';
export * from './common';

View File

@ -0,0 +1,29 @@
import type { Snowflake } from '../common';
import { baseEndpoints as Endpoints } from './constants';
export function USER_AVATAR(userId: Snowflake, icon: string): string {
return `${Endpoints.CDN_URL}/avatars/${userId}/${icon}`;
}
export function EMOJI_URL(id: Snowflake, animated = false): string {
return `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
}
export function USER_DEFAULT_AVATAR(
/** user discriminator */
altIcon: number,
): string {
return `${Endpoints.CDN_URL}/embed/avatars/${altIcon}.png`;
}
export function GUILD_BANNER(guildId: Snowflake, icon: string): string {
return `${Endpoints.CDN_URL}/banners/${guildId}/${icon}`;
}
export function GUILD_SPLASH(guildId: Snowflake, icon: string): string {
return `${Endpoints.CDN_URL}/splashes/${guildId}/${icon}`;
}
export function GUILD_ICON(guildId: Snowflake, icon: string): string {
return `${Endpoints.CDN_URL}/icons/${guildId}/${icon}`;
}

View File

@ -0,0 +1,26 @@
/** https://discord.com/developers/docs/reference#api-reference-base-url */
export const BASE_URL = 'https://discord.com/api';
/** https://discord.com/developers/docs/reference#api-versioning-api-versions */
export const API_VERSION = 10;
/** https://github.com/discordeno/discordeno/releases */
export const BISCUIT_VERSION = '0.2.2';
/** https://discord.com/developers/docs/reference#user-agent */
export const USER_AGENT = `DiscordBot (https://github.com/oasisjs/biscuit, v${BISCUIT_VERSION})`;
/** https://discord.com/developers/docs/reference#image-formatting-image-base-url */
export const IMAGE_BASE_URL = 'https://cdn.discordapp.com';
// This can be modified by big brain bots and use a proxy
export const baseEndpoints = {
BASE_URL: `${BASE_URL}/v${API_VERSION}`,
CDN_URL: IMAGE_BASE_URL,
};
export const SLASH_COMMANDS_NAME_REGEX =
/^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
export const CONTEXT_MENU_COMMANDS_NAME_REGEX = /^[\w-\s]{1,32}$/;
export const CHANNEL_MENTION_REGEX = /<#[0-9]+>/g;
export const DISCORD_SNOWFLAKE_REGEX = /^(?<id>\d{17,19})$/;

View File

@ -0,0 +1,471 @@
import type { Snowflake } from '../common';
export * from './cdn';
export function USER(userId?: Snowflake): string {
if (!userId) { return '/users/@me'; }
return `/users/${userId}`;
}
export function GATEWAY_BOT(): string {
return '/gateway/bot';
}
export interface GetMessagesOptions {
limit?: number;
}
export interface GetMessagesOptions {
around?: Snowflake;
limit?: number;
}
export interface GetMessagesOptions {
before?: Snowflake;
limit?: number;
}
export interface GetMessagesOptions {
after?: Snowflake;
limit?: number;
}
export function CHANNEL(channelId: Snowflake): string {
return `/channels/${channelId}`;
}
export function CHANNEL_INVITES(channelId: Snowflake): string {
return `/channels/${channelId}/invites`;
}
export function CHANNEL_TYPING(channelId: Snowflake): string {
return `/channels/${channelId}/typing`;
}
export function CHANNEL_CREATE_THREAD(channelId: Snowflake): string {
return `/channels/${channelId}/threads`;
}
export function MESSAGE_CREATE_THREAD(channelId: Snowflake, messageId: Snowflake): string {
return `/channels/${channelId}/messages/${messageId}/threads`;
}
/** used to send messages */
export function CHANNEL_MESSAGES(channelId: Snowflake, options?: GetMessagesOptions): string {
let url = `/channels/${channelId}/messages?`;
if (options) {
if (options.after) { url += `after=${options.after}`; }
if (options.before) { url += `&before=${options.before}`; }
if (options.around) { url += `&around=${options.around}`; }
if (options.limit) { url += `&limit=${options.limit}`; }
}
return url;
}
/** used to edit messages */
export function CHANNEL_MESSAGE(channelId: Snowflake, messageId: Snowflake): string {
return `/channels/${channelId}/messages/${messageId}`;
}
/** used to kick members */
export function GUILD_MEMBER(guildId: Snowflake, userId: Snowflake): string {
return `/guilds/${guildId}/members/${userId}`;
}
/** used to ban members */
export function GUILD_BAN(guildId: Snowflake, userId: Snowflake): string {
return `/guilds/${guildId}/bans/${userId}`;
}
export interface GetBans {
limit?: number;
before?: Snowflake;
after?: Snowflake;
}
/** used to unban members */
export function GUILD_BANS(guildId: Snowflake, options?: GetBans): string {
let url = `/guilds/${guildId}/bans?`;
if (options) {
if (options.limit) { url += `limit=${options.limit}`; }
if (options.after) { url += `&after=${options.after}`; }
if (options.before) { url += `&before=${options.before}`; }
}
return url;
}
export function GUILD_ROLE(guildId: Snowflake, roleId: Snowflake): string {
return `/guilds/${guildId}/roles/${roleId}`;
}
export function GUILD_ROLES(guildId: Snowflake): string {
return `/guilds/${guildId}/roles`;
}
export function USER_GUILDS(guildId?: Snowflake): string {
if (guildId) { return `/users/@me/guilds/${guildId}`; }
return `/users/@me/guilds/`;
}
export function USER_DM() {
return `/users/@me/channels`;
}
export function GUILD_EMOJIS(guildId: Snowflake): string {
return `/guilds/${guildId}/emojis`;
}
export function GUILD_EMOJI(guildId: Snowflake, emojiId: Snowflake): string {
return `/guilds/${guildId}/emojis/${emojiId}`;
}
export interface GetInvite {
withCounts?: boolean;
withExpiration?: boolean;
scheduledEventId?: Snowflake;
}
export function GUILDS(guildId?: Snowflake): string {
if (guildId) { return `/guilds/${guildId}`; }
return `/guilds`;
}
export function AUTO_MODERATION_RULES(guildId: Snowflake, ruleId?: Snowflake): string {
if (ruleId) {
return `/guilds/${guildId}/auto-moderation/rules/${ruleId}`;
}
return `/guilds/${guildId}/auto-moderation/rules`;
}
export function INVITE(inviteCode: string, options?: GetInvite): string {
let url = `/invites/${inviteCode}?`;
if (options) {
if (options.withCounts) { url += `with_counts=${options.withCounts}`; }
if (options.withExpiration) { url += `&with_expiration=${options.withExpiration}`; }
if (options.scheduledEventId) { url += `&guild_scheduled_event_id=${options.scheduledEventId}`; }
}
return url;
}
export function GUILD_INVITES(guildId: Snowflake): string {
return `/guilds/${guildId}/invites`;
}
export function INTERACTION_ID_TOKEN(interactionId: Snowflake, token: string): string {
return `/interactions/${interactionId}/${token}/callback`;
}
export function WEBHOOK_MESSAGE_ORIGINAL(webhookId: Snowflake, token: string, options?: { threadId?: bigint }): string {
let url = `/webhooks/${webhookId}/${token}/messages/@original?`;
if (options) {
if (options.threadId) { url += `thread_id=${options.threadId}`; }
}
return url;
}
export function WEBHOOK_MESSAGE(
webhookId: Snowflake,
token: string,
messageId: Snowflake,
options?: { threadId?: Snowflake },
): string {
let url = `/webhooks/${webhookId}/${token}/messages/${messageId}?`;
if (options) {
if (options.threadId) { url += `thread_id=${options.threadId}`; }
}
return url;
}
export function WEBHOOK_TOKEN(webhookId: Snowflake, token?: string): string {
if (!token) { return `/webhooks/${webhookId}`; }
return `/webhooks/${webhookId}/${token}`;
}
export interface WebhookOptions {
wait?: boolean;
threadId?: Snowflake;
}
export function WEBHOOK(webhookId: Snowflake, token: string, options?: WebhookOptions): string {
let url = `/webhooks/${webhookId}/${token}`;
if (options?.wait) { url += `?wait=${options.wait}`; }
if (options?.threadId) { url += `?thread_id=${options.threadId}`; }
if (options?.wait && options.threadId) { url += `?wait=${options.wait}&thread_id=${options.threadId}`; }
return url;
}
export function USER_NICK(guildId: Snowflake): string {
return `/guilds/${guildId}/members/@me`;
}
/**
* @link https://discord.com/developers/docs/resources/guild#get-guild-prune-count
*/
export interface GetGuildPruneCountQuery {
days?: number;
includeRoles?: Snowflake | Snowflake[];
}
export function GUILD_PRUNE(guildId: Snowflake, options?: GetGuildPruneCountQuery): string {
let url = `/guilds/${guildId}/prune?`;
if (options?.days) { url += `days=${options.days}`; }
if (options?.includeRoles) { url += `&include_roles=${options.includeRoles}`; }
return url;
}
export function CHANNEL_PIN(channelId: Snowflake, messageId: Snowflake): string {
return `/channels/${channelId}/pins/${messageId}`;
}
export function CHANNEL_PINS(channelId: Snowflake): string {
return `/channels/${channelId}/pins`;
}
export function CHANNEL_MESSAGE_REACTION_ME(channelId: Snowflake, messageId: Snowflake, emoji: string): string {
return `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`;
}
export function CHANNEL_MESSAGE_REACTION_USER(
channelId: Snowflake,
messageId: Snowflake,
emoji: string,
userId: Snowflake,
) {
return `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/${userId}`;
}
export function CHANNEL_MESSAGE_REACTIONS(channelId: Snowflake, messageId: Snowflake) {
return `/channels/${channelId}/messages/${messageId}/reactions`;
}
/**
* @link https://discord.com/developers/docs/resources/channel#get-reactions-query-string-params
*/
export interface GetReactions {
after?: string;
limit?: number;
}
export function CHANNEL_MESSAGE_REACTION(
channelId: Snowflake,
messageId: Snowflake,
emoji: string,
options?: GetReactions,
): string {
let url = `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}?`;
if (options?.after) { url += `after=${options.after}`; }
if (options?.limit) { url += `&limit=${options.limit}`; }
return url;
}
export function CHANNEL_MESSAGE_CROSSPOST(channelId: Snowflake, messageId: Snowflake): string {
return `/channels/${channelId}/messages/${messageId}/crosspost`;
}
export function GUILD_MEMBER_ROLE(guildId: Snowflake, memberId: Snowflake, roleId: Snowflake): string {
return `/guilds/${guildId}/members/${memberId}/roles/${roleId}`;
}
export function CHANNEL_WEBHOOKS(channelId: Snowflake): string {
return `/channels/${channelId}/webhooks`;
}
export function THREAD_START_PUBLIC(channelId: Snowflake, messageId: Snowflake): string {
return `/channels/${channelId}/messages/${messageId}/threads`;
}
export function THREAD_START_PRIVATE(channelId: Snowflake): string {
return `/channels/${channelId}/threads`;
}
export function THREAD_ACTIVE(guildId: Snowflake): string {
return `/guilds/${guildId}/threads/active`;
}
export interface ListArchivedThreads {
before?: number;
limit?: number;
}
export function THREAD_ME(channelId: Snowflake): string {
return `/channels/${channelId}/thread-members/@me`;
}
export function THREAD_MEMBERS(channelId: Snowflake): string {
return `/channels/${channelId}/thread-members`;
}
export function THREAD_USER(channelId: Snowflake, userId: Snowflake): string {
return `/channels/${channelId}/thread-members/${userId}`;
}
export function THREAD_ARCHIVED(channelId: Snowflake): string {
return `/channels/${channelId}/threads/archived`;
}
export function THREAD_ARCHIVED_PUBLIC(channelId: Snowflake, options?: ListArchivedThreads): string {
let url = `/channels/${channelId}/threads/archived/public?`;
if (options) {
if (options.before) { url += `before=${new Date(options.before).toISOString()}`; }
if (options.limit) { url += `&limit=${options.limit}`; }
}
return url;
}
export function THREAD_ARCHIVED_PRIVATE(channelId: Snowflake, options?: ListArchivedThreads): string {
let url = `/channels/${channelId}/threads/archived/private?`;
if (options) {
if (options.before) { url += `before=${new Date(options.before).toISOString()}`; }
if (options.limit) { url += `&limit=${options.limit}`; }
}
return url;
}
export function THREAD_ARCHIVED_PRIVATE_JOINED(channelId: Snowflake, options?: ListArchivedThreads): string {
let url = `/channels/${channelId}/users/@me/threads/archived/private?`;
if (options) {
if (options.before) { url += `before=${new Date(options.before).toISOString()}`; }
if (options.limit) { url += `&limit=${options.limit}`; }
}
return url;
}
export function FORUM_START(channelId: Snowflake): string {
return `/channels/${channelId}/threads?has_message=true`;
}
export function STAGE_INSTANCES(): string {
return `/stage-instances`;
}
export function STAGE_INSTANCE(channelId: Snowflake): string {
return `/stage-instances/${channelId}`;
}
export function APPLICATION_COMMANDS(appId: Snowflake, commandId?: Snowflake): string {
if (commandId) { return `/applications/${appId}/commands/${commandId}`; }
return `/applications/${appId}/commands`;
}
export function GUILD_APPLICATION_COMMANDS(appId: Snowflake, guildId: Snowflake, commandId?: Snowflake): string {
if (commandId) { return `/applications/${appId}/guilds/${guildId}/commands/${commandId}`; }
return `/applications/${appId}/guilds/${guildId}/commands`;
}
export function GUILD_APPLICATION_COMMANDS_PERMISSIONS(
appId: Snowflake,
guildId: Snowflake,
commandId?: Snowflake,
): string {
if (commandId) { return `/applications/${appId}/guilds/${guildId}/commands/${commandId}/permissions`; }
return `/applications/${appId}/guilds/${guildId}/commands/permissions`;
}
export function APPLICATION_COMMANDS_LOCALIZATIONS(
appId: Snowflake,
commandId: Snowflake,
withLocalizations?: boolean,
): string {
let url = `/applications/${appId}/commands/${commandId}?`;
if (withLocalizations !== undefined) {
url += `withLocalizations=${withLocalizations}`;
}
return url;
}
export function GUILD_APPLICATION_COMMANDS_LOCALIZATIONS(
appId: Snowflake,
guildId: Snowflake,
commandId: Snowflake,
withLocalizations?: boolean,
): string {
let url = `/applications/${appId}/guilds/${guildId}/commands/${commandId}?`;
if (withLocalizations !== undefined) {
url += `with_localizations=${withLocalizations}`;
}
return url;
}
export function STICKER(id: Snowflake): string {
return `stickers/${id}`;
}
export function STICKER_PACKS(): string {
return `stickers-packs`;
}
export function GUILD_STICKERS(guildId: Snowflake, stickerId?: Snowflake): string {
if (stickerId) { return `/guilds/${guildId}/stickers/${stickerId}`; }
return `/guilds/${guildId}/stickers`;
}
/**
* Return the widget for the guild.
* @link https://discord.com/developers/docs/resources/guild#get-guild-widget-settings
*/
export interface GetWidget {
get: 'json' | 'image' | 'settings';
}
/**
* /guilds/{guildId}/widget
* @link https://discord.com/developers/docs/resources/guild#get-guild-widget-settings
*/
export function GUILD_WIDGET(guildId: Snowflake, options: GetWidget = { get: 'settings' }): string {
let url = `/guilds/${guildId}/widget`;
if (options.get === 'json') {
url += '.json';
} else if (options.get === 'image') {
url += '.png';
}
return url;
}
/** @link https://discord.com/developers/docs/resources/guild#get-guild-voice-regions */
export function GUILD_VOICE_REGIONS(guildId: Snowflake): string {
return `/guilds/${guildId}/regions`;
}
/**
* @link https://discord.com/developers/docs/resources/guild#get-guild-vanity-url
* @param guildId The guild
* @returns Get vanity URL
*/
export function GUILD_VANITY(guildId: Snowflake): string {
return `/guilds/${guildId}/vanity-url`;
}
/**
* @link https://discord.com/developers/docs/resources/guild#get-guild-preview
* @param guildId The guild
* @returns Get guild preview url
*/
export function GUILD_PREVIEW(guildId: Snowflake): string {
return `/guilds/${guildId}/preview`;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
const isProduction = process.env.NODE_ENV === 'production';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
minify: isProduction,
sourcemap: true,
});

0
packages/cache/README.md vendored Normal file
View File

22
packages/cache/package.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"name": "@biscuitland/cache",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist && rm -rf .turbo",
"dev": "tsup --watch"
},
"dependencies": {
"@biscuitland/api-types": "^1.0.0",
"ioredis": "^5.2.2"
},
"devDependencies": {
"tsup": "^6.1.3"
}
}

View File

@ -0,0 +1,31 @@
export interface CacheAdapter {
/**
* @inheritDoc
*/
get<T = unknown>(name: string): Promise<T | unknown>;
/**
* @inheritDoc
*/
set(name: string, data: unknown): Promise<void>;
/**
* @inheritDoc
*/
remove(name: string): Promise<void>;
/**
* @inheritDoc
*/
clear(): Promise<void>;
/**
* @inheritDoc
*/
close?(): Promise<void>;
}

View File

@ -0,0 +1,51 @@
import { CacheAdapter } from './cache-adapter';
export class MemoryCacheAdapter implements CacheAdapter {
/**
* @inheritDoc
*/
private readonly data = new Map<string, any>();
/**
* @inheritDoc
*/
async get<T = unknown>(name: string): Promise<T | null> {
const data = this.data.get(name);
if (!data) {
return null;
}
return JSON.parse(data);
}
/**
* @inheritDoc
*/
async set(name: string, data: unknown): Promise<void> {
const stringData = JSON.stringify(data, (_, v) =>
typeof v === 'bigint' ? v.toString() : v
);
this.data.set(name, stringData);
}
/**
* @inheritDoc
*/
async remove(name: string): Promise<void> {
this.data.delete(name);
}
/**
* @inheritDoc
*/
async clear(): Promise<void> {
this.data.clear();
}
}

View File

@ -0,0 +1,95 @@
import type { Redis, RedisOptions } from 'ioredis';
import { CacheAdapter } from './cache-adapter';
import IORedis from 'ioredis';
export interface BaseOptions {
prefix?: string;
}
export interface BuildOptions extends BaseOptions, RedisOptions {}
export interface ClientOptions extends BaseOptions {
client: Redis;
}
export type Options = BuildOptions | ClientOptions;
export class RedisCacheAdapter implements CacheAdapter {
static readonly DEFAULTS = {
prefix: 'biscuitland',
};
private readonly client: Redis;
options: Options;
constructor(options?: Options) {
this.options = Object.assign(RedisCacheAdapter.DEFAULTS, options);
if ((this.options as ClientOptions).client) {
this.client = (this.options as ClientOptions).client;
} else {
const { ...redisOpt } = this.options as BuildOptions;
this.client = new IORedis(redisOpt);
}
}
_getPrefix(name: string) {
return `${this.options.prefix}:${name}`;
}
/**
* @inheritDoc
*/
async get<T = unknown>(name: string): Promise<T | null> {
const completKey = this._getPrefix(name);
const data = await this.client.get(completKey);
if (!data) {
return null;
}
return JSON.parse(data);
}
/**
* @inheritDoc
*/
async set(name: string, data: unknown): Promise<void> {
const stringData = JSON.stringify(data, (_, v) =>
typeof v === 'bigint' ? v.toString() : v
);
const completeKey = this._getPrefix(name);
await this.client.set(completeKey, stringData);
}
/**
* @inheritDoc
*/
async remove(name: string): Promise<void> {
const completKey = this._getPrefix(name);
await this.client.del(completKey);
}
/**
* @inheritDoc
*/
async clear(): Promise<void> {
this.client.disconnect();
}
/**
* @inheritDoc
*/
async close(): Promise<void> {
this.client.disconnect();
}
}

4
packages/cache/src/index.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export { CacheAdapter } from './adapters/cache-adapter';
export { MemoryCacheAdapter } from './adapters/memory-cache-adapter';
export { RedisCacheAdapter } from './adapters/redis-cache-adapter';

7
packages/cache/tsconfig.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}

12
packages/cache/tsup.config.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
const isProduction = process.env.NODE_ENV === 'production';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
minify: isProduction,
sourcemap: true,
});

0
packages/core/README.md Normal file
View File

View File

@ -0,0 +1,23 @@
{
"name": "@biscuitland/core",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist && rm -rf .turbo",
"dev": "tsup --watch"
},
"dependencies": {
"@biscuitland/api-types": "^1.0.0",
"@biscuitland/rest": "^1.0.0",
"@biscuitland/ws": "^1.0.0"
},
"devDependencies": {
"tsup": "^6.1.3"
}
}

View File

@ -0,0 +1,25 @@
import type { EventAdapter } from './event-adapter';
import type { Events } from './events';
import EventEmitter from 'node:events';
export class DefaultEventAdapter extends EventEmitter implements EventAdapter {
override on<K extends keyof Events>(event: K, func: Events[K]): this;
override on<K extends string>(event: K, func: (...args: unknown[]) => unknown): this {
return super.on(event, func);
}
override off<K extends keyof Events>(event: K, func: Events[K]): this;
override off<K extends keyof Events>(event: K, func: (...args: unknown[]) => unknown): this {
return super.off(event, func);
}
override once<K extends keyof Events>(event: K, func: Events[K]): this;
override once<K extends string>(event: K, func: (...args: unknown[]) => unknown): this {
return super.once(event, func);
}
override emit<K extends keyof Events>(event: K, ...params: Parameters<Events[K]>): boolean;
override emit<K extends string>(event: K, ...params: unknown[]): boolean {
return super.emit(event, ...params);
}
}

View File

@ -0,0 +1,25 @@
import type { Events } from './events';
export interface EventAdapter extends Omit<NodeJS.EventEmitter, "emit" | "on" | "off" | "once"> {
options?: any;
emit<K extends keyof Events>(
event: K,
...params: Parameters<Events[K]>
): boolean;
on<K extends keyof Events>(
event: K,
func: Events[K]
): unknown;
off<K extends keyof Events>(
event: K,
func: Events[K]
): unknown;
once<K extends keyof Events>(
event: K,
func: Events[K]
): unknown;
}

View File

@ -0,0 +1,784 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import type {
DiscordAutoModerationActionExecution,
DiscordAutoModerationRule,
DiscordChannel,
DiscordChannelPinsUpdate,
DiscordEmoji,
DiscordGuild,
DiscordGuildBanAddRemove,
DiscordGuildEmojisUpdate,
DiscordGuildMemberAdd,
DiscordGuildMemberRemove,
DiscordGuildMemberUpdate,
DiscordGuildRoleCreate,
DiscordGuildRoleDelete,
DiscordGuildRoleUpdate,
DiscordIntegration,
DiscordIntegrationDelete,
DiscordInteraction,
DiscordInviteCreate,
DiscordInviteDelete,
DiscordMemberWithUser,
DiscordMessage,
DiscordMessageDelete,
DiscordMessageReactionAdd,
DiscordMessageReactionRemove,
DiscordMessageReactionRemoveAll,
DiscordMessageReactionRemoveEmoji,
DiscordPresenceUpdate,
DiscordReady,
DiscordRole,
DiscordScheduledEvent,
DiscordScheduledEventUserAdd,
DiscordScheduledEventUserRemove,
DiscordThreadListSync,
DiscordThreadMembersUpdate,
DiscordThreadMemberUpdate,
DiscordTypingStart,
DiscordUser,
DiscordWebhookUpdate,
} from '@biscuitland/api-types';
import type { Session } from '../biscuit';
import type { Interaction } from '../structures/interactions';
import type { Snowflake } from '../snowflakes';
import {
AutoModerationRule,
AutoModerationExecution,
} from '../structures/automod';
import type { Channel } from '../structures/channels';
import {
ChannelFactory,
GuildChannel,
ThreadChannel,
} from '../structures/channels';
import type { DiscordStageInstanceB } from '../structures/stage-instance';
import { StageInstance } from '../structures/stage-instance';
import { ScheduledEvent } from '../structures/scheduled-events';
import { Presence } from '../structures/presence';
import { Member, ThreadMember } from '../structures/members';
import { Message } from '../structures/message';
import { User } from '../structures/user';
import { Integration } from '../structures/integration';
import { Guild } from '../structures/guilds';
import { InteractionFactory } from '../structures/interactions';
import type { InviteCreate } from '../structures/invite';
import { NewInviteCreate } from '../structures/invite';
import type {
MessageReactionAdd,
MessageReactionRemove,
MessageReactionRemoveAll,
MessageReactionRemoveEmoji,
} from '../structures/message-reaction';
import { NewMessageReactionAdd } from '../structures/message-reaction';
export type RawHandler<T> = (...args: [Session, number, T]) => void;
export type Handler<T extends [obj?: unknown, ddy?: unknown]> = (
...args: T
) => unknown;
export const READY: RawHandler<DiscordReady> = (session, shardId, payload) => {
session.applicationId = payload.application.id;
session.botId = payload.user.id;
session.events.emit(
'ready',
{ ...payload, user: new User(session, payload.user) },
shardId
);
};
export const MESSAGE_CREATE: RawHandler<DiscordMessage> = (
session,
_shardId,
message
) => {
session.events.emit('messageCreate', new Message(session, message));
};
export const MESSAGE_UPDATE: RawHandler<DiscordMessage> = (
session,
_shardId,
new_message
) => {
// message is partial
if (!new_message.edited_timestamp) {
const message = {
// TODO: improve this
// ...new_message,
session,
id: new_message.id,
guildId: new_message.guild_id,
channelId: new_message.channel_id,
};
// all methods of Message can run on partial messages
// we aknowledge people that their callback could be partial but giving them all functions of Message
Object.setPrototypeOf(message, Message.prototype);
session.events.emit('messageUpdate', message);
return;
}
session.events.emit('messageUpdate', new Message(session, new_message));
};
export const MESSAGE_DELETE: RawHandler<DiscordMessageDelete> = (
session,
_shardId,
{ id, channel_id, guild_id }
) => {
session.events.emit('messageDelete', {
id,
channelId: channel_id,
guildId: guild_id,
});
};
export const GUILD_CREATE: RawHandler<DiscordGuild> = (
session,
_shardId,
guild
) => {
session.events.emit('guildCreate', new Guild(session, guild));
};
export const GUILD_DELETE: RawHandler<DiscordGuild> = (
session,
_shardId,
guild
) => {
session.events.emit('guildDelete', { id: guild.id, unavailable: true });
};
export const GUILD_MEMBER_ADD: RawHandler<DiscordGuildMemberAdd> = (
session,
_shardId,
member
) => {
session.events.emit(
'guildMemberAdd',
new Member(session, member, member.guild_id)
);
};
export const GUILD_MEMBER_UPDATE: RawHandler<DiscordGuildMemberUpdate> = (
session,
_shardId,
member
) => {
session.events.emit(
'guildMemberUpdate',
new Member(session, member, member.guild_id)
);
};
export const GUILD_MEMBER_REMOVE: RawHandler<DiscordGuildMemberRemove> = (
session,
_shardId,
member
) => {
session.events.emit(
'guildMemberRemove',
new User(session, member.user),
member.guild_id
);
};
export const GUILD_BAN_ADD: RawHandler<DiscordGuildBanAddRemove> = (
session,
_shardId,
data
) => {
session.events.emit('guildBanAdd', {
guildId: data.guild_id,
user: data.user,
});
};
export const GUILD_BAN_REMOVE: RawHandler<DiscordGuildBanAddRemove> = (
session,
_shardId,
data
) => {
session.events.emit('guildBanRemove', {
guildId: data.guild_id,
user: data.user,
});
};
export const GUILD_EMOJIS_UPDATE: RawHandler<DiscordGuildEmojisUpdate> = (
session,
_shardId,
data
) => {
session.events.emit('guildEmojisUpdate', {
guildId: data.guild_id,
emojis: data.emojis,
});
};
export const GUILD_ROLE_CREATE: RawHandler<DiscordGuildRoleCreate> = (
session,
_shardId,
data
) => {
session.events.emit('guildRoleCreate', {
guildId: data.guild_id,
role: data.role,
});
};
export const GUILD_ROLE_UPDATE: RawHandler<DiscordGuildRoleUpdate> = (
session,
_shardId,
data
) => {
session.events.emit('guildRoleUpdate', {
guildId: data.guild_id,
role: data.role,
});
};
export const GUILD_ROLE_DELETE: RawHandler<DiscordGuildRoleDelete> = (
session,
_shardId,
data
) => {
session.events.emit('guildRoleDelete', {
guildId: data.guild_id,
roleId: data.role_id,
});
};
export const TYPING_START: RawHandler<DiscordTypingStart> = (
session,
_shardId,
payload
) => {
session.events.emit('typingStart', {
channelId: payload.channel_id,
guildId: payload.guild_id ? payload.guild_id : undefined,
userId: payload.user_id,
timestamp: payload.timestamp,
member: payload.guild_id
? new Member(
session,
payload.member as DiscordMemberWithUser,
payload.guild_id
)
: undefined,
});
};
export const INTERACTION_CREATE: RawHandler<DiscordInteraction> = (
session,
_shardId,
interaction
) => {
session.events.emit(
'interactionCreate',
InteractionFactory.from(session, interaction)
);
};
export const CHANNEL_CREATE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
session.events.emit('channelCreate', ChannelFactory.from(session, channel));
};
export const CHANNEL_UPDATE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
session.events.emit('channelUpdate', ChannelFactory.from(session, channel));
};
export const CHANNEL_DELETE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
if (!channel.guild_id) {
return;
}
session.events.emit(
'channelDelete',
new GuildChannel(session, channel, channel.guild_id)
);
};
export const THREAD_CREATE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
if (!channel.guild_id) {
return;
}
session.events.emit(
'threadCreate',
new ThreadChannel(session, channel, channel.guild_id)
);
};
export const THREAD_UPDATE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
if (!channel.guild_id) {
return;
}
session.events.emit(
'threadUpdate',
new ThreadChannel(session, channel, channel.guild_id)
);
};
export const THREAD_DELETE: RawHandler<DiscordChannel> = (
session,
_shardId,
channel
) => {
if (!channel.guild_id) {
return;
}
session.events.emit(
'threadDelete',
new ThreadChannel(session, channel, channel.guild_id)
);
};
export const THREAD_MEMBER_UPDATE: RawHandler<DiscordThreadMemberUpdate> = (
session,
_shardId,
payload
) => {
session.events.emit('threadMemberUpdate', {
guildId: payload.guild_id,
id: payload.id,
userId: payload.user_id,
joinedAt: payload.joined_at,
flags: payload.flags,
});
};
export const THREAD_MEMBERS_UPDATE: RawHandler<DiscordThreadMembersUpdate> = (
session,
_shardId,
payload
) => {
session.events.emit('threadMembersUpdate', {
memberCount: payload.member_count,
addedMembers: payload.added_members
? payload.added_members.map(tm => new ThreadMember(session, tm))
: undefined,
removedMemberIds: payload.removed_member_ids
? payload.removed_member_ids
: undefined,
guildId: payload.guild_id,
id: payload.id,
});
};
export const THREAD_LIST_SYNC: RawHandler<DiscordThreadListSync> = (
session,
_shardId,
payload
) => {
session.events.emit('threadListSync', {
guildId: payload.guild_id,
channelIds: payload.channel_ids ?? [],
threads: payload.threads.map(
channel => new ThreadChannel(session, channel, payload.guild_id)
),
members: payload.members.map(
member => new ThreadMember(session, member)
),
});
};
export const CHANNEL_PINS_UPDATE: RawHandler<DiscordChannelPinsUpdate> = (
session,
_shardId,
payload
) => {
session.events.emit('channelPinsUpdate', {
guildId: payload.guild_id,
channelId: payload.channel_id,
lastPinTimestamp: payload.last_pin_timestamp
? Date.parse(payload.last_pin_timestamp)
: undefined,
});
};
export const USER_UPDATE: RawHandler<DiscordUser> = (
session,
_shardId,
payload
) => {
session.events.emit('userUpdate', new User(session, payload));
};
export const PRESENCE_UPDATE: RawHandler<DiscordPresenceUpdate> = (
session,
_shardId,
payload
) => {
session.events.emit('presenceUpdate', new Presence(session, payload));
};
export const WEBHOOKS_UPDATE: RawHandler<DiscordWebhookUpdate> = (
session,
_shardId,
webhook
) => {
session.events.emit('webhooksUpdate', {
guildId: webhook.guild_id,
channelId: webhook.channel_id,
});
};
export const INTEGRATION_CREATE: RawHandler<
DiscordIntegration & { guildId?: Snowflake }
> = (session, _shardId, payload) => {
session.events.emit('integrationCreate', new Integration(session, payload));
};
export const INTEGRATION_UPDATE: RawHandler<
DiscordIntegration & { guildId?: Snowflake }
> = (session, _shardId, payload) => {
session.events.emit('integrationCreate', new Integration(session, payload));
};
export const INTEGRATION_DELETE: RawHandler<DiscordIntegrationDelete> = (
session,
_shardId,
payload
) => {
session.events.emit('integrationDelete', {
id: payload.id,
guildId: payload.guild_id,
applicationId: payload.application_id,
});
};
export const AUTO_MODERATION_RULE_CREATE: RawHandler<
DiscordAutoModerationRule
> = (session, _shardId, payload) => {
session.events.emit(
'autoModerationRuleCreate',
new AutoModerationRule(session, payload)
);
};
export const AUTO_MODERATION_RULE_UPDATE: RawHandler<
DiscordAutoModerationRule
> = (session, _shardId, payload) => {
session.events.emit(
'autoModerationRuleUpdate',
new AutoModerationRule(session, payload)
);
};
export const AUTO_MODERATION_RULE_DELETE: RawHandler<
DiscordAutoModerationRule
> = (session, _shardId, payload) => {
session.events.emit(
'autoModerationRuleDelete',
new AutoModerationRule(session, payload)
);
};
export const AUTO_MODERATION_ACTION_EXECUTE: RawHandler<
DiscordAutoModerationActionExecution
> = (session, _shardId, payload) => {
session.events.emit(
'autoModerationActionExecution',
new AutoModerationExecution(session, payload)
);
};
export const MESSAGE_REACTION_ADD: RawHandler<DiscordMessageReactionAdd> = (
session,
_shardId,
reaction
) => {
session.events.emit(
'messageReactionAdd',
NewMessageReactionAdd(session, reaction)
);
};
export const MESSAGE_REACTION_REMOVE: RawHandler<
DiscordMessageReactionRemove
> = (session, _shardId, reaction) => {
session.events.emit(
'messageReactionRemove',
NewMessageReactionAdd(session, reaction)
);
};
export const MESSAGE_REACTION_REMOVE_ALL: RawHandler<
DiscordMessageReactionRemoveAll
> = (session, _shardId, reaction) => {
session.events.emit(
'messageReactionRemoveAll',
NewMessageReactionAdd(session, reaction as DiscordMessageReactionAdd)
);
};
export const MESSAGE_REACTION_REMOVE_EMOJI: RawHandler<
DiscordMessageReactionRemoveEmoji
> = (session, _shardId, reaction) => {
session.events.emit(
'messageReactionRemoveEmoji',
NewMessageReactionAdd(session, reaction as DiscordMessageReactionAdd)
);
};
export const INVITE_CREATE: RawHandler<DiscordInviteCreate> = (
session,
_shardId,
invite
) => {
session.events.emit('inviteCreate', NewInviteCreate(session, invite));
};
export const INVITE_DELETE: RawHandler<DiscordInviteDelete> = (
session,
_shardId,
data
) => {
session.events.emit('inviteDelete', {
channelId: data.channel_id,
guildId: data.guild_id,
code: data.code,
});
};
export const STAGE_INSTANCE_CREATE: RawHandler<DiscordStageInstanceB> = (
session,
_shardId,
payload
) => {
session.events.emit(
'stageInstanceCreate',
new StageInstance(session, payload)
);
};
export const STAGE_INSTANCE_UPDATE: RawHandler<DiscordStageInstanceB> = (
session,
_shardId,
payload
) => {
session.events.emit(
'stageInstanceUpdate',
new StageInstance(session, payload)
);
};
export const STAGE_INSTANCE_DELETE: RawHandler<DiscordStageInstanceB> = (
session,
_shardId,
payload
) => {
session.events.emit(
'stageInstanceDelete',
new StageInstance(session, payload)
);
};
export const GUILD_SCHEDULED_EVENT_CREATE: RawHandler<DiscordScheduledEvent> = (
session,
_shardId,
payload
) => {
session.events.emit(
'guildScheduledEventCreate',
new ScheduledEvent(session, payload)
);
};
export const GUILD_SCHEDULED_EVENT_UPDATE: RawHandler<DiscordScheduledEvent> = (
session,
_shardId,
payload
) => {
session.events.emit(
'guildScheduledEventUpdate',
new ScheduledEvent(session, payload)
);
};
export const GUILD_SCHEDULED_EVENT_DELETE: RawHandler<DiscordScheduledEvent> = (
session,
_shardId,
payload
) => {
session.events.emit(
'guildScheduledEventDelete',
new ScheduledEvent(session, payload)
);
};
export const GUILD_SCHEDULED_EVENT_USER_ADD: RawHandler<
DiscordScheduledEventUserAdd
> = (session, _shardId, payload) => {
session.events.emit('guildScheduledEventUserAdd', {
scheduledEventId: payload.guild_scheduled_event_id,
userId: payload.user_id,
guildId: payload.guild_id,
});
};
export const GUILD_SCHEDULED_EVENT_USER_REMOVE: RawHandler<
DiscordScheduledEventUserRemove
> = (session, _shardId, payload) => {
session.events.emit('guildScheduledEventUserRemove', {
scheduledEventId: payload.guild_scheduled_event_id,
userId: payload.user_id,
guildId: payload.guild_id,
});
};
export const raw: RawHandler<unknown> = (session, shardId, data) => {
session.events.emit('raw', data as { t: string; d: unknown }, shardId);
};
export interface Ready extends Omit<DiscordReady, 'user'> {
user: User;
}
export interface Events {
ready: Handler<[Ready, number]>;
messageCreate: Handler<[Message]>;
messageUpdate: Handler<[Partial<Message>]>;
messageDelete: Handler<
[{ id: Snowflake; channelId: Snowflake; guildId?: Snowflake }]
>;
messageReactionAdd: Handler<[MessageReactionAdd]>;
messageReactionRemove: Handler<[MessageReactionRemove]>;
messageReactionRemoveAll: Handler<[MessageReactionRemoveAll]>;
messageReactionRemoveEmoji: Handler<[MessageReactionRemoveEmoji]>;
guildCreate: Handler<[Guild]>;
guildDelete: Handler<[{ id: Snowflake; unavailable: boolean }]>;
guildMemberAdd: Handler<[Member]>;
guildMemberUpdate: Handler<[Member]>;
guildMemberRemove: Handler<[User, Snowflake]>;
guildBanAdd: Handler<[{ guildId: Snowflake; user: DiscordUser }]>;
guildBanRemove: Handler<[{ guildId: Snowflake; user: DiscordUser }]>;
guildEmojisUpdate: Handler<
[{ guildId: Snowflake; emojis: DiscordEmoji[] }]
>;
guildRoleCreate: Handler<[{ guildId: Snowflake; role: DiscordRole }]>;
guildRoleUpdate: Handler<[{ guildId: Snowflake; role: DiscordRole }]>;
guildRoleDelete: Handler<[{ guildId: Snowflake; roleId: Snowflake }]>;
typingStart: Handler<
[
{
channelId: Snowflake;
guildId?: Snowflake;
userId: Snowflake;
timestamp: number;
member?: Member;
}
]
>;
channelCreate: Handler<[Channel]>;
channelUpdate: Handler<[Channel]>;
channelDelete: Handler<[GuildChannel]>;
channelPinsUpdate: Handler<
[
{
guildId?: Snowflake;
channelId: Snowflake;
lastPinTimestamp?: number;
}
]
>;
threadCreate: Handler<[ThreadChannel]>;
threadUpdate: Handler<[ThreadChannel]>;
threadDelete: Handler<[ThreadChannel]>;
threadListSync: Handler<
[
{
guildId: Snowflake;
channelIds: Snowflake[];
threads: ThreadChannel[];
members: ThreadMember[];
}
]
>;
threadMemberUpdate: Handler<
[
{
id: Snowflake;
userId: Snowflake;
guildId: Snowflake;
joinedAt: string;
flags: number;
}
]
>;
threadMembersUpdate: Handler<
[
{
id: Snowflake;
memberCount: number;
addedMembers?: ThreadMember[];
guildId: Snowflake;
removedMemberIds?: Snowflake[];
}
]
>;
interactionCreate: Handler<[Interaction]>;
integrationCreate: Handler<[Integration]>;
integrationUpdate: Handler<[Integration]>;
integrationDelete: Handler<
[{ id: Snowflake; guildId?: Snowflake; applicationId?: Snowflake }]
>;
inviteCreate: Handler<[InviteCreate]>;
inviteDelete: Handler<
[{ channelId: string; guildId?: string; code: string }]
>;
autoModerationRuleCreate: Handler<[AutoModerationRule]>;
autoModerationRuleUpdate: Handler<[AutoModerationRule]>;
autoModerationRuleDelete: Handler<[AutoModerationRule]>;
autoModerationActionExecution: Handler<[AutoModerationExecution]>;
stageInstanceCreate: Handler<[StageInstance]>;
stageInstanceUpdate: Handler<[StageInstance]>;
stageInstanceDelete: Handler<[StageInstance]>;
guildScheduledEventCreate: Handler<[ScheduledEvent]>;
guildScheduledEventUpdate: Handler<[ScheduledEvent]>;
guildScheduledEventDelete: Handler<[ScheduledEvent]>;
guildScheduledEventUserAdd: Handler<
[{ scheduledEventId: Snowflake; userId: Snowflake; guildId: Snowflake }]
>;
guildScheduledEventUserRemove: Handler<
[{ scheduledEventId: Snowflake; userId: Snowflake; guildId: Snowflake }]
>;
raw: Handler<[{ t: string; d: unknown }, number]>;
webhooksUpdate: Handler<[{ guildId: Snowflake; channelId: Snowflake }]>;
userUpdate: Handler<[User]>;
presenceUpdate: Handler<[Presence]>;
debug: Handler<[string]>;
}

View File

@ -0,0 +1,212 @@
import type {
DiscordGatewayPayload,
GatewayIntents,
Snowflake,
} from '@biscuitland/api-types';
// DiscordGetGatewayBot;
import type { RestAdapter } from '@biscuitland/rest';
import { DefaultRestAdapter } from '@biscuitland/rest';
import type { WsAdapter } from '@biscuitland/ws';
import { DefaultWsAdapter } from '@biscuitland/ws';
import type { EventAdapter } from './adapters/event-adapter';
import { DefaultEventAdapter } from './adapters/default-event-adapter';
import { Util } from './utils/util';
import { Shard } from '@biscuitland/ws';
export type DiscordRawEventHandler = (
shard: Shard,
data: MessageEvent<any>
) => unknown;
export type PickOptions = Pick<
BiscuitOptions,
Exclude<keyof BiscuitOptions, keyof typeof Session.DEFAULTS>
> &
Partial<BiscuitOptions>;
export interface BiscuitOptions {
intents?: GatewayIntents;
token: string;
events?: {
adapter?: { new (...args: any[]): EventAdapter };
options: any;
};
rest: {
adapter?: { new (...args: any[]): RestAdapter };
options: any;
};
ws: {
adapter?: { new (...args: any[]): WsAdapter };
options: any;
};
}
import * as Actions from './adapters/events';
export class Session {
#applicationId?: Snowflake;
#botId?: Snowflake;
token: string;
set botId(snowflake: Snowflake) {
this.#botId = snowflake;
}
get botId(): Snowflake {
return this.#botId ?? Util.getBotIdFromToken(this.token);
}
set applicationId(snowflake: Snowflake) {
this.#applicationId = snowflake;
}
get applicationId(): Snowflake {
return this.#applicationId ?? this.botId;
}
static readonly DEFAULTS = {
rest: {
adapter: DefaultRestAdapter,
options: null,
},
ws: {
adapter: DefaultWsAdapter,
options: null,
},
};
options: BiscuitOptions;
readonly events: EventAdapter;
readonly rest: RestAdapter;
readonly ws: WsAdapter;
private adapters = new Map<string, any>();
constructor(options: PickOptions) {
this.options = Object.assign(options, Session.DEFAULTS);
// makeRest
if (!this.options.rest.options) {
this.options.rest.options = {
intents: this.options.intents,
token: this.options.token,
};
}
this.rest = this.getRest();
// makeWs
const defHandler: DiscordRawEventHandler = (shard, event) => {
let message = event.data;
let data = JSON.parse(message) as DiscordGatewayPayload;
Actions.raw(this, shard.id, data);
if (!data.t || !data.d) {
return;
}
Actions[data.t as keyof typeof Actions]?.(
this,
shard.id,
data.d as any
);
};
if (!this.options.ws.options) {
this.options.ws.options = {
handleDiscordPayload: defHandler,
gatewayConfig: {
token: this.options.token,
intents: this.options.intents,
},
intents: this.options.intents,
token: this.options.token,
};
}
// makeEvents
this.events = this.options.events?.adapter
? new this.options.events.adapter()
: new DefaultEventAdapter();
this.ws = this.getWs();
this.token = options.token;
}
/**
* @inheritDoc
*/
private getAdapter<T extends { new (...args: any[]): InstanceType<T> }>(
adapter: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
if (!this.adapters.has(adapter.name)) {
const Class = adapter as { new (...args: any[]): T };
this.adapters.set(adapter.name, new Class(...args));
}
return this.adapters.get(adapter.name);
}
/**
* @inheritDoc
*/
private getRest(): RestAdapter {
return this.getAdapter(
this.options.rest.adapter!,
this.options.rest.options
);
}
/**
* @inheritDoc
*/
private getWs(): WsAdapter {
return this.getAdapter(
this.options.ws.adapter!,
this.options.ws.options
);
}
/**
* @inheritDoc
*/
async start(): Promise<void> {
const nonParsed = await this.rest.get<any>('/gateway/bot');
this.ws.options.gatewayBot = {
url: nonParsed.url,
shards: nonParsed.shards,
sessionStartLimit: {
total: nonParsed.session_start_limit.total,
remaining: nonParsed.session_start_limit.remaining,
resetAfter: nonParsed.session_start_limit.reset_after,
maxConcurrency: nonParsed.session_start_limit.max_concurrency,
},
};
this.ws.options.lastShardId = this.ws.options.gatewayBot.shards - 1;
this.ws.agent.options.totalShards = this.ws.options.gatewayBot.shards;
this.ws.shards();
}
}

View File

@ -0,0 +1,49 @@
import type { DiscordInputTextComponent, MessageComponentTypes, TextStyles } from '@biscuitland/api-types';
export class InputTextBuilder {
constructor() {
this.#data = {} as DiscordInputTextComponent;
this.type = 4;
}
#data: DiscordInputTextComponent;
type: MessageComponentTypes.InputText;
setStyle(style: TextStyles): this {
this.#data.style = style;
return this;
}
setLabel(label: string): this {
this.#data.label = label;
return this;
}
setPlaceholder(placeholder: string): this {
this.#data.placeholder = placeholder;
return this;
}
setLength(max?: number, min?: number): this {
this.#data.max_length = max;
this.#data.min_length = min;
return this;
}
setCustomId(id: string): this {
this.#data.custom_id = id;
return this;
}
setValue(value: string): this {
this.#data.value = value;
return this;
}
setRequired(required = true): this {
this.#data.required = required;
return this;
}
toJSON(): DiscordInputTextComponent {
return { ...this.#data, type: this.type };
}
}

View File

@ -0,0 +1,33 @@
import type { DiscordActionRow, MessageComponentTypes } from '@biscuitland/api-types';
import type { ComponentBuilder } from '../../utils/util';
export class ActionRowBuilder<T extends ComponentBuilder> {
constructor() {
this.components = [] as T[];
this.type = 1;
}
components: T[];
type: MessageComponentTypes.ActionRow;
addComponents(...components: T[]): this {
this.components.push(...components);
return this;
}
setComponents(...components: T[]): this {
this.components.splice(
0,
this.components.length,
...components,
);
return this;
}
toJSON(): DiscordActionRow {
return {
type: this.type,
// @ts-ignore: socram fix this
components: this.components.map((c) => c.toJSON()) as DiscordActionRow['components'],
};
}
}

View File

@ -0,0 +1,45 @@
import { ButtonStyles, DiscordButtonComponent, MessageComponentTypes } from '@biscuitland/api-types';
import type { ComponentEmoji } from '../../utils/util';
export class ButtonBuilder {
constructor() {
this.#data = {} as DiscordButtonComponent;
this.type = MessageComponentTypes.Button;
}
#data: DiscordButtonComponent;
type: MessageComponentTypes.Button;
setStyle(style: ButtonStyles): this {
this.#data.style = style;
return this;
}
setLabel(label: string): this {
this.#data.label = label;
return this;
}
setCustomId(id: string): this {
this.#data.custom_id = id;
return this;
}
setEmoji(emoji: ComponentEmoji): this {
this.#data.emoji = emoji;
return this;
}
setDisabled(disabled = true): this {
this.#data.disabled = disabled;
return this;
}
setURL(url: string): this {
this.#data.url = url;
return this;
}
toJSON(): DiscordButtonComponent {
return { ...this.#data, type: this.type };
}
}

View File

@ -0,0 +1,91 @@
import type { DiscordSelectOption, DiscordSelectMenuComponent, } from '@biscuitland/api-types';
import type { ComponentEmoji } from '../../utils/util';
import { MessageComponentTypes } from '@biscuitland/api-types';
export class SelectMenuOptionBuilder {
constructor() {
this.#data = {} as DiscordSelectOption;
}
#data: DiscordSelectOption;
setLabel(label: string): SelectMenuOptionBuilder {
this.#data.label = label;
return this;
}
setValue(value: string): SelectMenuOptionBuilder {
this.#data.value = value;
return this;
}
setDescription(description: string): SelectMenuOptionBuilder {
this.#data.description = description;
return this;
}
setDefault(Default = true): SelectMenuOptionBuilder {
this.#data.default = Default;
return this;
}
setEmoji(emoji: ComponentEmoji): SelectMenuOptionBuilder {
this.#data.emoji = emoji;
return this;
}
toJSON(): DiscordSelectOption {
return { ...this.#data };
}
}
export class SelectMenuBuilder {
constructor() {
this.#data = {} as DiscordSelectMenuComponent;
this.type = MessageComponentTypes.SelectMenu;
this.options = [];
}
#data: DiscordSelectMenuComponent;
type: MessageComponentTypes.SelectMenu;
options: SelectMenuOptionBuilder[];
setPlaceholder(placeholder: string): this {
this.#data.placeholder = placeholder;
return this;
}
setValues(max?: number, min?: number): this {
this.#data.max_values = max;
this.#data.min_values = min;
return this;
}
setDisabled(disabled = true): this {
this.#data.disabled = disabled;
return this;
}
setCustomId(id: string): this {
this.#data.custom_id = id;
return this;
}
setOptions(...options: SelectMenuOptionBuilder[]): this {
this.options.splice(
0,
this.options.length,
...options,
);
return this;
}
addOptions(...options: SelectMenuOptionBuilder[]): this {
this.options.push(
...options,
);
return this;
}
toJSON(): DiscordSelectMenuComponent {
return { ...this.#data, type: this.type, options: this.options.map((option) => option.toJSON()) };
}
}

View File

@ -0,0 +1,109 @@
import type { DiscordEmbed, DiscordEmbedField, DiscordEmbedProvider } from '@biscuitland/api-types';
export interface EmbedFooter {
text: string;
iconUrl?: string;
proxyIconUrl?: string;
}
export interface EmbedAuthor {
name: string;
text?: string;
url?: string;
iconUrl?: string;
proxyIconUrl?: string;
}
export interface EmbedVideo {
height?: number;
proxyUrl?: string;
url?: string;
width?: number;
}
export class EmbedBuilder {
#data: DiscordEmbed;
constructor(data: DiscordEmbed = {}) {
this.#data = data;
if (!this.#data.fields) this.#data.fields = [];
}
setAuthor(author: EmbedAuthor): EmbedBuilder {
this.#data.author = {
name: author.name,
icon_url: author.iconUrl,
proxy_icon_url: author.proxyIconUrl,
url: author.url,
};
return this;
}
setColor(color: number): EmbedBuilder {
this.#data.color = color;
return this;
}
setDescription(description: string): EmbedBuilder {
this.#data.description = description;
return this;
}
addField(field: DiscordEmbedField): EmbedBuilder {
this.#data.fields!.push(field);
return this;
}
setFooter(footer: EmbedFooter): EmbedBuilder {
this.#data.footer = {
text: footer.text,
icon_url: footer.iconUrl,
proxy_icon_url: footer.proxyIconUrl,
};
return this;
}
setImage(image: string): EmbedBuilder {
this.#data.image = { url: image };
return this;
}
setProvider(provider: DiscordEmbedProvider): EmbedBuilder {
this.#data.provider = provider;
return this;
}
setThumbnail(thumbnail: string): EmbedBuilder {
this.#data.thumbnail = { url: thumbnail };
return this;
}
setTimestamp(timestamp: string | Date): EmbedBuilder {
this.#data.timestamp = timestamp instanceof Date ? timestamp.toISOString() : timestamp;
return this;
}
setTitle(title: string, url?: string): EmbedBuilder {
this.#data.title = title;
if (url) this.setUrl(url);
return this;
}
setUrl(url: string): EmbedBuilder {
this.#data.url = url;
return this;
}
setVideo(video: EmbedVideo): EmbedBuilder {
this.#data.video = {
height: video.height,
proxy_url: video.proxyUrl,
url: video.url,
width: video.width,
};
return this;
}
toJSON(): DiscordEmbed {
return this.#data;
}
}

View File

@ -0,0 +1,129 @@
import type { Localization, PermissionStrings, DiscordApplicationCommandOption } from '@biscuitland/api-types';
import type { PermissionResolvable } from '../../structures/special/permissions';
import { ApplicationCommandTypes } from '@biscuitland/api-types';
import { OptionBased } from './ApplicationCommandOption';
/**
* @link https://discord.com/developers/docs/interactions/application-commands#endpoints-json-params
*/
export interface CreateApplicationCommand {
name: string;
nameLocalizations?: Localization;
description: string;
descriptionLocalizations?: Localization;
type?: ApplicationCommandTypes;
options?: DiscordApplicationCommandOption[];
defaultMemberPermissions?: PermissionResolvable;
dmPermission?: boolean;
}
export abstract class ApplicationCommandBuilder implements CreateApplicationCommand {
constructor(
type: ApplicationCommandTypes = ApplicationCommandTypes.ChatInput,
name: string = '',
description: string = '',
defaultMemberPermissions?: PermissionStrings[],
nameLocalizations?: Localization,
descriptionLocalizations?: Localization,
dmPermission: boolean = true,
) {
this.type = type;
this.name = name;
this.description = description;
this.defaultMemberPermissions = defaultMemberPermissions;
this.nameLocalizations = nameLocalizations;
this.descriptionLocalizations = descriptionLocalizations;
this.dmPermission = dmPermission;
}
type: ApplicationCommandTypes;
name: string;
description: string;
defaultMemberPermissions?: PermissionStrings[];
nameLocalizations?: Localization;
descriptionLocalizations?: Localization;
dmPermission: boolean;
setType(type: ApplicationCommandTypes): this {
return (this.type = type), this;
}
setName(name: string): this {
return (this.name = name), this;
}
setDescription(description: string): this {
return (this.description = description), this;
}
setDefaultMemberPermission(perm: PermissionStrings[]): this {
return (this.defaultMemberPermissions = perm), this;
}
setNameLocalizations(l: Localization): this {
return (this.nameLocalizations = l), this;
}
setDescriptionLocalizations(l: Localization): this {
return (this.descriptionLocalizations = l), this;
}
setDmPermission(perm: boolean): this {
return (this.dmPermission = perm), this;
}
}
export type MessageApplicationCommandBuilderJSON = { name: string; type: ApplicationCommandTypes.Message };
export class MessageApplicationCommandBuilder {
type: ApplicationCommandTypes;
name?: string;
constructor(
type?: ApplicationCommandTypes,
name?: string,
) {
this.type = type ?? ApplicationCommandTypes.Message;
this.name = name;
}
setName(name: string): this {
return (this.name = name), this;
}
toJSON(): MessageApplicationCommandBuilderJSON {
if (!this.name) throw new TypeError('Propety \'name\' is required');
return {
type: ApplicationCommandTypes.Message,
name: this.name,
};
}
}
export class ChatInputApplicationCommandBuilder extends ApplicationCommandBuilder {
type: ApplicationCommandTypes.ChatInput = ApplicationCommandTypes.ChatInput;
toJSON(): CreateApplicationCommand {
if (!this.type) throw new TypeError('Propety \'type\' is required');
if (!this.name) throw new TypeError('Propety \'name\' is required');
if (!this.description) {
throw new TypeError('Propety \'description\' is required');
}
return {
type: ApplicationCommandTypes.ChatInput,
name: this.name,
description: this.description,
options: this.options?.map((o) => o.toJSON()) ?? [],
defaultMemberPermissions: this.defaultMemberPermissions,
nameLocalizations: this.nameLocalizations,
descriptionLocalizations: this.descriptionLocalizations,
dmPermission: this.dmPermission,
};
}
}
OptionBased.applyTo(ChatInputApplicationCommandBuilder);
export interface ChatInputApplicationCommandBuilder extends ApplicationCommandBuilder, OptionBased {
// pass
}

View File

@ -0,0 +1,346 @@
import { ApplicationCommandOptionTypes, ChannelTypes, Localization } from '@biscuitland/api-types';
import { ApplicationCommandOptionChoice } from '../../structures/interactions';
export class ChoiceBuilder {
name?: string;
value?: string;
setName(name: string): ChoiceBuilder {
this.name = name;
return this;
}
setValue(value: string): this {
this.value = value;
return this;
}
toJSON(): ApplicationCommandOptionChoice {
if (!this.name) throw new TypeError('Property \'name\' is required');
if (!this.value) throw new TypeError('Property \'value\' is required');
return {
name: this.name,
value: this.value,
};
}
}
export class OptionBuilder {
required?: boolean;
autocomplete?: boolean;
type?: ApplicationCommandOptionTypes;
name?: string;
description?: string;
constructor(type?: ApplicationCommandOptionTypes, name?: string, description?: string) {
this.type = type;
this.name = name;
this.description = description;
}
setType(type: ApplicationCommandOptionTypes): this {
return (this.type = type), this;
}
setName(name: string): this {
return (this.name = name), this;
}
setDescription(description: string): this {
return (this.description = description), this;
}
setRequired(required: boolean): this {
return (this.required = required), this;
}
toJSON(): ApplicationCommandOption {
if (!this.type) throw new TypeError('Property \'type\' is required');
if (!this.name) throw new TypeError('Property \'name\' is required');
if (!this.description) {
throw new TypeError('Property \'description\' is required');
}
const applicationCommandOption: ApplicationCommandOption = {
type: this.type,
name: this.name,
description: this.description,
required: this.required ? true : false,
};
return applicationCommandOption;
}
}
export class OptionBuilderLimitedValues extends OptionBuilder {
choices?: ChoiceBuilder[];
minValue?: number;
maxValue?: number;
constructor(
type?: ApplicationCommandOptionTypes.Integer | ApplicationCommandOptionTypes.Number,
name?: string,
description?: string,
) {
super();
this.type = type;
this.name = name;
this.description = description;
}
setMinValue(n: number): this {
return (this.minValue = n), this;
}
setMaxValue(n: number): this {
return (this.maxValue = n), this;
}
addChoice(fn: (choice: ChoiceBuilder) => ChoiceBuilder): this {
const choice = fn(new ChoiceBuilder());
this.choices ??= [];
this.choices.push(choice);
return this;
}
override toJSON(): ApplicationCommandOption {
return {
...super.toJSON(),
choices: this.choices?.map((c) => c.toJSON()) ?? [],
minValue: this.minValue,
maxValue: this.maxValue,
};
}
}
export class OptionBuilderString extends OptionBuilder {
choices?: ChoiceBuilder[];
constructor(
type?: ApplicationCommandOptionTypes.String,
name?: string,
description?: string,
) {
super();
this.type = type;
this.name = name;
this.description = description;
this;
}
addChoice(fn: (choice: ChoiceBuilder) => ChoiceBuilder): this {
const choice = fn(new ChoiceBuilder());
this.choices ??= [];
this.choices.push(choice);
return this;
}
override toJSON(): ApplicationCommandOption {
return {
...super.toJSON(),
choices: this.choices?.map((c) => c.toJSON()) ?? [],
};
}
}
export class OptionBuilderChannel extends OptionBuilder {
channelTypes?: ChannelTypes[];
constructor(
type?: ApplicationCommandOptionTypes.Channel,
name?: string,
description?: string,
) {
super();
this.type = type;
this.name = name;
this.description = description;
this;
}
addChannelTypes(...channels: ChannelTypes[]): this {
this.channelTypes ??= [];
this.channelTypes.push(...channels);
return this;
}
override toJSON(): ApplicationCommandOption {
return {
...super.toJSON(),
channelTypes: this.channelTypes ?? [],
};
}
}
export interface OptionBuilderLike {
toJSON(): ApplicationCommandOption;
}
export class OptionBased {
options?:
& (
| OptionBuilder[]
| OptionBuilderString[]
| OptionBuilderLimitedValues[]
| OptionBuilderNested[]
| OptionBuilderChannel[]
)
& OptionBuilderLike[];
addOption(fn: (option: OptionBuilder) => OptionBuilder, type?: ApplicationCommandOptionTypes): this {
const option = fn(new OptionBuilder(type));
this.options ??= [];
this.options.push(option);
return this;
}
addNestedOption(fn: (option: OptionBuilder) => OptionBuilder): this {
const option = fn(new OptionBuilder(ApplicationCommandOptionTypes.SubCommand));
this.options ??= [];
this.options.push(option);
return this;
}
addStringOption(fn: (option: OptionBuilderString) => OptionBuilderString): this {
const option = fn(new OptionBuilderString(ApplicationCommandOptionTypes.String));
this.options ??= [];
this.options.push(option);
return this;
}
addIntegerOption(fn: (option: OptionBuilderLimitedValues) => OptionBuilderLimitedValues): this {
const option = fn(new OptionBuilderLimitedValues(ApplicationCommandOptionTypes.Integer));
this.options ??= [];
this.options.push(option);
return this;
}
addNumberOption(fn: (option: OptionBuilderLimitedValues) => OptionBuilderLimitedValues): this {
const option = fn(new OptionBuilderLimitedValues(ApplicationCommandOptionTypes.Number));
this.options ??= [];
this.options.push(option);
return this;
}
addBooleanOption(fn: (option: OptionBuilder) => OptionBuilder): this {
return this.addOption(fn, ApplicationCommandOptionTypes.Boolean);
}
addSubCommand(fn: (option: OptionBuilderNested) => OptionBuilderNested): this {
const option = fn(new OptionBuilderNested(ApplicationCommandOptionTypes.SubCommand));
this.options ??= [];
this.options.push(option);
return this;
}
addSubCommandGroup(fn: (option: OptionBuilderNested) => OptionBuilderNested): this {
const option = fn(new OptionBuilderNested(ApplicationCommandOptionTypes.SubCommandGroup));
this.options ??= [];
this.options.push(option);
return this;
}
addUserOption(fn: (option: OptionBuilder) => OptionBuilder): this {
return this.addOption(fn, ApplicationCommandOptionTypes.User);
}
addChannelOption(fn: (option: OptionBuilderChannel) => OptionBuilderChannel): this {
const option = fn(new OptionBuilderChannel(ApplicationCommandOptionTypes.Channel));
this.options ??= [];
this.options.push(option);
return this;
}
addRoleOption(fn: (option: OptionBuilder) => OptionBuilder): this {
return this.addOption(fn, ApplicationCommandOptionTypes.Role);
}
addMentionableOption(fn: (option: OptionBuilder) => OptionBuilder): this {
return this.addOption(fn, ApplicationCommandOptionTypes.Mentionable);
}
// deno-lint-ignore ban-types
static applyTo(klass: Function, ignore: Array<keyof OptionBased> = []): void {
const methods: Array<keyof OptionBased> = [
'addOption',
'addNestedOption',
'addStringOption',
'addIntegerOption',
'addNumberOption',
'addBooleanOption',
'addSubCommand',
'addSubCommandGroup',
'addUserOption',
'addChannelOption',
'addRoleOption',
'addMentionableOption',
];
for (const method of methods) {
if (ignore.includes(method)) continue;
klass.prototype[method] = OptionBased.prototype[method];
}
}
}
export class OptionBuilderNested extends OptionBuilder {
constructor(
type?: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup,
name?: string,
description?: string,
) {
super();
this.type = type;
this.name = name;
this.description = description;
}
override toJSON(): ApplicationCommandOption {
if (!this.type) throw new TypeError('Property \'type\' is required');
if (!this.name) throw new TypeError('Property \'name\' is required');
if (!this.description) {
throw new TypeError('Property \'description\' is required');
}
return {
type: this.type,
name: this.name,
description: this.description,
options: this.options?.map((o) => o.toJSON()) ?? [],
required: this.required ? true : false,
};
}
}
OptionBased.applyTo(OptionBuilderNested);
export interface OptionBuilderNested extends OptionBuilder, OptionBased {
// pass
}
export interface ApplicationCommandOption {
/** Value of Application Command Option Type */
type: ApplicationCommandOptionTypes;
/** 1-32 character name matching lowercase `^[\w-]{1,32}$` */
name: string;
/** Localization object for the `name` field. Values follow the same restrictions as `name` */
nameLocalizations?: Localization;
/** 1-100 character description */
description: string;
/** Localization object for the `description` field. Values follow the same restrictions as `description` */
descriptionLocalizations?: Localization;
/** If the parameter is required or optional--default `false` */
required?: boolean;
/** Choices for `string` and `int` types for the user to pick from */
choices?: ApplicationCommandOptionChoice[];
/** If the option is a subcommand or subcommand group type, this nested options will be the parameters */
options?: ApplicationCommandOption[];
/** if autocomplete interactions are enabled for this `String`, `Integer`, or `Number` type option */
autocomplete?: boolean;
/** If the option is a channel type, the channels shown will be restricted to these types */
channelTypes?: ChannelTypes[];
/** Minimum number desired. */
minValue?: number;
/** Maximum number desired. */
maxValue?: number;
}

View File

@ -0,0 +1,21 @@
// SESSION
export * as Actions from './adapters/events';
export { Session as Biscuit } from './biscuit';
export * from './biscuit';
// STRUCTURES
export * from './structures';
// EVENTS
export * from './adapters/events';
export * from './adapters/event-adapter';
export * from './adapters/default-event-adapter';
// ETC
export * from './snowflakes';
// UTIL
export * from './utils/calculate-shard';
export * from './utils/url-to-base-64';
export * from './utils/util';

View File

@ -0,0 +1,12 @@
/** snowflake type */
export type Snowflake = string;
/** Discord epoch */
export const DiscordEpoch = 14200704e5;
/** utilities for Snowflakes */
export const Snowflake = {
snowflakeToTimestamp(id: Snowflake): number {
return (Number(id) >> 22) + DiscordEpoch;
},
};

View File

@ -0,0 +1,42 @@
// STRUCTURES
export * from './structures/application';
export * from './structures/attachment';
export * from './structures/automod';
export * from './structures/base';
export * from './structures/embed';
export * from './structures/emojis';
export * from './structures/scheduled-events';
export * from './structures/integration';
export * from './structures/invite';
export * from './structures/members';
export * from './structures/message';
export * from './structures/message-reaction';
export * from './structures/special/command-interaction-option-resolver';
export * from './structures/special/permissions';
export * from './structures/presence';
export * from './structures/role';
export * from './structures/stage-instance';
export * from './structures/sticker';
export * from './structures/user';
export * from './structures/webhook';
export * from './structures/welcome';
// INTERACTIONS
export * from './structures/interactions';
// CHANNELS
export * from './structures/channels';
// COMPONENTS
export * from './structures/components';
// GUILDS
export * from './structures/guilds';
// BUILDERS
export * from './builders/components/InputTextBuilder';
export * from './builders/components/MessageActionRowBuilder';
export * from './builders/components/MessageButtonBuilder';
export * from './builders/components/MessageSelectMenuBuilder';
export * from './builders/slash/ApplicationCommand';
export * from './builders/slash/ApplicationCommandOption';

View File

@ -0,0 +1,112 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type {
DiscordApplication,
DiscordInstallParams,
DiscordTeam,
DiscordUser,
TeamMembershipStates,
} from '@biscuitland/api-types';
import { User } from './user';
/**
* @internal
*/
export type SummaryDeprecated = '';
/**
* Discord team that holds members
*/
export interface Team {
/** a hash of the image of the team's icon */
icon?: string;
/** the unique id of the team */
id: string;
/** the members of the team */
members: TeamMember[];
/** user id of the current team owner */
ownerUserId: string;
/** team name */
name: string;
}
export interface TeamMember {
/** the user's membership state on the team */
membershipState: TeamMembershipStates;
permissions: '*'[];
teamId: string;
user: Partial<User> &
Pick<User, 'avatarHash' | 'discriminator' | 'id' | 'username'>;
}
// NewTeam create a new Team object for discord applications
export function NewTeam(session: Session, data: DiscordTeam): Team {
return {
icon: data.icon ? data.icon : undefined,
id: data.id,
members: data.members.map(member => {
return {
membershipState: member.membership_state,
permissions: member.permissions,
teamId: member.team_id,
user: new User(session, member.user),
};
}),
ownerUserId: data.owner_user_id,
name: data.name,
};
}
/**
* @link https://discord.com/developers/docs/resources/application#application-object
*/
export class Application implements Model {
constructor(session: Session, data: DiscordApplication) {
this.id = data.id;
this.session = session;
this.name = data.name;
this.icon = data.icon || undefined;
this.description = data.description;
this.rpcOrigins = data.rpc_origins;
this.botPublic = data.bot_public;
this.botRequireCodeGrant = data.bot_require_code_grant;
this.termsOfServiceURL = data.terms_of_service_url;
this.privacyPolicyURL = data.privacy_policy_url;
this.owner = data.owner
? new User(session, data.owner as DiscordUser)
: undefined;
this.summary = '';
this.verifyKey = data.verify_key;
this.team = data.team ? NewTeam(session, data.team) : undefined;
this.guildId = data.guild_id;
this.coverImage = data.cover_image;
this.tags = data.tags;
this.installParams = data.install_params;
this.customInstallURL = data.custom_install_url;
}
readonly session: Session;
id: Snowflake;
name: string;
icon?: string;
description: string;
rpcOrigins?: string[];
botPublic: boolean;
botRequireCodeGrant: boolean;
termsOfServiceURL?: string;
privacyPolicyURL?: string;
owner?: Partial<User>;
summary: SummaryDeprecated;
verifyKey: string;
team?: Team;
guildId?: Snowflake;
primarySkuId?: Snowflake;
slug?: string;
coverImage?: string;
flags?: number;
tags?: string[];
installParams?: DiscordInstallParams;
customInstallURL?: string;
}

View File

@ -0,0 +1,36 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type { DiscordAttachment } from '@biscuitland/api-types';
/**
* Represents an attachment
* @link https://discord.com/developers/docs/resources/channel#attachment-object
*/
export class Attachment implements Model {
constructor(session: Session, data: DiscordAttachment) {
this.session = session;
this.id = data.id;
this.contentType = data.content_type ? data.content_type : undefined;
this.attachment = data.url;
this.proxyUrl = data.proxy_url;
this.name = data.filename;
this.size = data.size;
this.height = data.height ? data.height : undefined;
this.width = data.width ? data.width : undefined;
this.ephemeral = !!data.ephemeral;
}
readonly session: Session;
readonly id: Snowflake;
contentType?: string;
attachment: string;
proxyUrl: string;
name: string;
size: number;
height?: number;
width?: number;
ephemeral: boolean;
}

View File

@ -0,0 +1,115 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type {
AutoModerationActionType,
AutoModerationEventTypes,
AutoModerationTriggerTypes,
DiscordAutoModerationRule,
DiscordAutoModerationRuleTriggerMetadataPresets,
DiscordAutoModerationActionExecution,
} from '@biscuitland/api-types';
export interface AutoModerationRuleTriggerMetadata {
keywordFilter?: string[];
presets?: DiscordAutoModerationRuleTriggerMetadataPresets[];
}
export interface ActionMetadata {
channelId: Snowflake;
durationSeconds: number;
}
export interface AutoModerationAction {
type: AutoModerationActionType;
metadata: ActionMetadata;
}
export class AutoModerationRule implements Model {
constructor(session: Session, data: DiscordAutoModerationRule) {
this.session = session;
this.id = data.id;
this.guildId = data.guild_id;
this.name = data.name;
this.creatorId = data.creator_id;
this.eventType = data.event_type;
this.triggerType = data.trigger_type;
this.triggerMetadata = {
keywordFilter: data.trigger_metadata.keyword_filter,
presets: data.trigger_metadata.presets,
};
this.actions = data.actions.map(action =>
Object.create({
type: action.type,
metadata: {
channelId: action.metadata.channel_id,
durationSeconds: action.metadata.duration_seconds,
},
})
);
this.enabled = !!data.enabled;
this.exemptRoles = data.exempt_roles;
this.exemptChannels = data.exempt_channels;
}
session: Session;
id: Snowflake;
guildId: Snowflake;
name: string;
creatorId: Snowflake;
eventType: AutoModerationEventTypes;
triggerType: AutoModerationTriggerTypes;
triggerMetadata: AutoModerationRuleTriggerMetadata;
actions: AutoModerationAction[];
enabled: boolean;
exemptRoles: Snowflake[];
exemptChannels: Snowflake[];
}
export class AutoModerationExecution {
constructor(session: Session, data: DiscordAutoModerationActionExecution) {
this.session = session;
this.guildId = data.guild_id;
this.action = Object.create({
type: data.action.type,
metadata: {
channelId: data.action.metadata.channel_id,
durationSeconds: data.action.metadata.duration_seconds,
},
});
this.ruleId = data.rule_id;
this.ruleTriggerType = data.rule_trigger_type;
this.userId = data.user_id;
this.content = data.content;
if (data.channel_id) {
this.channelId = data.channel_id;
}
if (data.message_id) {
this.messageId = data.message_id;
}
if (data.alert_system_message_id) {
this.alertSystemMessageId = data.alert_system_message_id;
}
if (data.matched_keyword) {
this.matchedKeyword = data.matched_keyword;
}
if (data.matched_content) {
this.matched_content = data.matched_content;
}
}
session: Session;
guildId: Snowflake;
action: AutoModerationAction;
ruleId: Snowflake;
ruleTriggerType: AutoModerationTriggerTypes;
userId: Snowflake;
channelId?: Snowflake;
messageId?: Snowflake;
alertSystemMessageId?: Snowflake;
content?: string;
matchedKeyword?: string;
matched_content?: string;
}

View File

@ -0,0 +1,12 @@
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
/**
* Represents a Discord data model
*/
export interface Model {
/** id of the model */
id: Snowflake;
/** reference to the client that instantiated the model */
session: Session;
}

View File

@ -0,0 +1,897 @@
/** Types */
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type { PermissionsOverwrites } from '../utils/util';
/** Functions and others */
// import { calculateShardId } from '../utils/calculate-shard';
import { urlToBase64 } from '../utils/url-to-base-64';
/** Classes and routes */
import type {
DiscordChannel,
DiscordInvite,
DiscordInviteMetadata,
DiscordListArchivedThreads,
DiscordMessage,
DiscordOverwrite,
DiscordThreadMember,
DiscordWebhook,
TargetTypes,
VideoQualityModes,
GetReactions,
GetMessagesOptions,
ListArchivedThreads } from '@biscuitland/api-types';
import {
CHANNEL,
CHANNEL_PINS,
CHANNEL_INVITES,
CHANNEL_TYPING,
CHANNEL_MESSAGES,
CHANNEL_WEBHOOKS,
THREAD_USER,
THREAD_ME,
THREAD_MEMBERS,
THREAD_START_PRIVATE,
THREAD_ARCHIVED_PUBLIC,
THREAD_ARCHIVED_PRIVATE_JOINED,
THREAD_START_PUBLIC,
ChannelTypes,
GatewayOpcodes as _GatewayOpcodes
} from '@biscuitland/api-types';
import type { CreateMessage, EditMessage, EmojiResolvable } from './message';
import { Message } from './message';
import { Invite } from './invite';
import { Webhook } from './webhook';
import { User } from './user';
import { ThreadMember } from './members';
import { Permissions } from './special/permissions';
/**
* Abstract class that represents the base for creating a new channel.
*/
export abstract class BaseChannel implements Model {
constructor(session: Session, data: DiscordChannel) {
this.id = data.id;
this.session = session;
this.name = data.name;
this.type = data.type;
}
/** id's refers to the identification of the channel */
readonly id: Snowflake;
/** The session that instantiated the channel */
readonly session: Session;
/** Channel name defined by the entity */
name?: string;
/** Refers to the possible channel type implemented (Guild, DM, Voice, News, etc...) */
type: ChannelTypes;
/** If the channel is a TextChannel */
isText(): this is TextChannel {
return textBasedChannels.includes(this.type);
}
/** If the channel is a VoiceChannel */
isVoice(): this is VoiceChannel {
return this.type === ChannelTypes.GuildVoice;
}
/** If the channel is a DMChannel */
isDM(): this is DMChannel {
return this.type === ChannelTypes.DM;
}
/** If the channel is a NewChannel */
isNews(): this is NewsChannel {
return this.type === ChannelTypes.GuildNews;
}
/** If the channel is a ThreadChannel */
isThread(): this is ThreadChannel {
return this.type === ChannelTypes.GuildPublicThread || this.type === ChannelTypes.GuildPrivateThread;
}
/** If the channel is a StageChannel */
isStage(): this is StageChannel {
return this.type === ChannelTypes.GuildStageVoice;
}
toString(): string {
return `<#${this.id}>`;
}
}
/**
* Represents a category channel.
*/
export class CategoryChannel extends BaseChannel {
constructor(session: Session, data: DiscordChannel) {
super(session, data);
this.id = data.id;
this.name = data.name ? data.name : '';
this.nsfw = data.nsfw ? data.nsfw : false;
this.guildId = data.guild_id ? data.guild_id : undefined;
this.type = ChannelTypes.GuildCategory;
this.position = data.position ? data.position : undefined;
this.parentId = data.parent_id ? data.parent_id : undefined;
this.permissionOverwrites = data.permission_overwrites
? ChannelFactory.permissionOverwrites(data.permission_overwrites)
: [];
}
id: Snowflake;
parentId?: string;
name: string;
permissionOverwrites: PermissionsOverwrites[];
nsfw: boolean;
guildId?: Snowflake;
position?: number;
}
/** TextChannel */
/**
* @link https://discord.com/developers/docs/resources/channel#create-channel-invite-json-params
* Represents the options object to create an invitation
*/
export interface DiscordInviteOptions {
/** duration of invite in seconds before expiry, or 0 for never. between 0 and 604800 (7 days) */
maxAge?: number;
/** max number of uses or 0 for unlimited. between 0 and 100 */
maxUses?: number;
/** if the invitation is unique. If it's true, don't try to reuse a similar invite (useful for creating many unique one time use invites) */
unique?: boolean;
/** whether this invite only grants temporary membership */
temporary: boolean;
reason?: string;
/** the type of target for this voice channel invite */
targetType?: TargetTypes;
/** the id of the user whose stream to display for this invite, required if targetType is 1, the user must be streaming in the channel */
targetUserId?: Snowflake;
/** the id of the embedded application to open for this invite, required if targetType is 2, the application must have the EMBEDDED flag */
targetApplicationId?: Snowflake;
}
/** Webhook create object */
export interface CreateWebhook {
/** name of the webhook (1-80 characters) */
name: string;
/** image for the default webhook avatar */
avatar?: string;
reason?: string;
}
/** Available text-channel-types list */
export const textBasedChannels: ChannelTypes[] = [
ChannelTypes.DM,
ChannelTypes.GroupDm,
ChannelTypes.GuildPrivateThread,
ChannelTypes.GuildPublicThread,
ChannelTypes.GuildNews,
ChannelTypes.GuildVoice,
ChannelTypes.GuildText,
];
/** Available text-channel-types */
export type TextBasedChannels =
| ChannelTypes.DM
| ChannelTypes.GroupDm
| ChannelTypes.GuildPrivateThread
| ChannelTypes.GuildPublicThread
| ChannelTypes.GuildNews
| ChannelTypes.GuildVoice
| ChannelTypes.GuildText;
/**
* Represents a text channel.
*/
export class TextChannel {
constructor(session: Session, data: DiscordChannel) {
this.session = session;
this.id = data.id;
this.name = data.name;
this.type = data.type as number;
this.rateLimitPerUser = data.rate_limit_per_user ?? 0;
this.nsfw = !!data.nsfw ?? false;
if (data.last_message_id) {
this.lastMessageId = data.last_message_id;
}
if (data.last_pin_timestamp) {
this.lastPinTimestamp = data.last_pin_timestamp;
}
}
/** The session that instantiated the channel */
readonly session: Session;
/** id's refers to the identification of the channel */
readonly id: Snowflake;
/** Current channel name */
name?: string;
/** The type of the channel */
type: TextBasedChannels;
/** The id of the last message sent in this channel (or thread for GUILD_FORUM channels) (may not point to an existing or valid message or thread) */
lastMessageId?: Snowflake;
/** When the last pinned message was pinned. This may be undefined in events such as GUILD_CREATE when a message is not pinned. */
lastPinTimestamp?: string;
/** Amount of seconds a user has to wait before sending another message (0-21600); bots, as well as users with the permission manage_messages or manage_channel, are unaffected */
rateLimitPerUser: number;
/** If the channel is NSFW (Not-Safe-For-Work content) */
nsfw: boolean;
/**
* Mixin
*/
// deno-lint-ignore ban-types
static applyTo(klass: Function, ignore: (keyof TextChannel)[] = []): void {
const methods: (keyof TextChannel)[] = [
'fetchPins',
'createInvite',
'fetchMessages',
'sendTyping',
'pinMessage',
'unpinMessage',
'addReaction',
'removeReaction',
'nukeReactions',
'fetchPins',
'sendMessage',
'editMessage',
'createWebhook',
];
for (const method of methods) {
if (ignore.includes(method)) { continue; }
klass.prototype[method] = TextChannel.prototype[method];
}
}
/**
* fetchPins makes an asynchronous request and gets the current channel pins.
* @returns A promise that resolves with an array of Message objects.
*/
async fetchPins(): Promise<Message[] | []> {
const messages = await this.session.rest.get<DiscordMessage[]>(
CHANNEL_PINS(this.id),
);
return messages[0] ? messages.map((x: DiscordMessage) => new Message(this.session, x)) : [];
}
/**
* createInvite makes an asynchronous request to create a new invitation.
* @param options - The options to create the invitation
* @returns The created invite
*/
async createInvite(options?: DiscordInviteOptions): Promise<Invite> {
const invite = await this.session.rest.post<DiscordInvite>(
CHANNEL_INVITES(this.id),
options
? {
max_age: options.maxAge,
max_uses: options.maxUses,
temporary: options.temporary,
unique: options.unique,
target_type: options.targetType,
target_user_id: options.targetUserId,
target_application_id: options.targetApplicationId,
}
: {},
);
return new Invite(this.session, invite);
}
/**
* fetchMessages makes an asynchronous request and gets the channel messages
* @param options - The options to get the messages
* @returns The messages
*/
async fetchMessages(options?: GetMessagesOptions): Promise<Message[] | []> {
if (options?.limit! > 100) { throw Error('Values must be between 0-100'); }
const messages = await this.session.rest.get<DiscordMessage[]>(
CHANNEL_MESSAGES(this.id, options),
);
return messages[0] ? messages.map(x => new Message(this.session, x)) : [];
}
/** sendTyping sends a typing POST request */
async sendTyping(): Promise<void> {
await this.session.rest.post<undefined>(CHANNEL_TYPING(this.id), {});
}
/**
* pinMessage pins a channel message.
* Same as Message.pin().
* @param messageId - The id of the message to pin
* @returns The promise that resolves when the request is complete
*/
async pinMessage(messageId: Snowflake): Promise<void> {
await Message.prototype.pin.call({ id: messageId, channelId: this.id, session: this.session });
}
/**
* unpinMessage unpin a channel message.
* Same as Message.unpin()
* @param messageId - The id of the message to unpin
* @returns The promise of the request
*/
async unpinMessage(messageId: Snowflake): Promise<void> {
await Message.prototype.unpin.call({ id: messageId, channelId: this.id, session: this.session });
}
/**
* addReaction adds a reaction to the message.
* Same as Message.addReaction().
* @param messageId - The message to add the reaction to
* @param reaction - The reaction to add
* @returns The promise of the request
*/
async addReaction(messageId: Snowflake, reaction: EmojiResolvable): Promise<void> {
await Message.prototype.addReaction.call(
{ channelId: this.id, id: messageId, session: this.session },
reaction,
);
}
/**
* removeReaction removes a reaction from the message.
* Same as Message.removeReaction().
* @param messageId - The id of the message to remove the reaction from
* @param reaction - The reaction to remove
* @param options - The user to remove the reaction from
*/
async removeReaction(
messageId: Snowflake,
reaction: EmojiResolvable,
options?: { userId: Snowflake },
): Promise<void> {
await Message.prototype.removeReaction.call(
{ channelId: this.id, id: messageId, session: this.session },
reaction,
options,
);
}
/**
* removeReactionEmoji removes an emoji reaction from the messageId provided.
* Same as Message.removeReactionEmoji().
* @param messageId - The message id to remove the reaction from.
*/
async removeReactionEmoji(messageId: Snowflake, reaction: EmojiResolvable): Promise<void> {
await Message.prototype.removeReactionEmoji.call(
{ channelId: this.id, id: messageId, session: this.session },
reaction,
);
}
/** nukeReactions nukes every reaction on the message.
* Same as Message.nukeReactions().
* @param messageId The message id to nuke reactions from.
* @returns A promise that resolves when the reactions are nuked.
*/
async nukeReactions(messageId: Snowflake): Promise<void> {
await Message.prototype.nukeReactions.call({ channelId: this.id, id: messageId });
}
/**
* fetchReactions gets the users who reacted with this emoji on the message.
* Same as Message.fetchReactions().
* @param messageId - The message id to get the reactions from.
* @param reaction - The emoji to get the reactions from.
* @param options - The options to get the reactions with.
* @returns The users who reacted with this emoji on the message.
*/
async fetchReactions(
messageId: Snowflake,
reaction: EmojiResolvable,
options?: GetReactions,
): Promise<User[]> {
const users = await Message.prototype.fetchReactions.call(
{ channelId: this.id, id: messageId, session: this.session },
reaction,
options,
);
return users;
}
/**
* sendMessage sends a message to the channel.
* Same as Message.reply().
* @param options - Options for a new message.
* @returns The sent message.
*/
sendMessage(options: CreateMessage): Promise<Message> {
return Message.prototype.reply.call({ channelId: this.id, session: this.session }, options);
}
/**
* editMessage edits a message.
* Same as Message.edit().
* @param messageId - Message ID.
* @param options - Options for edit a message.
* @returns The edited message.
*/
editMessage(messageId: Snowflake, options: EditMessage): Promise<Message> {
return Message.prototype.edit.call({ channelId: this.id, id: messageId, session: this.session }, options);
}
/**
* createWebhook creates a webhook.
* @param options - Options for a new webhook.
* @returns The created webhook.
*/
async createWebhook(options: CreateWebhook): Promise<Webhook> {
const webhook = await this.session.rest.post<DiscordWebhook>(
CHANNEL_WEBHOOKS(this.id),
{
name: options.name,
avatar: options.avatar ? urlToBase64(options.avatar) : undefined,
reason: options.reason,
},
);
return new Webhook(this.session, webhook);
}
}
/** GuildChannel */
/**
* Represent the options object to create a thread channel
* @link https://discord.com/developers/docs/resources/channel#start-thread-without-message
*/
export interface ThreadCreateOptions {
name: string;
autoArchiveDuration?: 60 | 1440 | 4320 | 10080;
type: 10 | 11 | 12;
invitable?: boolean;
rateLimitPerUser?: number;
reason?: string;
}
/**
* Representations of the objects to edit a guild channel
* @link https://discord.com/developers/docs/resources/channel#modify-channel-json-params-guild-channel
*/
export interface EditGuildChannelOptions {
name?: string;
position?: number;
permissionOverwrites?: PermissionsOverwrites[];
}
export interface EditNewsChannelOptions extends EditGuildChannelOptions {
type?: ChannelTypes.GuildNews | ChannelTypes.GuildText;
topic?: string | null;
nfsw?: boolean | null;
parentId?: Snowflake | null;
defaultAutoArchiveDuration?: number | null;
}
export interface EditGuildTextChannelOptions extends EditNewsChannelOptions {
rateLimitPerUser?: number | null;
}
export interface EditStageChannelOptions extends EditGuildChannelOptions {
bitrate?: number | null;
rtcRegion?: Snowflake | null;
}
export interface EditVoiceChannelOptions extends EditStageChannelOptions {
nsfw?: boolean | null;
userLimit?: number | null;
parentId?: Snowflake | null;
videoQualityMode?: VideoQualityModes | null;
}
/**
* Represents the option object to create a thread channel from a message
* @link https://discord.com/developers/docs/resources/channel#start-thread-from-message
*/
export interface ThreadCreateOptions {
name: string;
autoArchiveDuration?: 60 | 1440 | 4320 | 10080;
rateLimitPerUser?: number;
messageId: Snowflake;
}
/**
* @link https://discord.com/developers/docs/resources/channel#list-public-archived-threads-response-body
*/
export interface ReturnThreadsArchive {
threads: Record<Snowflake, ThreadChannel>;
members: Record<Snowflake, ThreadMember>;
hasMore: boolean;
}
export class GuildChannel extends BaseChannel implements Model {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data);
this.type = data.type as number;
this.guildId = guildId;
this.position = data.position;
data.topic ? this.topic = data.topic : null;
data.parent_id ? this.parentId = data.parent_id : undefined;
this.permissionOverwrites = data.permission_overwrites
? ChannelFactory.permissionOverwrites(data.permission_overwrites)
: [];
}
override type: Exclude<ChannelTypes, ChannelTypes.DM | ChannelTypes.GroupDm>;
guildId: Snowflake;
topic?: string;
position?: number;
parentId?: Snowflake;
permissionOverwrites: PermissionsOverwrites[];
async fetchInvites(): Promise<Invite[]> {
const invites = await this.session.rest.get<DiscordInviteMetadata[]>(CHANNEL_INVITES(this.id));
return invites.map(invite => new Invite(this.session, invite));
}
async edit(options: EditNewsChannelOptions): Promise<NewsChannel>;
async edit(options: EditStageChannelOptions): Promise<StageChannel>;
async edit(options: EditVoiceChannelOptions): Promise<VoiceChannel>;
async edit(
options: EditGuildTextChannelOptions | EditNewsChannelOptions | EditVoiceChannelOptions,
): Promise<Channel> {
const channel = await this.session.rest.patch<DiscordChannel>(
CHANNEL(this.id),
{
name: options.name,
type: 'type' in options ? options.type : undefined,
position: options.position,
topic: 'topic' in options ? options.topic : undefined,
nsfw: 'nfsw' in options ? options.nfsw : undefined,
rate_limit_per_user: 'rateLimitPerUser' in options ? options.rateLimitPerUser : undefined,
bitrate: 'bitrate' in options ? options.bitrate : undefined,
user_limit: 'userLimit' in options ? options.userLimit : undefined,
permissions_overwrites: options.permissionOverwrites,
parent_id: 'parentId' in options ? options.parentId : undefined,
rtc_region: 'rtcRegion' in options ? options.rtcRegion : undefined,
video_quality_mode: 'videoQualityMode' in options ? options.videoQualityMode : undefined,
default_auto_archive_duration: 'defaultAutoArchiveDuration' in options
? options.defaultAutoArchiveDuration
: undefined,
},
);
return ChannelFactory.from(this.session, channel);
}
async getArchivedThreads(
options: ListArchivedThreads & { type: 'public' | 'private' | 'privateJoinedThreads' },
): Promise<ReturnThreadsArchive> {
let func: (channelId: Snowflake, options: ListArchivedThreads) => string;
switch (options.type) {
case 'public':
func = THREAD_ARCHIVED_PUBLIC;
break;
case 'private':
func = THREAD_START_PRIVATE;
break;
case 'privateJoinedThreads':
func = THREAD_ARCHIVED_PRIVATE_JOINED;
break;
}
const { threads, members, has_more } = await this.session.rest.get<DiscordListArchivedThreads>(
func(this.id, options),
);
return {
threads: Object.fromEntries(
threads.map(thread => [thread.id, new ThreadChannel(this.session, thread, this.id)]),
) as Record<Snowflake, ThreadChannel>,
members: Object.fromEntries(
members.map(threadMember => [threadMember.id, new ThreadMember(this.session, threadMember)]),
) as Record<Snowflake, ThreadMember>,
hasMore: has_more,
};
}
async createThread(options: ThreadCreateOptions): Promise<ThreadChannel> {
const thread = await this.session.rest.post<DiscordChannel>(
'messageId' in options
? THREAD_START_PUBLIC(this.id, options.messageId)
: THREAD_START_PRIVATE(this.id),
{
name: options.name,
auto_archive_duration: options.autoArchiveDuration,
},
);
return new ThreadChannel(this.session, thread, thread.guild_id ?? this.guildId);
}
}
/** BaseVoiceChannel */
/**
* @link https://discord.com/developers/docs/topics/gateway#update-voice-state
*/
export interface UpdateVoiceState {
guildId: string;
channelId?: string;
selfMute: boolean;
selfDeaf: boolean;
}
export abstract class BaseVoiceChannel extends GuildChannel {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.bitRate = data.bitrate;
this.userLimit = data.user_limit ?? 0;
this.videoQuality = data.video_quality_mode;
this.nsfw = !!data.nsfw;
this.type = data.type as number;
if (data.rtc_region) {
this.rtcRegion = data.rtc_region;
}
}
override type: ChannelTypes.GuildVoice | ChannelTypes.GuildStageVoice;
bitRate?: number;
userLimit: number;
rtcRegion?: Snowflake;
videoQuality?: VideoQualityModes;
nsfw: boolean;
// TODO: CONNECT TO VOICE CHAT
}
/** DMChannel */
export class DMChannel extends BaseChannel implements Model {
constructor(session: Session, data: DiscordChannel) {
super(session, data);
this.user = new User(this.session, data.recipents!.find(r => r.id !== this.session.botId)!);
this.type = data.type as ChannelTypes.DM | ChannelTypes.GroupDm;
if (data.last_message_id) {
this.lastMessageId = data.last_message_id;
}
}
override type: ChannelTypes.DM | ChannelTypes.GroupDm;
user: User;
lastMessageId?: Snowflake;
async close(): Promise<DMChannel> {
const channel = await this.session.rest.delete<DiscordChannel>(CHANNEL(this.id), {});
return new DMChannel(this.session, channel);
}
}
export interface DMChannel extends Omit<TextChannel, 'type'>, Omit<BaseChannel, 'type'> {}
TextChannel.applyTo(DMChannel);
/** VoiceChannel */
export class VoiceChannel extends BaseVoiceChannel {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.type = data.type as number;
}
override type: ChannelTypes.GuildVoice;
}
export interface VoiceChannel extends TextChannel, BaseVoiceChannel {}
TextChannel.applyTo(VoiceChannel);
/** NewsChannel */
export class NewsChannel extends GuildChannel {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.type = data.type as ChannelTypes.GuildNews;
this.defaultAutoArchiveDuration = data.default_auto_archive_duration;
}
override type: ChannelTypes.GuildNews;
defaultAutoArchiveDuration?: number;
crosspostMessage(messageId: Snowflake): Promise<Message> {
return Message.prototype.crosspost.call({ id: messageId, channelId: this.id, session: this.session });
}
get publishMessage() {
return this.crosspostMessage;
}
}
TextChannel.applyTo(NewsChannel);
export interface NewsChannel extends TextChannel, GuildChannel {}
/** StageChannel */
export class StageChannel extends BaseVoiceChannel {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.type = data.type as number;
this.topic = data.topic ? data.topic : undefined;
}
override type: ChannelTypes.GuildStageVoice;
topic?: string;
}
/** ThreadChannel */
export class ThreadChannel extends GuildChannel implements Model {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.type = data.type as number;
this.archived = !!data.thread_metadata?.archived;
this.archiveTimestamp = data.thread_metadata?.archive_timestamp;
this.autoArchiveDuration = data.thread_metadata?.auto_archive_duration;
this.locked = !!data.thread_metadata?.locked;
this.messageCount = data.message_count;
this.memberCount = data.member_count;
this.ownerId = data.owner_id;
if (data.member) {
this.member = new ThreadMember(session, data.member);
}
}
override type: ChannelTypes.GuildNewsThread | ChannelTypes.GuildPrivateThread | ChannelTypes.GuildPublicThread;
archived?: boolean;
archiveTimestamp?: string;
autoArchiveDuration?: number;
locked?: boolean;
messageCount?: number;
memberCount?: number;
member?: ThreadMember;
ownerId?: Snowflake;
async joinThread(): Promise<void> {
await this.session.rest.put<undefined>(THREAD_ME(this.id), {});
}
async addToThread(guildMemberId: Snowflake): Promise<void> {
await this.session.rest.put<undefined>(THREAD_USER(this.id, guildMemberId), {});
}
async leaveToThread(guildMemberId: Snowflake): Promise<void> {
await this.session.rest.delete<undefined>(THREAD_USER(this.id, guildMemberId), {});
}
removeMember(memberId: Snowflake = this.session.botId): Promise<void> {
return ThreadMember.prototype.quitThread.call({ id: this.id, session: this.session }, memberId);
}
fetchMember(memberId: Snowflake = this.session.botId): Promise<ThreadMember> {
return ThreadMember.prototype.fetchMember.call({ id: this.id, session: this.session }, memberId);
}
async fetchMembers(): Promise<ThreadMember[]> {
const members = await this.session.rest.get<DiscordThreadMember[]>(
THREAD_MEMBERS(this.id),
);
return members.map(threadMember => new ThreadMember(this.session, threadMember));
}
}
export interface ThreadChannel extends Omit<GuildChannel, 'type'>, Omit<TextChannel, 'type'> {}
TextChannel.applyTo(ThreadChannel);
export class GuildTextChannel extends GuildChannel {
constructor(session: Session, data: DiscordChannel, guildId: Snowflake) {
super(session, data, guildId);
this.type = data.type as ChannelTypes.GuildText;
}
override type: ChannelTypes.GuildText;
}
export interface GuildTextChannel extends GuildChannel, TextChannel {}
TextChannel.applyTo(GuildTextChannel);
/** ChannelFactory */
export type Channel =
| GuildTextChannel
| TextChannel
| VoiceChannel
| DMChannel
| NewsChannel
| ThreadChannel
| StageChannel
| CategoryChannel;
export type ChannelInGuild =
| GuildTextChannel
| VoiceChannel
| StageChannel
| NewsChannel
| ThreadChannel;
export type ChannelWithMessages =
| GuildTextChannel
| VoiceChannel
| DMChannel
| NewsChannel
| ThreadChannel;
export type ChannelWithMessagesInGuild = Exclude<ChannelWithMessages, DMChannel>;
export type PartialChannel = {
id: string;
name: string;
position: number;
};
export class ChannelFactory {
static fromGuildChannel(session: Session, channel: DiscordChannel): ChannelInGuild {
switch (channel.type) {
case ChannelTypes.GuildPublicThread:
case ChannelTypes.GuildPrivateThread:
return new ThreadChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildText:
return new GuildTextChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildNews:
return new NewsChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildVoice:
return new VoiceChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildStageVoice:
return new StageChannel(session, channel, channel.guild_id!);
default:
throw new Error('Channel was not implemented');
}
}
static from(session: Session, channel: DiscordChannel): Channel {
switch (channel.type) {
case ChannelTypes.GuildPublicThread:
case ChannelTypes.GuildPrivateThread:
return new ThreadChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildText:
return new GuildTextChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildNews:
return new NewsChannel(session, channel, channel.guild_id!);
case ChannelTypes.DM:
return new DMChannel(session, channel);
case ChannelTypes.GuildVoice:
return new VoiceChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildStageVoice:
return new StageChannel(session, channel, channel.guild_id!);
case ChannelTypes.GuildCategory:
return new CategoryChannel(session, channel);
default:
if (textBasedChannels.includes(channel.type)) {
return new TextChannel(session, channel);
}
throw new Error('Channel was not implemented');
}
}
static permissionOverwrites(overwrites: DiscordOverwrite[]): PermissionsOverwrites[] {
return overwrites.map(v => {
return {
id: v.id,
type: v.type,
allow: new Permissions(parseInt(v.allow!)),
deny: new Permissions(parseInt(v.deny!)),
};
});
}
}

View File

@ -0,0 +1,268 @@
import type { Session } from '../biscuit';
import type {
DiscordComponent,
DiscordInputTextComponent,
TextStyles,
} from '@biscuitland/api-types';
import { Emoji } from './emojis';
import { ButtonStyles, MessageComponentTypes } from '@biscuitland/api-types';
export class BaseComponent {
constructor(type: MessageComponentTypes) {
this.type = type;
}
type: MessageComponentTypes;
isActionRow(): this is ActionRowComponent {
return this.type === MessageComponentTypes.ActionRow;
}
isButton(): this is ButtonComponent {
return this.type === MessageComponentTypes.Button;
}
isSelectMenu(): this is SelectMenuComponent {
return this.type === MessageComponentTypes.SelectMenu;
}
isTextInput(): this is TextInputComponent {
return this.type === MessageComponentTypes.InputText;
}
}
/** Action Row Component */
export interface ActionRowComponent {
type: MessageComponentTypes.ActionRow;
components: Exclude<Component, ActionRowComponent>[];
}
/** All Components */
export type Component =
| ActionRowComponent
| ButtonComponent
| LinkButtonComponent
| SelectMenuComponent
| TextInputComponent;
/** Button Component */
export type ClassicButton = Exclude<ButtonStyles, ButtonStyles.Link>;
export type ComponentsWithoutRow = Exclude<Component, ActionRowComponent>;
export interface ButtonComponent {
type: MessageComponentTypes.Button;
style: ClassicButton;
label?: string;
emoji?: Emoji;
customId?: string;
disabled?: boolean;
}
/** Link Button Component */
export interface LinkButtonComponent {
type: MessageComponentTypes.Button;
style: ButtonStyles.Link;
label?: string;
url: string;
disabled?: boolean;
}
/** Select Menu Component */
export interface SelectMenuComponent {
type: MessageComponentTypes.SelectMenu;
customId: string;
options: SelectMenuOption[];
placeholder?: string;
minValue?: number;
maxValue?: number;
disabled?: boolean;
}
/** Text Input Component */
export interface TextInputComponent {
type: MessageComponentTypes.InputText;
customId: string;
style: TextStyles;
label: string;
minLength?: number;
maxLength?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export interface SelectMenuOption {
label: string;
value: string;
description?: string;
emoji?: Emoji;
default?: boolean;
}
export class Button extends BaseComponent implements ButtonComponent {
constructor(session: Session, data: DiscordComponent) {
super(data.type);
this.session = session;
this.type = data.type as MessageComponentTypes.Button;
this.customId = data.custom_id;
this.label = data.label;
this.style = data.style as ClassicButton;
this.disabled = data.disabled;
if (data.emoji) {
this.emoji = new Emoji(session, data.emoji);
}
}
readonly session: Session;
override type: MessageComponentTypes.Button;
customId?: string;
label?: string;
style: ClassicButton;
disabled?: boolean;
emoji?: Emoji;
}
export class LinkButton extends BaseComponent implements LinkButtonComponent {
constructor(session: Session, data: DiscordComponent) {
super(data.type);
this.session = session;
this.type = data.type as MessageComponentTypes.Button;
this.url = data.url!;
this.label = data.label;
this.style = data.style as number;
this.disabled = data.disabled;
if (data.emoji) {
this.emoji = new Emoji(session, data.emoji);
}
}
readonly session: Session;
override type: MessageComponentTypes.Button;
url: string;
label?: string;
style: ButtonStyles.Link;
disabled?: boolean;
emoji?: Emoji;
}
export class SelectMenu extends BaseComponent implements SelectMenuComponent {
constructor(session: Session, data: DiscordComponent) {
super(data.type);
this.session = session;
this.type = data.type as MessageComponentTypes.SelectMenu;
this.customId = data.custom_id!;
this.options = data.options!.map(option => {
return <SelectMenuOption>{
label: option.label,
description: option.description,
emoji: option.emoji || new Emoji(session, option.emoji!),
value: option.value,
};
});
this.placeholder = data.placeholder;
this.minValues = data.min_values;
this.maxValues = data.max_values;
this.disabled = data.disabled;
}
readonly session: Session;
override type: MessageComponentTypes.SelectMenu;
customId: string;
options: SelectMenuOption[];
placeholder?: string;
minValues?: number;
maxValues?: number;
disabled?: boolean;
}
export class TextInput extends BaseComponent implements TextInputComponent {
constructor(session: Session, data: DiscordInputTextComponent) {
super(data.type);
this.session = session;
this.type = data.type as MessageComponentTypes.InputText;
this.customId = data.custom_id!;
this.label = data.label!;
this.style = data.style as TextStyles;
this.placeholder = data.placeholder;
this.value = data.value;
this.minLength = data.min_length;
this.maxLength = data.max_length;
}
readonly session: Session;
override type: MessageComponentTypes.InputText;
style: TextStyles;
customId: string;
label: string;
placeholder?: string;
value?: string;
minLength?: number;
maxLength?: number;
}
export class ActionRow extends BaseComponent implements ActionRowComponent {
constructor(session: Session, data: DiscordComponent) {
super(data.type);
this.session = session;
this.type = data.type as MessageComponentTypes.ActionRow;
this.components = data.components!.map(component => {
switch (component.type) {
case MessageComponentTypes.Button:
if (component.style === ButtonStyles.Link) {
return new LinkButton(session, component);
}
return new Button(session, component);
case MessageComponentTypes.SelectMenu:
return new SelectMenu(session, component);
case MessageComponentTypes.InputText:
return new TextInput(
session,
component as DiscordInputTextComponent
);
case MessageComponentTypes.ActionRow:
throw new Error(
'Cannot have an action row inside an action row'
);
}
});
}
readonly session: Session;
override type: MessageComponentTypes.ActionRow;
components: ComponentsWithoutRow[];
}
export class ComponentFactory {
/**
* Component factory
* @internal
*/
static from(session: Session, component: DiscordComponent): Component {
switch (component.type) {
case MessageComponentTypes.ActionRow:
return new ActionRow(session, component);
case MessageComponentTypes.Button:
if (component.style === ButtonStyles.Link) {
return new LinkButton(session, component);
}
return new Button(session, component);
case MessageComponentTypes.SelectMenu:
return new SelectMenu(session, component);
case MessageComponentTypes.InputText:
return new TextInput(
session,
component as DiscordInputTextComponent
);
}
}
}

View File

@ -0,0 +1,100 @@
import type { DiscordEmbed, EmbedTypes } from '@biscuitland/api-types';
export interface Embed {
title?: string;
timestamp?: string;
type?: EmbedTypes;
url?: string;
color?: number;
description?: string;
author?: {
name: string;
iconURL?: string;
proxyIconURL?: string;
url?: string;
};
footer?: {
text: string;
iconURL?: string;
proxyIconURL?: string;
};
fields?: {
name: string;
value: string;
inline?: boolean;
}[];
thumbnail?: {
url: string;
proxyURL?: string;
width?: number;
height?: number;
};
video?: {
url?: string;
proxyURL?: string;
width?: number;
height?: number;
};
image?: {
url: string;
proxyURL?: string;
width?: number;
height?: number;
};
provider?: {
url?: string;
name?: string;
};
}
export function embed(data: Embed): DiscordEmbed {
return {
title: data.title,
timestamp: data.timestamp,
type: data.type,
url: data.url,
color: data.color,
description: data.description,
author: data.author && {
name: data.author.name,
url: data.author.url,
icon_url: data.author.iconURL,
proxy_icon_url: data.author.proxyIconURL,
},
footer: data.footer && {
text: data.footer.text,
icon_url: data.footer.iconURL,
proxy_icon_url: data.footer.proxyIconURL,
},
fields: data.fields?.map(f => {
return {
name: f.name,
value: f.value,
inline: f.inline,
};
}),
thumbnail: data.thumbnail && {
url: data.thumbnail.url,
proxy_url: data.thumbnail.proxyURL,
width: data.thumbnail.width,
height: data.thumbnail.height,
},
video: {
url: data.video?.url,
proxy_url: data.video?.proxyURL,
width: data.video?.width,
height: data.video?.height,
},
image: data.image && {
url: data.image.url,
proxy_url: data.image.proxyURL,
width: data.image.width,
height: data.image.height,
},
provider: {
url: data.provider?.url,
name: data.provider?.name,
},
};
}

View File

@ -0,0 +1,70 @@
import type { Session } from '../biscuit';
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { DiscordEmoji } from '@biscuitland/api-types';
import type { ModifyGuildEmoji } from './guilds';
import { Guild } from './guilds';
import { User } from './user';
import { EMOJI_URL } from '@biscuitland/api-types';
export class Emoji implements Partial<Model> {
constructor(session: Session, data: DiscordEmoji) {
this.id = data.id;
this.name = data.name;
this.animated = !!data.animated;
this.available = !!data.available;
this.requireColons = !!data.require_colons;
this.session = session;
}
readonly id?: Snowflake;
readonly session: Session;
name?: string;
animated: boolean;
available: boolean;
requireColons: boolean;
}
export class GuildEmoji extends Emoji implements Model {
constructor(session: Session, data: DiscordEmoji, guildId: Snowflake) {
super(session, data);
this.guildId = guildId;
this.roles = data.roles;
this.user = data.user ? new User(this.session, data.user) : undefined;
this.managed = !!data.managed;
this.id = super.id!;
}
guildId: Snowflake;
roles?: Snowflake[];
user?: User;
managed?: boolean;
// id cannot be null in a GuildEmoji
override id: Snowflake;
async edit(options: ModifyGuildEmoji): Promise<GuildEmoji> {
const emoji = await Guild.prototype.editEmoji.call(
{ id: this.guildId, session: this.session },
this.id,
options
);
return emoji;
}
async delete(reason?: string): Promise<GuildEmoji> {
await Guild.prototype.deleteEmoji.call(
{ id: this.guildId, session: this.session },
this.id,
reason
);
return this;
}
get url(): string {
return EMOJI_URL(this.id, this.animated);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type {
DiscordIntegration,
IntegrationExpireBehaviors,
} from '@biscuitland/api-types';
import { User } from './user';
export type IntegrationTypes = 'twitch' | 'youtube' | 'discord';
export interface IntegrationAccount {
id: Snowflake;
name: string;
}
export interface IntegrationApplication {
id: Snowflake;
name: string;
icon?: string;
description: string;
bot?: User;
}
export class Integration implements Model {
constructor(
session: Session,
data: DiscordIntegration & { guild_id?: Snowflake }
) {
this.id = data.id;
this.session = session;
data.guild_id ? (this.guildId = data.guild_id) : null;
this.name = data.name;
this.type = data.type;
this.enabled = !!data.enabled;
this.syncing = !!data.syncing;
this.roleId = data.role_id;
this.enableEmoticons = !!data.enable_emoticons;
this.expireBehavior = data.expire_behavior;
this.expireGracePeriod = data.expire_grace_period;
this.syncedAt = data.synced_at;
this.subscriberCount = data.subscriber_count;
this.revoked = !!data.revoked;
this.user = data.user ? new User(session, data.user) : undefined;
this.account = {
id: data.account.id,
name: data.account.name,
};
if (data.application) {
this.application = {
id: data.application.id,
name: data.application.name,
icon: data.application.icon ? data.application.icon : undefined,
description: data.application.description,
bot: data.application.bot
? new User(session, data.application.bot)
: undefined,
};
}
}
readonly session: Session;
id: Snowflake;
guildId?: Snowflake;
name: string;
type: IntegrationTypes;
enabled?: boolean;
syncing?: boolean;
roleId?: string;
enableEmoticons?: boolean;
expireBehavior?: IntegrationExpireBehaviors;
expireGracePeriod?: number;
syncedAt?: string;
subscriberCount?: number;
revoked?: boolean;
user?: User;
account: IntegrationAccount;
application?: IntegrationApplication;
}
export default Integration;

View File

@ -0,0 +1,602 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type {
ApplicationCommandTypes,
DiscordInteraction,
DiscordMessage,
DiscordMessageComponents,
DiscordMemberWithUser,
DiscordMessageInteraction,
} from '@biscuitland/api-types';
import type { CreateMessage } from './message';
import type { MessageFlags } from '../utils/util';
import type { EditWebhookMessage } from './webhook';
import {
InteractionResponseTypes,
InteractionTypes,
MessageComponentTypes,
} from '@biscuitland/api-types';
import { Role } from './role';
import { Attachment } from './attachment';
import { Snowflake } from '../snowflakes';
import { User } from './user';
import { Member } from './members';
import { Message } from './message';
import { Permissions } from './special/permissions';
import { Webhook } from './webhook';
import { CommandInteractionOptionResolver } from './special/command-interaction-option-resolver';
import {
INTERACTION_ID_TOKEN,
WEBHOOK_MESSAGE,
WEBHOOK_MESSAGE_ORIGINAL,
} from '@biscuitland/api-types';
export type InteractionResponseWith = {
with: InteractionApplicationCommandCallbackData;
};
export type InteractionResponseWithData =
| InteractionResponse
| InteractionResponseWith;
/**
* @link https://discord.com/developers/docs/interactions/slash-commands#interaction-response
*/
export interface InteractionResponse {
type: InteractionResponseTypes;
data?: InteractionApplicationCommandCallbackData;
}
/**
* @link https://discord.com/developers/docs/interactions/slash-commands#interaction-response-interactionapplicationcommandcallbackdata
*/
export interface InteractionApplicationCommandCallbackData
extends Pick<
CreateMessage,
'allowedMentions' | 'content' | 'embeds' | 'files'
> {
customId?: string;
title?: string;
components?: DiscordMessageComponents;
flags?: MessageFlags;
choices?: ApplicationCommandOptionChoice[];
}
/**
* @link https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptionchoice
*/
export interface ApplicationCommandOptionChoice {
name: string;
value: string | number;
}
export abstract class BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
this.session = session;
this.id = data.id;
this.token = data.token;
this.type = data.type;
this.guildId = data.guild_id;
this.channelId = data.channel_id;
this.applicationId = data.application_id;
this.version = data.version;
// @ts-expect-error: vendor error
const perms = data.app_permissions as string;
if (perms) {
this.appPermissions = new Permissions(BigInt(perms));
}
if (!data.guild_id) {
this.user = new User(session, data.user!);
} else {
this.member = new Member(session, data.member!, data.guild_id);
}
}
readonly session: Session;
readonly id: Snowflake;
readonly token: string;
type: InteractionTypes;
guildId?: Snowflake;
channelId?: Snowflake;
applicationId?: Snowflake;
user?: User;
member?: Member;
appPermissions?: Permissions;
readonly version: 1;
responded = false;
get createdTimestamp(): number {
return Snowflake.snowflakeToTimestamp(this.id);
}
get createdAt(): Date {
return new Date(this.createdTimestamp);
}
isCommand(): this is CommandInteraction {
return this.type === InteractionTypes.ApplicationCommand;
}
isAutoComplete(): this is AutoCompleteInteraction {
return this.type === InteractionTypes.ApplicationCommandAutocomplete;
}
isComponent(): this is ComponentInteraction {
return this.type === InteractionTypes.MessageComponent;
}
isPing(): this is PingInteraction {
return this.type === InteractionTypes.Ping;
}
isModalSubmit(): this is ModalSubmitInteraction {
return this.type === InteractionTypes.ModalSubmit;
}
inGuild(): this is this & { guildId: Snowflake } {
return !!this.guildId;
}
// webhooks methods:
async editReply(
options: EditWebhookMessage & { messageId?: Snowflake }
): Promise<Message | undefined> {
const message = await this.session.rest.patch<
DiscordMessage | undefined
>(
options.messageId
? WEBHOOK_MESSAGE(this.id, this.token, options.messageId)
: WEBHOOK_MESSAGE_ORIGINAL(this.id, this.token),
{
content: options.content,
embeds: options.embeds,
file: options.files,
components: options.components,
allowed_mentions: options.allowedMentions && {
parse: options.allowedMentions.parse,
replied_user: options.allowedMentions.repliedUser,
users: options.allowedMentions.users,
roles: options.allowedMentions.roles,
},
attachments: options.attachments?.map(attachment => {
return {
id: attachment.id,
filename: attachment.name,
content_type: attachment.contentType,
size: attachment.size,
url: attachment.attachment,
proxy_url: attachment.proxyUrl,
height: attachment.height,
width: attachment.width,
};
}),
message_id: options.messageId,
}
);
if (!message || !options.messageId) {
return message as undefined;
}
return new Message(this.session, message);
}
async sendFollowUp(
options: InteractionApplicationCommandCallbackData
): Promise<Message> {
const message = await Webhook.prototype.execute.call(
{
id: this.applicationId!,
token: this.token,
session: this.session,
},
options
);
return message!;
}
async editFollowUp(
messageId: Snowflake,
options?: { threadId: Snowflake }
): Promise<Message> {
const message = await Webhook.prototype.editMessage.call(
{
id: this.session.applicationId,
token: this.token,
},
messageId,
options
);
return message;
}
async deleteFollowUp(
messageId: Snowflake,
threadId?: Snowflake
): Promise<void> {
await Webhook.prototype.deleteMessage.call(
{
id: this.session.applicationId,
token: this.token,
},
messageId,
threadId
);
}
async fetchFollowUp(
messageId: Snowflake,
threadId?: Snowflake
): Promise<Message | undefined> {
const message = await Webhook.prototype.fetchMessage.call(
{
id: this.session.applicationId,
token: this.token,
},
messageId,
threadId
);
return message;
}
// end webhook methods
async respond(resp: InteractionResponse): Promise<Message | undefined>;
async respond(resp: InteractionResponseWith): Promise<Message | undefined>;
async respond(
resp: InteractionResponseWithData
): Promise<Message | undefined> {
const options = 'with' in resp ? resp.with : resp.data;
const type =
'type' in resp
? resp.type
: InteractionResponseTypes.ChannelMessageWithSource;
const data = {
content: options?.content,
custom_id: options?.customId,
file: options?.files,
allowed_mentions: options?.allowedMentions,
flags: options?.flags,
chocies: options?.choices,
embeds: options?.embeds,
title: options?.title,
components: options?.components,
};
if (!this.responded) {
await this.session.rest.post<undefined>(
INTERACTION_ID_TOKEN(this.id, this.token),
{
file: options?.files,
type,
data,
}
);
this.responded = true;
return;
}
return this.sendFollowUp(data);
}
// start custom methods
async respondWith(
resp: InteractionApplicationCommandCallbackData
): Promise<Message | undefined> {
const m = await this.respond({ with: resp });
return m;
}
async defer() {
await this.respond({
type: InteractionResponseTypes.DeferredChannelMessageWithSource,
});
}
async autocomplete() {
await this.respond({
type: InteractionResponseTypes.ApplicationCommandAutocompleteResult,
});
}
// end custom methods
}
export class AutoCompleteInteraction extends BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
super(session, data);
this.type = data.type as number;
this.commandId = data.data!.id;
this.commandName = data.data!.name;
this.commandType = data.data!.type;
this.commandGuildId = data.data!.guild_id;
}
override type: InteractionTypes.ApplicationCommandAutocomplete;
commandId: Snowflake;
commandName: string;
commandType: ApplicationCommandTypes;
commandGuildId?: Snowflake;
async respondWithChoices(
choices: ApplicationCommandOptionChoice[]
): Promise<void> {
await this.session.rest.post<undefined>(
INTERACTION_ID_TOKEN(this.id, this.token),
{
data: { choices },
type: InteractionResponseTypes.ApplicationCommandAutocompleteResult,
}
);
}
}
export interface CommandInteractionDataResolved {
users: Map<Snowflake, User>;
members: Map<Snowflake, Member>;
roles: Map<Snowflake, Role>;
attachments: Map<Snowflake, Attachment>;
messages: Map<Snowflake, Message>;
}
export class CommandInteraction extends BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
super(session, data);
this.type = data.type as number;
this.commandId = data.data!.id;
this.commandName = data.data!.name;
this.commandType = data.data!.type;
this.commandGuildId = data.data!.guild_id;
this.options = new CommandInteractionOptionResolver(
data.data!.options ?? []
);
this.resolved = {
users: new Map(),
members: new Map(),
roles: new Map(),
attachments: new Map(),
messages: new Map(),
};
if (data.data!.resolved?.users) {
for (const [id, u] of Object.entries(data.data!.resolved.users)) {
this.resolved.users.set(id, new User(session, u));
}
}
if (data.data!.resolved?.members && !!super.guildId) {
for (const [id, m] of Object.entries(data.data!.resolved.members)) {
this.resolved.members.set(
id,
new Member(
session,
m as DiscordMemberWithUser,
super.guildId!
)
);
}
}
if (data.data!.resolved?.roles && !!super.guildId) {
for (const [id, r] of Object.entries(data.data!.resolved.roles)) {
this.resolved.roles.set(
id,
new Role(session, r, super.guildId!)
);
}
}
if (data.data!.resolved?.attachments) {
for (const [id, a] of Object.entries(
data.data!.resolved.attachments
)) {
this.resolved.attachments.set(id, new Attachment(session, a));
}
}
if (data.data!.resolved?.messages) {
for (const [id, m] of Object.entries(
data.data!.resolved.messages
)) {
this.resolved.messages.set(id, new Message(session, m));
}
}
}
override type: InteractionTypes.ApplicationCommand;
commandId: Snowflake;
commandName: string;
commandType: ApplicationCommandTypes;
commandGuildId?: Snowflake;
resolved: CommandInteractionDataResolved;
options: CommandInteractionOptionResolver;
}
export type ModalInMessage = ModalSubmitInteraction & {
message: Message;
};
export class ModalSubmitInteraction extends BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
super(session, data);
this.type = data.type as number;
this.componentType = data.data!.component_type!;
this.customId = data.data!.custom_id;
this.targetId = data.data!.target_id;
this.values = data.data!.values;
this.components = data.data?.components?.map(
ModalSubmitInteraction.transformComponent
);
if (data.message) {
this.message = new Message(session, data.message);
}
}
override type: InteractionTypes.MessageComponent;
componentType: MessageComponentTypes;
customId?: string;
targetId?: Snowflake;
values?: string[];
message?: Message;
components;
static transformComponent(component: DiscordMessageComponents[number]) {
return {
type: component.type,
components: component.components.map(component => {
return {
customId: component.custom_id,
value: (component as typeof component & { value: string })
.value,
};
}),
};
}
inMessage(): this is ModalInMessage {
return !!this.message;
}
}
export class PingInteraction extends BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
super(session, data);
this.type = data.type as number;
this.commandId = data.data!.id;
this.commandName = data.data!.name;
this.commandType = data.data!.type;
this.commandGuildId = data.data!.guild_id;
}
override type: InteractionTypes.Ping;
commandId: Snowflake;
commandName: string;
commandType: ApplicationCommandTypes;
commandGuildId?: Snowflake;
async pong(): Promise<void> {
await this.session.rest.post<undefined>(
INTERACTION_ID_TOKEN(this.id, this.token),
{
type: InteractionResponseTypes.Pong,
}
);
}
}
export class ComponentInteraction extends BaseInteraction implements Model {
constructor(session: Session, data: DiscordInteraction) {
super(session, data);
this.type = data.type as number;
this.componentType = data.data!.component_type!;
this.customId = data.data!.custom_id;
this.targetId = data.data!.target_id;
this.values = data.data!.values;
this.message = new Message(session, data.message!);
}
override type: InteractionTypes.MessageComponent;
componentType: MessageComponentTypes;
customId?: string;
targetId?: Snowflake;
values?: string[];
message: Message;
isButton(): boolean {
return this.componentType === MessageComponentTypes.Button;
}
isActionRow(): boolean {
return this.componentType === MessageComponentTypes.ActionRow;
}
isTextInput(): boolean {
return this.componentType === MessageComponentTypes.InputText;
}
isSelectMenu(): boolean {
return this.componentType === MessageComponentTypes.SelectMenu;
}
async deferUpdate() {
await this.respond({
type: InteractionResponseTypes.DeferredUpdateMessage,
});
}
}
/**
* @link https://discord.com/developers/docs/interactions/receiving-and-responding#message-interaction-object-message-interaction-structure
*/
export interface MessageInteraction {
/** id of the interaction */
id: Snowflake;
/** type of interaction */
type: InteractionTypes;
/** name of the application command, including subcommands and subcommand groups */
name: string;
/** user who invoked the interaction */
user: User;
/** member who invoked the interaction in the guild */
member?: Partial<Member>;
}
export type Interaction =
| CommandInteraction
| ComponentInteraction
| PingInteraction
| AutoCompleteInteraction
| ModalSubmitInteraction;
export class InteractionFactory {
static from(
session: Session,
interaction: DiscordInteraction
): Interaction {
switch (interaction.type) {
case InteractionTypes.Ping:
return new PingInteraction(session, interaction);
case InteractionTypes.ApplicationCommand:
return new CommandInteraction(session, interaction);
case InteractionTypes.MessageComponent:
return new ComponentInteraction(session, interaction);
case InteractionTypes.ApplicationCommandAutocomplete:
return new AutoCompleteInteraction(session, interaction);
case InteractionTypes.ModalSubmit:
return new ModalSubmitInteraction(session, interaction);
}
}
static fromMessage(
session: Session,
interaction: DiscordMessageInteraction,
_guildId?: Snowflake
): MessageInteraction {
const obj = {
id: interaction.id,
type: interaction.type,
name: interaction.name,
user: new User(session, interaction.user),
// TODO: Parse member somehow with the guild id passed in message
};
return obj;
}
}

View File

@ -0,0 +1,207 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type {
DiscordApplication,
DiscordChannel,
DiscordInvite,
DiscordInviteCreate,
DiscordMemberWithUser,
DiscordScheduledEventEntityMetadata,
ScheduledEventEntityType,
ScheduledEventPrivacyLevel,
ScheduledEventStatus,
TargetTypes,
} from '@biscuitland/api-types';
import { GuildChannel } from './channels';
import { Member } from './members';
import { Guild, InviteGuild } from './guilds';
import { User } from './user';
import { Application } from './application';
export interface InviteStageInstance {
/** The members speaking in the Stage */
members: Partial<Member>[];
/** The number of users in the Stage */
participantCount: number;
/** The number of users speaking in the Stage */
speakerCount: number;
/** The topic of the Stage instance (1-120 characters) */
topic: string;
}
export interface InviteScheduledEvent {
id: Snowflake;
guildId: string;
channelId?: string;
creatorId?: string;
name: string;
description?: string;
scheduledStartTime: string;
scheduledEndTime?: string;
privacyLevel: ScheduledEventPrivacyLevel;
status: ScheduledEventStatus;
entityType: ScheduledEventEntityType;
entityId?: string;
entityMetadata?: DiscordScheduledEventEntityMetadata;
creator?: User;
userCount?: number;
image?: string;
}
export interface InviteCreate {
channelId: string;
code: string;
createdAt: string;
guildId?: string;
inviter?: User;
maxAge: number;
maxUses: number;
targetType: TargetTypes;
targetUser?: User;
targetApplication?: Partial<Application>;
temporary: boolean;
uses: number;
}
export function NewInviteCreate(
session: Session,
invite: DiscordInviteCreate
): InviteCreate {
return {
channelId: invite.channel_id,
code: invite.code,
createdAt: invite.created_at,
guildId: invite.guild_id,
inviter: invite.inviter ? new User(session, invite.inviter) : undefined,
maxAge: invite.max_age,
maxUses: invite.max_uses,
targetType: invite.target_type,
targetUser: invite.target_user
? new User(session, invite.target_user)
: undefined,
targetApplication:
invite.target_application &&
new Application(
session,
invite.target_application as DiscordApplication
),
temporary: invite.temporary,
uses: invite.uses,
};
}
/**
* @link https://discord.com/developers/docs/resources/invite#invite-object
*/
export class Invite {
constructor(session: Session, data: DiscordInvite) {
this.session = session;
this.guild = data.guild
? new InviteGuild(session, data.guild)
: undefined;
this.approximateMemberCount = data.approximate_member_count
? data.approximate_member_count
: undefined;
this.approximatePresenceCount = data.approximate_presence_count
? data.approximate_presence_count
: undefined;
this.code = data.code;
this.expiresAt = data.expires_at
? Number.parseInt(data.expires_at)
: undefined;
this.inviter = data.inviter
? new User(session, data.inviter)
: undefined;
this.targetUser = data.target_user
? new User(session, data.target_user)
: undefined;
this.targetApplication = data.target_application
? new Application(
session,
data.target_application as DiscordApplication
)
: undefined;
this.targetType = data.target_type;
if (data.channel) {
const guildId = data.guild?.id ? data.guild.id : '';
this.channel = new GuildChannel(
session,
data.channel as DiscordChannel,
guildId
);
}
if (data.guild_scheduled_event) {
this.guildScheduledEvent = {
id: data.guild_scheduled_event.id,
guildId: data.guild_scheduled_event.guild_id,
channelId: data.guild_scheduled_event.channel_id
? data.guild_scheduled_event.channel_id
: undefined,
creatorId: data.guild_scheduled_event.creator_id
? data.guild_scheduled_event.creator_id
: undefined,
name: data.guild_scheduled_event.name,
description: data.guild_scheduled_event.description
? data.guild_scheduled_event.description
: undefined,
scheduledStartTime:
data.guild_scheduled_event.scheduled_start_time,
scheduledEndTime: data.guild_scheduled_event.scheduled_end_time
? data.guild_scheduled_event.scheduled_end_time
: undefined,
privacyLevel: data.guild_scheduled_event.privacy_level,
status: data.guild_scheduled_event.status,
entityType: data.guild_scheduled_event.entity_type,
entityId: data.guild ? data.guild.id : undefined,
entityMetadata: data.guild_scheduled_event.entity_metadata
? data.guild_scheduled_event.entity_metadata
: undefined,
creator: data.guild_scheduled_event.creator
? new User(session, data.guild_scheduled_event.creator)
: undefined,
userCount: data.guild_scheduled_event.user_count
? data.guild_scheduled_event.user_count
: undefined,
image: data.guild_scheduled_event.image
? data.guild_scheduled_event.image
: undefined,
};
}
if (data.stage_instance) {
const guildId = data.guild?.id ? data.guild.id : '';
this.stageInstance = {
members: data.stage_instance.members.map(
m =>
new Member(session, m as DiscordMemberWithUser, guildId)
),
participantCount: data.stage_instance.participant_count,
speakerCount: data.stage_instance.speaker_count,
topic: data.stage_instance.topic,
};
}
}
readonly session: Session;
guild?: InviteGuild;
approximateMemberCount?: number;
approximatePresenceCount?: number;
code: string;
expiresAt?: number;
inviter?: User;
targetUser?: User;
targetType?: TargetTypes;
channel?: Partial<GuildChannel>;
stageInstance?: InviteStageInstance;
guildScheduledEvent?: InviteScheduledEvent;
targetApplication?: Partial<Application>;
async delete(): Promise<Invite> {
await Guild.prototype.deleteInvite.call(this.guild, this.code);
return this;
}
}

View File

@ -0,0 +1,227 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type {
DiscordMemberWithUser,
DiscordThreadMember,
} from '@biscuitland/api-types';
import type { CreateGuildBan, ModifyGuildMember } from './guilds';
import type { AvatarOptions } from './user';
import { User } from './user';
import { Guild } from './guilds';
import { Util } from '../utils/util';
import {
USER_AVATAR,
USER_DEFAULT_AVATAR,
THREAD_USER,
} from '@biscuitland/api-types';
/**
* Represents a guild member
* @link https://discord.com/developers/docs/resources/guild#guild-member-object
*/
export class Member implements Model {
constructor(
session: Session,
data: DiscordMemberWithUser,
guildId: Snowflake
) {
this.session = session;
this.user = new User(session, data.user);
this.guildId = guildId;
this.avatarHash = data.avatar
? data.avatar
: undefined;
this.nickname = data.nick ? data.nick : undefined;
this.premiumSince = data.premium_since
? Number.parseInt(data.premium_since)
: undefined;
this.joinedTimestamp = Number.parseInt(data.joined_at);
this.roles = data.roles;
this.deaf = !!data.deaf;
this.mute = !!data.mute;
this.pending = !!data.pending;
this.communicationDisabledUntilTimestamp =
data.communication_disabled_until
? Number.parseInt(data.communication_disabled_until)
: undefined;
}
/** the session that instantiated this member */
readonly session: Session;
/** the user this guild member represents */
user: User;
/** the choosen guild id */
guildId: Snowflake;
/** the member's guild avatar hash optimized as a bigint */
avatarHash?: string;
/** this user's guild nickname */
nickname?: string;
/** when the user started boosting the guild */
premiumSince?: number;
/** when the user joined the guild */
joinedTimestamp: number;
/** array of role object ids */
roles: Snowflake[];
/** whether the user is deafened in voice channels */
deaf: boolean;
/** whether the user is muted in voice channels */
mute: boolean;
/** whether the user has not yet passed the guild's Membership Screening requirements */
pending: boolean;
/** when the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out */
communicationDisabledUntilTimestamp?: number;
/** shorthand to User.id */
get id(): Snowflake {
return this.user.id;
}
/** gets the nickname or the username */
get nicknameOrUsername(): string {
return this.nickname ?? this.user.username;
}
/** gets the joinedAt timestamp as a Date */
get joinedAt(): Date {
return new Date(this.joinedTimestamp);
}
/** bans a member from this guild and delete previous messages sent by the member */
async ban(options: CreateGuildBan): Promise<Member> {
await Guild.prototype.banMember.call(
{ id: this.guildId, session: this.session },
this.user.id,
options
);
return this;
}
/** kicks a member from this guild */
async kick(options: { reason?: string }): Promise<Member> {
await Guild.prototype.kickMember.call(
{ id: this.guildId, session: this.session },
this.user.id,
options.reason
);
return this;
}
/** unbans a member from this guild */
async unban(): Promise<void> {
await Guild.prototype.unbanMember.call(
{ id: this.guildId, session: this.session },
this.user.id
);
}
/** edits member's nickname, roles, etc */
async edit(options: ModifyGuildMember): Promise<Member> {
const member = await Guild.prototype.editMember.call(
{ id: this.guildId, session: this.session },
this.user.id,
options
);
return member;
}
/** adds a role to this member */
async addRole(roleId: Snowflake, reason?: string): Promise<void> {
await Guild.prototype.addRole.call(
{ id: this.guildId, session: this.session },
this.user.id,
roleId,
reason
);
}
/** removes a role from this member */
async removeRole(
roleId: Snowflake,
options: { reason?: string } = {}
): Promise<void> {
await Guild.prototype.removeRole.call(
{ id: this.guildId, session: this.session },
this.user.id,
roleId,
options.reason
);
}
/** gets the members's guild avatar, not to be confused with Member.user.avatarURL() */
avatarURL(options: AvatarOptions): string {
let url: string;
if (this.user.bot) {
return this.user.avatarURL(options);
}
if (!this.avatarHash) {
url = USER_DEFAULT_AVATAR(Number(this.user.discriminator) % 5);
} else {
url = USER_AVATAR(
this.user.id,
this.avatarHash
);
}
return Util.formatImageURL(url, options.size ?? 128, options.format);
}
toString(): string {
return `<@!${this.user.id}>`;
}
}
/**
* A member that comes from a thread
* @link https://discord.com/developers/docs/resources/channel#thread-member-object
* **/
export class ThreadMember implements Model {
constructor(session: Session, data: DiscordThreadMember) {
this.session = session;
this.id = data.id;
this.flags = data.flags;
this.timestamp = Date.parse(data.join_timestamp);
}
readonly session: Session;
readonly id: Snowflake;
flags: number;
timestamp: number;
get threadId(): Snowflake {
return this.id;
}
async quitThread(memberId?: Snowflake): Promise<void> {
await this.session.rest.delete<undefined>(
THREAD_USER(this.id, memberId ?? this.session.botId),
{}
);
}
async fetchMember(memberId?: Snowflake): Promise<ThreadMember> {
const member = await this.session.rest.get<DiscordThreadMember>(
THREAD_USER(this.id, memberId ?? this.session.botId)
);
return new ThreadMember(this.session, member);
}
}

View File

@ -0,0 +1,98 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import type { Session } from '../biscuit';
import type {
DiscordMemberWithUser,
DiscordMessageReactionAdd,
DiscordReaction,
} from '@biscuitland/api-types';
import { Emoji } from './emojis';
import { Member } from './members';
/**
* Represents when a new reaction was added to a message.
* @link https://discord.com/developers/docs/topics/gateway#message-reaction-add
*/
export interface MessageReactionAdd {
userId: string;
channelId: string;
messageId: string;
guildId?: string;
member?: Member;
emoji: Partial<Emoji>;
}
/**
* Represents when a reaction was removed from a message.
* Equal to MessageReactionAdd but without 'member' property.
* @see {@link MessageReactionAdd}
* @link https://discord.com/developers/docs/topics/gateway#message-reaction-remove-message-reaction-remove-event-fields
*/
export type MessageReactionRemove = Omit<MessageReactionAdd, 'member'>;
/**
* Represents when all reactions were removed from a message.
* Equals to MessageReactionAdd but with 'channelId', 'messageId' and 'guildId' properties guaranteed.
* @see {@link MessageReactionAdd}
* @link https://discord.com/developers/docs/topics/gateway#message-reaction-remove-all
*/
export type MessageReactionRemoveAll = Pick<
MessageReactionAdd,
'channelId' | 'messageId' | 'guildId'
>;
/**
* Represents when a reaction-emoji was removed from a message.
* Equals to MessageReactionAdd but with 'channelId', 'messageId', 'emoji' and 'guildId' properties guaranteed.
* @see {@link MessageReactionRemove}
* @see {@link Emoji}
* @link https://discord.com/developers/docs/topics/gateway#message-reaction-remove-emoji
*/
export type MessageReactionRemoveEmoji = Pick<
MessageReactionAdd,
'channelId' | 'guildId' | 'messageId' | 'emoji'
>;
/**
* Creates a new MessageReactionAdd object.
* @param session - Current application session.
* @param data - Discord message reaction to parse.
*/
export function NewMessageReactionAdd(
session: Session,
data: DiscordMessageReactionAdd
): MessageReactionAdd {
return {
userId: data.user_id,
channelId: data.channel_id,
messageId: data.message_id,
guildId: data.guild_id,
member: data.member
? new Member(
session,
data.member as DiscordMemberWithUser,
data.guild_id || ''
)
: undefined,
emoji: new Emoji(session, data.emoji),
};
}
/**
* Represents a reaction
* @link https://discord.com/developers/docs/resources/channel#reaction-object
*/
export class MessageReaction {
constructor(session: Session, data: DiscordReaction) {
this.session = session;
this.me = data.me;
this.count = data.count;
this.emoji = new Emoji(session, data.emoji);
}
readonly session: Session;
me: boolean;
count: number;
emoji: Emoji;
}
export default MessageReaction;

View File

@ -0,0 +1,615 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import type { Model } from './base';
import type { Session } from '../biscuit';
import type {
AllowedMentionsTypes,
DiscordEmbed,
DiscordMessage,
DiscordMessageComponents,
DiscordUser,
FileContent,
MessageActivityTypes,
MessageTypes,
GetReactions,
} from '@biscuitland/api-types';
import type { Channel } from './channels';
import type { Component } from './components';
import type { MessageInteraction } from './interactions';
import { MessageFlags, Util } from '../utils/util';
import { Snowflake } from '../snowflakes';
import { ChannelFactory, ThreadChannel } from './channels';
import { User } from './user';
import { Member } from './members';
import { Attachment } from './attachment';
import { ComponentFactory } from './components';
import { MessageReaction } from './message-reaction';
import { Application, NewTeam } from './application';
import { InteractionFactory } from './interactions';
import type { StickerItem } from './sticker';
import {
CHANNEL_PIN,
CHANNEL_MESSAGE,
CHANNEL_MESSAGES,
CHANNEL_MESSAGE_REACTION_ME,
CHANNEL_MESSAGE_REACTION_USER,
CHANNEL_MESSAGE_REACTION,
CHANNEL_MESSAGE_REACTIONS,
CHANNEL_MESSAGE_CROSSPOST,
} from '@biscuitland/api-types';
export interface GuildMessage extends Message {
guildId: Snowflake;
}
export type WebhookMessage = Message & {
author: Partial<User>;
webhook: WebhookAuthor;
member: undefined;
};
export interface MessageActivity {
partyId?: Snowflake;
type: MessageActivityTypes;
}
/**
* @link https://discord.com/developers/docs/resources/channel#allowed-mentions-object
*/
export interface AllowedMentions {
parse?: AllowedMentionsTypes[];
repliedUser?: boolean;
roles?: Snowflake[];
users?: Snowflake[];
}
/**
* @link https://github.com/denoland/deno_doc/blob/main/lib/types.d.ts
* channelId is optional when creating a reply, but will always be present when receiving an event/response that includes this data model.
*/
export interface CreateMessageReference {
messageId: Snowflake;
channelId?: Snowflake;
guildId?: Snowflake;
failIfNotExists?: boolean;
}
/**
* @link https://discord.com/developers/docs/resources/channel#create-message-json-params
* Posts a message to a guild text or DM channel. Returns a message object. Fires a Message Create Gateway event.
*/
export interface CreateMessage {
embeds?: DiscordEmbed[];
content?: string;
allowedMentions?: AllowedMentions;
files?: FileContent[];
messageReference?: CreateMessageReference;
tts?: boolean;
components?: DiscordMessageComponents;
}
/**
* @link https://discord.com/developers/docs/resources/channel#edit-message-json-params
* Edit a previously sent message.
* Returns a {@link Message} object. Fires a Message Update Gateway event.
*/
export interface EditMessage extends Partial<CreateMessage> {
flags?: MessageFlags;
}
/**
* Represents a guild or unicode {@link Emoji}
*/
export type EmojiResolvable =
| string
| {
name: string;
id: Snowflake;
};
/**
* A partial {@link User} to represent the author of a message sent by a webhook
*/
export interface WebhookAuthor {
id: string;
username: string;
discriminator: string;
avatar?: bigint;
}
/**
* @link https://discord.com/developers/docs/resources/channel#message-object
* Represents a message
*/
export class Message implements Model {
constructor(session: Session, data: DiscordMessage) {
this.session = session;
this.id = data.id;
this.type = data.type;
this.channelId = data.channel_id;
this.guildId = data.guild_id;
this.applicationId = data.application_id;
this.mentions = {
users: data.mentions?.map(user => new User(session, user)) ?? [],
roleIds: data.mention_roles ?? [],
channels:
data.mention_channels?.map(channel =>
ChannelFactory.from(session, channel)
) ?? [],
};
if (!data.webhook_id) {
this.author = new User(session, data.author);
}
this.flags = data.flags;
this.pinned = !!data.pinned;
this.tts = !!data.tts;
this.content = data.content!;
this.nonce = data.nonce;
this.mentionEveryone = data.mention_everyone;
this.timestamp = Date.parse(data.timestamp);
this.editedTimestamp = data.edited_timestamp
? Date.parse(data.edited_timestamp)
: undefined;
this.reactions =
data.reactions?.map(react => new MessageReaction(session, react)) ??
[];
this.attachments = data.attachments.map(
attachment => new Attachment(session, attachment)
);
this.embeds = data.embeds;
if (data.interaction) {
this.interaction = InteractionFactory.fromMessage(
session,
data.interaction,
data.guild_id
);
}
if (data.thread && data.guild_id) {
this.thread = new ThreadChannel(
session,
data.thread,
data.guild_id
);
}
// webhook handling
if (data.webhook_id && data.author.discriminator === '0000') {
this.webhook = {
id: data.webhook_id!,
username: data.author.username,
discriminator: data.author.discriminator,
avatar: data.author.avatar
? Util.iconHashToBigInt(data.author.avatar)
: undefined,
};
}
// user is always null on MessageCreate and its replaced with author
if (data.guild_id && data.member && !this.isWebhookMessage()) {
this.member = new Member(
session,
{ ...data.member, user: data.author },
data.guild_id
);
}
this.components =
data.components?.map(component =>
ComponentFactory.from(session, component)
) ?? [];
if (data.activity) {
this.activity = {
partyId: data.activity.party_id,
type: data.activity.type,
};
}
if (data.sticker_items) {
this.stickers = data.sticker_items.map(si => {
return {
id: si.id,
name: si.name,
formatType: si.format_type,
};
});
}
if (data.application) {
const application: Partial<Application> = {
id: data.application.id,
icon: data.application.icon ? data.application.icon : undefined,
name: data.application.name,
guildId: data.application.guild_id,
flags: data.application.flags,
botPublic: data.application.bot_public,
owner: data.application.owner
? new User(session, data.application.owner as DiscordUser)
: undefined,
botRequireCodeGrant: data.application.bot_require_code_grant,
coverImage: data.application.cover_image,
customInstallURL: data.application.custom_install_url,
description: data.application.description,
installParams: data.application.install_params,
tags: data.application.tags,
verifyKey: data.application.verify_key,
team: data.application.team
? NewTeam(session, data.application.team)
: undefined,
primarySkuId: data.application.primary_sku_id,
privacyPolicyURL: data.application.privacy_policy_url,
rpcOrigins: data.application.rpc_origins,
slug: data.application.slug,
};
Object.setPrototypeOf(application, Application.prototype);
this.application = application;
}
}
/** Reference to the client that instantiated this Message */
readonly session: Session;
/** id of the message */
readonly id: Snowflake;
/** type of message */
type: MessageTypes;
/** id of the channel the message was sent in */
channelId: Snowflake;
/** id of the guild the message was sent in, this should exist on MESSAGE_CREATE and MESSAGE_UPDATE events */
guildId?: Snowflake;
/** if the message is an Interaction or application-owned webhook, this is the id of the application */
applicationId?: Snowflake;
/** mentions if any */
mentions: {
/** users specifically mentioned in the message */
users: User[];
/** roles specifically mentioned in this message */
roleIds: Snowflake[];
/** channels specifically mentioned in the message */
channels: Channel[];
};
/** sent if the message is a response to an Interaction */
interaction?: MessageInteraction;
/** the author of this message, this field is **not** sent on webhook messages */
author!: User;
/** message flags combined as a bitfield */
flags?: MessageFlags;
/** whether this message is pinned */
pinned: boolean;
/** whether this was a TTS message */
tts: boolean;
/** contents of the message */
content: string;
/** used for validating a message was sent */
nonce?: string | number;
/** whether this message mentions everyone */
mentionEveryone: boolean;
/** when this message was sent */
timestamp: number;
/** when this message was edited */
editedTimestamp?: number;
/**
* sent if the message contains stickers
* **this contains sticker items not stickers**
*/
stickers?: StickerItem[];
/** reactions to the message */
reactions: MessageReaction[];
/** any attached files */
attachments: Attachment[];
/** any embedded content */
embeds: DiscordEmbed[];
/** member properties for this message's author */
member?: Member;
/** the thread that was started from this message, includes {@link ThreadMember} */
thread?: ThreadChannel;
/** sent if the message contains components like buttons, action rows, or other interactive components */
components: Component[];
/** if the message is generated by a webhook, this is the webhook's author data */
webhook?: WebhookAuthor;
/** sent with Rich Presence-related chat embeds */
application?: Partial<Application>;
/** sent with Rich Presence-related chat embeds */
activity?: MessageActivity;
/** gets the timestamp of this message, this does not requires the timestamp field */
get createdTimestamp(): number {
return Snowflake.snowflakeToTimestamp(this.id);
}
/** gets the timestamp of this message as a Date */
get createdAt(): Date {
return new Date(this.createdTimestamp);
}
/** gets the timestamp of this message (sent by the API) */
get sentAt(): Date {
return new Date(this.timestamp);
}
/** gets the edited timestamp as a Date */
get editedAt(): Date | undefined {
return this.editedTimestamp
? new Date(this.editedTimestamp)
: undefined;
}
/** whether this message was edited */
get edited(): number | undefined {
return this.editedTimestamp;
}
/** gets the url of the message that points to the message */
get url(): string {
return `https://discord.com/channels/${this.guildId ?? '@me'}/${
this.channelId
}/${this.id}`;
}
/**
* Compatibility with Discordeno
* same as Message.author.bot
*/
get isBot(): boolean {
return this.author.bot;
}
/**
* Pins this message
*/
async pin(): Promise<void> {
await this.session.rest.put<undefined>(
CHANNEL_PIN(this.channelId, this.id),
{}
);
}
/**
* Unpins this message
*/
async unpin(): Promise<void> {
await this.session.rest.delete<undefined>(
CHANNEL_PIN(this.channelId, this.id),
{}
);
}
/** Edits the current message */
async edit(options: EditMessage): Promise<Message> {
const message = await this.session.rest.post<DiscordMessage>(
CHANNEL_MESSAGE(this.id, this.channelId),
{
content: options.content,
allowed_mentions: {
parse: options.allowedMentions?.parse,
roles: options.allowedMentions?.roles,
users: options.allowedMentions?.users,
replied_user: options.allowedMentions?.repliedUser,
},
flags: options.flags,
embeds: options.embeds,
}
);
return new Message(this.session, message);
}
/** edits the current message flags to supress its embeds */
async suppressEmbeds(suppress: true): Promise<Message>;
async suppressEmbeds(suppress: false): Promise<Message | undefined>;
async suppressEmbeds(suppress = true) {
if (this.flags === MessageFlags.SupressEmbeds && suppress === false) {
return;
}
const message = await this.edit({ flags: MessageFlags.SupressEmbeds });
return message;
}
/** deletes this message */
async delete(reason?: string): Promise<Message> {
await this.session.rest.delete<undefined>(
CHANNEL_MESSAGE(this.channelId, this.id),
{ reason }
);
return this;
}
/** Replies directly in the channel where the message was sent */
async reply(options: CreateMessage): Promise<Message> {
const message = await this.session.rest.post<DiscordMessage>(
CHANNEL_MESSAGES(this.channelId),
{
content: options.content,
file: options.files,
allowed_mentions: {
parse: options.allowedMentions?.parse,
roles: options.allowedMentions?.roles,
users: options.allowedMentions?.users,
replied_user: options.allowedMentions?.repliedUser,
},
message_reference: options.messageReference
? {
message_id: options.messageReference.messageId,
channel_id: options.messageReference.channelId,
guild_id: options.messageReference.guildId,
fail_if_not_exists:
options.messageReference.failIfNotExists ??
true,
}
: undefined,
embeds: options.embeds,
tts: options.tts,
components: options.components,
}
);
return new Message(this.session, message);
}
/** alias for Message.addReaction */
get react() {
return this.addReaction;
}
/** adds a Reaction */
async addReaction(reaction: EmojiResolvable): Promise<void> {
const r =
typeof reaction === 'string'
? reaction
: `${reaction.name}:${reaction.id}`;
await this.session.rest.put<undefined>(
CHANNEL_MESSAGE_REACTION_ME(this.channelId, this.id, r),
{}
);
}
/** removes a reaction from someone */
async removeReaction(
reaction: EmojiResolvable,
options?: { userId: Snowflake }
): Promise<void> {
const r =
typeof reaction === 'string'
? reaction
: `${reaction.name}:${reaction.id}`;
await this.session.rest.delete<undefined>(
options?.userId
? CHANNEL_MESSAGE_REACTION_USER(
this.channelId,
this.id,
r,
options.userId
)
: CHANNEL_MESSAGE_REACTION_ME(this.channelId, this.id, r),
{}
);
}
/**
* Get users who reacted with this emoji
* not recommended since the cache handles reactions already
*/
async fetchReactions(
reaction: EmojiResolvable,
options?: GetReactions
): Promise<User[]> {
const r =
typeof reaction === 'string'
? reaction
: `${reaction.name}:${reaction.id}`;
const users = await this.session.rest.get<DiscordUser[]>(
CHANNEL_MESSAGE_REACTION(
this.channelId,
this.id,
encodeURIComponent(r),
options
)
);
return users.map(user => new User(this.session, user));
}
/**
* same as Message.removeReaction but removes using a unicode emoji
*/
async removeReactionEmoji(reaction: EmojiResolvable): Promise<void> {
const r =
typeof reaction === 'string'
? reaction
: `${reaction.name}:${reaction.id}`;
await this.session.rest.delete<undefined>(
CHANNEL_MESSAGE_REACTION(this.channelId, this.id, r),
{}
);
}
/** nukes every reaction on the message */
async nukeReactions(): Promise<void> {
await this.session.rest.delete<undefined>(
CHANNEL_MESSAGE_REACTIONS(this.channelId, this.id),
{}
);
}
/** publishes/crossposts a message to all followers */
async crosspost(): Promise<Message> {
const message = await this.session.rest.post<DiscordMessage>(
CHANNEL_MESSAGE_CROSSPOST(this.channelId, this.id),
{}
);
return new Message(this.session, message);
}
/** fetches this message, meant to be used with Function.call since its redundant */
async fetch(): Promise<Message | undefined> {
const message = await this.session.rest.get<DiscordMessage>(
CHANNEL_MESSAGE(this.channelId, this.id)
);
if (!message?.id) {
return;
}
return new Message(this.session, message);
}
/** alias of Message.crosspost */
get publish() {
return this.crosspost;
}
/** wheter the message comes from a guild **/
inGuild(): this is GuildMessage {
return !!this.guildId;
}
/** wheter the messages comes from a Webhook */
isWebhookMessage(): this is WebhookMessage {
return !!this.webhook;
}
}

View File

@ -0,0 +1,94 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import type {
ActivityTypes,
DiscordActivityButton,
DiscordActivitySecrets,
DiscordClientStatus,
DiscordPresenceUpdate,
} from '@biscuitland/api-types';
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type { ComponentEmoji } from '../utils/util';
import { User } from './user';
export interface ActivityAssets {
largeImage?: string;
largeText?: string;
smallImage?: string;
smallText?: string;
}
export interface Activities {
name: string;
type: ActivityTypes;
url?: string;
createdAt: number;
timestamps?: {
start?: number;
end?: number;
};
applicationId?: Snowflake;
details?: string;
state?: string;
emoji?: ComponentEmoji;
party?: {
id?: string;
size?: number[];
};
assets?: ActivityAssets;
secrets?: DiscordActivitySecrets;
instance?: boolean;
flags?: number;
buttons?: DiscordActivityButton;
}
export enum StatusTypes {
online = 0,
dnd = 1,
idle = 2,
invisible = 3,
offline = 4,
}
export class Presence {
constructor(session: Session, data: DiscordPresenceUpdate) {
this.session = session;
this.user = new User(this.session, data.user);
this.guildId = data.guild_id;
this.status = StatusTypes[data.status];
this.activities = data.activities.map<Activities>(activity =>
Object({
name: activity.name,
type: activity.type,
url: activity.url ? activity.url : undefined,
createdAt: activity.created_at,
timestamps: activity.timestamps,
applicationId: activity.application_id,
details: activity.details ? activity.details : undefined,
state: activity.state,
emoji: activity.emoji ? activity.emoji : undefined,
party: activity.party ? activity.party : undefined,
assets: activity.assets
? {
largeImage: activity.assets.large_image,
largeText: activity.assets.large_text,
smallImage: activity.assets.small_image,
smallText: activity.assets.small_text,
}
: null,
secrets: activity.secrets ? activity.secrets : undefined,
instance: !!activity.instance,
flags: activity.flags,
buttons: activity.buttons,
})
);
this.clientStatus = data.client_status;
}
session: Session;
user: User;
guildId: Snowflake;
status: StatusTypes;
activities: Activities[];
clientStatus: DiscordClientStatus;
}

View File

@ -0,0 +1,95 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type { DiscordRole } from '@biscuitland/api-types';
import type { ModifyGuildRole } from './guilds';
import { Snowflake } from '../snowflakes';
import { Guild } from './guilds';
import { Util } from '../utils/util';
import { Permissions } from './special/permissions';
export class Role implements Model {
constructor(session: Session, data: DiscordRole, guildId: Snowflake) {
this.session = session;
this.id = data.id;
this.guildId = guildId;
this.hoist = data.hoist;
this.iconHash = data.icon
? Util.iconHashToBigInt(data.icon)
: undefined;
this.color = data.color;
this.name = data.name;
this.unicodeEmoji = data.unicode_emoji;
this.mentionable = data.mentionable;
this.managed = data.managed;
this.permissions = new Permissions(BigInt(data.permissions));
}
readonly session: Session;
readonly id: Snowflake;
readonly guildId: Snowflake;
hoist: boolean;
iconHash?: bigint;
color: number;
name: string;
unicodeEmoji?: string;
mentionable: boolean;
managed: boolean;
permissions: Permissions;
get createdTimestamp(): number {
return Snowflake.snowflakeToTimestamp(this.id);
}
get createdAt(): Date {
return new Date(this.createdTimestamp);
}
get hexColor(): string {
return `#${this.color.toString(16).padStart(6, '0')}`;
}
async delete(): Promise<void> {
await Guild.prototype.deleteRole.call(
{ id: this.guildId, session: this.session },
this.id
);
}
async edit(options: ModifyGuildRole): Promise<Role> {
const role = await Guild.prototype.editRole.call(
{ id: this.guildId, session: this.session },
this.id,
options
);
return role;
}
async add(memberId: Snowflake, reason?: string): Promise<void> {
await Guild.prototype.addRole.call(
{ id: this.guildId, session: this.session },
memberId,
this.id,
reason
);
}
async remove(memberId: Snowflake, reason?: string): Promise<void> {
await Guild.prototype.removeRole.call(
{ id: this.guildId, session: this.session },
memberId,
this.id,
reason
);
}
toString(): string {
switch (this.id) {
case this.guildId:
return '@everyone';
default:
return `<@&${this.id}>`;
}
}
}

View File

@ -0,0 +1,53 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type {
DiscordScheduledEvent,
DiscordScheduledEventEntityMetadata,
ScheduledEventEntityType,
ScheduledEventStatus,
} from '@biscuitland/api-types';
import { PrivacyLevels } from './stage-instance';
import { User } from './user';
export class ScheduledEvent implements Model {
constructor(session: Session, data: DiscordScheduledEvent) {
this.session = session;
this.id = data.id;
this.guildId = data.guild_id;
this.channelId = data.channel_id;
this.creatorId = data.creator_id ? data.creator_id : undefined;
this.name = data.name;
this.description = data.description;
this.scheduledStartTime = data.scheduled_start_time;
this.scheduledEndTime = data.scheduled_end_time;
this.privacyLevel = PrivacyLevels.GuildOnly;
this.status = data.status;
this.entityType = data.entity_type;
this.entityMetadata = data.entity_metadata
? data.entity_metadata
: undefined;
this.creator = data.creator
? new User(session, data.creator)
: undefined;
this.userCount = data.user_count;
this.image = data.image ? data.image : undefined;
}
session: Session;
id: Snowflake;
guildId: Snowflake;
channelId: Snowflake | null;
creatorId?: Snowflake;
name: string;
description?: string;
scheduledStartTime: string;
scheduledEndTime: string | null;
privacyLevel: PrivacyLevels;
status: ScheduledEventStatus;
entityType: ScheduledEventEntityType;
entityMetadata?: DiscordScheduledEventEntityMetadata;
creator?: User;
userCount?: number;
image?: string;
}

View File

@ -0,0 +1,337 @@
import type {
DiscordInteractionDataOption,
DiscordInteractionDataResolved,
} from '@biscuitland/api-types';
import { ApplicationCommandOptionTypes } from '@biscuitland/api-types';
export function transformOasisInteractionDataOption(
o: DiscordInteractionDataOption
): CommandInteractionOption {
const output: CommandInteractionOption = {
...o,
Otherwise: o.value as string | boolean | number | undefined,
};
switch (o.type) {
case ApplicationCommandOptionTypes.String:
output.String = o.value as string;
break;
case ApplicationCommandOptionTypes.Number:
output.Number = o.value as number;
break;
case ApplicationCommandOptionTypes.Integer:
output.Integer = o.value as number;
break;
case ApplicationCommandOptionTypes.Boolean:
output.Boolean = o.value as boolean;
break;
case ApplicationCommandOptionTypes.Role:
output.Role = BigInt(o.value as string);
break;
case ApplicationCommandOptionTypes.User:
output.User = BigInt(o.value as string);
break;
case ApplicationCommandOptionTypes.Channel:
output.Channel = BigInt(o.value as string);
break;
case ApplicationCommandOptionTypes.Mentionable:
case ApplicationCommandOptionTypes.SubCommand:
case ApplicationCommandOptionTypes.SubCommandGroup:
default:
output.Otherwise = o.value as string | boolean | number | undefined;
}
return output;
}
export interface CommandInteractionOption
extends Omit<DiscordInteractionDataOption, 'value'> {
Attachment?: string;
Boolean?: boolean;
User?: bigint;
Role?: bigint;
Number?: number;
Integer?: number;
Channel?: bigint;
String?: string;
Mentionable?: string;
Otherwise: string | number | boolean | bigint | undefined;
}
/**
* Utility class to get the resolved options for a command
* It is really typesafe
* @example const option = ctx.options.getStringOption("name");
*/
export class CommandInteractionOptionResolver {
#subcommand?: string;
#group?: string;
hoistedOptions: CommandInteractionOption[];
resolved?: DiscordInteractionDataResolved;
constructor(
options?: DiscordInteractionDataOption[],
resolved?: DiscordInteractionDataResolved
) {
this.hoistedOptions =
options?.map(transformOasisInteractionDataOption) ?? [];
// warning: black magic do not edit and thank djs authors
if (
this.hoistedOptions[0]?.type ===
ApplicationCommandOptionTypes.SubCommandGroup
) {
this.#group = this.hoistedOptions[0].name;
this.hoistedOptions = (this.hoistedOptions[0].options ?? []).map(
transformOasisInteractionDataOption
);
}
if (
this.hoistedOptions[0]?.type ===
ApplicationCommandOptionTypes.SubCommand
) {
this.#subcommand = this.hoistedOptions[0].name;
this.hoistedOptions = (this.hoistedOptions[0].options ?? []).map(
transformOasisInteractionDataOption
);
}
this.resolved = resolved;
}
private getTypedOption(
name: string | number,
type: ApplicationCommandOptionTypes,
properties: (keyof CommandInteractionOption)[],
required: boolean
): CommandInteractionOption | void {
const option: CommandInteractionOption | undefined = this.get(
name,
required
);
if (!option) {
return;
}
if (option.type !== type) {
// pass
}
if (
required === true &&
properties.every(prop => typeof option[prop] === 'undefined')
) {
throw new TypeError(
`Properties ${properties.join(
', '
)} are missing in option ${name}`
);
}
return option;
}
get(name: string | number, required: true): CommandInteractionOption;
get(
name: string | number,
required: boolean
): CommandInteractionOption | undefined;
get(name: string | number, required?: boolean) {
const option: CommandInteractionOption | undefined =
this.hoistedOptions.find(o =>
typeof name === 'number'
? o.name === name.toString()
: o.name === name
);
if (!option) {
if (required && name in this.hoistedOptions.map(o => o.name)) {
throw new TypeError('Option marked as required was undefined');
}
return;
}
return option;
}
/** searches for a string option */
getString(name: string | number, required: true): string;
getString(name: string | number, required?: boolean): string | undefined;
getString(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.String,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a number option */
getNumber(name: string | number, required: true): number;
getNumber(name: string | number, required?: boolean): number | undefined;
getNumber(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Number,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searhces for an integer option */
getInteger(name: string | number, required: true): number;
getInteger(name: string | number, required?: boolean): number | undefined;
getInteger(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Integer,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a boolean option */
getBoolean(name: string | number, required: true): boolean;
getBoolean(name: string | number, required?: boolean): boolean | undefined;
getBoolean(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Boolean,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a user option */
getUser(name: string | number, required: true): bigint;
getUser(name: string | number, required?: boolean): bigint | undefined;
getUser(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.User,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a channel option */
getChannel(name: string | number, required: true): bigint;
getChannel(name: string | number, required?: boolean): bigint | undefined;
getChannel(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Channel,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a mentionable-based option */
getMentionable(name: string | number, required: true): string;
getMentionable(
name: string | number,
required?: boolean
): string | undefined;
getMentionable(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Mentionable,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for a mentionable-based option */
getRole(name: string | number, required: true): bigint;
getRole(name: string | number, required?: boolean): bigint | undefined;
getRole(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Role,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for an attachment option */
getAttachment(name: string | number, required: true): string;
getAttachment(
name: string | number,
required?: boolean
): string | undefined;
getAttachment(name: string | number, required = false) {
const option: CommandInteractionOption | void = this.getTypedOption(
name,
ApplicationCommandOptionTypes.Attachment,
['Otherwise'],
required
);
return option?.Otherwise ?? undefined;
}
/** searches for the focused option */
getFocused(
full = false
):
| string
| number
| bigint
| boolean
| undefined
| CommandInteractionOption {
const focusedOption: CommandInteractionOption | void =
this.hoistedOptions.find(option => option.focused);
if (!focusedOption) {
throw new TypeError('No option found');
}
return full ? focusedOption : focusedOption.Otherwise;
}
getSubCommand(
required = true
): (string | CommandInteractionOption[] | undefined)[] {
if (required && !this.#subcommand) {
throw new TypeError('Option marked as required was undefined');
}
return [this.#subcommand, this.hoistedOptions];
}
getSubCommandGroup(
required = false
): (string | CommandInteractionOption[] | undefined)[] {
if (required && !this.#group) {
throw new TypeError('Option marked as required was undefined');
}
return [this.#group, this.hoistedOptions];
}
}

View File

@ -0,0 +1,50 @@
import { BitwisePermissionFlags } from '@biscuitland/api-types';
export type PermissionString = keyof typeof BitwisePermissionFlags;
export type PermissionResolvable =
| bigint
| PermissionString
| PermissionString[]
| BitwisePermissionFlags;
export class Permissions {
static Flags = BitwisePermissionFlags;
bitfield: bigint;
constructor(bitfield: PermissionResolvable) {
this.bitfield = Permissions.resolve(bitfield);
}
has(bit: PermissionResolvable): boolean {
if (this.bitfield & BigInt(Permissions.Flags.ADMINISTRATOR)) {
return true;
}
return !!(this.bitfield & Permissions.resolve(bit));
}
static resolve(bit: PermissionResolvable): bigint {
switch (typeof bit) {
case 'bigint':
return bit;
case 'number':
return BigInt(bit);
case 'string':
return BigInt(Permissions.Flags[bit]);
case 'object':
return Permissions.resolve(
bit
.map(p => BigInt(Permissions.Flags[p]))
.reduce((acc, cur) => acc | cur, 0n)
);
default:
throw new TypeError(`Cannot resolve permission: ${bit}`);
}
}
toJSON(): { fields: string[] } {
const fields = Object.keys(Permissions.Flags).filter((bit) => typeof bit !== 'string' && this.has(bit));
return { fields };
}
}

View File

@ -0,0 +1,63 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type { DiscordStageInstance as DiscordAutoClosingStageInstance } from '@biscuitland/api-types';
import { STAGE_INSTANCE } from '@biscuitland/api-types';
export interface DiscordStageInstanceB extends DiscordAutoClosingStageInstance {
privacy_level: PrivacyLevels;
discoverable_disabled: boolean;
guild_scheduled_event_id: Snowflake;
}
export enum PrivacyLevels {
Public = 1,
GuildOnly = 2,
}
export type StageEditOptions = {
topic?: string;
privacy?: PrivacyLevels;
};
export class StageInstance implements Model {
constructor(session: Session, data: DiscordStageInstanceB) {
this.session = session;
this.id = data.id;
this.channelId = data.channel_id;
this.guildId = data.guild_id;
this.topic = data.topic;
this.privacyLevel = data.privacy_level;
this.discoverableDisabled = data.discoverable_disabled;
this.guildScheduledEventId = data.guild_scheduled_event_id;
}
readonly session: Session;
readonly id: Snowflake;
channelId: Snowflake;
guildId: Snowflake;
topic: string;
// TODO: see if this works
privacyLevel: PrivacyLevels;
discoverableDisabled: boolean;
guildScheduledEventId: Snowflake;
async edit(options: StageEditOptions): Promise<StageInstance> {
const stageInstance =
await this.session.rest.patch<DiscordStageInstanceB>(
STAGE_INSTANCE(this.id),
{
topic: options.topic,
privacy_level: options.privacy,
}
);
return new StageInstance(this.session, stageInstance);
}
async delete(): Promise<void> {
await this.session.rest.delete<undefined>(STAGE_INSTANCE(this.id), {});
}
}

View File

@ -0,0 +1,73 @@
import type {
DiscordSticker,
DiscordStickerPack,
StickerFormatTypes,
StickerTypes,
} from '@biscuitland/api-types';
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import { User } from './user';
import { STICKER_PACKS } from '@biscuitland/api-types';
export interface StickerItem {
id: Snowflake;
name: string;
formatType: StickerFormatTypes;
}
export interface StickerPack {
id: Snowflake;
stickers: Sticker[];
name: string;
skuId: Snowflake;
coverStickerId?: Snowflake;
description: string;
bannerAssetId?: Snowflake;
}
export class Sticker implements Model {
constructor(session: Session, data: DiscordSticker) {
this.session = session;
this.id = data.id;
this.packId = data.pack_id;
this.name = data.name;
this.description = data.description;
this.tags = data.tags.split(',');
this.type = data.type;
this.formatType = data.format_type;
this.available = !!data.available;
this.guildId = data.guild_id;
this.user = data.user ? new User(this.session, data.user) : undefined;
this.sortValue = data.sort_value;
}
session: Session;
id: Snowflake;
packId?: Snowflake;
name: string;
description?: string;
tags: string[];
type: StickerTypes;
formatType: StickerFormatTypes;
available?: boolean;
guildId?: Snowflake;
user?: User;
sortValue?: number;
async fetchPremiumPack(): Promise<StickerPack> {
const data = await this.session.rest.get<DiscordStickerPack>(
STICKER_PACKS()
);
return {
id: data.id,
stickers: data.stickers.map(st => new Sticker(this.session, st)),
name: data.name,
skuId: data.sku_id,
coverStickerId: data.cover_sticker_id,
description: data.description,
bannerAssetId: data.banner_asset_id,
};
}
}

View File

@ -0,0 +1,119 @@
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type { Session } from '../biscuit';
import type { DiscordUser, PremiumTypes, UserFlags } from '@biscuitland/api-types';
import type { ImageFormat, ImageSize } from '../utils/util';
import { USER, USER_AVATAR, USER_DEFAULT_AVATAR } from '@biscuitland/api-types';
import { Util } from '../utils/util';
export type AvatarOptions = {
format?: ImageFormat;
size?: ImageSize;
};
/**
* Represents a user
* @link https://discord.com/developers/docs/resources/user#user-object
*/
export class User implements Model {
constructor(session: Session, data: DiscordUser) {
this.session = session;
this.id = data.id;
this.username = data.username;
this.discriminator = data.discriminator;
this.avatarHash = data.avatar
? data.avatar
: undefined;
this.accentColor = data.accent_color;
this.bot = !!data.bot;
this.system = !!data.system;
this.banner = data.banner
? data.banner
: undefined;
this.mfaEnabled = !!data.mfa_enabled;
this.locale = data.locale;
this.email = data.email ? data.email : undefined;
this.verified = !!data.verified;
this.flags = data.flags;
}
/** the session that instantiated this User */
readonly session: Session;
/** the user's id */
readonly id: Snowflake;
/** the user's username, not unique across the platform */
username: string;
/** the user's 4-digit discord-tag */
discriminator: string;
/** the user's avatar hash */
avatarHash?: string;
/** the user's banner color encoded as an integer representation of hexadecimal color code */
accentColor?: number;
/** whether the user belongs to an OAuth2 application */
bot: boolean;
/** whether the user is an Official Discord System user (part of the urgent message system) */
system: boolean;
/** the user's banner hash */
banner?: string;
/** whether the user has two factor enabled on their account */
mfaEnabled: boolean;
/** the user's chosen language option */
locale?: string;
/** the user's email */
email?: string;
/** the flags on a user's account */
flags?: UserFlags;
/** whether the email on this account has been verified */
verified: boolean;
/** the type of Nitro subscription on a user's account */
premiumType?: PremiumTypes;
/** the public flags on a user's account */
publicFlags?: UserFlags;
/** gets the user's username#discriminator */
get tag(): string {
return `${this.username}#${this.discriminator}}`;
}
/** fetches this user */
async fetch(): Promise<User> {
const user = await this.session.rest.get<DiscordUser>(USER(this.id));
return new User(this.session, user);
}
/** gets the user's avatar */
avatarURL(options: AvatarOptions): string {
let url: string;
if (!this.avatarHash) {
url = USER_DEFAULT_AVATAR(Number(this.discriminator) % 5);
} else {
url = USER_AVATAR(this.id, this.avatarHash);
}
return Util.formatImageURL(url, options.size ?? 128, options.format);
}
toString(): string {
return `<@${this.id}>`;
}
}

View File

@ -0,0 +1,188 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type {
DiscordEmbed,
DiscordMessage,
DiscordMessageComponents,
DiscordWebhook,
FileContent,
WebhookTypes,
WebhookOptions,
} from '@biscuitland/api-types';
import type { Attachment } from './attachment';
import type { AllowedMentions, CreateMessage } from './message';
import { User } from './user';
import { Message } from './message';
import { Util } from '../utils/util';
import {
WEBHOOK,
WEBHOOK_TOKEN,
WEBHOOK_MESSAGE,
WEBHOOK_MESSAGE_ORIGINAL,
} from '@biscuitland/api-types';
export type ExecuteWebhookOptions = WebhookOptions &
CreateMessage & { avatarUrl?: string; username?: string };
export type EditMessageWithThread = EditWebhookMessage & {
threadId?: Snowflake;
};
/**
* @link https://discord.com/developers/docs/resources/webhook#edit-webhook-message-jsonform-params
*/
export interface EditWebhookMessage {
content?: string;
embeds?: DiscordEmbed[];
files?: FileContent[];
allowedMentions?: AllowedMentions;
attachments?: Attachment[];
components?: DiscordMessageComponents;
}
export class Webhook implements Model {
constructor(session: Session, data: DiscordWebhook) {
this.session = session;
this.id = data.id;
this.type = data.type;
this.token = data.token;
if (data.avatar) {
this.avatar = Util.iconHashToBigInt(data.avatar);
}
if (data.user) {
this.user = new User(session, data.user);
}
if (data.guild_id) {
this.guildId = data.guild_id;
}
if (data.channel_id) {
this.channelId = data.channel_id;
}
if (data.application_id) {
this.applicationId = data.application_id;
}
}
readonly session: Session;
readonly id: Snowflake;
type: WebhookTypes;
token?: string;
avatar?: bigint;
applicationId?: Snowflake;
channelId?: Snowflake;
guildId?: Snowflake;
user?: User;
async execute(
options?: ExecuteWebhookOptions
): Promise<Message | undefined> {
if (!this.token) {
return;
}
const data = {
content: options?.content,
embeds: options?.embeds,
tts: options?.tts,
allowed_mentions: options?.allowedMentions,
components: options?.components,
file: options?.files,
};
const message = await this.session.rest.post<DiscordMessage>(
WEBHOOK(this.id, this.token, {
wait: options?.wait,
threadId: options?.threadId,
}),
data
);
return options?.wait ?? true
? new Message(this.session, message)
: undefined;
}
async fetch(): Promise<Webhook> {
const message = await this.session.rest.get<DiscordWebhook>(
WEBHOOK_TOKEN(this.id, this.token)
);
return new Webhook(this.session, message);
}
async fetchMessage(
messageId: Snowflake,
threadId?: Snowflake
): Promise<Message | undefined> {
if (!this.token) {
return;
}
const message = await this.session.rest.get<DiscordMessage>(
WEBHOOK_MESSAGE(this.id, this.token, messageId, { threadId })
);
return new Message(this.session, message);
}
async deleteMessage(
messageId: Snowflake,
threadId?: Snowflake
): Promise<void> {
if (!this.token) {
throw new Error('No token found');
}
await this.session.rest.delete<undefined>(
WEBHOOK_MESSAGE(this.id, this.token, messageId, { threadId }),
{}
);
}
async editMessage(
messageId?: Snowflake,
options?: EditMessageWithThread
): Promise<Message> {
if (!this.token) {
throw new Error('No token found');
}
const message = await this.session.rest.patch<DiscordMessage>(
messageId
? WEBHOOK_MESSAGE(this.id, this.token, messageId)
: WEBHOOK_MESSAGE_ORIGINAL(this.id, this.token),
{
content: options?.content,
embeds: options?.embeds,
file: options?.files,
components: options?.components,
allowed_mentions: options?.allowedMentions && {
parse: options?.allowedMentions.parse,
replied_user: options?.allowedMentions.repliedUser,
users: options?.allowedMentions.users,
roles: options?.allowedMentions.roles,
},
attachments: options?.attachments?.map(attachment => {
return {
id: attachment.id,
filename: attachment.name,
content_type: attachment.contentType,
size: attachment.size,
url: attachment.attachment,
proxy_url: attachment.proxyUrl,
height: attachment.height,
width: attachment.width,
ephemeral: attachment.ephemeral,
};
}),
}
);
return new Message(this.session, message);
}
}

View File

@ -0,0 +1,55 @@
import type { Session } from '../biscuit';
import type { Model } from './base';
import type { Snowflake } from '../snowflakes';
import type {
DiscordWelcomeScreen,
DiscordWelcomeScreenChannel,
} from '@biscuitland/api-types';
import { Emoji } from './emojis';
/**
* Not a channel
* @link https://discord.com/developers/docs/resources/guild#welcome-screen-object-welcome-screen-channel-structure
*/
export class WelcomeChannel implements Model {
constructor(session: Session, data: DiscordWelcomeScreenChannel) {
this.session = session;
this.channelId = data.channel_id;
this.description = data.description;
this.emoji = new Emoji(session, {
name: data.emoji_name ? data.emoji_name : undefined,
id: data.emoji_id ? data.emoji_id : undefined,
});
}
session: Session;
channelId: Snowflake;
description: string;
emoji: Emoji;
/** alias for WelcomeScreenChannel.channelId */
get id(): Snowflake {
return this.channelId;
}
}
/**
* @link https://discord.com/developers/docs/resources/guild#welcome-screen-object
*/
export class WelcomeScreen {
constructor(session: Session, data: DiscordWelcomeScreen) {
this.session = session;
this.welcomeChannels = data.welcome_channels.map(
welcomeChannel => new WelcomeChannel(session, welcomeChannel)
);
if (data.description) {
this.description = data.description;
}
}
readonly session: Session;
description?: string;
welcomeChannels: WelcomeChannel[];
}

View File

@ -0,0 +1,41 @@
import type { Model } from './base';
import type { Session } from '../biscuit';
import type { Snowflake } from '../snowflakes';
import type { DiscordGuildWidget } from '@biscuitland/api-types';
import type { PartialChannel } from './channels';
export interface WidgetMember {
id?: string;
username: string;
avatar?: string | null;
status: string;
avatarURL: string;
}
export class Widget implements Model {
constructor(session: Session, data: DiscordGuildWidget) {
this.session = session;
this.id = data.id;
this.name = data.name;
this.instantInvite = data.instant_invite;
this.channels = data.channels;
this.members = data.members.map(x => {
return {
id: x.id,
username: x.username,
avatar: x.avatar,
status: x.status,
avatarURL: x.avatar_url,
};
});
this.presenceCount = data.presence_count;
}
session: Session;
id: Snowflake;
name: string;
instantInvite?: string;
channels: PartialChannel[];
members: WidgetMember[];
presenceCount: number;
}

View File

@ -0,0 +1,5 @@
export function calculateShardId(totalShards: number, guildId: bigint) {
if (totalShards === 1) return 0;
return Number((guildId >> 22n) % BigInt(totalShards - 1));
}

View File

@ -0,0 +1,69 @@
/** Converts a url to base 64. Useful for example, uploading/creating server emojis. */
export async function urlToBase64(url: string): Promise<string> {
const buffer = await fetch(url).then((res) => res.arrayBuffer());
const imageStr = encode(buffer);
const type = url.substring(url.lastIndexOf('.') + 1);
return `data:image/${type};base64,${imageStr}`;
}
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
// deno-fmt-ignore
const base64abc: string[] = [
"A", "B", "C",
"D", "E", "F",
"G", "H", "I",
"J", "K", "L",
"M", "N", "O",
"P", "Q", "R",
"S", "T", "U",
"V", "W", "X",
"Y", "Z", "a",
"b", "c", "d",
"e", "f", "g",
"h", "i", "j",
"k", "l", "m",
"n", "o", "p",
"q", "r", "s",
"t", "u", "v",
"w", "x", "y",
"z", "0", "1",
"2", "3", "4",
"5", "6", "7",
"8", "9", "+", "/",
];
/**
* CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
* Encodes a given Uint8Array, ArrayBuffer or string into RFC4648 base64 representation
* @param data
*/
export function encode(data: ArrayBuffer | string): string {
const uint8: Uint8Array = typeof data === 'string'
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: new Uint8Array(data);
let result = '',
i;
const l: number = uint8.length;
for (i = 2; i < l; i += 3) {
result += base64abc[uint8[i - 2] >> 2];
result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)];
result += base64abc[((uint8[i - 1] & 0x0f) << 2) | (uint8[i] >> 6)];
result += base64abc[uint8[i] & 0x3f];
}
if (i === l + 1) {
// 1 octet yet to write
result += base64abc[uint8[i - 2] >> 2];
result += base64abc[(uint8[i - 2] & 0x03) << 4];
result += '==';
}
if (i === l) {
// 2 octets yet to write
result += base64abc[uint8[i - 2] >> 2];
result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)];
result += base64abc[(uint8[i - 1] & 0x0f) << 2];
result += '=';
}
return result;
}

View File

@ -0,0 +1,106 @@
import type { ButtonBuilder } from '../builders/components/MessageButtonBuilder';
import type { InputTextBuilder } from '../builders/components/InputTextBuilder';
import type { SelectMenuBuilder } from '../builders/components/MessageSelectMenuBuilder';
import type { Permissions } from '../structures/special/permissions';
import type { Snowflake } from '../snowflakes';
/*
* Represents a session's cache
* */
export interface SymCache {
readonly cache: symbol;
}
/*
* @link https://discord.com/developers/docs/resources/channel#message-object-message-flags
*/
export enum MessageFlags {
/** this message has been published to subscribed channels (via Channel Following) */
CrossPosted = 1 << 0,
/** this message originated from a message in another channel (via Channel Following) */
IsCrosspost = 1 << 1,
/** do not include any embeds when serializing this message */
SupressEmbeds = 1 << 2,
/** the source message for this crosspost has been deleted (via Channel Following) */
SourceMessageDeleted = 1 << 3,
/** this message came from the urgent message system */
Urgent = 1 << 4,
/** this message has an associated thread, with the same id as the message */
HasThread = 1 << 5,
/** this message is only visible to the user who invoked the Interaction */
Ephemeral = 1 << 6,
/** this message is an Interaction Response and the bot is "thinking" */
Loading = 1 << 7,
/** this message failed to mention some roles and add their members to the thread */
FailedToMentionSomeRolesInThread = 1 << 8,
}
export type ComponentBuilder =
| InputTextBuilder
| SelectMenuBuilder
| ButtonBuilder;
/** *
* Utility type
*/
export type ComponentEmoji = {
id: Snowflake;
name: string;
animated?: boolean;
};
/**
* Utility type
*/
export interface PermissionsOverwrites {
id: Snowflake;
type: 0 | 1;
allow: Permissions;
deny: Permissions;
}
/**
* @link https://discord.com/developers/docs/reference#image-formatting
*/
export type ImageFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'gif' | 'json';
/**
* @link https://discord.com/developers/docs/reference#image-formatting
*/
export type ImageSize = 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096;
/**
* Utility functions
*/
export abstract class Util {
static formatImageURL(url: string, size: ImageSize = 128, format?: ImageFormat): string {
return `${url}.${format || (url.includes('/a_') ? 'gif' : 'jpg')}?size=${size}`;
}
static iconHashToBigInt(hash: string): bigint {
return BigInt('0x' + (hash.startsWith('a_') ? `a${hash.substring(2)}` : `b${hash}`));
}
static iconBigintToHash(icon: bigint): string {
const hash: string = icon.toString(16);
return hash.startsWith('a') ? `a_${hash.substring(1)}` : hash.substring(1);
}
/** Removes the Bot before the token. */
static removeTokenPrefix(token?: string, type: 'GATEWAY' | 'REST' = 'REST'): string {
// If no token is provided, throw an error
if (!token) throw new Error(`The ${type} was not given a token. Please provide a token and try again.`);
// If the token does not have a prefix just return token
if (!token.startsWith('Bot ')) return token;
// Remove the prefix and return only the token.
return token.substring(token.indexOf(' ') + 1);
}
/** Get the bot id from the bot token. WARNING: Discord staff has mentioned this may not be stable forever. Use at your own risk. However, note for over 5 years this has never broken. */
static getBotIdFromToken(token: string): string {
return atob(token.split('.')[0]);
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
const isProduction = process.env.NODE_ENV === 'production';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
minify: isProduction,
sourcemap: true,
});

0
packages/rest/README.md Normal file
View File

View File

@ -0,0 +1,21 @@
{
"name": "@biscuitland/rest",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist && rm -rf .turbo",
"dev": "tsup --watch"
},
"dependencies": {
"@biscuitland/api-types": "^1.0.0"
},
"devDependencies": {
"tsup": "^6.1.3"
}
}

View File

@ -0,0 +1,809 @@
import type {
CreateRequestBodyOptions,
RestAdapter,
RestPayload,
RestRateLimitedPath,
RestRequest,
RestRequestRejection,
RestRequestResponse,
RestSendRequestOptions,
} from './rest-adapter';
import type { FileContent } from '@biscuitland/api-types';
import { Constants, HTTPResponseCodes } from '@biscuitland/api-types';
export class DefaultRestAdapter implements RestAdapter {
static readonly DEFAULTS = {
url: '',
version: Constants.API_VERSION,
maxRetryCount: 10,
};
options: Options;
/** current invalid amount */
protected invalidRequests = 0;
/** max invalid requests allowed until ban */
protected maxInvalidRequests = 10000;
/** 10 minutes */
protected invalidRequestsInterval = 600000;
/** timer to reset to 0 */
protected invalidRequestsTimeoutId = 0;
/** how safe to be from max */
protected invalidRequestsSafetyAmount = 1;
/** when first request in this period was made */
protected invalidRequestErrorStatuses = [401, 403, 429];
protected processingQueue = false;
protected processingRateLimitedPaths = false;
protected globallyRateLimited = false;
protected globalQueueProcessing = false;
private rateLimitedPaths = new Map<string, RestRateLimitedPath>();
private globalQueue = [] as {
request: RestRequest;
payload: RestPayload;
basicURL: string;
urlToUse: string;
}[];
private pathQueues = new Map<
string,
{
isWaiting: boolean;
requests: {
request: RestRequest;
payload: RestPayload;
}[];
}
>();
private url: string;
constructor(options: DefaultRestOptions) {
this.options = Object.assign(options, DefaultRestAdapter.DEFAULTS);
if (this.options.url) {
this.url = `${options.url}/v${this.options.version}`;
} else {
this.url = `${Constants.BASE_URL}/v${this.options.version}`;
}
}
/**
* @inheritDoc
*/
async get<T>(
route: string,
body?: any,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T> {
const url = route[0] === '/' ? `${this.url}${route}` : route;
return new Promise((resolve, reject) => {
this.processRequest(
{
url,
method: 'GET',
reject: (data: RestRequestRejection) => {
const restError = this.convertRestError(
new Error('Location:'),
data
);
reject(restError);
},
resolve: (data: RestRequestResponse) =>
resolve(
data.status !== 204
? JSON.parse(data.body ?? '{}')
: (undefined as unknown as T)
),
},
{
bucketId: options?.bucketId,
body: body as Record<string, unknown> | undefined,
retryCount: options?.retryCount ?? 0,
headers: options?.headers,
}
);
});
}
/**
* @inheritDoc
*/
async put<T>(
route: string,
body?: any,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T> {
const url = route[0] === '/' ? `${this.url}${route}` : route;
return new Promise((resolve, reject) => {
this.processRequest(
{
url,
method: 'PUT',
reject: (data: RestRequestRejection) => {
const restError = this.convertRestError(
new Error('Location:'),
data
);
reject(restError);
},
resolve: (data: RestRequestResponse) =>
resolve(
data.status !== 204
? JSON.parse(data.body ?? '{}')
: (undefined as unknown as T)
),
},
{
bucketId: options?.bucketId,
body: body as Record<string, unknown> | undefined,
retryCount: options?.retryCount ?? 0,
headers: options?.headers,
}
);
});
}
/**
* @inheritDoc
*/
async post<T>(
route: string,
body?: any,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T> {
const url = route[0] === '/' ? `${this.url}${route}` : route;
return new Promise((resolve, reject) => {
this.processRequest(
{
url,
method: 'POST',
reject: err => reject(err),
resolve: (data: RestRequestResponse) =>
resolve(
data.status !== 204
? JSON.parse(data.body ?? '{}')
: (undefined as unknown as T)
),
},
{
bucketId: options?.bucketId,
body: body as Record<string, unknown> | undefined,
retryCount: options?.retryCount ?? 0,
headers: options?.headers,
}
);
});
}
/**
* @inheritDoc
*/
async patch<T>(
route: string,
body?: any,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T> {
const url = route[0] === '/' ? `${this.url}${route}` : route;
return new Promise((resolve, reject) => {
this.processRequest(
{
url,
method: 'PATCH',
reject: (data: RestRequestRejection) => {
const restError = this.convertRestError(
new Error('Location:'),
data
);
reject(restError);
},
resolve: (data: RestRequestResponse) =>
resolve(
data.status !== 204
? JSON.parse(data.body ?? '{}')
: (undefined as unknown as T)
),
},
{
bucketId: options?.bucketId,
body: body as Record<string, unknown> | undefined,
retryCount: options?.retryCount ?? 0,
headers: options?.headers,
}
);
});
}
/**
* @inheritDoc
*/
async delete<T>(
route: string,
body?: any,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T> {
const url = route[0] === '/' ? `${this.url}${route}` : route;
return new Promise((resolve, reject) => {
this.processRequest(
{
url,
method: 'DELETE',
reject: (data: RestRequestRejection) => {
const restError = this.convertRestError(
new Error('Location:'),
data
);
reject(restError);
},
resolve: (data: any) => resolve(JSON.parse(data.body)),
},
{
bucketId: options?.bucketId,
body: body as Record<string, unknown> | undefined,
retryCount: options?.retryCount ?? 0,
headers: options?.headers,
}
);
});
}
/**
* @inheritDoc
*/
private async sendRequest(options: RestSendRequestOptions) {
try {
const response = await fetch(
new Request(options.url, {
method: options.method,
headers: options.payload?.headers,
body: options.payload?.body,
})
);
const bucketIdFromHeaders = this.processRequestHeaders(
options.url,
response.headers
);
if (bucketIdFromHeaders) {
options.bucketId = bucketIdFromHeaders;
}
if (response.status < 200 || response.status >= 400) {
let error = 'REQUEST_UNKNOWN_ERROR';
switch (response.status) {
case HTTPResponseCodes.BadRequest:
error =
"The options was improperly formatted, or the server couldn't understand it.";
break;
case HTTPResponseCodes.Unauthorized:
error =
'The Authorization header was missing or invalid.';
break;
case HTTPResponseCodes.Forbidden:
error =
'The Authorization token you passed did not have permission to the resource.';
break;
case HTTPResponseCodes.NotFound:
error =
"The resource at the location specified doesn't exist.";
break;
case HTTPResponseCodes.MethodNotAllowed:
error =
'The HTTP method used is not valid for the location specified.';
break;
case HTTPResponseCodes.GatewayUnavailable:
error =
'There was not a gateway available to process your options. Wait a bit and retry.';
break;
}
if (
this.invalidRequestErrorStatuses.includes(
response.status
) &&
!(
response.status === 429 &&
response.headers.get('X-RateLimit-Scope')
)
) {
++this.invalidRequests;
if (!this.invalidRequestsTimeoutId) {
const it: any = setTimeout(() => {
this.invalidRequests = 0;
this.invalidRequestsTimeoutId = 0;
}, this.invalidRequestsInterval);
this.invalidRequestsTimeoutId = it;
}
}
if (response.status !== 429) {
options.reject?.({
ok: false,
status: response.status,
error,
body: response.type
? JSON.stringify(await response.json())
: undefined,
});
throw new Error(
JSON.stringify({
ok: false,
status: response.status,
error,
body: response.type
? JSON.stringify(await response.json())
: undefined,
})
);
} else {
if (
options.retryCount &&
options.retryCount++ >= this.options.maxRetryCount
) {
options.reject?.({
ok: false,
status: response.status,
error: 'The options was rate limited and it maxed out the retries limit.',
});
return;
}
}
}
if (response.status === 204) {
options.resolve?.({
ok: true,
status: 204,
});
return;
}
const json = JSON.stringify(await response.json());
options.resolve?.({
ok: true,
status: 200,
body: json,
});
return JSON.parse(json);
} catch (error) {
options.reject?.({
ok: false,
status: 599,
error: 'Internal Error',
});
throw new Error('Something went wrong in sendRequest');
}
}
/**
* @inheritDoc
*/
private processRequest(request: RestRequest, payload: RestPayload) {
const queue = this.pathQueues.get(request.url);
if (queue) {
queue.requests.push({ request, payload });
} else {
this.pathQueues.set(request.url, {
isWaiting: false,
requests: [
{
request,
payload,
},
],
});
this.processQueue(request.url);
}
}
/**
* @inheritDoc
*/
private processQueue(id: string) {
const queue = this.pathQueues.get(id);
if (!queue) {
return;
}
while (queue.requests.length) {
const request = queue.requests[0];
if (!request) {
break;
}
const basicURL = request.request.url + '@' + request.request.method;
const urlResetIn = this.checkRateLimits(basicURL);
if (urlResetIn) {
if (!queue.isWaiting) {
queue.isWaiting = true;
setTimeout(() => {
queue.isWaiting = false;
this.processQueue(id);
}, urlResetIn);
}
break;
}
const bucketResetIn = request.payload.bucketId
? this.checkRateLimits(request.payload.bucketId)
: false;
if (bucketResetIn) {
continue;
}
this.globalQueue.push({
...request,
urlToUse: request.request.url,
basicURL,
});
this.processGlobalQueue();
queue.requests.shift();
}
this.cleanupQueues();
}
/**
* @inheritDoc
*/
private async processGlobalQueue() {
if (!this.globalQueue.length) {
return;
}
if (this.globalQueueProcessing) {
return;
}
this.globalQueueProcessing = true;
while (this.globalQueue.length) {
if (this.globallyRateLimited) {
setTimeout(() => {
this.processGlobalQueue();
}, 1000);
break;
}
if (
this.invalidRequests ===
this.maxInvalidRequests - this.invalidRequestsSafetyAmount
) {
setTimeout(() => {
this.processGlobalQueue();
}, 1000);
break;
}
const request = this.globalQueue.shift();
if (!request) {
continue;
}
const urlResetIn = this.checkRateLimits(request.basicURL);
const bucketResetIn = request.payload.bucketId
? this.checkRateLimits(request.payload.bucketId)
: false;
if (urlResetIn || bucketResetIn) {
setTimeout(() => {
this.globalQueue.unshift(request);
this.processGlobalQueue();
}, urlResetIn || (bucketResetIn as number));
continue;
}
await this.sendRequest({
url: request.urlToUse,
method: request.request.method,
bucketId: request.payload.bucketId,
reject: request.request.reject,
resolve: request.request.resolve,
retryCount: request.payload.retryCount ?? 0,
payload: this.createRequestBody({
method: request.request.method,
body: request.payload.body,
}),
}).catch(() => null);
}
this.globalQueueProcessing = false;
}
/**
* @inheritDoc
*/
private processRequestHeaders(url: string, headers: Headers) {
let rateLimited = false;
const remaining = headers.get('x-ratelimit-remaining');
const retryAfter = headers.get('x-ratelimit-reset-after');
const reset = Date.now() + Number(retryAfter) * 1000;
const global = headers.get('x-ratelimit-global');
const bucketId = headers.get('x-ratelimit-bucket') || undefined;
if (remaining === '0') {
rateLimited = true;
this.rateLimitedPaths.set(url, {
url,
resetTimestamp: reset,
bucketId,
});
if (bucketId) {
this.rateLimitedPaths.set(bucketId, {
url,
resetTimestamp: reset,
bucketId,
});
}
}
if (global) {
const retryAfter = headers.get('retry-after');
const globalReset = Date.now() + Number(retryAfter) * 1000;
this.globallyRateLimited = true;
rateLimited = true;
this.rateLimitedPaths.set('global', {
url: 'global',
resetTimestamp: globalReset,
bucketId,
});
if (bucketId) {
this.rateLimitedPaths.set(bucketId, {
url: 'global',
resetTimestamp: globalReset,
bucketId,
});
}
}
if (rateLimited && !this.processingRateLimitedPaths) {
this.processRateLimitedPaths();
}
return rateLimited ? bucketId : undefined;
}
/**
* @inheritDoc
*/
private processRateLimitedPaths() {
const now = Date.now();
for (const [key, value] of this.rateLimitedPaths.entries()) {
if (value.resetTimestamp > now) {
continue;
}
this.rateLimitedPaths.delete(key);
if (key === 'global') {
this.globallyRateLimited = false;
}
}
if (!this.rateLimitedPaths.size) {
this.processingRateLimitedPaths = false;
} else {
this.processingRateLimitedPaths = true;
setTimeout(() => {
this.processRateLimitedPaths();
}, 1000);
}
}
/**
* @inheritDoc
*/
private createRequestBody(options: CreateRequestBodyOptions) {
const headers: Record<string, string> = {
'user-agent': Constants.USER_AGENT,
};
if (!options.unauthorized) {
headers.authorization = `Bot ${this.options.token}`;
}
if (options.headers) {
for (const key in options.headers) {
headers[key.toLowerCase()] = options.headers[key];
}
}
if (options.method === 'GET') {
options.body = undefined;
}
if (options.body?.reason) {
headers['X-Audit-Log-Reason'] = encodeURIComponent(
options.body.reason as string
);
options.body.reason = undefined;
}
if (options.body?.file) {
if (!Array.isArray(options.body.file)) {
options.body.file = [options.body.file];
}
const form = new FormData();
for (
let i = 0;
i < (options.body.file as FileContent[]).length;
i++
) {
form.append(
`file${i}`,
(options.body.file as FileContent[])[i].blob,
(options.body.file as FileContent[])[i].name
);
}
form.append(
'payload_json',
JSON.stringify({ ...options.body, file: undefined })
);
options.body.file = form;
} else if (
options.body &&
!['GET', 'DELETE'].includes(options.method)
) {
headers['Content-Type'] = 'application/json';
}
return {
headers,
body: (options.body?.file ?? JSON.stringify(options.body)) as
| FormData
| string,
method: options.method,
};
}
/**
* @inheritDoc
*/
private checkRateLimits(url: string) {
const limited = this.rateLimitedPaths.get(url);
const global = this.rateLimitedPaths.get('global');
const now = Date.now();
if (limited && now < limited.resetTimestamp) {
return limited.resetTimestamp - now;
}
if (global && now < global.resetTimestamp) {
return global.resetTimestamp - now;
}
return false;
}
/**
* @inheritDoc
*/
private convertRestError(errorStack: Error, data: RestRequestRejection) {
errorStack.message = `[${data.status}] ${data.error}\n${data.body}`;
return errorStack;
}
/**
* @inheritDoc
*/
private cleanupQueues() {
for (const [key, queue] of this.pathQueues) {
if (queue.requests.length) {
continue;
}
this.pathQueues.delete(key);
}
if (!this.pathQueues.size) {
this.processingQueue = false;
}
}
}
export type DefaultRestOptions = Pick<
Options,
Exclude<keyof Options, keyof typeof DefaultRestAdapter.DEFAULTS>
> &
Partial<Options>;
export interface Options {
url: string;
token: string;
version: number;
maxRetryCount: number;
}

View File

@ -0,0 +1,125 @@
export interface RestRequest {
url: string;
method: RequestMethod;
reject: (payload: RestRequestRejection) => unknown;
resolve: (payload: RestRequestResponse) => unknown;
}
export interface RestRequestResponse {
ok: boolean;
status: number;
body?: string;
}
export interface RestRequestRejection extends RestRequestResponse {
error: string;
}
export interface RestPayload {
bucketId?: string;
body?: Record<string, unknown>;
retryCount: number;
headers?: Record<string, string>;
}
export interface RestRateLimitedPath {
url: string;
resetTimestamp: number;
bucketId?: string;
}
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface RestSendRequestOptions {
url: string;
method: RequestMethod;
bucketId?: string;
reject?: CallableFunction;
resolve?: CallableFunction;
retryCount?: number;
payload?: {
headers: Record<string, string>;
body: string | FormData;
};
}
export interface CreateRequestBodyOptions {
headers?: Record<string, string>;
method: RequestMethod;
body?: Record<string, unknown>;
unauthorized?: boolean;
}
export interface RestAdapter {
options: any;
/**
* @inheritDoc
*/
get<T>(
route: string,
data?: unknown,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T>;
/**
* @inheritDoc
*/
put<T>(
router: string,
data: unknown,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T>;
/**
* @inheritDoc
*/
post<T>(
router: string,
data: unknown,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T>;
/**
* @inheritDoc
*/
patch<T>(
router: string,
data: unknown,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T>;
/**
* @inheritDoc
*/
delete<T>(
router: string,
data: unknown,
options?: {
retryCount?: number;
bucketId?: string;
headers?: Record<string, string>;
}
): Promise<T>;
}

View File

@ -0,0 +1,3 @@
export { RestAdapter } from './adapters/rest-adapter';
export { DefaultRestAdapter } from './adapters/default-rest-adapter';

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
const isProduction = process.env.NODE_ENV === 'production';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
minify: isProduction,
sourcemap: true,
});

0
packages/ws/README.md Normal file
View File

23
packages/ws/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "@biscuitland/ws",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist && rm -rf .turbo",
"dev": "tsup --watch"
},
"dependencies": {
"@biscuitland/api-types": "^1.0.0",
"ws": "^8.8.1"
},
"devDependencies": {
"@types/ws": "^8.5.3",
"tsup": "^6.1.3"
}
}

View File

@ -0,0 +1,233 @@
import { createLeakyBucket } from '../utils/bucket-util';
import type { LeakyBucket } from '../utils/bucket-util';
import type { GatewayBot, PickPartial } from '@biscuitland/api-types';
import type { WsAdapter } from './ws-adapter';
import type {
Shard,
ShardGatewayConfig,
ShardOptions,
} from '../services/shard';
import { Agent } from '../services/agent';
export class DefaultWsAdapter implements WsAdapter {
static readonly DEFAULTS = {
spawnShardDelay: 5000,
shardsPerWorker: 5,
totalWorkers: 1,
gatewayBot: {
url: 'wss://gateway.discord.gg',
shards: 1,
sessionStartLimit: {
total: 1000,
remaining: 1000,
resetAfter: 0,
maxConcurrency: 1,
},
},
firstShardId: 0, // remove
lastShardId: 1, // remove
};
buckets = new Map<
number,
{
workers: { id: number; queue: number[] }[];
leak: LeakyBucket;
}
>();
options: Options;
agent: Agent;
constructor(options: DefaultWsOptions) {
this.options = Object.assign(options, DefaultWsAdapter.DEFAULTS);
this.agent = new Agent({
totalShards: this.options.totalShards ?? 1,
gatewayConfig: this.options.gatewayConfig,
createShardOptions: this.options.createShardOptions,
handleMessage: (shard: Shard, message: MessageEvent<any>) => {
return this.options.handleDiscordPayload(shard, message);
},
handleIdentify: (id: number) => {
return this.buckets.get(id)!.leak.acquire(1);
},
});
}
/**
* @inheritDoc
*/
prepareBuckets() {
for (
let i = 0;
i < this.options.gatewayBot.sessionStartLimit.maxConcurrency;
++i
) {
this.buckets.set(i, {
workers: [],
leak: createLeakyBucket({
max: 1,
refillAmount: 1,
refillInterval: this.options.spawnShardDelay,
}),
});
}
for (
let shardId = this.options.firstShardId;
shardId <= this.options.lastShardId;
++shardId
) {
if (shardId >= this.agent.options.totalShards) {
throw new Error(
`Shard (id: ${shardId}) is bigger or equal to the used amount of used shards which is ${this.agent.options.totalShards}`
);
}
const bucketId =
shardId %
this.options.gatewayBot.sessionStartLimit.maxConcurrency;
const bucket = this.buckets.get(bucketId);
if (!bucket) {
throw new Error(
`Shard (id: ${shardId}) got assigned to an illegal bucket id: ${bucketId}, expected a bucket id between 0 and ${
this.options.gatewayBot.sessionStartLimit
.maxConcurrency - 1
}`
);
}
const workerId = this.workerId(shardId);
const worker = bucket.workers.find(w => w.id === workerId);
if (worker) {
worker.queue.push(shardId);
} else {
bucket.workers.push({ id: workerId, queue: [shardId] });
}
}
}
/**
* @inheritDoc
*/
prepareShards() {
this.buckets.forEach((bucket, bucketId) => {
for (const worker of bucket.workers) {
for (const shardId of worker.queue) {
this.workerToIdentify(worker.id, shardId, bucketId);
}
}
});
}
/**
* @inheritDoc
*/
calculateTotalShards(): number {
if (this.agent.options.totalShards < 100) {
return this.agent.options.totalShards;
}
return (
Math.ceil(
this.agent.options.totalShards /
(this.options.gatewayBot.sessionStartLimit
.maxConcurrency === 1
? 16
: this.options.gatewayBot.sessionStartLimit
.maxConcurrency)
) * this.options.gatewayBot.sessionStartLimit.maxConcurrency
);
}
/**
* @inheritDoc
*/
workerToIdentify(_workerId: number, shardId: number, _bucketId: number) {
return this.agent.identify(shardId);
}
/**
* @inheritDoc
*/
workerId(shardId: number) {
let workerId = Math.floor(shardId / this.options.shardsPerWorker);
if (workerId >= this.options.totalWorkers) {
workerId = this.options.totalWorkers - 1;
}
return workerId;
}
/**
* @inheritDoc
*/
shards() {
this.prepareBuckets();
this.prepareShards();
}
}
export type DefaultWsOptions = Pick<
Options,
Exclude<keyof Options, keyof typeof DefaultWsAdapter.DEFAULTS>
> &
Partial<Options>;
interface Options {
/** Delay in milliseconds to wait before spawning next shard. */
spawnShardDelay: number;
/** The amount of shards to load per worker. */
shardsPerWorker: number;
/** The total amount of workers to use for your bot. */
totalWorkers: number;
/** Total amount of shards your bot uses. Useful for zero-downtime updates or resharding. */
totalShards: number;
/** Id of the first Shard which should get controlled by this manager. */
firstShardId: number;
/** Id of the last Shard which should get controlled by this manager. */
lastShardId: number;
createShardOptions?: Omit<
ShardOptions,
'id' | 'totalShards' | 'requestIdentify' | 'gatewayConfig'
>;
/** Important data which is used by the manager to connect shards to the gateway. */
gatewayBot: GatewayBot;
// REMOVE THIS
gatewayConfig: PickPartial<ShardGatewayConfig, 'token'>;
/** Sends the discord payload to another guild. */
handleDiscordPayload: (shard: Shard, data: MessageEvent<any>) => any;
}

View File

@ -0,0 +1,29 @@
import type { Agent } from '../services/agent';
import { GatewayBot } from '@biscuitland/api-types';
export interface WsAdapter {
options: Partial<Options | any>;
/**
* @inheritDoc
*/
agent: Agent;
/**
* @inheritDoc
*/
shards(): void;
}
interface Options {
/** Id of the first Shard which should get controlled by this manager. */
firstShardId: number;
/** Id of the last Shard which should get controlled by this manager. */
lastShardId: number;
/** Important data which is used by the manager to connect shards to the gateway. */
gatewayBot: GatewayBot;
}

Some files were not shown because too many files have changed in this diff Show More