chore: base

This commit is contained in:
xHyroM 2022-07-10 18:30:00 +02:00
parent 9812eadced
commit 28584ed76a
21 changed files with 806 additions and 3 deletions

146
.gitignore vendored Normal file
View file

@ -0,0 +1,146 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
files/config.toml
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# 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
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# 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.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

0
README.md Normal file
View file

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -0,0 +1,9 @@
# For start application rename it to config.toml !!
[server]
port = 3000
[client]
public_key = "CLIENT PUBLIC KEY"
token = "CLIENT TOKEN"
id = "CLIENT USER ID"

11
files/tags.toml Normal file
View file

@ -0,0 +1,11 @@
[test]
keywords = ["test", "t"]
content = """
Hello, new bot :)
"""
[lol]
keywords = ["l"]
content = """
Lol
"""

View file

@ -1,5 +1,16 @@
{ {
"version": "1.0.0", "version": "0.0.0",
"name": "bun-discord-bot", "name": "bun-discord-bot",
"main": "src/index.js" "scripts": {
"start": "bun src/index.ts"
},
"devDependencies": {
"bun-types": "^0.1.2"
},
"dependencies": {
"@discordjs/collection": "^0.7.0",
"discord-api-types": "^0.36.1",
"hono": "^1.6.4",
"tweetnacl": "^1.0.3"
}
} }

16
src/commands/help.ts Normal file
View file

@ -0,0 +1,16 @@
import { InteractionResponseType } from 'discord-api-types/v10';
import { Command } from '../structures/Command';
new Command({
name: 'help',
description: 'Help command',
guildId: '924395690451423332',
run: (c) => {
return c.respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: 'hello'
}
})
}
})

45
src/commands/tags.ts Normal file
View file

@ -0,0 +1,45 @@
import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOptionType, InteractionResponseType, MessageFlags } from 'discord-api-types/v10';
import { Command } from '../structures/Command';
import { findTags, getTag } from '../utils/tagsUtils';
new Command({
name: 'tags',
description: 'Send a tag by name or alias',
guildId: '924395690451423332',
options: [
{
name: 'query',
description: 'Tag name or alias',
type: ApplicationCommandOptionType.String,
required: true,
run: (ctx) => {
return ctx.respond(findTags(ctx.value));
}
},
{
name: 'target',
description: 'User to mention',
type: ApplicationCommandOptionType.User,
required: false
}
],
run: (c) => {
const query: APIApplicationCommandInteractionDataStringOption = c.options[0] as APIApplicationCommandInteractionDataStringOption;
const target = c?.resolved?.users?.[0];
const tag = getTag(query.value);
if (!tag)
return c.respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`\` Could not find a tag \`${query.value}\``,
flags: MessageFlags.Ephemeral
}
});
return c.respond([
target ? `*Tag suggestion for <@${target.id}>:*` : '',
tag.content
].join('\n'));
}
})

View file

@ -1 +0,0 @@
console.log('Hello World');

84
src/index.ts Normal file
View file

