From 56e10f043abe6c6a5d73347729faefffa6227c07 Mon Sep 17 00:00:00 2001 From: Jaron Date: Sun, 10 Nov 2024 01:55:07 -0600 Subject: [PATCH] init for rewrite --- .github/workflows/main.yml | 29 -- .github/workflows/publish.yml | 17 -- .gitignore | 176 +++++++++++- .prettierrc | 7 - bun.lockb | Bin 0 -> 3143 bytes package.json | 29 +- src/index.ts | 182 +------------ src/lib/cache.ts | 37 --- src/lib/context.ts | 52 ---- src/lib/gateway.ts | 374 ------------------------- src/lib/guilds.ts | 8 - src/lib/index.ts | 7 - src/lib/interaction.ts | 61 ----- src/services/api.ts | 52 ++++ src/services/cache.ts | 52 ++++ src/services/client.ts | 92 +++++++ src/services/gateway.ts | 216 +++++++++++++++ src/services/guilds.ts | 10 + src/services/processor.ts | 86 ++++++ src/services/users.ts | 10 + src/types/channel.ts | 39 +++ src/types/command.ts | 21 -- src/types/common.ts | 111 +++----- src/types/context.ts | 128 +++++++++ src/{lib => types}/embed.ts | 2 +- src/types/emoji.ts | 25 ++ src/types/guild.ts | 130 +++++++++ src/types/hander.ts | 14 + src/types/handler.ts | 8 - src/types/index.ts | 5 - src/types/interaction.ts | 19 -- src/types/member.ts | 33 +++ src/{lib => types}/message.ts | 43 ++- src/types/role.ts | 35 +++ src/types/slash_command.ts | 78 ------ src/types/user.ts | 79 ++++++ src/utils/index.ts | 4 + test/main.test.ts | 162 ----------- testing/demo.test.ts | 38 +++ tsconfig.json | 36 ++- yarn.lock | 495 ---------------------------------- 41 files changed, 1329 insertions(+), 1673 deletions(-) delete mode 100644 .github/workflows/main.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .prettierrc create mode 100755 bun.lockb delete mode 100644 src/lib/cache.ts delete mode 100644 src/lib/context.ts delete mode 100644 src/lib/gateway.ts delete mode 100644 src/lib/guilds.ts delete mode 100644 src/lib/index.ts delete mode 100644 src/lib/interaction.ts create mode 100644 src/services/api.ts create mode 100644 src/services/cache.ts create mode 100644 src/services/client.ts create mode 100644 src/services/gateway.ts create mode 100644 src/services/guilds.ts create mode 100644 src/services/processor.ts create mode 100644 src/services/users.ts create mode 100644 src/types/channel.ts delete mode 100644 src/types/command.ts create mode 100644 src/types/context.ts rename src/{lib => types}/embed.ts (98%) create mode 100644 src/types/emoji.ts create mode 100644 src/types/guild.ts create mode 100644 src/types/hander.ts delete mode 100644 src/types/handler.ts delete mode 100644 src/types/index.ts delete mode 100644 src/types/interaction.ts create mode 100644 src/types/member.ts rename src/{lib => types}/message.ts (74%) create mode 100644 src/types/role.ts delete mode 100644 src/types/slash_command.ts create mode 100644 src/types/user.ts create mode 100644 src/utils/index.ts delete mode 100644 test/main.test.ts create mode 100644 testing/demo.test.ts delete mode 100644 yarn.lock diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 2b66f63..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: CI -on: workflow_dispatch -jobs: - build: - name: Build, and test on Node ${{ matrix.node }} and ${{ matrix.os }} - - runs-on: ${{ matrix.os }} - strategy: - matrix: - node: ['12.x', '14.x', '16.x'] - os: [ubuntu-latest, windows-latest, macOS-latest] - - steps: - - name: Checkout repo - uses: actions/checkout@v2 - - - name: Use Node ${{ matrix.node }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node }} - - - name: Install deps and build (with cache) - uses: bahmutov/npm-install@v1 - - - name: Test - run: yarn test --ci --coverage --maxWorkers=2 - - - name: Build - run: yarn build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 98c2566..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Publish -on: workflow_dispatch -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 - with: - node-version: 14 - registry-url: https://registry.npmjs.org/ - - run: yarn - - run: yarn build - # - run: yarn test - - run: yarn publish --access public - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8ba145a..9b1ee42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + .env -/dist -/node_modules \ No newline at end of file +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index b5bca2a..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "singleQuote": true, - "printWidth": 120, - "semi": true, - "tabWidth": 4, - "trailingComma": "none" -} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..b2e680dbeeccb89dba2d47f7ee45f0f1137b5c9f GIT binary patch literal 3143 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p*q8@}Ed zO}3V|Ji18a@oP`~#fZw2XMRT})b{kUzzyx!2pE@k{la^s^m8 zuaze1NA;?0wYB8Tw>kDIaMtd{r)N~o&$^U3U$sF;yEQe55vZRM015>VPy=E!pu0fk zfiN!+69ZuSQ-O-Dk@PDOqZz0Mrhf~NZ3WaX0;GZA!@vi`!~mH7UqHoe&hS_+6k zG%*0C-x}y?6QF)j41@HN>i=q>ejBL$Y(V|Y#8?hh19AfhgTfu855xw!0mKKH1p*)% z#79nO5uxoXc_7j!HwNUZiwiq^8Vmuh@p z$FJJmvsJ_Li(c&GcYpn-A6%ZX_tsy(Hp$drHMyTz^R`Wtb@_;7F37Jiz>>Ew;1tUd z{VyNbyC+!guhQ1t*5A15W=@#=f4)_Jx77K$XYp{w_1~Hx-`&|ERByhK>)w)yN2S8@ zXKN`xeIMh$AIV(gcw*Uj^6>L3nF;o#$11cPnT}2H-xaj+ioJPPYDQv@$gDdhOAeV` zD6Y3#U6P>~XmFHq;^AVu{u!Aj%y!dQ%L}8lXCax(0&^^oW>E|1v3|a=#EENFVBzFN z@?i}kVHw76k6NFxfB3bmWVQKe-dxV!?^bCi=gEWG&lmUw8vCE!q`s)5_wLWHtAXYY6o7?0 zj3&n&1L%BmoI6l@HbBdwBT$;lrno3sFEg*WBrzvP4_4#qg%qWx+9?S+PSI8!|X zJp%>?Sjzxb({nK8B$lKWmoPBE+6u6`pNY{>&rr`8SS>98+6rn#z-oUc#ySfn?9S!$7< zYeh+FQC?zBa7j^SUb>xvAtDeg;DJz5oD4Scz;BVIMN=k-fEr{VG5&jtZQjWi?kXuR zPAvkO7JFHtF(}RZM?NqTLG7Xz3)3@<6@34GKwYpN+5!Wn0bqC=;tFntl+5Df{Gt@S zl49MW)bgUtlGKvQg4E*VqRfI42QX6~*bdAnDJdwn($_CaP0uVYDXP@VE6B|%*2^zS z*GDMQ*99rn1%+ zq*xact%hLJO7l`OlQRiA3RxM@7-X(4I4zihoJQCg<;6hV<;A+-GzSS1aCAbV5RZOD V8~_bKaDbMZ>4D7wr;$NO007ORNf-bC literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 633957f..dfbc3ac 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,14 @@ { - "name": "@jaronp/discord.ts", - "version": "0.1.1", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "author": "Jaron Pate", - "license": "Unlicense", - "repository": "https://github.com/jaronpate/discord.ts", - "files": [ - "/dist" - ], + "name": "discord.ts-rewrite", + "module": "src/index.ts", + "type": "module", "scripts": { - "start": "npx ts-node test/main.test.ts", - "start:watch": "npx ts-node-dev --respawn test/main.test.ts", - "build": "npx tsc" + "test": "dotenvx run -f .env -- bun run testing/demo.test.ts" }, "devDependencies": { - "@types/ws": "^8.5.3", - "dotenv": "^16.0.2", - "ts-node-dev": "^2.0.0", - "typescript": "^4.8.3" + "@types/bun": "latest" }, - "dependencies": { - "axios": "^0.27.2", - "ws": "^8.8.1" + "peerDependencies": { + "typescript": "^5.0.0" } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5b3ad2b..4fd623f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,177 +1,5 @@ -import { ComponentStyle, Context, Gateway, Guilds, Message, Embed } from './lib'; -import { SlashCommand, SlashCommandFunction, ChatCommand, Command, CommandFunction, Intent, Presence } from './types'; -export { - ComponentStyle, - Context, - Gateway, - Guilds, - Message, - Embed, - Command, - CommandFunction, - ChatCommand, - Intent, - Presence -}; - -type Config = { - token: string; - application_id: string; - prefix?: string; - intents?: Intent[]; -}; - -export class Client { - readonly prefix: string; - readonly token: string; - readonly application_id: string; - private readonly gateway: Gateway; - private readonly client_commands: Map = new Map(); - private readonly slash_commands: Map = new Map(); - readonly guilds: Guilds; - - constructor({ token, application_id, prefix, intents }: Config) { - this.token = token; - this.application_id = application_id; - this.prefix = prefix ?? '!'; - this.gateway = new Gateway({ token, intents, client: this }); - this.guilds = new Guilds({ token, gateway: this.gateway }); - // Do some default stuff - this.initialize(); - } - - get user() { - return this.gateway.user; - } - - get handlers() { - return this.gateway.handlers; - } - - get interactions() { - return this.gateway.interactions; - } - - get api() { - return this.gateway.api; - } - - public connect = () => { - this.gateway.connect(); - return this; - }; - - public reconnect = () => { - this.gateway.reconnect(); - return this; - }; - - public setPresence = (presence: Presence) => { - this.gateway.setPresence(presence); - return this; - }; - - send = (channel_id: string, message: Message | string) => { - if (typeof message === 'string') { - message = new Message().setContent(message); - } - return this.api.post(`/channels/${channel_id}/messages`, message); - }; - - public commands = { - chat: { - register: (command: ChatCommand | string, handler?: CommandFunction) => { - if (typeof command === 'string') { - this.client_commands.set(command, handler); - } else { - this.client_commands.set(command.name, command); - } - }, - get: (command_name: string) => { - return this.client_commands.get(command_name); - }, - execute: (command_name: string, context: Context, ...args: any[]) => { - const command = this.client_commands.get(command_name); - if (command) { - command instanceof ChatCommand - ? command.execute(this, context, args.slice(1)) - : command(this, context, args.slice(1)); - } - }, - delete: (command_name: string) => { - this.client_commands.delete(command_name); - }, - list: () => { - return Array.from(this.client_commands.entries()); - }, - map: () => { - return this.client_commands; - } - }, - application: { - register: (command: SlashCommand | Record, handler?: SlashCommandFunction) => { - const payload = - command instanceof SlashCommand - ? command.toJSON() - : { - name: command.name, - description: command.description, - type: command.type ?? 1 - }; - this.api - .post(`/applications/${this.application_id}/commands`, payload) - .then((response) => { - console.log(response); - if (command instanceof SlashCommand) { - this.slash_commands.set(command.name, command); - } else { - this.slash_commands.set(command.name, handler); - } - }) - .catch((error) => { - this.log.error('Error registering application command', error); - }); - }, - get: (command_name: string) => { - return this.slash_commands.get(command_name); - }, - list: () => { - return Array.from(this.slash_commands.entries()); - }, - map: () => { - return this.slash_commands; - } - } - }; - - private log = { - error: (error_or_message: Error | string, error?: Error) => { - if (error && typeof error_or_message === 'string') { - console.error(`[discord.ts] ${error_or_message}:`, error); - } else { - console.error('[discord.ts] Error:', error_or_message); - } - } - }; - - private initialize = () => { - // Register default help command - this.commands.chat.register('help', (client, context, _args) => { - const embed = new Embed() - .setTitle('Commands') - .setDescription('This is a list of commands you can use.') - .setColor('#f6c221'); - - const commands = client.commands.chat.list(); - - for (const [name, command] of commands) { - embed.addField({ - name: name, - value: command instanceof ChatCommand ? command.description : 'No description provided' - }); - } - - context.reply(embed.toMessage()); - }); - }; -} +export { Client } from './services/client'; +export { Intent } from './types/common'; +export { BaseHandler, type Handler } from './types/hander'; +export type { ClientConfig } from './types/common'; +export type { Context } from './types/context'; \ No newline at end of file diff --git a/src/lib/cache.ts b/src/lib/cache.ts deleted file mode 100644 index c38cefd..0000000 --- a/src/lib/cache.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Context, Gateway } from '.'; - -export class ObjectCache { - cache = new Map(); - private readonly token: string; - private readonly type: string; - private readonly gateway: Gateway; - - constructor({ token, gateway, type, events }: { token: string; gateway: Gateway; type: string; events: string[] }) { - this.token = token; - this.type = type; - this.gateway = gateway; - gateway.handlers.register(events, (_client, _context: Context, e: Record) => { - this.cache.set(e.id, e as T); - }); - } - - async fetch(object_id: string): Promise { - return this.gateway.api.get(`${this.type}/${object_id}`).then((res) => { - const object: T & { id: string } = res.data; - this.cache.set(object.id, object); - return object; - }); - } - - get(object_id: string): T | Promise { - if (this.cache.has(object_id)) { - return Promise.resolve(this.cache.get(object_id)!); - } else { - return this.fetch(object_id); - } - } - - list(): Map { - return this.cache; - } -} diff --git a/src/lib/context.ts b/src/lib/context.ts deleted file mode 100644 index 82fb4b6..0000000 --- a/src/lib/context.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Client } from '..'; -import { Message } from '../lib'; - -export class Context { - private readonly client: Client; - // Public properties - message_id: string; - channel_id: string; - guild_id: string; - - constructor({ client, message }: { client: Client; message: Partial }) { - this.client = client; - this.message_id = message.id; - this.channel_id = message.channel_id; - this.guild_id = message.guild_id; - } - - get guild() { - return this.client.guilds.get(this.guild_id); - } - - /** - * Reply to the message that this command - * @param message The message to send to the channel - * @param reference If true the message will be a reference the the message that triggered the command - * @returns The message that was sent - */ - public reply = (message: Message | string, reference: boolean = false) => { - if (typeof message === 'string') { - message = new Message().setContent(message); - } - if (reference) { - message.message_reference = { - message_id: this.message_id, - channel_id: this.channel_id, - guild_id: this.guild_id - }; - } - return this.client.send(this.channel_id, message); - }; - - public delete = (message_id?: string) => { - return this.client.api.delete(`/channels/${this.channel_id}/messages/${message_id ?? this.message_id}`); - }; - - public edit = (message: Message | string) => { - if (typeof message === 'string') { - message = new Message().setContent(message); - } - return this.client.api.patch(`/channels/${this.channel_id}/messages/${this.message_id}`, message); - }; -} diff --git a/src/lib/gateway.ts b/src/lib/gateway.ts deleted file mode 100644 index d545fa4..0000000 --- a/src/lib/gateway.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { WebSocket, Data } from 'ws'; -import axios, { AxiosInstance } from 'axios'; -import { Client } from '..'; -import { - Session, - User, - Payload, - Intent, - Handler, - HandlerFunction, - Command, - Status, - Presence, - ActivityType, - CommandFunction, - Interaction, - InteractionFunction, - SlashCommand -} from '../types'; -import { Context } from './context'; -import { InteractionContext } from './interaction'; - -export class Gateway { - socket: WebSocket; - axios: AxiosInstance; - // Config - readonly token: string; - readonly client: Client; - private intents: number = 513; - private commandNotFound: CommandFunction = null; - // Discord gateway data - private readonly url: string = 'wss://gateway.discord.gg/?v=10&encoding=json'; - private heartbeat_timeout: NodeJS.Timeout; - private heartbeat_function: NodeJS.Timer; - private heartbeat_interval: number | null = null; - private session: Session = null; - public user: User = null; - // Utils - public event_handlers: Map> = new Map(); - public interaction_handlers: Map> = new Map(); - - constructor({ - token, - intents, - client, - commandNotFound - }: { - token: string; - intents?: Intent[]; - client: Client; - commandNotFound?: CommandFunction; - }) { - // Copy config - this.token = token; - this.client = client; - this.commandNotFound = commandNotFound; - // Initialize socket - this.socket = new WebSocket(this.url); - // Initialize axios - this.axios = axios.create({ - headers: { - 'User-Agent': 'DiscordBot (discord.ts, 0.0.1)', - Authorization: `Bot ${this.token}` - }, - baseURL: 'https://discord.com/api/v10' - }); - this.axios.interceptors.response.use((response) => response.data); - // Calculate intents - if (intents) { - this.intents = intents.reduce((acc, intent) => acc | intent, 0); - } - } - - public connect = () => { - // Initialize socket - this.socket = new WebSocket(this.url); - // Register the listeners - this.socket.on('open', this.onOpen); - this.socket.on('message', this.onMessage); - // this.socket.on('close', this.onClose); - // this.socket.on('error', this.onError); - return this; - }; - - public reconnect = () => { - this.socket.terminate(); - return this.connect(); - }; - - public setPresence = (presence: Presence) => { - this.send({ - op: 3, - d: presence - }); - }; - - public handlers = { - register: (events: string | string[], handler: Handler | HandlerFunction) => { - this.registerHandler(events, handler, 'command'); - }, - get: (event: string) => { - return this.event_handlers.get(event); - } - }; - - public interactions = { - register: (custom_ids: string | string[], handler: Interaction | InteractionFunction) => { - this.registerHandler(custom_ids, handler, 'interaction'); - }, - get: (event: string) => { - return this.interaction_handlers.get(event); - } - }; - - public api = { - get: (url: string, params?: any) => { - return this.axios.get>(url, { params }); - }, - post: (url: string, data?: any) => { - return this.axios.post>(url, data); - }, - patch: (url: string, data?: any) => { - return this.axios.patch>(url, data); - }, - delete: (url: string, data?: any) => { - return this.axios.delete>(url, data); - } - }; - - // prettier-ignore - private registerHandler(events: string | string[], handler: Handler | HandlerFunction, type: 'command'): void; - // prettier-ignore - private registerHandler(events: string | string[], handler: Interaction | InteractionFunction, type: 'interaction'): void; - - private registerHandler(events: string | string[], handler: any, type: 'command' | 'interaction') { - const handler_map = type === 'command' ? this.event_handlers : this.interaction_handlers; - if (Array.isArray(events)) { - // If Array register handler for each event - for (const event of events) { - const current_handlers = handler_map.get(event) ?? []; - handler_map.set(event, [...current_handlers, handler]); - } - } else { - // Otherwise register for the single event - const current_handlers = handler_map.get(events) ?? []; - handler_map.set(events, [...current_handlers, handler]); - } - } - private send = (payload: Partial) => { - payload.s = this.session?.s ?? null; - console.log('sending: %s', payload); - this.socket.send(JSON.stringify(payload)); - }; - - private indentify = () => { - this.send({ - op: 2, - d: { - token: this.token, - intents: this.intents, - properties: { - os: process.platform, - browser: 'discord.ts', - device: 'discord.ts' - } - } - }); - }; - - private heartbeat = () => { - if (!this.heartbeat_interval) { - throw new Error('No heartbeat interval set'); - } - if (this.heartbeat_function) { - clearInterval(this.heartbeat_function); - } - if (this.heartbeat_timeout) { - clearTimeout(this.heartbeat_timeout); - } - this.heartbeat_function = setInterval(() => { - this.send({ - op: 1, - d: {} - }); - }, this.heartbeat_interval); - this.heartbeat_timeout = setTimeout(() => { - this.reconnect(); - }, this.heartbeat_interval * 2); - }; - - private initialize = (payload: Payload) => { - this.session = payload.d.session; - this.user = payload.d.user; - this.user.tag = `${this.user.username}#${this.user.discriminator}`; - // Construct presence - const status: Presence = { - status: Status.Online, - since: null, - activities: [ - { - name: payload.d.guilds.length + ' servers', - type: ActivityType.Watching, - created_at: Date.now() - } - ], - afk: false - }; - // Set presence - this.setPresence(status); - // Cache guilds? (these are not whole guilds, just guild ids) - // for (const guild of payload.d.guilds) { - // this.guilds.set(guild.id, guild); - // } - console.log('session: %s', this.session); - console.log('user: %s', this.user); - }; - - private parseEvent = (payload: Payload) => { - // If not a dispatch event, return - if (!payload.t) { - return; - } - // Run default handlers - switch (payload.t) { - case 'READY': - this.initialize(payload); - break; - case 'INTERACTION_CREATE': - this.handleInteractionCreate(payload); - break; - case 'MESSAGE_CREATE': - this.handleMessageCreate(payload); - break; - case 'MESSAGE_UPDATE': - this.handleMessageCreate(payload); - break; - default: - break; - } - // Call event handlers - const handlers = this.handlers.get(payload.t); - const context = new Context({ client: this.client, message: payload.d }); - if (handlers) { - for (const handler of handlers) { - handler instanceof Handler - ? handler.execute(this.client, context, payload.d) - : handler(this.client, context, payload.d); - } - } - }; - - private handleInteractionCreate = (payload: Payload) => { - if (payload.d.type === 2) { - this.handleShashInteraction(payload); - } else { - this.handleChatInteraction(payload); - } - }; - - private handleShashInteraction = (payload: Payload) => { - console.log(payload.d); - const context = new InteractionContext({ client: this.client, interaction: payload.d }); - const handler = this.client.commands.application.get(payload.d.data.name); - if (handler) { - if (handler instanceof SlashCommand) { - handler.execute(this.client, context, payload.d); - } else { - handler(this.client, context, payload.d); - } - } - }; - - private handleChatInteraction = (payload: Payload) => { - const context = new InteractionContext({ client: this.client, interaction: payload.d }); - const handlers = this.client.interactions.get(payload.d.data.custom_id); - if (handlers) { - for (const handler of handlers) { - if (handler instanceof Interaction) { - handler.execute(this.client, context, payload.d); - } else { - handler(this.client, context, payload.d); - } - } - } - }; - - private handleMessageCreate = async (payload: Payload) => { - // Ignore bots - if (payload.d.author.bot) { - return; - } - console.log(payload.d); - const content = payload.d.content.trim(); - // Is this a command? - if (content.startsWith(this.client.prefix) | content.startsWith(`<@${this.client.user.id}>`)) { - // Parse command - const args = content - .slice(this.client.prefix.length) - .trim() - .split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g); - // Check if command exists - const handler = this.client.commands.chat.get(args[0]); - // Generate context - const context = new Context({ client: this.client, message: payload.d }); - if (handler) { - // Execute command - handler instanceof Command - ? handler.execute(this.client, context, args.slice(1)) - : handler(this.client, context, args.slice(1)); - } else { - let reply = null; - // Send error message if command does not exist - if (this.commandNotFound) { - // If custom command not found handler is set, use that - reply = this.commandNotFound(this.client, context); - } else { - // Otherwise send default message - reply = await context.reply('Command not found :(', true); - } - if (reply) { - // If reply is set, delete it after 3 seconds - setTimeout(() => { - context.delete(reply.id); - }, 3000); - } - } - } - }; - - private parseOpCode = (payload: Payload) => { - switch (payload.op) { - case 0: - this.parseEvent(payload); - break; - case 9: - setTimeout(this.reconnect, 500); - break; - case 10: - this.heartbeat_interval = payload.d.heartbeat_interval; - this.heartbeat(); - this.indentify(); - break; - case 11: - console.log('heartbeat acknowledged'); - this.heartbeat_timeout.refresh(); - break; - default: - break; - } - }; - - private onMessage = (packet: Data): void => { - console.log('============================'); - let data: Payload | null = null; - try { - data = JSON.parse(packet.toString()); - } catch (e) { - console.error(e); - } - if (data) { - console.log('received: %s', data); - console.log('received data: %s', data.d); - if (this.session && data.s && data.s > this.session.s) { - this.session.s = data.s; - } else { - this.parseOpCode(data); - } - } - }; - - private onOpen = () => { - console.log('Socket open'); - }; -} diff --git a/src/lib/guilds.ts b/src/lib/guilds.ts deleted file mode 100644 index d587741..0000000 --- a/src/lib/guilds.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Gateway, ObjectCache } from "."; -import { Guild } from "../types"; - -export class Guilds extends ObjectCache { - constructor ({ token, gateway }: { token: string, gateway: Gateway }) { - super({token, gateway, type: 'guilds', events: ['GUILD_CREATE', 'GUILD_UPDATE']}); - } -} \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 7075c9c..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './gateway'; -export * from './cache'; -export * from './guilds'; -export * from './context'; -export * from './message'; -export * from './embed'; -export * from './interaction'; diff --git a/src/lib/interaction.ts b/src/lib/interaction.ts deleted file mode 100644 index 3bdb062..0000000 --- a/src/lib/interaction.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Client } from ".."; -import { InteractionResponseType } from "../types"; -import { Message } from "./message"; - -export class InteractionContext { - public client: Client; - public interaction: Record; - private is_loading: boolean; - constructor({ client, interaction, is_loading = false }: { client: Client; interaction: Record, is_loading?: boolean }) { - this.client = client; - this.interaction = interaction; - this.is_loading = is_loading; - } - - ack = (type: InteractionResponseType = InteractionResponseType.Pong, data?: Record) => { - return this.client.api.post(`/interactions/${this.interaction.id}/${this.interaction.token}/callback`, { - type, - data - }); - }; - - reply = (message: Message | string) => { - if (typeof message === 'string') { - message = new Message().setContent(message); - } - return this.ack(InteractionResponseType.Reply, { - ...message - }); - }; - - edit = (message: Message | string) => { - if (typeof message === 'string') { - message = new Message().setContent(message); - } - if (!this.is_loading) { - return this.ack(InteractionResponseType.UpdateMessage, { - ...message - }); - } else { - return this.client.api.patch( - `/webhooks/${this.client.application_id}/${this.interaction.token}/messages/@original`, - { - type: InteractionResponseType.DeferredUpdateMessage, - ...message - } - ); - } - - }; - - deleteOriginal = () => { - return this.client.api.delete( - `/webhooks/${this.client.application_id}/${this.interaction.token}/messages/@original` - ); - }; - - loading = async () => { - await this.ack(InteractionResponseType.DeferredReply); - return new InteractionContext({ client: this.client, interaction: this.interaction, is_loading: true }); - }; -} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..d53544a --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,52 @@ +import type { Client } from "./client"; + +export class API { + private readonly base_url: string = 'https://discord.com/api/v10'; + + constructor(private client: Client) {} + + async request(method: string, path: string, data?: any): Promise { + const url = `${this.base_url}${path}`; + const headers = { + Authorization: `Bot ${this.client.token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + console.log(`API Request: ${method} ${url}`); + + const response = await fetch(url, { + method, + headers, + body: JSON.stringify(data), + }); + + if (!response.ok) { + console.error(`Failed to fetch: ${response.statusText} ${response.url} ${response.status}`); + console.error(response.json()); + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + const body = await response.json(); + + console.log('API Response:', JSON.stringify(body, null, 4)); + + return body; + } + + async get(path: string): Promise { + return this.request('GET', path); + } + + async post(path: string, data: any): Promise { + return this.request('POST', path, data); + } + + async patch(path: string, data: any): Promise { + return this.request('PATCH', path, data); + } + + async delete(path: string): Promise { + return this.request('DELETE', path); + } +} \ No newline at end of file diff --git a/src/services/cache.ts b/src/services/cache.ts new file mode 100644 index 0000000..b968030 --- /dev/null +++ b/src/services/cache.ts @@ -0,0 +1,52 @@ +import type { Context } from "../types/context"; +import { exists } from "../utils"; +import type { API } from "./api"; +import type { Processor } from "./processor"; + + +export class ObjectCache { + cache = new Map(); + private readonly type: string; + + constructor(private api: API, processor: Processor, type: string, events: string[]) { + this.type = type; + processor.events.register(events, (_context: Context, e: Record) => { + this.cache.set(e.id, e as T); + }); + } + + set(object_id: string, object: T): void { + this.cache.set(object_id, object); + } + + // update(object_id: string, object: Partial): void { + // const existing = this.cache.get(object_id); + + // if (exists(existing)) { + // // TODO: Idk wtf happens if this is called on two classes + // this.cache.set(object_id, { ...existing, ...object }); + // } else { + // this.cache.set(object_id, object as T); + // } + // } + + async fetch(object_id: string): Promise { + return this.api.get(`${this.type}/${object_id}`).then((res) => { + const object: T & { id: string } = res.data; + this.cache.set(object.id, object); + return object; + }); + } + + get(object_id: string): T | Promise { + if (this.cache.has(object_id)) { + return Promise.resolve(this.cache.get(object_id)!); + } else { + return this.fetch(object_id); + } + } + + list(): Map { + return this.cache; + } +} diff --git a/src/services/client.ts b/src/services/client.ts new file mode 100644 index 0000000..4996237 --- /dev/null +++ b/src/services/client.ts @@ -0,0 +1,92 @@ +import { API } from './api'; +import { Gateway } from './gateway'; +import { Processor } from './processor'; +import { Intent, type ClientConfig } from '../types/common'; +import { exists, isNil } from '../utils/index'; +import type { Handler } from '../types/hander'; +import { Message } from '../types/message'; +import { Guilds } from './guilds'; +import { Users } from './users'; + +export class Client { + readonly token: string; + readonly application_id: string; + readonly intents: Intent[] = [Intent.DEFAULT]; + prefix: string; + // Internal services + protected _api: API; + protected _processor: Processor; + protected _gateway: Gateway; + guilds: Guilds; + users: Users; + + constructor(config: ClientConfig) { + // Validate the configuration + Client.validateConfig(config); + // Save the configuration + this.token = config.token; + this.application_id = config.application_id; + this.intents = config.intents ?? this.intents; + this.prefix = config.prefix ?? '!'; + // Initialize internal services + this._api = new API(this); + this._processor = new Processor(this); + this._gateway = new Gateway(this, this.processor, this.api); + this.guilds = new Guilds(this.api, this.processor); + this.users = new Users(this.api, this.processor); + } + + private get api() { + return this._api; + } + + private get gateway() { + return this._gateway; + } + + private get processor() { + return this._processor; + } + + private static validateConfig(config: ClientConfig) { + if (isNil(config.token)) { + throw new Error('Token is required'); + } + + if (isNil(config.application_id)) { + throw new Error('Application ID is required'); + } + + if (exists(config.prefix) && typeof config.prefix !== 'string') { + throw new Error('Prefix must be a string'); + } + + if (exists(config.intents)) { + if (!Array.isArray(config.intents)) { + throw new Error('Intents must be an array'); + } + + for (const intent of config.intents) { + if (!Intent[intent]) { + throw new Error(`Invalid intent: ${intent}`); + } + } + } + } + + sendMessage = async (channel_id: string, message: Message | string) => { + if (typeof message === 'string') { + message = new Message().setContent(message); + } + + return Message.fromAPIResponse(await this.api.post(`/channels/${channel_id}/messages`, message.toJSON())); + } + + chatCommands = { + register: (name: string | string[], handler: Handler) => this.processor.commands.register(name, handler), + unregister: (name: string | string[]) => this.processor.commands.unregister(name), + has: (name: string) => this.processor.commands.has(name) + } + + connect = () => this.gateway.connect(); +} \ No newline at end of file diff --git a/src/services/gateway.ts b/src/services/gateway.ts new file mode 100644 index 0000000..8aa3252 --- /dev/null +++ b/src/services/gateway.ts @@ -0,0 +1,216 @@ +import { WebSocket, type Data } from 'ws'; +import type { Client } from "./client"; +import type { Payload } from "../types/common"; +import { exists, isNil, isObject, log } from "../utils"; +import type { API } from "./api"; +import type { Processor } from './processor'; +import { Context } from '../types/context'; +import { Message } from '../types/message'; +import { User } from '../types/user'; + +export class Gateway { + private socket: WebSocket | null = null; + private heartbeat_interval: number | null = null; + private heartbeat_timeout: Timer | null = null; + private sequence: number | null = null; + // Internal state + private user: any; + + constructor(private client: Client, private processor: Processor, private api: API) {} + + private onOpen = () => { + log('Connected to gateway'); + } + + private onMessage = (packet: Data) => { + // console.log(packet); + // Allocate a variable to store the parsed payload + let data: Payload | null = null; + + try { + // Attempt to parse the packet data + data = JSON.parse(packet.toString()); + } catch (e) { + console.error(e); + } + + if (data) { + log('='.repeat(25)); + log('received: ', data.op); + log('received event: ', data.t ?? 'no event'); + log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data'); + log('='.repeat(25)); + // If the data is successfully parsed check the opcode + const code = data.op; + if (code === 0) { + // Event received + this.parseEvent(data); + } else if (code === 1) { + // Heartbeat requested + this.send({ op: 1, d: this.sequence }); + } else if (code === 9) { + // Reconnect requested + } else if (code === 10) { + // Hello + if (!isObject(data.d)) { + throw new Error('Invalid HELLO payload received'); + } + // Store the heartbeat interval + this.heartbeat_interval = data.d?.heartbeat_interval; + // Kick off the heartbeat loop + this.heartbeat(); + setTimeout(this.indentify, 2500); + } else if (code === 11) { + // Heartbeat ACK + log('Heartbeat ACK'); + } + } + } + + private heartbeat() { + log('Sending heartbeat'); + if (exists(this.heartbeat_interval)) { + // If a previous heartbeat timeout exists, clear it + if (exists(this.heartbeat_timeout)) { + clearTimeout(this.heartbeat_timeout); + } + // Set a timeout to ensure the heartbeat is sent within the interval + this.heartbeat_timeout = setTimeout(() => { + this.send({ op: 1, d: this.sequence }); + this.heartbeat(); + }, this.heartbeat_interval * Math.random()); + } else { + throw new Error('Heartbeat interval not set'); + } + } + + protected send = (payload: Partial) => { + if (isNil(this.socket)) { + throw new Error('Socket is not connected'); + } + + log('='.repeat(25)); + log('sending: ', JSON.stringify(payload, null, 4)); + log('='.repeat(25)); + + this.socket.send(JSON.stringify(payload)); + }; + + private indentify = () => { + this.send({ + op: 2, + d: { + token: this.client.token, + intents: this.client.intents.reduce((acc, intent) => acc | intent, 0), + properties: { + os: process.platform, + browser: 'discord.ts', + device: 'discord.ts' + } + } + }); + }; + + private parseEvent(payload: Payload) { + if (isNil(payload.t)) { + throw new Error('Event name is required'); + } + + log('Event:', payload.t); + + const event_name = payload.t; + const event_data = payload.d; + + if (!isObject(event_data)) { + throw new Error('Invalid event data received'); + } + + if (event_name === 'READY') { + this.user = event_data.user; + log('User:', this.user); + } else if (event_name === 'INTERACTION_CREATE') { + // this.handleInteractionCreate(payload); + } else if (event_name === 'MESSAGE_CREATE') { + this.handleMessageCreate(payload as Payload); + } else if (event_name === 'MESSAGE_UPDATE') { + // this.handleMessageCreate(payload); + } + + // Allocate a var to store the context + let context = null; + + if (['MESSAGE_CREATE', 'MESSAGE_UPDATE'].includes(event_name)) { + // If the event is a message event, create a message object + const message = Message.fromAPIResponse(event_data); + // And then generate the context + context = new Context(this.client, this.api, message); + } + + // Call event handlers + this.processor.events.execute(event_name, context, event_data); + } + + private handleMessageCreate = async (payload: Payload) => { + if (!isObject(payload.d)) { + throw new Error('Invalid MESSAGE_CREATE payload received'); + } + + // Ignore bots + if (payload.d.author.bot) { + return; + } + // Ignore self + if (payload.d.author.id === this.user.id) { + return; + } + // Extract the author + const author = User.fromAPIResponse(payload.d.author); + // Store the author in the cache + this.client.users.set(author.id, author); + // Extract the content + const content = payload.d.content.trim(); + // Is this a command? + if (content.startsWith(this.client.prefix) || content.startsWith(`<@${this.user.id}>`)) { + // Determine the prefix + const prefix = content.startsWith(this.client.prefix) ? this.client.prefix : `<@${this.user.id}> `; + // Parse command + const args = content + .slice(prefix.length) + .trim() + .split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g); + // Ignore empty commands + if (args.length === 0) { + return; + } + // Extract the command name + // TODO: Case insensitive config? + const command = args.shift()!; + // Create a new message object from the payload + const message = Message.fromAPIResponse(payload.d); + // Generate context + const context = new Context(this.client, this.api, message); + // Check if command exists + if (this.processor.commands.has(command)) { + // Execute the handler + this.processor.commands.execute(command, context, args); + } else { + // TODO: Custom unknown command handler + context.reply(`Unknown command: ${command}`); + } + } + }; + + async connect() { + // Fetch the Discord gateway URL + const response = await this.api.request('GET', '/gateway/bot'); + // Append the query parameters to the URL + const url = new URL(response.url); + url.searchParams.append('v', '10'); + url.searchParams.append('encoding', 'json'); + // Open a new WebSocket connection to the Discord gateway + this.socket = new WebSocket(url); + // Register event listeners + this.socket.on('open', this.onOpen); + this.socket.on('message', this.onMessage); + } +} \ No newline at end of file diff --git a/src/services/guilds.ts b/src/services/guilds.ts new file mode 100644 index 0000000..8c57ba8 --- /dev/null +++ b/src/services/guilds.ts @@ -0,0 +1,10 @@ +import type { Guild } from "../types/guild"; +import type { API } from "./api"; +import { ObjectCache } from "./cache"; +import type { Processor } from "./processor"; + +export class Guilds extends ObjectCache { + constructor (api: API, processor: Processor) { + super(api, processor, 'guilds', ['GUILD_CREATE', 'GUILD_UPDATE']); + } +} \ No newline at end of file diff --git a/src/services/processor.ts b/src/services/processor.ts new file mode 100644 index 0000000..eca1a42 --- /dev/null +++ b/src/services/processor.ts @@ -0,0 +1,86 @@ +import type { Client } from "./client"; +import type { Context } from "../types/context"; +import { HandlerType, type EventPayload, type Payload } from "../types/common"; +import { BaseHandler, type Handler } from "../types/hander"; + +export class Processor { + private handlers = { + [HandlerType.Events]: new Map(), + [HandlerType.Commands]: new Map(), + [HandlerType.Interactions]: new Map() + }; + + constructor(private client: Client) {}; + + public register(type: HandlerType, event: string | string[], handler: Handler) { + // Normalize events into an array + const events = Array.isArray(event) ? event : [event]; + // Register the handler for each event + for (const e of events) { + const handlers = this.handlers[type].get(e) || []; + handlers.push(handler); + this.handlers[type].set(e, handlers); + } + }; + + public unregister(type: HandlerType, event: string | string[], handler?: Handler) { + // Normalize events into an array + const events = Array.isArray(event) ? event : [event]; + // Unregister the handler for each event + for (const e of events) { + if (handler) { + // Get the handlers for the event + const handlers = this.handlers[type].get(e); + if (handlers) { + // Find the index of the handler + const index = handlers.indexOf(handler); + if (index !== -1) { + // Remove the handler from the array + handlers.splice(index, 1); + } + } + } else { + // If no handler is provided, clear all handlers for the event + this.handlers[type].clear(); + } + } + }; + + public execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): void + public execute(type: Omit, event: string, context: Context, args: any[]): void + public execute(type: HandlerType, event: string, context: Context | null, argsOrPayload: any[] | EventPayload) { + const handlers = this.handlers[type].get(event); + + if (handlers) { + for (const handler of handlers) { + if (handler instanceof BaseHandler) { + handler.execute(context, argsOrPayload); + } else { + // TODO: Fix ts error + handler(context, argsOrPayload); + } + } + } + } + + [HandlerType.Events] = { + register: (event: string | string[], handler: Handler) => this.register(HandlerType.Events, event, handler), + unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Events, event, handler), + execute: (event: string, context: Context | null, payload: EventPayload) => this.execute(HandlerType.Events, event, context, payload), + has: (event: string) => this.handlers[HandlerType.Events].has(event) && this.handlers[HandlerType.Events].get(event)?.length! > 0 + }; + + [HandlerType.Commands] = { + register: (event: string | string[], handler: Handler) => this.register(HandlerType.Commands, event, handler), + unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Commands, event, handler), + execute: (event: string, context: Context, args: any[]) => this.execute(HandlerType.Commands, event, context, args), + has: (event: string) => this.handlers[HandlerType.Commands].has(event) ?? this.handlers[HandlerType.Commands].get(event)?.length! > 0 + }; + + [HandlerType.Interactions] = { + register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler), + unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Interactions, event, handler), + execute: (event: string, context: Context, args: any[]) => this.execute(HandlerType.Interactions, event, context, args), + has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0 + }; +} diff --git a/src/services/users.ts b/src/services/users.ts new file mode 100644 index 0000000..1b59288 --- /dev/null +++ b/src/services/users.ts @@ -0,0 +1,10 @@ +import type { User } from "../types/user"; +import type { API } from "./api"; +import { ObjectCache } from "./cache"; +import type { Processor } from "./processor"; + +export class Users extends ObjectCache { + constructor (api: API, processor: Processor) { + super(api, processor, 'users', []); + } +} \ No newline at end of file diff --git a/src/types/channel.ts b/src/types/channel.ts new file mode 100644 index 0000000..5736741 --- /dev/null +++ b/src/types/channel.ts @@ -0,0 +1,39 @@ +export class Channel { + version: number; + type: number; + position: number; + permission_overwrites: any[]; + name: string; + id: string; + flags: number; + topic?: string | null; + rate_limit_per_user?: number; + parent_id?: string; + last_message_id?: string | null; + user_limit?: number; + rtc_region?: string | null; + bitrate?: number; + last_pin_timestamp?: string; + + constructor(data: any) { + this.version = data.version; + this.type = data.type; + this.position = data.position; + this.permission_overwrites = data.permission_overwrites; + this.name = data.name; + this.id = data.id; + this.flags = data.flags; + this.topic = data.topic; + this.rate_limit_per_user = data.rate_limit_per_user; + this.parent_id = data.parent_id; + this.last_message_id = data.last_message_id; + this.user_limit = data.user_limit; + this.rtc_region = data.rtc_region; + this.bitrate = data.bitrate; + this.last_pin_timestamp = data.last_pin_timestamp; + } + + static fromAPIResponse(data: any): Channel { + return new Channel(data); + } +} \ No newline at end of file diff --git a/src/types/command.ts b/src/types/command.ts deleted file mode 100644 index 16df10e..0000000 --- a/src/types/command.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Client } from '..'; -import { Context } from '../lib'; - -export abstract class Command { - readonly name: string; - readonly description: string; - readonly execute: CommandFunction; - constructor(data: Command) { - this.name = data.name; - this.description = data.description; - this.execute = data.execute; - } -} - -export type CommandFunction = (client: Client, context: Context, ...args: any[]) => void; - -export class ChatCommand extends Command { - constructor(data: ChatCommand) { - super(data); - } -} diff --git a/src/types/common.ts b/src/types/common.ts index e70d8f9..efc2c90 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,81 +1,40 @@ +import type { Client } from "../services/client"; +import type { Context } from "./context"; +import type { BaseHandler } from "./hander"; + +export type ClientConfig = { + token: string; + application_id: string; + prefix?: string; + intents?: Intent[]; +} +export enum HandlerType { + Events = 'events', + Commands = 'commands', + Interactions = 'interactions' +} + + +export type EventPayload = any; + +export type Primitive = string | number | boolean | null; + +/** + * Represents a payload structure used in the Discord API. + * + * @typedef {Object} Payload + * @property {number} op - The opcode of the payload. + * @property {Record} d - The data associated with the payload. + * @property {number | null} s - The sequence number, or null if not applicable. + * @property {string | null} t - The event name, or null if not applicable. + */ export type Payload = { - op: number; // opcode - d: Record; // data - s: number | null; // sequence number - t: string | null; // event name + op: number; + d: Record | Primitive | null; + s: number | null; + t: string | null; }; -export type Session = { - s: number; // sequence number - session_id: string; - session_type: string; - resume_gateway_url: string; -}; - -export type Guild = Record; - -export type User = Record; - -export enum Status { - Online = "online", - DND = "dnd", - Idle = "idle", - Invisible = "invisible", - Offline = "offline" -} - -export type Presence = { - since: number | null; - activities: Activity[]; - status: Status; - afk: boolean; -} - -export type Activity = { - name: string; - type: number; - created_at: number; - url?: string; - timestamps?: { - start?: number; - end?: number; - }; - application_id?: string; - details?: string; - state?: string; - emoji?: { - name: string; - id?: string; - animated?: boolean; - }; - party?: { - id?: string; - size?: [number, number]; - }; - assets?: { - large_image?: string; - large_text?: string; - small_image?: string; - small_text?: string; - }; - secrets?: { - join?: string; - spectate?: string; - match?: string; - }; - instance?: boolean; - flags?: number; -} - -export enum ActivityType { - Game, - Streaming, - Listening, - Watching, - Custom , - Competing -} - export enum Intent { GUILDS = 1 << 0, GUILD_MEMBERS = 1 << 1, @@ -97,7 +56,7 @@ export enum Intent { ALL = 32767 } -export const IntentGroup : {[key in Intent]: [string[], number]} = { +export const IntentGroup : {[key in Intent]?: [string[], number]} = { [Intent.GUILDS]: [[ "GUILD_CREATE", "GUILD_UPDATE", diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 0000000..f928c7e --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,128 @@ +import type { API } from "../services/api"; +import type { Client } from "../services/client"; +import { isNil } from "../utils"; +import { Message } from "./message"; +/** + * Represents the context of a message in a Discord channel. + * Provides methods to interact with the message and the channel it was sent in. + * + * @class Context + * + * @property {string} message_id - The ID of the message that this context is associated with. + * @property {string} channel_id - The ID of the channel where the message was sent. + * @property {string} [guild_id] - The ID of the guild where the message was sent, if applicable. + * + * @constructor + * @param {Client} client - The client instance that is interacting with the Discord API. + * @param {API} api - The API instance used to make requests to the Discord API. + * @param {Message} message - The message object that this context is based on. + * + * @throws {Error} If the message ID is not provided. + * @throws {Error} If the channel ID is not provided. + */ +export class Context { + // Public properties + message_id: string; + channel_id: string; + guild_id?: string; + + constructor(private client: Client, private api: API, private _message: Message) { + if (isNil(_message.id)) { + throw new Error('Message ID is required to create a context'); + } + + if (isNil(_message.channel_id)) { + throw new Error('Channel ID is required to create a context'); + } + + this.client = client; + this.message_id = _message.id; + this.channel_id = _message.channel_id; + this.guild_id = _message.guild_id; + } + + // get author() { + // return this.client.users.get(this.author_id); + // }; + + get message(): Message & { getReference: () => Promise } { + return Object.assign(this._message, { + getReference: async () => { + const reference = this._message.message_reference; + if (isNil(reference)) { + return null; + } + + return Message.fromAPIResponse(await this.api.get(`/channels/${reference.channel_id}/messages/${reference.message_id}`)); + } + }); + } + + fetchGuild() { + if (isNil(this.guild_id)) { + return Promise.resolve(null); + } + + return this.client.guilds.get(this.guild_id); + } + + /** + * Reply to the message that this command + * @param message The message to send to the channel + * @param reference If true the message will be a reference the the message that triggered the command + * @returns The message that was sent + */ + public reply = (message: Message | string, reference: boolean = true) => { + if (typeof message === 'string') { + message = new Message().setContent(message); + } + + if (reference) { + message.message_reference = { + message_id: this.message_id, + channel_id: this.channel_id, + guild_id: this.guild_id + }; + } + + return this.client.sendMessage(this.channel_id, message); + }; + + /** + * @name sendMessage + * @description Sends a message to the channel that this context is associated with. + * @param {Message | string} message - The message to send to the channel. + * @returns {Promise} The message that was sent. + */ + public sendMessage = (message: Message | string) => { + if (typeof message === 'string') { + message = new Message().setContent(message); + } + return this.client.sendMessage(this.channel_id, message); + } + + /** + * + * @name delete + * @description Deletes a message in the channel that this context is associated with. + * @param {string} [message_id] - The ID of the message to delete. If not provided, the message ID of this context will be used. + * @returns {Promise} A promise that resolves when the message is deleted. + */ + public delete = (message_id?: string) => { + return this.api.delete(`/channels/${this.channel_id}/messages/${message_id ?? this.message_id}`); + }; + + /** + * + * @name edit + * @description Edits a message in the channel that this context is associated with. + * @param {Message | string} message - The updated message content. + * @returns {Promise} The updated message. + */ + public edit = async (message: Message | string) => { + if (typeof message === 'string') { + message = new Message().setContent(message); + } + return Message.fromAPIResponse(await this.api.patch(`/channels/${this.channel_id}/messages/${this.message_id}`, message)); + }; +} \ No newline at end of file diff --git a/src/lib/embed.ts b/src/types/embed.ts similarity index 98% rename from src/lib/embed.ts rename to src/types/embed.ts index 66aa2a6..5d33d65 100644 --- a/src/lib/embed.ts +++ b/src/types/embed.ts @@ -1,4 +1,4 @@ -import { Message } from '.'; +import type { Message } from './message'; type EmbedFooter = { text: string; diff --git a/src/types/emoji.ts b/src/types/emoji.ts new file mode 100644 index 0000000..bc28e76 --- /dev/null +++ b/src/types/emoji.ts @@ -0,0 +1,25 @@ +export class Emoji { + version: number; + roles: string[]; + require_colons: boolean; + name: string; + managed: boolean; + id: string; + available: boolean; + animated: boolean; + + constructor(data: any) { + this.version = data.version; + this.roles = data.roles; + this.require_colons = data.require_colons; + this.name = data.name; + this.managed = data.managed; + this.id = data.id; + this.available = data.available; + this.animated = data.animated; + } + + static fromAPIResponse(data: any): Emoji { + return new Emoji(data); + } +} \ No newline at end of file diff --git a/src/types/guild.ts b/src/types/guild.ts new file mode 100644 index 0000000..2bac8c4 --- /dev/null +++ b/src/types/guild.ts @@ -0,0 +1,130 @@ +import { Channel } from "./channel"; +import { Emoji } from "./emoji"; +import { Member } from "./member"; +import { Role } from "./role"; + +export class Guild { + system_channel_id: string | null; + premium_progress_bar_enabled: boolean; + region: string; + application_id: string | null; + premium_tier: number; + presences: any[]; + discovery_splash: string | null; + latest_onboarding_question_id: string | null; + unavailable: boolean; + afk_channel_id: string | null; + large: boolean; + nsfw: boolean; + max_video_channel_users: number; + stickers: any[]; + id: string; + premium_subscription_count: number; + stage_instances: any[]; + preferred_locale: string; + guild_scheduled_events: any[]; + member_count: number; + name: string; + default_message_notifications: number; + system_channel_flags: number; + hub_type: string | null; + lazy: boolean; + soundboard_sounds: any[]; + threads: any[]; + banner: string | null; + description: string | null; + clan: string | null; + incidents_data: string | null; + application_command_counts: any; + embedded_activities: any[]; + icon: string | null; + home_header: string | null; + joined_at: string; + safety_alerts_channel_id: string | null; + verification_level: number; + max_stage_video_channel_users: number; + emojis: Emoji[]; + owner_id: string; + public_updates_channel_id: string | null; + features: string[]; + rules_channel_id: string | null; + roles: Role[]; + afk_timeout: number; + inventory_settings: any; + activity_instances: any[]; + max_members: number; + channels: Channel[]; + voice_states: any[]; + splash: string | null; + mfa_level: number; + members: Member[]; + version: number; + vanity_url_code: string | null; + nsfw_level: number; + explicit_content_filter: number; + + constructor(data: any) { + this.system_channel_id = data.system_channel_id; + this.premium_progress_bar_enabled = data.premium_progress_bar_enabled; + this.region = data.region; + this.application_id = data.application_id; + this.premium_tier = data.premium_tier; + this.presences = data.presences; + this.discovery_splash = data.discovery_splash; + this.latest_onboarding_question_id = data.latest_onboarding_question_id; + this.unavailable = data.unavailable; + this.afk_channel_id = data.afk_channel_id; + this.large = data.large; + this.nsfw = data.nsfw; + this.max_video_channel_users = data.max_video_channel_users; + this.stickers = data.stickers; + this.id = data.id; + this.premium_subscription_count = data.premium_subscription_count; + this.stage_instances = data.stage_instances; + this.preferred_locale = data.preferred_locale; + this.guild_scheduled_events = data.guild_scheduled_events; + this.member_count = data.member_count; + this.name = data.name; + this.default_message_notifications = data.default_message_notifications; + this.system_channel_flags = data.system_channel_flags; + this.hub_type = data.hub_type; + this.lazy = data.lazy; + this.soundboard_sounds = data.soundboard_sounds; + this.threads = data.threads; + this.banner = data.banner; + this.description = data.description; + this.clan = data.clan; + this.incidents_data = data.incidents_data; + this.application_command_counts = data.application_command_counts; + this.embedded_activities = data.embedded_activities; + this.icon = data.icon; + this.home_header = data.home_header; + this.joined_at = data.joined_at; + this.safety_alerts_channel_id = data.safety_alerts_channel_id; + this.verification_level = data.verification_level; + this.max_stage_video_channel_users = data.max_stage_video_channel_users; + this.emojis = data.emojis.map((emoji: any) => Emoji.fromAPIResponse(emoji)); + this.owner_id = data.owner_id; + this.public_updates_channel_id = data.public_updates_channel_id; + this.features = data.features; + this.rules_channel_id = data.rules_channel_id; + this.roles = data.roles.map((role: any) => Role.fromAPIResponse(role)); + this.afk_timeout = data.afk_timeout; + this.inventory_settings = data.inventory_settings; + this.activity_instances = data.activity_instances; + this.max_members = data.max_members; + this.channels = data.channels.map((channel: any) => Channel.fromAPIResponse(channel)); + this.voice_states = data.voice_states; + this.splash = data.splash; + this.mfa_level = data.mfa_level; + this.members = data.members.map((member: any) => Member.fromAPIResponse(member)); + this.version = data.version; + this.vanity_url_code = data.vanity_url_code; + this.nsfw_level = data.nsfw_level; + this.explicit_content_filter = data.explicit_content_filter; + } + + static fromAPIResponse(data: any): Guild { + return new Guild(data); + } +} \ No newline at end of file diff --git a/src/types/hander.ts b/src/types/hander.ts new file mode 100644 index 0000000..4fb2cf0 --- /dev/null +++ b/src/types/hander.ts @@ -0,0 +1,14 @@ +import type { EventPayload } from "./common"; +import type { Context } from "./context"; + +export class BaseHandler { + constructor() {}; + + public execute(context: Context | null, args: any[]) {}; +} + +export type CommandHandlerFunction = (context: Context, args: any[]) => void; +export type EventHandlerFunction = (context: Context | null, payload: EventPayload) => void; + +export type HandlerFunction = CommandHandlerFunction | EventHandlerFunction; +export type Handler = CommandHandlerFunction | EventHandlerFunction | BaseHandler; diff --git a/src/types/handler.ts b/src/types/handler.ts deleted file mode 100644 index 9fe0d79..0000000 --- a/src/types/handler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Client, Context } from '..'; - -export abstract class Handler { - abstract events: string | string[]; - abstract execute: HandlerFunction; -} - -export type HandlerFunction = (client: Client, context: Context, data: Record) => void; diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 3a4ef41..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './common'; -export * from './command'; -export * from './slash_command'; -export * from './handler'; -export * from './interaction'; diff --git a/src/types/interaction.ts b/src/types/interaction.ts deleted file mode 100644 index 4f4c2b1..0000000 --- a/src/types/interaction.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Client } from '..'; -import { InteractionContext } from '../lib'; - -export abstract class Interaction { - abstract custom_id: string | string[]; - abstract execute: InteractionFunction; -} - -export type InteractionFunction = (client: Client, context: InteractionContext, data: Record) => void; - -export enum InteractionResponseType { - Pong = 1, - Reply = 4, - DeferredReply = 5, - DeferredUpdateMessage = 6, - UpdateMessage = 7, - AutocompleteResult = 8, - Modal = 9 -} diff --git a/src/types/member.ts b/src/types/member.ts new file mode 100644 index 0000000..ea8a01e --- /dev/null +++ b/src/types/member.ts @@ -0,0 +1,33 @@ +export class Member { + user: any; + roles: string[]; + premium_since: string | null; + pending: boolean; + nick: string | null; + mute: boolean; + joined_at: string; + flags: number; + deaf: boolean; + communication_disabled_until: string | null; + banner: string | null; + avatar: string | null; + + constructor(data: any) { + this.user = data.user; + this.roles = data.roles; + this.premium_since = data.premium_since; + this.pending = data.pending; + this.nick = data.nick; + this.mute = data.mute; + this.joined_at = data.joined_at; + this.flags = data.flags; + this.deaf = data.deaf; + this.communication_disabled_until = data.communication_disabled_until; + this.banner = data.banner; + this.avatar = data.avatar; + } + + static fromAPIResponse(data: any): Member { + return new Member(data); + } +} \ No newline at end of file diff --git a/src/lib/message.ts b/src/types/message.ts similarity index 74% rename from src/lib/message.ts rename to src/types/message.ts index df74202..2240757 100644 --- a/src/lib/message.ts +++ b/src/types/message.ts @@ -1,4 +1,6 @@ -import { Embed } from '.'; +import { isNil } from '../utils'; +import { Embed } from './embed'; +import { User } from './user'; export type Component = Partial