This commit is contained in:
Jaron Pate
2022-09-17 20:25:07 -05:00
commit 3fb707ad93
21 changed files with 1784 additions and 0 deletions
+89
View File
@@ -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<string, Command | CommandFuntion> = 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);
}
};
}
+37
View File
@@ -0,0 +1,37 @@
import { Context, Gateway } from '.';
export class ObjectCache<T> {
cache = new Map<string, T>();
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<string, any>) => {
this.cache.set(e.id, e as T);
});
}
async fetch(object_id: string): Promise<T> {
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<T> {
if (this.cache.has(object_id)) {
return Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
list(): Map<string, T> {
return this.cache;
}
}
+52
View File
@@ -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<Message> }) {
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);
};
}
+337
View File
@@ -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<string, Array<Handler | HandlerFunction>> = new Map();
public interaction_handlers: Map<string, Array<Interaction | InteractionFunction>> = 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<unknown, Record<string, any>>(url, { params });
},
post: (url: string, data?: any) => {
return this.axios.post<unknown, Record<string, any>>(url, data);
},
patch: (url: string, data?: any) => {
return this.axios.patch<unknown, Record<string, any>>(url, data);
},
delete: (url: string, data?: any) => {
return this.axios.delete<unknown, Record<string, any>>(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>) => {
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');
};
}
+8
View File
@@ -0,0 +1,8 @@
import { Gateway, ObjectCache } from ".";
import { Guild } from "../types";
export class Guilds extends ObjectCache<Guild> {
constructor ({ token, gateway }: { token: string, gateway: Gateway }) {
super({token, gateway, type: 'guilds', events: ['GUILD_CREATE', 'GUILD_UPDATE']});
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './gateway';
export * from './cache';
export * from './guilds';
export * from './context';
export * from './message';
+153
View File
@@ -0,0 +1,153 @@
export type Embed = {
}
export type Component = Partial<Button | SelectMenu> & {
type: ComponentType;
content?: string;
components?: Component[];
};
// {
// content?: string;
// type?: ComponentType;
// description?: string;
// label?: string;
// value?: string;
// style?: ComponentStyle;
// custom_id?: string;
// url?: string;
// disabled?: boolean;
// required?: boolean;
// emoji?: {
// name: string;
// id?: string;
// animated?: boolean;
// }
// options?: Component[];
// placeholder?: string;
// min_values?: number;
// max_values?: number;
// components?: Component[];
// }
export type Emoji = {
name: string | null;
id: string | null;
animated?: boolean;
}
export type Button = {
style: ComponentStyle;
label: string;
emoji?: Emoji
custom_id?: string;
url?: string;
disabled?: boolean;
}
export type SelectMenu = {
custom_id: string;
options: {
label: string;
value: string;
description?: string;
emoji?: Emoji
default?: boolean;
}[];
placeholder?: string;
min_values?: number;
max_values?: number;
}
export enum ComponentStyle {
Primary = 1,
Secondary,
Success,
Danger,
Link
}
export enum ComponentType {
ActionRow = 1,
Button,
SelectMenu,
TextInput
}
export class Message {
id?: string;
channel_id?: string;
guild_id?: string;
public content?: string;
public embeds: Embed[] = [];
public components: Component[] = [];
public message_reference?: {
message_id?: string;
channel_id?: string;
guild_id?: string;
fail_if_not_exists?: boolean;
}
constructor() {}
public static button(config: Button): Component {
return {
type: ComponentType.Button,
...config
}
}
public static selectMenu(config: SelectMenu): Component {
return {
type: ComponentType.SelectMenu,
...config
}
}
public setContent = (content: string) => {
this.content = content;
return this;
}
public addEmbed = (embed: Embed) => {
this.embeds.push(embed);
return this;
}
public addComponent = (content: Component | string, ...components: Component[]) => {
if (typeof content === 'string') {
console.log('string');
this.components.push({
type: ComponentType.ActionRow,
content,
components
});
} else {
this.components.push({
type: ComponentType.ActionRow,
components: [content, ...components]
});
}
return this;
}
public setReference = (message: Partial<Message>) => {
this.message_reference = {
message_id: message.id,
channel_id: message.channel_id,
guild_id: message.guild_id,
fail_if_not_exists: false
}
return this;
}
public toJSON = () => {
return {
content: this.content,
embeds: this.embeds,
components: this.components,
message_reference: this.message_reference
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Client } from "..";
import { Context } from "../lib";
export abstract class Command {
abstract name: string;
abstract description: string;
abstract execute: CommandFuntion;
}
export type CommandFuntion = (client: Client, context: Context, ...args: any[]) => void;
+203
View File
@@ -0,0 +1,203 @@
export type Payload = {
op: number; // opcode
d: Record<string, any>; // data
s: number | null; // sequence number
t: string | null; // event name
};
export type Session = {
s: number; // sequence number
session_id: string;
session_type: string;
resume_gateway_url: string;
};
export type Guild = Record<string, any>;
export type User = Record<string, any>;
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,
GUILD_BANS = 1 << 2,
GUILD_EMOJIS_AND_STICKERS = 1 << 3,
GUILD_INTEGRATIONS = 1 << 4,
GUILD_WEBHOOKS = 1 << 5,
GUILD_INVITES = 1 << 6,
GUILD_VOICE_STATES = 1 << 7,
GUILD_PRESENCES = 1 << 8,
GUILD_MESSAGES = 1 << 9,
GUILD_MESSAGE_REACTIONS = 1 << 10,
GUILD_MESSAGE_TYPING = 1 << 11,
DIRECT_MESSAGES = 1 << 12,
DIRECT_MESSAGE_REACTIONS = 1 << 13,
DIRECT_MESSAGE_TYPING = 1 << 14,
MESSAGE_CONTENT = 1 << 15,
DEFAULT = 513,
ALL = 32767
}
export const IntentGroup : {[key in Intent]: [string[], number]} = {
[Intent.GUILDS]: [[
"GUILD_CREATE",
"GUILD_UPDATE",
"GUILD_DELETE",
"GUILD_ROLE_CREATE",
"GUILD_ROLE_UPDATE",
"GUILD_ROLE_DELETE",
"CHANNEL_CREATE",
"CHANNEL_UPDATE",
"CHANNEL_DELETE",
"CHANNEL_PINS_UPDATE",
"THREAD_CREATE",
"THREAD_UPDATE",
"THREAD_DELETE",
"THREAD_LIST_SYNC",
"THREAD_MEMBER_UPDATE",
"THREAD_MEMBERS_UPDATE *",
"STAGE_INSTANCE_CREATE",
"STAGE_INSTANCE_UPDATE",
"STAGE_INSTANCE_DELETE"
], 0],
[Intent.GUILD_MEMBERS]: [[
"GUILD_MEMBER_ADD",
"GUILD_MEMBER_UPDATE",
"GUILD_MEMBER_REMOVE",
"THREAD_MEMBERS_UPDATE *",
], 1],
[Intent.GUILD_BANS]: [[
"GUILD_BAN_ADD",
"GUILD_BAN_REMOVE",
], 2],
[Intent.GUILD_EMOJIS_AND_STICKERS]: [[
"GUILD_EMOJIS_UPDATE",
"GUILD_STICKERS_UPDATE",
], 3],
[Intent.GUILD_INTEGRATIONS]: [[
"GUILD_INTEGRATIONS_UPDATE",
"INTEGRATION_CREATE",
"INTEGRATION_UPDATE",
"INTEGRATION_DELETE",
], 4],
[Intent.GUILD_WEBHOOKS]: [[
"WEBHOOKS_UPDATE"
], 5],
[Intent.GUILD_INVITES]: [[
"INVITE_CREATE",
"INVITE_DELETE",
], 6],
[Intent.GUILD_VOICE_STATES]: [[
"VOICE_STATE_UPDATE"
], 7],
[Intent.GUILD_PRESENCES]: [[
"PRESENCE_UPDATE"
], 8],
[Intent.GUILD_MESSAGES]: [[
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"MESSAGE_DELETE_BULK",
], 9],
[Intent.GUILD_MESSAGE_REACTIONS]: [[
"MESSAGE_REACTION_ADD",
"MESSAGE_REACTION_REMOVE",
"MESSAGE_REACTION_REMOVE_ALL",
"MESSAGE_REACTION_REMOVE_EMOJI",
], 10],
[Intent.GUILD_MESSAGE_TYPING]: [[
"TYPING_START"
], 11],
[Intent.DIRECT_MESSAGES]: [[
"MESSAGE_CREATE",
"MESSAGE_UPDATE",
"MESSAGE_DELETE",
"CHANNEL_PINS_UPDATE",
], 12],
[Intent.DIRECT_MESSAGE_REACTIONS]: [[
"MESSAGE_REACTION_ADD",
"MESSAGE_REACTION_REMOVE",
"MESSAGE_REACTION_REMOVE_ALL",
"MESSAGE_REACTION_REMOVE_EMOJI",
], 13],
[Intent.DIRECT_MESSAGE_TYPING]: [[
"TYPING_START"
], 14],
[Intent.MESSAGE_CONTENT]: [[
"MESSAGE_CONTENT"
], 15]
}
+8
View File
@@ -0,0 +1,8 @@
import { Client, Context } from '..';
export abstract class Handler {
abstract events: string | string[];
abstract execute: HandlerFunction;
}
export type HandlerFunction = (client: Client, context: Context, data: Record<string, any>) => void;
+4
View File
@@ -0,0 +1,4 @@
export * from './common';
export * from './command';
export * from './handler';
export * from './interaction';
+76
View File
@@ -0,0 +1,76 @@
import { Client, Message } from '..';
export abstract class Interaction {
abstract custom_id: string | string[];
abstract execute: InteractionFunction;
}
export type InteractionFunction = (client: Client, context: InteractionContext, data: Record<string, any>) => void;
export enum InteractionResponseType {
Pong = 1,
Reply = 4,
DeferredReply = 5,
DeferredUpdateMessage = 6,
UpdateMessage = 7,
AutocompleteResult = 8,
Modal = 9
}
export class InteractionContext {
public client: Client;
public interaction: Record<string, any>;
constructor({ client, interaction }: { client: Client; interaction: Record<string, any> }) {
this.client = client;
this.interaction = interaction;
}
ack = (type: InteractionResponseType = InteractionResponseType.Pong, data?: Record<string, any>) => {
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);
}
return this.ack(InteractionResponseType.UpdateMessage, {
...message
});
};
editOriginal = (message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
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 });
};
}