@ -0,0 +1,84 @@
import { Hono } from 'hono';
import { bodyParse } from 'hono/body-parse';
import { Logger } from './utils/Logger';
// @ts-expect-error Types :(
import config from '../files/config.toml';
import loadCommands from './utils/loadCommands';
import { verifyKey } from './utils/verify';
import { APIPingInteraction, APIApplicationCommandInteraction, APIMessageComponentInteraction, InteractionType, InteractionResponseType, ApplicationCommandType, APIApplicationCommandAutocompleteInteraction, ApplicationCommandOptionType, APIApplicationCommandOption } from 'discord-api-types/v10';
import { CommandContext } from './structures/contexts/CommandContext';
import { Commands } from './managers/CommandManager';
import registerCommands from './utils/registerCommands';
import { Option, OptionOptions } from './structures/Option';
import { AutocompleteContext } from './structures/contexts/AutocompleteContext';
await loadCommands();
try {
await registerCommands(config.client.token, config.client.id);
} catch(e) {
console.log(e);
}
const app = new Hono();
app.post('/interaction', bodyParse(), async(c) => {
const signature = c.req.headers.get('X-Signature-Ed25519');
const timestamp = c.req.headers.get('X-Signature-Timestamp');
if (!signature || !timestamp) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D
if (!await verifyKey(JSON.stringify(c.req.parsedBody), signature, timestamp, config.client.public_key)) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D
const interaction = c.req.parsedBody as unknown as APIPingInteraction | APIApplicationCommandInteraction | APIMessageComponentInteraction | APIApplicationCommandAutocompleteInteraction;
if (interaction.type === InteractionType.Ping) {
return new CommandContext(c).respond({
type: InteractionResponseType.Pong
});
}
if (interaction.type === InteractionType.ApplicationCommandAutocomplete && interaction.data.type === ApplicationCommandType.ChatInput) {
const command = Commands.get(interaction.data.name);
let options = command.options;
const subCommandGroup = interaction.data.options.find(option => option.type === ApplicationCommandOptionType.SubcommandGroup)
const subCommand = interaction.data.options.find(option => option.type === ApplicationCommandOptionType.Subcommand);
// @ts-expect-error ?? find
if (subCommandGroup) options = options.find(option => option.name === subCommandGroup.name)?.options;
// @ts-expect-error ?? find
if (subCommand) options = options.find(option => option.name === subCommand.name)?.options;
// @ts-expect-error i dont want waste time
const focused: APIApplicationCommandBasicOption = interaction.data.options.find(option => option.focused === true);
// @ts-expect-error ?? find
const option: Option | OptionOptions = options.find(option => option.name === focused.name);
return option.run(new AutocompleteContext(
c,
option,
focused.value
));
}
if (interaction.type === InteractionType.ApplicationCommand && interaction.data.type === ApplicationCommandType.ChatInput) {
return Commands.get(interaction.data.name).run(new CommandContext(
c,
interaction.data.options,
interaction.data.resolved
));
}
return new CommandContext(c).respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: 'Beep boop. Boop beep?'
}
})
})
await Bun.serve({
port: config.server.port,
fetch: app.fetch,
});
Logger.info('🚀 Server started at', config.server.port.toString());
Logger.debug(`🌍 http://localhost:${config.server.port}`);

View file

@ -0,0 +1,20 @@
import Collection from '@discordjs/collection';
import { Command } from '../structures/Command';
class CommandManager extends Collection<String, Command> {
constructor() {
super();
}
public register(command: Command): CommandManager {
this.set(command.name, command);
return this;
}
public unregister(command: Command): CommandManager {
this.delete(command.name);
return this;
}
}
export const Commands = new CommandManager();

59
src/structures/Command.ts Normal file
View file

@ -0,0 +1,59 @@
// Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Command.ts
import { LocaleString } from 'discord-api-types/v10';
import { Commands } from '../managers/CommandManager';
import { CommandContext } from './contexts/CommandContext';
import { Option, OptionOptions } from './Option';
export interface CommandOptions {
name: string;
nameLocalizations?: Record<LocaleString, string>;
description?: string;
descriptionLocalizations?: Record<LocaleString, string>;
guildId?: string;
defaultMemberPermissions?: string;
options?: Option[] | OptionOptions[];
run: (ctx: CommandContext) => Response;
}
export class Command {
public name: string;
public nameLocalizations?: Record<LocaleString, string>;
public description?: string;
public descriptionLocalizations?: Record<LocaleString, string>;
public guildId?: string;
public defaultMemberPermissions?: string;
public options: Option[] | OptionOptions[];
public run: (ctx: CommandContext) => Response;
public constructor(options: CommandOptions) {
this.name = options.name;
this.nameLocalizations = options.nameLocalizations;
this.description = options.description;
this.descriptionLocalizations = options.descriptionLocalizations;
this.guildId = options.guildId;
this.defaultMemberPermissions = options.defaultMemberPermissions;
this.options = options.options?.map(option => {
if (option instanceof Option) return option;
else return new Option(option);
});
this.run = options.run;
Commands.register(this);
}
public toJSON(): Record<string, any> {
return {
name: this.name,
name_localizations: this.nameLocalizations,
description: this.description,
description_localizations: this.descriptionLocalizations,
guild_id: this.guildId,
default_member_permissions: this.defaultMemberPermissions,
options: this.options?.map(option => option.toJSON()),
}
}
}

96
src/structures/Option.ts Normal file
View file

@ -0,0 +1,96 @@
// Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Argument.ts
import { ApplicationCommandOptionType, ChannelType, LocaleString } from 'discord-api-types/v10';
import { AutocompleteContext } from './contexts/AutocompleteContext';
export interface OptionChoice {
name: string;
nameLocalizations?: Record<LocaleString, string>;
value: string | number;
}
export interface OptionOptions {
name: string;
nameLocalizations?: Record<LocaleString, string>;
description: string;
descriptionLocalizations?: Record<LocaleString, string>;
type: ApplicationCommandOptionType
required?: boolean;
choices?: OptionChoice[];
options?: Array<Option | OptionOptions>;
channelTypes?: ChannelType[];
minValue?: number;
maxValue?: number;
minLength?: number;
maxLength?: number;
run?: (ctx: AutocompleteContext) => Response;
}
export class Option {
public name: string;
public nameLocalizations?: Record<LocaleString, string>;
public description: string;
public descriptionLocalizations?: Record<LocaleString, string>;
public type: ApplicationCommandOptionType;
public required?: boolean;
public choices?: Array<OptionChoice>;
public options?: Array<Option>;
public channelTypes?: Array<ChannelType | keyof typeof ChannelType>;
public minValue?: number;
public maxValue?: number;
public minLength?: number;
public maxLength?: number;
public run?: (ctx: AutocompleteContext) => Response;
public constructor(options: OptionOptions) {
this.name = options.name;
this.nameLocalizations = options.nameLocalizations;
this.description = options.description;
this.descriptionLocalizations = options.descriptionLocalizations;
this.type = options.type;
this.required = options.required;
this.choices = options.choices;
this.options = options.options?.map(argument => {
if (argument instanceof Option) return argument;
else return new Option(argument);
});
this.channelTypes = options.channelTypes;
this.minValue = options.minValue;
this.maxValue = options.maxValue;
this.minLength = options.minLength;
this.maxLength = options.maxLength;
this.run = options.run;
}
public toJSON(): Record<string, any> {
if (
this.type === ApplicationCommandOptionType.Subcommand ||
this.type === ApplicationCommandOptionType.SubcommandGroup
) {
return {
name: this.name,
name_localizations: this.nameLocalizations,
description: this.description,
description_localizations: this.descriptionLocalizations,
type: this.type,
options: this.options?.map(option => option.toJSON()),
};
}
return {
name: this.name,
name_localizations: this.nameLocalizations,
description: this.description,
description_localizations: this.descriptionLocalizations,
type: this.type,
required: this.required,
choices: this.choices,
channel_types: this.channelTypes,
min_value: this.minValue,
max_value: this.maxValue,
min_length: this.minLength,
max_length: this.maxLength,
autocomplete: typeof this.run === 'function',
};
}
}

View file

@ -0,0 +1,24 @@
import { APIApplicationCommandOptionChoice, InteractionResponseType } from 'discord-api-types/v10';
import { Context } from 'hono';
import { Option, OptionOptions } from '../Option';
export class AutocompleteContext {
public context: Context;
public option?: Option | OptionOptions;
public value?: string;
public constructor(c: Context, option: Option | OptionOptions, value: string) {
this.context = c;
this.option = option;
this.value = value;
}
public respond(response: APIApplicationCommandOptionChoice[]) {
return this.context.json({
type: InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: response
}
});
}
}

View file

