mirror of
https://github.com/xHyroM/bun-discord-bot.git
synced 2024-11-22 14:41:05 +01:00
chore: base
This commit is contained in:
parent
9812eadced
commit
28584ed76a
21 changed files with 806 additions and 3 deletions
146
.gitignore
vendored
Normal file
146
.gitignore
vendored
Normal 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
0
README.md
Normal file
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
9
files/config.example.toml
Normal file
9
files/config.example.toml
Normal 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
11
files/tags.toml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
[test]
|
||||||
|
keywords = ["test", "t"]
|
||||||
|
content = """
|
||||||
|
Hello, new bot :)
|
||||||
|
"""
|
||||||
|
|
||||||
|
[lol]
|
||||||
|
keywords = ["l"]
|
||||||
|
content = """
|
||||||
|
Lol
|
||||||
|
"""
|
15
package.json
15
package.json
|
@ -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
16
src/commands/help.ts
Normal 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
45
src/commands/tags.ts
Normal 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'));
|
||||||
|
}
|
||||||
|
})
|
|
@ -1 +0,0 @@
|
||||||
console.log('Hello World');
|
|
84
src/index.ts
Normal file
84
src/index.ts
Normal 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}`);
|
20
src/managers/CommandManager.ts
Normal file
20
src/managers/CommandManager.ts
Normal 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
59
src/structures/Command.ts
Normal 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
96
src/structures/Option.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
24
src/structures/contexts/AutocompleteContext.ts
Normal file
24
src/structures/contexts/AutocompleteContext.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
27
src/structures/contexts/CommandContext.ts
Normal file
27
src/structures/contexts/CommandContext.ts
Normal 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
47
src/utils/Logger.ts
Normal 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
17
src/utils/loadCommands.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
56
src/utils/registerCommands.ts
Normal file
56
src/utils/registerCommands.ts
Normal 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
40
src/utils/tagsUtils.ts
Normal 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
86
src/utils/verify.ts
Normal 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
10
tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
// "bun-types" is the important part
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue