From 3fb707ad93b482590b52d19d881fbb1f792cab2f Mon Sep 17 00:00:00 2001 From: Jaron Pate Date: Sat, 17 Sep 2022 20:25:07 -0500 Subject: [PATCH] init --- .github/workflows/main.yml | 29 ++ .github/workflows/publish.yml | 17 ++ .gitignore | 3 + .prettierrc | 7 + README.md | 67 +++++ package.json | 26 ++ src/index.ts | 89 ++++++ src/lib/cache.ts | 37 +++ src/lib/context.ts | 52 ++++ src/lib/gateway.ts | 337 +++++++++++++++++++++++ src/lib/guilds.ts | 8 + src/lib/index.ts | 5 + src/lib/message.ts | 153 +++++++++++ src/types/command.ts | 10 + src/types/common.ts | 203 ++++++++++++++ src/types/handler.ts | 8 + src/types/index.ts | 4 + src/types/interaction.ts | 76 ++++++ test/main.test.ts | 147 ++++++++++ tsconfig.json | 11 + yarn.lock | 495 ++++++++++++++++++++++++++++++++++ 21 files changed, 1784 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/lib/cache.ts create mode 100644 src/lib/context.ts create mode 100644 src/lib/gateway.ts create mode 100644 src/lib/guilds.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/message.ts create mode 100644 src/types/command.ts create mode 100644 src/types/common.ts create mode 100644 src/types/handler.ts create mode 100644 src/types/index.ts create mode 100644 src/types/interaction.ts create mode 100644 test/main.test.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..364023b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,29 @@ +name: CI +on: [push] +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 new file mode 100644 index 0000000..98c2566 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..8ba145a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +/dist +/node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b5bca2a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "printWidth": 120, + "semi": true, + "tabWidth": 4, + "trailingComma": "none" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bd6f2b --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +
+

discord.ts

+

Build good bots fast

+
+ + +## Quick Start +A small example implementation. +```ts +// Initialize and configure +const bot = new Client({ + token: '', + application_id: '', + intents: [ + Intent.GUILDS, + Intent.GUILD_MESSAGES, + Intent.GUILD_MESSAGE_REACTIONS, + Intent.MESSAGE_CONTENT + ] +}); + +// Register a listener +bot.handlers.register('READY', (client, context, data) => { + console.log(`Logged in as ${client.user.tag}`); +}); + +// Register a command +bot.commands.register('ping', (client, context, args) => { + context.reply('Pong!', true); +}) + +// Connect +bot.connect(); +``` + +## Interactions +First create the interactions. +```ts +bot.commands.register('test', (_client, context, _args) => { + // Add components to use interactions + const msg = new Message().setContent('Test interaction!').addComponent( + // This generations an action row which can optionaly contain content + 'This is an action row', + // Create components with helper functions + Message.button({ + label: 'Click me!', + style: ComponentStyle.Primary, + // Assign id's to reference them later + custom_id: 'click_me', + emoji: { + name: '🔥', + id: null, + animated: false + } + }) + ); + context.reply(msg); +}); +``` +Then provide a handler for them. +```ts +bot.interactions.register('click_me', async (client, context, interaction) => { + const response = context.reply( + `You clicked me! Thank you ${interaction.member.user.username} <3` + ); +}); +``` \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..94aa532 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "discord-ts-wrapper", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": "Jaron Pate", + "license": "Unlicense", + "files": [ + "/dist" + ], + "scripts": { + "start": "npx ts-node-dev src/index.ts", + "start:watch": "npx ts-node-dev --respawn src/index.ts", + "build": "npx tsc" + }, + "devDependencies": { + "@types/ws": "^8.5.3", + "dotenv": "^16.0.2", + "ts-node-dev": "^2.0.0", + "typescript": "^4.8.3" + }, + "dependencies": { + "axios": "^0.27.2", + "ws": "^8.8.1" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d2e6c5f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,89 @@ +import { ComponentStyle, Context, Gateway, Guilds, Message } from './lib'; +import { Command, CommandFuntion, Intent, Presence } from './types'; +export { ComponentStyle, Context, Gateway, Guilds, Message, Command, CommandFuntion, 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(); + 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 }); + } + + 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 = { + register: (command: Command | string, handler: CommandFuntion) => { + 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 Command + ? command.execute(this, context, args.slice(1)) + : command(this, context, args.slice(1)); + } + }, + delete: (command_name: string) => { + this.client_commands.delete(command_name); + } + }; +} diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..c38cefd --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..82fb4b6 --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..8229d48 --- /dev/null +++ b/src/lib/gateway.ts @@ -0,0 +1,337 @@ +import { WebSocket, Data } from 'ws'; +import axios, { AxiosInstance } from 'axios'; +import { Client } from '..'; +import { + Session, + User, + Payload, + Intent, + Handler, + HandlerFunction, + Command, + Status, + Presence, + ActivityType, + CommandFuntion, + Interaction, + InteractionFunction, + InteractionContext +} from '../types'; +import { Context } from './context'; + +export class Gateway { + socket: WebSocket; + axios: AxiosInstance; + // Config + readonly token: string; + readonly client: Client; + private intents: number = 513; + private commandNotFound: CommandFuntion = null; + // Discord gateway data + private readonly url: string = 'wss://gateway.discord.gg/?v=10&encoding=json'; + 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?: CommandFuntion; + }) { + // 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 = () => { + 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'); + } + setInterval(() => { + this.send({ + op: 1, + d: {} + }); + }, this.heartbeat_interval); + }; + + 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) => { + 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); + // Is this a command? + if (payload.d.content.startsWith(this.client.prefix)) { + // Parse command + const args = payload.d.content + .slice(this.client.prefix.length) + .trim() + .split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g); + // Check if command exists + const handler = this.client.commands.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'); + break; + default: + break; + } + }; + + private onMessage = (packet: Data): void => { + 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: %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 new file mode 100644 index 0000000..d587741 --- /dev/null +++ b/src/lib/guilds.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..5374852 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,5 @@ +export * from './gateway'; +export * from './cache'; +export * from './guilds'; +export * from './context'; +export * from './message'; \ No newline at end of file diff --git a/src/lib/message.ts b/src/lib/message.ts new file mode 100644 index 0000000..a68fc40 --- /dev/null +++ b/src/lib/message.ts @@ -0,0 +1,153 @@ +export type Embed = { + +} + +export type Component = Partial