@ -0,0 +1,27 @@
import { APIApplicationCommandInteractionDataOption, APIChatInputApplicationCommandInteractionDataResolved, APIInteractionResponse, InteractionResponseType } from 'discord-api-types/v10';
import { Context } from 'hono';
export class CommandContext {
public context: Context;
public options?: APIApplicationCommandInteractionDataOption[];
public resolved?: APIChatInputApplicationCommandInteractionDataResolved;
public constructor(c: Context, options?: APIApplicationCommandInteractionDataOption[], resolved?: APIChatInputApplicationCommandInteractionDataResolved) {
this.context = c;
this.options = options;
this.resolved = resolved;
}
public respond(response: APIInteractionResponse | string) {
if (typeof response === 'string') {
return this.context.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: response
}
});
}
return this.context.json(response);
}
}

47
src/utils/Logger.ts Normal file
View file

@ -0,0 +1,47 @@
type Level = 'INFO' | 'SUCCESS' | 'WARN' | 'ERROR' | 'DEBUG'
export class Logger extends null {
public static info(...messages: any[]) {
this.log('INFO', messages);
}
public static success(...messages: any[]) {
this.log('SUCCESS', messages);
}
public static warn(...messages: any[]) {
this.log('WARN', messages);
}
public static error(...messages: any[]) {
this.log('ERROR', messages);
}
public static debug(...messages: any[]) {
this.log('DEBUG', messages);
}
private static log(level: Level, messages: any[]) {
const date = new Date();
let color = '';
switch (level) {
case 'INFO':
color = '\x1b[36m';
break;
case 'SUCCESS':
color = '\u001b[32m';
break;
case 'WARN':
color = '\x1b[93m';
break;
case 'ERROR':
color = '\x1b[91m';
break;
default:
color = '\x1b[2m';
}
console.log(`${color}[${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}/${level}]\x1b[0m`, ...messages);
}
}

17
src/utils/loadCommands.ts Normal file
View file

@ -0,0 +1,17 @@
import { readdirSync } from 'fs';
import { basename, dirname, join } from 'path';
import { Logger } from './Logger';
const __dirname = new URL('.', import.meta.url).pathname;
export default async() => {
const commandsDir = join(__dirname, '..', 'commands');
for (
const command of readdirSync(commandsDir)
) {
const name = basename(command, '.ts');
Logger.info(`Loading ${name} command`);
await import(join(commandsDir, command));
Logger.success(`Command ${name} has been loaded`);
}
}

View file

@ -0,0 +1,56 @@
import { RouteBases, Routes } from 'discord-api-types/v10';
import { Commands } from '../managers/CommandManager';
import { Command } from '../structures/Command';
import { Logger } from './Logger';
const sync = async(
clientToken: string,
clientUserId: string,
commands: Command[],
guildId?: string,
) => {
const res = await fetch(
`${RouteBases.api}${guildId ? Routes.applicationGuildCommands(clientUserId, guildId) : Routes.applicationCommands(clientUserId)}`,
{
method: 'PUT',
body: JSON.stringify(commands.flatMap(command => command.toJSON())),
headers: {
'Authorization': `Bot ${clientToken}`,
'Content-Type': 'application/json'
}
}
)
if (res.ok) return Logger.success('🌍 All commands has been synchronized with discord api.');
const data = await res.json() as any;
if (res.status === 429) {
setTimeout(
() => sync(clientToken, clientUserId, commands, guildId),
data.retry_after * 1000,
);
} else {
Logger.error(
typeof data.code !== 'undefined' ? data.code.toString() : '',
data.message
)
console.log(data);
}
}
export default async(clientToken: string, clientUserId: string) => {
if (Commands.size === 0) return;
const [guild, global] = Commands.partition(
command => typeof command.guildId === 'string',
);
const guildIds = new Set(guild.map(c => c.guildId));
for await (const guildId of guildIds) {
const commands = guild.filter(item => item.guildId === guildId);
await sync(clientToken, clientUserId, [...commands.values()], guildId);
}
if (global.size > 0) await sync(clientToken, clientUserId, [...global.values()]);
}

40
src/utils/tagsUtils.ts Normal file
View file

@ -0,0 +1,40 @@
import Collection from '@discordjs/collection';
// @ts-expect-error Types :(
import tags from '../../files/tags.toml';
export interface Tag {
keywords: string[];
content: string;
}
const tagCache: Collection<string, Tag> = new Collection();
for (const [key, value] of Object.entries(tags)) {
tagCache.set(key, value as unknown as Tag);
}
export const getTag = (name: string) => {
const tag = tagCache.get(name) || tagCache.find(tag => tag.keywords.includes(name));
return tag;
}
export const findTags = (name: string) => {
if (!name)
return [
...tagCache.map((tag, name) => new Object({
name,
value: name
})).slice(0, 25)
];
else {
const tag = getTag(name);
if (tag)
return [
{
name,
value: name
}
]
else return findTags(null);
}
}

86
src/utils/verify.ts Normal file
View file

@ -0,0 +1,86 @@
// from https://github.com/discord/discord-interactions-js/blob/main/src/index.ts
import { sign } from 'tweetnacl';
/**
* Converts different types to Uint8Array.
*
* @param value - Value to convert. Strings are parsed as hex.
* @param format - Format of value. Valid options: 'hex'. Defaults to utf-8.
* @returns Value in Uint8Array form.
*/
function valueToUint8Array(value: Uint8Array | ArrayBuffer | Buffer | string, format?: string): Uint8Array {
if (value == null) {
return new Uint8Array();
}
if (typeof value === 'string') {
if (format === 'hex') {
const matches = value.match(/.{1,2}/g);
if (matches == null) {
throw new Error('Value is not a valid hex string');
}
const hexVal = matches.map((byte: string) => parseInt(byte, 16));
return new Uint8Array(hexVal);
} else {
return new TextEncoder().encode(value);
}
}
try {
if (Buffer.isBuffer(value)) {
const arrayBuffer = value.buffer.slice(value.byteOffset, value.byteOffset + value.length);
return new Uint8Array(value);
}
} catch (ex) {
// Runtime doesn't have Buffer
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
if (value instanceof Uint8Array) {
return value;
}
throw new Error('Unrecognized value type, must be one of: string, Buffer, ArrayBuffer, Uint8Array');
}
/**
* Merge two arrays.
*
* @param arr1 - First array
* @param arr2 - Second array
* @returns Concatenated arrays
*/
function concatUint8Arrays(arr1: Uint8Array, arr2: Uint8Array): Uint8Array {
const merged = new Uint8Array(arr1.length + arr2.length);
merged.set(arr1);
merged.set(arr2, arr1.length);
return merged;
}
/**
* Validates a payload from Discord against its signature and key.
*
* @param rawBody - The raw payload data
* @param signature - The signature from the `X-Signature-Ed25519` header
* @param timestamp - The timestamp from the `X-Signature-Timestamp` header
* @param clientPublicKey - The public key from the Discord developer dashboard
* @returns Whether or not validation was successful
*/
export const verifyKey = (
body: Uint8Array | ArrayBuffer | Buffer | string,
signature: Uint8Array | ArrayBuffer | Buffer | string,
timestamp: Uint8Array | ArrayBuffer | Buffer | string,
clientPublicKey: Uint8Array | ArrayBuffer | Buffer | string,
): boolean => {
try {
const timestampData = valueToUint8Array(timestamp);
const bodyData = valueToUint8Array(body);
const message = concatUint8Arrays(timestampData, bodyData);
const signatureData = valueToUint8Array(signature, 'hex');
const publicKeyData = valueToUint8Array(clientPublicKey, 'hex');
return sign.detached.verify(message, signatureData, publicKeyData);
} catch (ex) {
console.error('[discord-interactions]: Invalid verifyKey parameters', ex);
return false;
}
}

10
tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "Node",
// "bun-types" is the important part
"types": ["bun-types"]
}
}